Files
Module-uo/server/test/shardVisibility.test.js
wtclaude 2fa4d87a40
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
feat(shard): ingest guild rosters and departures (protocol 4)
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00

479 lines
22 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 anything that builds a pool.
// Every DB call this suite would make is monkeypatched.
const { test, after, afterEach, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
// Unit-test the visibility framework's INVARIANTS — the rules that make it a
// security boundary rather than a convenience filter (docs/link/v3.md §3):
//
// 1. acct / webId are admin-only ALWAYS and cannot be configured down.
// 2. A kind absent from KIND_FEATURE reaches nobody below admin (fail closed).
// 3. The compiled defaults reproduce pre-v3 behavior, so installing this
// module changes nothing until an admin edits the config.
// 4. The ladder is ordered and each rung implies the ones below it.
const visibility = require('../utils/shardVisibility')
const model = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const originals = { listAll: model.listAll, listForUser: shardLinks.listForUser }
// Default both DB reads to "no rows" so a test that doesn't care never blocks on
// the dead pool (each such call would otherwise burn the 10s acquire timeout).
// Tests that exercise stored config or a DB failure override these.
beforeEach(() => {
model.listAll = async () => []
shardLinks.listForUser = async () => []
visibility.invalidate()
})
afterEach(() => {
model.listAll = originals.listAll
shardLinks.listForUser = originals.listForUser
visibility.invalidate()
})
// Stub the stored config; the framework merges rows over compiled defaults.
function withRows(rows) {
model.listAll = async () => rows
visibility.invalidate()
}
// ── The ladder ─────────────────────────────────────────────────────────────
test('ladder is ordered and each rung implies the ones below it', () => {
assert.deepEqual(visibility.LADDER, ['anonymous', 'logged_in', 'player', 'staff', 'admin'])
for (let i = 0; i < visibility.LADDER.length; i += 1) {
for (let j = 0; j <= i; j += 1) {
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), true)
}
for (let j = i + 1; j < visibility.LADDER.length; j += 1) {
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), false)
}
}
})
test('an unknown rung always loses, on BOTH sides of the comparison', () => {
assert.equal(visibility.isLevel('not-a-rung'), false)
// An unknown REQUIREMENT is satisfied by nobody below admin...
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.meets(level, 'not-a-rung'), false, `${level} vs unknown requirement`)
}
assert.equal(visibility.meets('admin', 'not-a-rung'), true)
// ...and an unknown VIEWER level grants nothing. This is the direction that
// matters: a shared admin fallback would have made a garbage viewer level
// pass every gate.
for (const required of visibility.LADDER.slice(1)) {
assert.equal(visibility.meets('not-a-rung', required), false, `unknown viewer vs ${required}`)
assert.equal(visibility.meets(undefined, required), false, `undefined viewer vs ${required}`)
assert.equal(visibility.meets(null, required), false, `null viewer vs ${required}`)
}
})
test('an unknown viewer level cannot see a gated kind or a locked field', async () => {
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('champ.update', 'not-a-rung', config), true) // anonymous-tier: fine
assert.equal(visibility.kindVisibleTo('audit.command', 'not-a-rung', config), false)
const out = visibility.projectFeature(
'guilds',
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
'not-a-rung',
config,
)
assert.equal('acct' in out.leader, false)
assert.equal('webId' in out.leader, false)
})
// ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => {
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.update',
name: 'The Nameless',
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.leader.name, 'Darrow', `${level} keeps the character name`)
assert.equal(out.leader.serial, '0x1A2B')
assert.equal('acct' in out.leader, false, `${level} must not see acct`)
assert.equal('webId' in out.leader, false, `${level} must not see webId`)
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.leader.acct, 'whitlocktech')
assert.equal(asAdmin.leader.webId, '42')
})
test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
// A roster is the first frame where the locked fields appear inside an ARRAY of
// actors rather than one nested actor. The walker recurses into arrays, so this
// should already hold — this test is here because it is the difference between a
// public Guilds page listing character names and one publishing 150 account names.
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.roster',
id: 7,
total: 3,
seq: 0,
more: false,
members: [
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
],
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.members.length, 3, `${level} still sees every member`)
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
for (const m of out.members) {
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
}
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.members[0].acct, 'ada_acct')
assert.equal(asAdmin.members[0].webId, '11')
})
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
// two it would silently keep the public Guilds page from ever seeing a roster.
const config = visibility.compileDefaults()
for (const kind of ['guild.roster', 'guild.leave']) {
assert.equal(
visibility.kindVisibleTo(kind, 'anonymous', config), true,
`${kind} should reach an anonymous viewer under the default guilds config`,
)
}
})
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
])
const config = await visibility.getConfig()
const out = visibility.projectFeature(
'guilds',
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
'anonymous',
config,
)
assert.equal('acct' in out.leader, false)
assert.equal('webId' in out.leader, false)
})
test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () => {
const config = visibility.compileDefaults()
// shapeHouse/shapeGuild flatten the actor into `<role>Acct` / `<role>WebId`.
// An exact-key check missed every one of these, which is how GET
// /public/shard/idoc served the owner's game account to anonymous callers.
const row = {
serial: '0x1',
name: 'Marble Tower',
ownerAcct: 'cadmus_acct',
leaderWebId: 42,
governorAcct: 'blackthorn_acct',
}
const out = visibility.projectFeature('houses', row, 'staff', config)
assert.equal('ownerAcct' in out, false, 'staff must not see a flattened acct')
assert.equal('leaderWebId' in out, false)
assert.equal('governorAcct' in out, false)
assert.equal(out.name, 'Marble Tower', 'ordinary fields are untouched')
const asAdmin = visibility.projectFeature('houses', row, 'admin', config)
assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
})
// ── Protocol 3.0 leaderboards ──────────────────────────────────────────────
//
// The leaderboards field rule is spelled `name` because that is the key
// points.board actually puts a ranked character's name under. v3.md §7.4 calls it
// "characterName", which describes the meaning — and projectValue matches on the
// literal key, so a rule under that spelling would have been silently inert. This
// is the same failure mode §3.6.1 records for the flattened `ownerAcct`, and this
// test is the guard on it: if someone renames the rule back, an admin who tightens
// character names would get no enforcement and no error.
test('a tightened leaderboards name rule actually strips ranked character names', async () => {
withRows([
{ feature: 'leaderboards', enabled: true, audience: 'anonymous', stream: true, fieldRules: { name: 'logged_in' } },
])
const config = await visibility.getConfig()
const board = {
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
top: [{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }],
}
const anon = visibility.projectFeature('leaderboards', board, 'anonymous', config)
assert.equal('name' in anon.top[0], false, 'anonymous must not see the ranked name')
assert.equal(anon.top[0].points, 29500, 'the rest of the entry survives')
// The BOARD's own display name is a different key and must not be caught by it.
assert.equal(anon.nameString, "Queen's Loyalty")
const member = visibility.projectFeature('leaderboards', board, 'logged_in', config)
assert.equal(member.top[0].name, 'Darrow')
})
// Default config: boards are public, exactly as v3.md §7 specifies.
test('leaderboards are anonymous-visible by default, names included', () => {
const config = visibility.compileDefaults()
const out = visibility.projectFeature(
'leaderboards',
{ top: [{ rank: 1, name: 'Darrow', points: 1 }] },
'anonymous',
config,
)
assert.equal(out.top[0].name, 'Darrow')
assert.equal(visibility.kindVisibleTo('points.board', 'anonymous', config), true)
})
test('isLockedField locks acct/webId and their suffixed forms, and nothing else', () => {
for (const key of ['acct', 'webId', 'WEBID', 'ownerAcct', 'leaderWebId', 'governorAcct']) {
assert.equal(visibility.isLockedField(key), true, `${key} must be locked`)
}
// Must not over-match: these are ordinary public fields.
for (const key of ['name', 'serial', 'ownerName', 'price', 'contact', 'region']) {
assert.equal(visibility.isLockedField(key), false, `${key} must stay configurable`)
}
})
test('a Date survives projection instead of collapsing to {}', () => {
const config = visibility.compileDefaults()
const when = new Date('2026-07-06T19:32:29.000Z')
// The DB-backed read models carry real Date columns; rebuilding one key-by-key
// yields `{}` because a Date has no enumerable own properties.
const out = visibility.projectFeature('houses', { name: 'Keep', updatedAt: when }, 'anonymous', config)
assert.ok(out.updatedAt instanceof Date)
assert.equal(out.updatedAt.toISOString(), when.toISOString())
})
test('visibleKinds tracks live config and stays independent of the stream flag', async () => {
const config = visibility.compileDefaults()
assert.ok(visibleIncludes(config, 'anonymous', 'guild.update'))
// `stream: false` suppresses SSE fan-out only — the stored history stays readable.
assert.ok(visibleIncludes(config, 'anonymous', 'vendor.listing'))
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
const gated = { ...config, guilds: { ...config.guilds, audience: 'staff' } }
assert.equal(visibleIncludes(gated, 'anonymous', 'guild.update'), false)
assert.ok(visibleIncludes(gated, 'staff', 'guild.update'))
const off = { ...config, guilds: { ...config.guilds, enabled: false } }
assert.equal(visibleIncludes(off, 'admin', 'guild.update'), false)
// Rule 2 still holds: an unmapped kind is in nobody's readable set.
assert.equal(visibleIncludes(config, 'admin', 'staff.audit'), false)
})
const visibleIncludes = (config, level, kind) => visibility.visibleKinds(level, config).includes(kind)
test('projection recurses into arrays and nested actors', () => {
const config = visibility.compileDefaults()
const rows = [
{ city: 'Britain', governor: { name: 'A', acct: 'a', webId: '1' } },
{ city: 'Vesper', governor: { name: 'B', acct: 'b' } },
]
const out = visibility.projectFeature('governors', rows, 'anonymous', config)
assert.equal(out.length, 2)
assert.equal(out[0].governor.name, 'A')
assert.equal('acct' in out[0].governor, false)
assert.equal('webId' in out[0].governor, false)
assert.equal('acct' in out[1].governor, false)
})
// ── Rule 2: fail closed on unmapped kinds ──────────────────────────────────
test('an unmapped kind reaches nobody below admin', async () => {
const config = await visibility.getConfig()
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'gold.change', 'made.up.kind']) {
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.kindVisibleTo(kind, level, config), false, `${kind} @ ${level}`)
}
assert.equal(visibility.kindVisibleTo(kind, 'admin', config), true, `${kind} @ admin`)
}
})
test('the full house registry stays off the kind map (owner/price are staff-only)', () => {
assert.equal(visibility.KIND_FEATURE.has('house.update'), false)
assert.equal(visibility.KIND_FEATURE.has('house.remove'), false)
// house.decay — the IDOC signal the public page renders — IS mapped.
assert.equal(visibility.KIND_FEATURE.get('house.decay'), 'houses')
})
test('vendor.sale is not public (sales are owner-private)', async () => {
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('vendor.sale', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.sale'), false)
})
// ── Rule 3: defaults reproduce pre-v3 behavior ─────────────────────────────
// The exact allowlist that shipped in shardBroadcast.js before v3. If a change
// makes the derived PUBLIC_KINDS differ from this, it is a deliberate widening
// or narrowing of what anonymous visitors see and must be reviewed as such.
const PRE_V3_PUBLIC_KINDS = [
'player.death',
'player.murdered',
'mob.killed',
'house.decay',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'mob.login',
'mob.logout',
'economy.supply',
'server.hello',
'server.shutdown',
'server.crashed',
'champ.update',
'champ.remove',
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
]
// The kinds v3 deliberately ADDS to the anonymous set. vendor.listing is
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
// already anonymous, so they join the public set — carrying character names and
// serials, never acct/webId, which the locked-field rules strip by suffix even
// inside the roster's member array (see the roster test above).
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
)
})
test('no pre-v3 public kind was dropped', () => {
for (const kind of PRE_V3_PUBLIC_KINDS) {
assert.equal(visibility.PUBLIC_KINDS.has(kind), true, `${kind} fell out of the public set`)
}
})
test('the market stream is off by default but its REST feature is not', async () => {
const config = await visibility.getConfig()
assert.equal(config.market.enabled, true)
assert.equal(config.market.audience, 'anonymous')
assert.equal(config.market.stream, false)
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
})
test('presence location defaults to staff, matching the old admin/moderator gate', async () => {
const config = await visibility.getConfig()
assert.equal(config.presence.fields.location, 'staff')
assert.equal(visibility.meets('player', 'staff'), false)
assert.equal(visibility.meets('staff', 'staff'), true)
})
test('every mapped kind names a real feature', () => {
for (const [kind, feature] of visibility.KIND_FEATURE) {
assert.equal(visibility.isFeature(feature), true, `${kind} → unknown feature ${feature}`)
}
})
// ── Config merge ───────────────────────────────────────────────────────────
test('a disabled feature is invisible to everyone below admin', async () => {
withRows([{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal(config.champs.enabled, false)
assert.equal(visibility.kindVisibleTo('champ.update', 'anonymous', config), false)
assert.equal(visibility.kindVisibleTo('champ.update', 'staff', config), false)
assert.equal(visibility.visibleFeatures('staff', config).includes('champs'), false)
})
test('raising a feature audience gates the lower rungs out', async () => {
withRows([{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('guild.update', 'anonymous', config), false)
assert.equal(visibility.kindVisibleTo('guild.update', 'logged_in', config), false)
assert.equal(visibility.kindVisibleTo('guild.update', 'player', config), true)
assert.equal(visibility.kindVisibleTo('guild.update', 'staff', config), true)
})
test('an unknown stored feature name is ignored, not resurrected', async () => {
withRows([{ feature: 'sekrit', enabled: true, audience: 'anonymous', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal('sekrit' in config, false)
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
})
test('an invalid stored rung falls back to the default rather than failing open', async () => {
withRows([{ feature: 'houses', enabled: true, audience: 'nonsense', stream: true, fieldRules: { owner: 'nonsense' } }])
const config = await visibility.getConfig()
assert.equal(config.houses.audience, 'anonymous') // the compiled default
assert.equal(config.houses.fields.owner, 'staff') // the compiled default
})
test('a DB failure degrades to compiled defaults, not to everything-public', async () => {
model.listAll = async () => {
throw new Error('db down')
}
visibility.invalidate()
const config = await visibility.getConfig()
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
assert.equal(config.presence.fields.location, 'staff')
assert.equal(visibility.kindVisibleTo('audit.command', 'anonymous', config), false)
})
// ── Viewer level ───────────────────────────────────────────────────────────
test('viewerLevel resolves the ladder from role and link status', async () => {
shardLinks.listForUser = async () => []
assert.equal(await visibility.viewerLevel({}), 'anonymous')
visibility.forgetUser(1)
assert.equal(await visibility.viewerLevel({ user: { id: 1, role: 'admin' } }), 'admin')
visibility.forgetUser(2)
assert.equal(await visibility.viewerLevel({ user: { id: 2, role: 'moderator' } }), 'staff')
// A member with no linked game account sits at logged_in...
visibility.forgetUser(3)
assert.equal(await visibility.viewerLevel({ user: { id: 3, role: 'player' } }), 'logged_in')
// ...and reaches `player` once a link exists.
shardLinks.listForUser = async () => [{ account: 'whitlocktech' }]
visibility.forgetUser(4)
assert.equal(await visibility.viewerLevel({ user: { id: 4, role: 'player' } }), 'player')
})
test('editor is a content role and gets no shard privilege', async () => {
// Mapping editor to `staff` here would silently widen what editors can see;
// today's modAccess gate is admin|moderator only.
shardLinks.listForUser = async () => []
visibility.forgetUser(5)
assert.equal(await visibility.viewerLevel({ user: { id: 5, role: 'editor' } }), 'logged_in')
})
test('a link lookup failure downgrades rather than escalating', async () => {
shardLinks.listForUser = async () => {
throw new Error('db down')
}
visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
})