22 test files moved from core, plus the two that were split out of files core keeps. 351 tests pass. One change runs through every moved test, and it is the boundary rather than a chore: core internals can no longer be stubbed by requiring them, because there are none to require. `../utils/db` and `../model/settings` do not exist here. What a test controls instead is the ctx core would have handed over, installed once by test/_setup.js -- which is a better seam anyway, since it is exactly the surface the contract promises and nothing wider. The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a module and entry.test.js still asserts against a frozen one; but a test that needs settings.get to return a path has to be able to say so. Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to assert through publish() into pushDevices and a captured fetch -- which endpoints were hit, how many requests went out. None of that is this module's any more: publish is ctx.push.publish, and the device registry and the relay are behind it. Reaching for them from here would be reaching past ctx. What remains is what the module owns and is the part worth guarding: a game account resolves to a website user, a personal target that resolves to nobody is dropped rather than published, and a sensitive kind never reaches publish at all. Co-Authored-By: Claude <noreply@anthropic.com>
416 lines
17 KiB
JavaScript
416 lines
17 KiB
JavaScript
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
|
// not exist here. What a test controls instead is the `ctx` core would have
|
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
|
// contract actually promises.
|
|
|
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
|
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
|
|
// the end releases the pool so the process exits cleanly.
|
|
|
|
const { test, after, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the
|
|
// bits that decide what the anonymous public may and may not see:
|
|
// - getFeed serves only kinds on the public allowlist (staff audit / cheat /
|
|
// login events are stored for the admin channel and must never leak here);
|
|
// - getHouses exposes only IDOC houses and only their location — owner, price,
|
|
// co-owners and decay detail are staff-only and must be stripped;
|
|
// - getStatus assembles the connection/economy summary;
|
|
// - a model failure degrades to a 500, never a thrown/uncaught error.
|
|
const ctrl = require('../router/public/shard.controller')
|
|
const shardEvents = require('../model/shardEvents/shardEvents.model')
|
|
const shardState = require('../model/shardState/shardState.model')
|
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
|
const broadcast = require('../utils/shardBroadcast')
|
|
const visibility = require('../utils/shardVisibility')
|
|
|
|
|
|
// The controller now resolves the visibility config and the caller's rung on
|
|
// every read. Stub the MODEL rather than the util's exports: getConfig() and
|
|
// project() call the module-internal getConfig, which an exports-level stub does
|
|
// not intercept — it would still hit the closed DB port and cost a ~10s pool
|
|
// timeout per test before falling back to these same defaults.
|
|
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
|
|
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
|
|
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
|
|
|
|
const DEFAULTS = visibility.compileDefaults()
|
|
|
|
function mockRes() {
|
|
return {
|
|
statusCode: 200,
|
|
body: null,
|
|
status(c) {
|
|
this.statusCode = c
|
|
return this
|
|
},
|
|
json(b) {
|
|
this.body = b
|
|
return this
|
|
},
|
|
}
|
|
}
|
|
|
|
const originals = {
|
|
eventsList: shardEvents.list,
|
|
listIdoc: shardState.listIdoc,
|
|
onlineCount: shardState.onlineCount,
|
|
latestEconomy: shardState.latestEconomy,
|
|
getRuleset: shardState.getRuleset,
|
|
getSafe: uoLinkConfig.getSafe,
|
|
}
|
|
afterEach(() => {
|
|
shardEvents.list = originals.eventsList
|
|
shardState.listIdoc = originals.listIdoc
|
|
shardState.onlineCount = originals.onlineCount
|
|
shardState.latestEconomy = originals.latestEconomy
|
|
shardState.getRuleset = originals.getRuleset
|
|
uoLinkConfig.getSafe = originals.getSafe
|
|
})
|
|
|
|
// ── getFeed: the public-safe allowlist is a security boundary ───────────
|
|
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
|
|
let queried = false
|
|
shardEvents.list = async () => {
|
|
queried = true
|
|
return [{ kind: 'staff.audit' }]
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
|
|
assert.deepEqual(res.body, [])
|
|
assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
|
|
})
|
|
|
|
test('getFeed serves a specific kind when it IS public-safe', async () => {
|
|
const publicKind = [...broadcast.PUBLIC_KINDS][0]
|
|
let seen
|
|
shardEvents.list = async (opts) => {
|
|
seen = opts
|
|
return [{ kind: publicKind }]
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
|
|
assert.equal(seen.kind, publicKind)
|
|
assert.equal(seen.limit, 5)
|
|
assert.equal(res.body[0].kind, publicKind)
|
|
})
|
|
|
|
test('getFeed with no kind restricts the query to the kinds THIS viewer may read', async () => {
|
|
let seen
|
|
shardEvents.list = async (opts) => {
|
|
seen = opts
|
|
return []
|
|
}
|
|
await ctrl.getFeed({ query: {} }, mockRes())
|
|
// Resolved from the LIVE config, not the module-load PUBLIC_KINDS constant, so
|
|
// an admin re-gating a feature takes effect on the stored history too.
|
|
assert.deepEqual(new Set(seen.kinds), new Set(visibility.visibleKinds('anonymous', DEFAULTS)))
|
|
// Sanity: a known admin-only kind is absent from what the public feed queries.
|
|
assert.ok(!seen.kinds.includes('staff.audit'))
|
|
// The `stream` flag governs SSE fan-out only, so a feature whose live firehose
|
|
// ships off is still readable from history — the one way this set is WIDER
|
|
// than PUBLIC_KINDS.
|
|
for (const kind of broadcast.PUBLIC_KINDS) assert.ok(seen.kinds.includes(kind))
|
|
assert.ok(seen.kinds.includes('vendor.listing'))
|
|
assert.ok(!broadcast.PUBLIC_KINDS.has('vendor.listing'))
|
|
})
|
|
|
|
test('getFeed projects each row against ITS OWN kind\'s feature', async () => {
|
|
shardEvents.list = async () => [
|
|
{
|
|
id: 1,
|
|
kind: 'player.death',
|
|
payload: { kind: 'player.death', actor: { serial: '0x1', name: 'Doomed', acct: 'secret', webId: 99 } },
|
|
},
|
|
{
|
|
id: 2,
|
|
kind: 'guild.join',
|
|
payload: { kind: 'guild.join', actor: { serial: '0x2', name: 'Joiner', acct: 'secret2', webId: 98 } },
|
|
},
|
|
]
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: {} }, res)
|
|
for (const row of res.body) {
|
|
assert.equal(row.payload.actor.acct, undefined, `${row.kind} leaked acct`)
|
|
assert.equal(row.payload.actor.webId, undefined, `${row.kind} leaked webId`)
|
|
assert.ok(row.payload.actor.name, 'the in-game name is still public')
|
|
}
|
|
})
|
|
|
|
test('getFeed serves nothing when the viewer may read no kinds at all', async () => {
|
|
let queried = false
|
|
shardEvents.list = async () => {
|
|
queried = true
|
|
return [{ kind: 'staff.audit' }]
|
|
}
|
|
const allGated = Object.fromEntries(
|
|
Object.entries(DEFAULTS).map(([name, f]) => [name, { ...f, enabled: false }]),
|
|
)
|
|
visibility.getConfig = async () => allGated
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: {} }, res)
|
|
visibility.getConfig = async () => DEFAULTS
|
|
assert.deepEqual(res.body, [])
|
|
// An empty allowlist must never fall through to an unfiltered "give me
|
|
// everything" query.
|
|
assert.equal(queried, false)
|
|
})
|
|
|
|
// ── getHouses: the public house view must strip owner/price ─────────────
|
|
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
|
|
shardState.listIdoc = async () => [
|
|
{
|
|
serial: 1,
|
|
name: 'Keep',
|
|
region: 'Britain',
|
|
map: 'Felucca',
|
|
x: 1,
|
|
y: 2,
|
|
z: 3,
|
|
// The following are staff-only and must NOT appear in the public payload:
|
|
ownerName: 'Lord British',
|
|
ownerAcct: 'secret',
|
|
price: 999999,
|
|
coOwners: 'a,b',
|
|
decay: 'IDOC',
|
|
},
|
|
]
|
|
const res = mockRes()
|
|
await ctrl.getHouses({}, res)
|
|
const [h] = res.body
|
|
assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
|
|
assert.equal(h.isIdoc, true)
|
|
assert.equal(h.ownerName, undefined)
|
|
assert.equal(h.price, undefined)
|
|
assert.equal(h.coOwners, undefined)
|
|
})
|
|
|
|
// ── getIdoc: the flattened owner fields are a security boundary too ──────
|
|
test('getIdoc never serves the owner game account to a viewer below admin', async () => {
|
|
shardState.listIdoc = async () => [
|
|
{
|
|
serial: '0x1',
|
|
name: 'Marble Tower',
|
|
region: 'Britain',
|
|
map: 'Felucca',
|
|
x: 1,
|
|
y: 2,
|
|
z: 3,
|
|
ownerSerial: '0x2A01',
|
|
ownerName: 'Sir Cadmus',
|
|
ownerAcct: 'cadmus_acct', // flattened spelling of the locked `acct`
|
|
price: 1250000,
|
|
isIdoc: true,
|
|
},
|
|
]
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const res = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: level }, res)
|
|
assert.equal(res.body[0].ownerAcct, undefined, `${level} saw the owner's game account`)
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'admin' }, res)
|
|
assert.equal(res.body[0].ownerAcct, 'cadmus_acct', 'admin still sees it')
|
|
})
|
|
|
|
test('getIdoc gates owner identity and price at `staff`, but never the location', async () => {
|
|
shardState.listIdoc = async () => [
|
|
{ serial: '0x1', name: 'Marble Tower', region: 'Britain', map: 'Felucca', x: 1, y: 2, z: 3,
|
|
ownerSerial: '0x2A01', ownerName: 'Sir Cadmus', price: 1250000, isIdoc: true },
|
|
]
|
|
const anon = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, anon)
|
|
assert.equal(anon.body[0].ownerName, undefined)
|
|
assert.equal(anon.body[0].ownerSerial, undefined)
|
|
assert.equal(anon.body[0].price, undefined)
|
|
// The public IDOC board still renders: name, region and location survive.
|
|
assert.equal(anon.body[0].name, 'Marble Tower')
|
|
assert.equal(anon.body[0].region, 'Britain')
|
|
assert.equal(anon.body[0].map, 'Felucca')
|
|
|
|
const staff = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'staff' }, staff)
|
|
assert.equal(staff.body[0].ownerName, 'Sir Cadmus')
|
|
assert.equal(staff.body[0].price, 1250000)
|
|
})
|
|
|
|
test('getIdoc preserves Date columns rather than flattening them to {}', async () => {
|
|
const when = new Date('2026-07-06T19:32:29.000Z')
|
|
shardState.listIdoc = async () => [
|
|
{ serial: '0x1', name: 'Marble Tower', isIdoc: true, lastRefreshed: when, updatedAt: when },
|
|
]
|
|
const res = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, res)
|
|
assert.ok(res.body[0].updatedAt instanceof Date, 'a Date must survive projection intact')
|
|
assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
|
|
})
|
|
|
|
// ── getRuleset: "never published" is a real answer ──────────────────────
|
|
test('getRuleset serves null when the shard has never published a ruleset', async () => {
|
|
shardState.getRuleset = async () => null
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
|
// Deliberately null, not {} — the page says "not published yet" rather than
|
|
// rendering an empty ruleset as though the shard had no rules.
|
|
assert.equal(res.body, null)
|
|
assert.equal(res.statusCode, 200)
|
|
})
|
|
|
|
test('getRuleset serves the published ruleset whole, nested blocks intact', async () => {
|
|
shardState.getRuleset = async () => ({
|
|
kind: 'world.ruleset',
|
|
rev: '1a2b3c4d',
|
|
shard: 'UOMysticmoon',
|
|
expansion: 'EJ',
|
|
systems: { cityLoyalty: true, vvv: true, factions: false },
|
|
caps: { skill: 1000, totalSkill: 7000, stat: 225 },
|
|
champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] },
|
|
})
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.body.expansion, 'EJ')
|
|
assert.equal(res.body.systems.vvv, true)
|
|
assert.equal(res.body.caps.totalSkill, 7000)
|
|
// Arrays must survive projection as arrays, not become objects.
|
|
assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13])
|
|
})
|
|
|
|
// §3.6.1's rule: a read path that returns shard data and does not project is a
|
|
// bug. The ruleset frame carries no actor today, but it goes through the same
|
|
// gate — so a future block that does cannot leak.
|
|
test('getRuleset projects: acct/webId never survive below admin', async () => {
|
|
shardState.getRuleset = async () => ({
|
|
expansion: 'EJ',
|
|
connect: 'play.example.com,2593',
|
|
owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 },
|
|
})
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: level }, res)
|
|
assert.equal(res.body.owner.acct, undefined, `${level} saw acct`)
|
|
assert.equal(res.body.owner.webId, undefined, `${level} saw webId`)
|
|
// `connect` defaults to the anonymous rung: an operator who published it
|
|
// meant it to be readable.
|
|
assert.equal(res.body.connect, 'play.example.com,2593')
|
|
}
|
|
})
|
|
|
|
// ── points boards ──────────────────────────────────────────────────────
|
|
const BOARD = {
|
|
system: 'QueensLoyalty',
|
|
nameString: "Queen's Loyalty",
|
|
nameNumber: 1114938,
|
|
maxPoints: 30000,
|
|
players: 842,
|
|
top: [
|
|
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
|
|
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
|
|
],
|
|
}
|
|
|
|
test('getPointsBoards serves every board with its ranked list intact', async () => {
|
|
shardState.listPointsBoards = async () => [BOARD]
|
|
const res = mockRes()
|
|
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.body.length, 1)
|
|
assert.equal(res.body[0].system, 'QueensLoyalty')
|
|
// The ranked list is an ARRAY through projection, not an object keyed 0/1 —
|
|
// the same trap the ruleset's rankThresholds assertion guards.
|
|
assert.ok(Array.isArray(res.body[0].top))
|
|
assert.equal(res.body[0].top[1].name, 'Mireille')
|
|
})
|
|
|
|
test('getPointsBoards serves an empty list before the shard has published any', async () => {
|
|
shardState.listPointsBoards = async () => []
|
|
const res = mockRes()
|
|
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
|
assert.deepEqual(res.body, [])
|
|
assert.equal(res.statusCode, 200)
|
|
})
|
|
|
|
// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry
|
|
// no actor today — they write entries inline as {serial, name} precisely so they
|
|
// never carry acct/webId — but the gate is what keeps that true if the shape grows.
|
|
test('getPointsBoard projects: acct/webId never survive below admin', async () => {
|
|
shardState.getPointsBoard = async () => ({
|
|
system: 'QueensLoyalty',
|
|
top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }],
|
|
})
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const res = mockRes()
|
|
await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res)
|
|
assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`)
|
|
assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`)
|
|
assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default')
|
|
}
|
|
})
|
|
|
|
// "No such system" and "a board nobody has scored in" are different answers.
|
|
test('getPointsBoard 404s for a system the shard has never published', async () => {
|
|
shardState.getPointsBoard = async () => null
|
|
const res = mockRes()
|
|
await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.statusCode, 404)
|
|
})
|
|
|
|
test('getPointsBoard rejects a malformed system name before touching the model', async () => {
|
|
let queried = false
|
|
shardState.getPointsBoard = async () => { queried = true; return null }
|
|
for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) {
|
|
const res = mockRes()
|
|
await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`)
|
|
}
|
|
assert.equal(queried, false, 'a malformed name must never reach the query')
|
|
})
|
|
|
|
test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => {
|
|
shardState.listPointsBoards = async () => {
|
|
throw new Error('pool down')
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.statusCode, 500)
|
|
assert.equal(res.body.message, 'Internal Server Error')
|
|
})
|
|
|
|
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
|
|
shardState.getRuleset = async () => {
|
|
throw new Error('pool down')
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.statusCode, 500)
|
|
assert.equal(res.body.message, 'Internal Server Error')
|
|
})
|
|
|
|
// ── getStatus assembles the summary ─────────────────────────────────────
|
|
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
|
uoLinkConfig.getSafe = async () => ({
|
|
enabled: true,
|
|
status: 'connected',
|
|
pluginConnected: true,
|
|
lastEventAt: 'ts',
|
|
})
|
|
shardState.onlineCount = async () => 12
|
|
shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
|
|
const res = mockRes()
|
|
await ctrl.getStatus({}, res)
|
|
assert.equal(res.body.enabled, true)
|
|
assert.equal(res.body.onlineCount, 12)
|
|
assert.equal(res.body.economy.gold, 100)
|
|
})
|
|
|
|
test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
|
|
uoLinkConfig.getSafe = async () => {
|
|
throw new Error('pool down')
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getStatus({}, res) // must resolve, not reject
|
|
assert.equal(res.statusCode, 500)
|
|
assert.equal(res.body.message, 'Internal Server Error')
|
|
})
|