feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.
Closes a live leak. BridgeJson.Actor() writes acct and webId;
shapeGuild() returned the stored payload verbatim; GET
/api/v1/public/shard/guilds is anonymous. Guild leaders' game account
names and website user ids were readable by anyone, and the same path
existed for governors. Both are now projected.
The ladder is anonymous < logged_in < player < staff < admin, each rung
implying the ones below. Staff satisfy `player` without a linked account
(as /player/* already does); `editor` is a content role and gets no
shard privilege, since mapping it to staff would silently widen what
editors see.
Two invariants are code, not configuration, and both reject rather than
silently ignore:
1. acct/webId are admin-only always - not configurable, discarded on
read as well as rejected on write.
2. A kind absent from KIND_FEATURE never reaches anyone below admin.
Fail closed, so a shard emitting a new event degrades to staff-only
rather than to public.
Enforcement is three points over one config: requireFeature() on routes
(404 disabled, 403 out-of-rung) plus field projection; per-connection
filtering on SSE, where a subscriber's rung is resolved once at subscribe
time and frozen so a long-open stream cannot gain privilege; and
/public/shard/features so the SPA hides links it cannot follow.
PUBLIC_KINDS still exists and is still exported (/feed filtering,
notificationStreams) but is now derived from the kind map, so the two
can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a
test pins the derived set against the old allowlist.
Also fixes an SSE resource leak found while testing: a client dropped
because its write threw was removed from the bucket but its keepalive
interval was never cleared, firing forever on a dead socket. Both paths
now go through one drop().
Tests: 478 server (33 new across shardVisibility + shardBroadcast),
43 client. Route manifest and OpenAPI spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
225
server/test/shardBroadcast.visibility.test.js
Normal file
225
server/test/shardBroadcast.visibility.test.js
Normal file
@@ -0,0 +1,225 @@
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
|
||||
// The SSE fan-out is the security boundary (docs/link/v3.md §3.6). Before v3 it
|
||||
// was a static kind allowlist; now each subscriber carries the audience rung it
|
||||
// resolved to at subscribe time, and every frame is gated + field-projected per
|
||||
// viewer. These tests pin the properties that must hold no matter how the config
|
||||
// is set:
|
||||
//
|
||||
// - the admin channel always gets the frame verbatim;
|
||||
// - a public subscriber never receives an unmapped kind;
|
||||
// - acct / webId never reach a public subscriber, at any rung below admin;
|
||||
// - two subscribers at different rungs get different frames from one event;
|
||||
// - a viewer's rung is frozen at subscribe time, not re-read per frame;
|
||||
// - if the visibility config can't be read, nothing goes out on public.
|
||||
|
||||
const broadcast = require('../src/utils/shardBroadcast')
|
||||
const visibility = require('../src/utils/shardVisibility')
|
||||
const model = require('../src/model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = {
|
||||
listAll: model.listAll,
|
||||
listForUser: shardLinks.listForUser,
|
||||
getConfig: visibility.getConfig,
|
||||
viewerLevel: visibility.viewerLevel,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
model.listAll = async () => []
|
||||
shardLinks.listForUser = async () => []
|
||||
visibility.invalidate()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
broadcast.closeAll()
|
||||
model.listAll = originals.listAll
|
||||
shardLinks.listForUser = originals.listForUser
|
||||
visibility.getConfig = originals.getConfig
|
||||
visibility.viewerLevel = originals.viewerLevel
|
||||
visibility.invalidate()
|
||||
})
|
||||
|
||||
// A fake req/res pair that records everything written to the stream.
|
||||
function fakeClient() {
|
||||
const req = new EventEmitter()
|
||||
const writes = []
|
||||
const res = {
|
||||
writeHead() {},
|
||||
write(chunk) {
|
||||
writes.push(chunk)
|
||||
},
|
||||
end() {},
|
||||
on() {},
|
||||
}
|
||||
// Frames only — drop the SSE comments/retry preamble and keepalive pings.
|
||||
const frames = () =>
|
||||
writes
|
||||
.filter((w) => w.startsWith('data: '))
|
||||
.map((w) => JSON.parse(w.slice('data: '.length).trim()))
|
||||
return { req, res, frames }
|
||||
}
|
||||
|
||||
async function subscribeAt(level, channel = 'public') {
|
||||
const client = fakeClient()
|
||||
visibility.viewerLevel = async () => level
|
||||
await broadcast.subscribe(client.req, client.res, channel)
|
||||
return client
|
||||
}
|
||||
|
||||
const GUILD_FRAME = {
|
||||
kind: 'guild.update',
|
||||
id: 7,
|
||||
name: 'The Nameless',
|
||||
abbr: 'TN',
|
||||
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
|
||||
}
|
||||
|
||||
test('the admin channel receives the frame verbatim, acct and webId included', async () => {
|
||||
const admin = await subscribeAt('admin', 'admin')
|
||||
await broadcast.broadcast(GUILD_FRAME)
|
||||
const [frame] = admin.frames()
|
||||
assert.deepEqual(frame, GUILD_FRAME)
|
||||
})
|
||||
|
||||
test('a public subscriber never sees acct or webId, at any rung below admin', async () => {
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const client = await subscribeAt(level)
|
||||
await broadcast.broadcast(GUILD_FRAME)
|
||||
const [frame] = client.frames()
|
||||
assert.ok(frame, `${level} should receive the guild frame`)
|
||||
assert.equal(frame.leader.name, 'Darrow')
|
||||
assert.equal('acct' in frame.leader, false, `${level} must not see acct`)
|
||||
assert.equal('webId' in frame.leader, false, `${level} must not see webId`)
|
||||
broadcast.closeAll()
|
||||
}
|
||||
})
|
||||
|
||||
test('an unmapped kind reaches the admin channel and nobody else', async () => {
|
||||
const anon = await subscribeAt('anonymous')
|
||||
const staff = await subscribeAt('staff')
|
||||
const admin = await subscribeAt('admin', 'admin')
|
||||
|
||||
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'vendor.sale']) {
|
||||
await broadcast.broadcast({ kind, secret: true })
|
||||
}
|
||||
|
||||
assert.deepEqual(anon.frames(), [])
|
||||
assert.deepEqual(staff.frames(), [])
|
||||
assert.equal(admin.frames().length, 4)
|
||||
})
|
||||
|
||||
test('one event yields different frames for subscribers at different rungs', async () => {
|
||||
model.listAll = async () => [
|
||||
{
|
||||
feature: 'guilds',
|
||||
enabled: true,
|
||||
audience: 'anonymous',
|
||||
stream: true,
|
||||
fieldRules: { abbr: 'staff' },
|
||||
},
|
||||
]
|
||||
visibility.invalidate()
|
||||
|
||||
const anon = await subscribeAt('anonymous')
|
||||
const staff = await subscribeAt('staff')
|
||||
await broadcast.broadcast(GUILD_FRAME)
|
||||
|
||||
assert.equal('abbr' in anon.frames()[0], false)
|
||||
assert.equal(staff.frames()[0].abbr, 'TN')
|
||||
// Both still lose the locked fields.
|
||||
assert.equal('acct' in staff.frames()[0].leader, false)
|
||||
})
|
||||
|
||||
test('raising a feature audience cuts off the lower rungs mid-stream', async () => {
|
||||
const anon = await subscribeAt('anonymous')
|
||||
const player = await subscribeAt('player')
|
||||
|
||||
await broadcast.broadcast(GUILD_FRAME)
|
||||
assert.equal(anon.frames().length, 1)
|
||||
assert.equal(player.frames().length, 1)
|
||||
|
||||
// Config changes DO take effect live — only the viewer's rung is frozen.
|
||||
model.listAll = async () => [
|
||||
{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} },
|
||||
]
|
||||
visibility.invalidate()
|
||||
|
||||
await broadcast.broadcast(GUILD_FRAME)
|
||||
assert.equal(anon.frames().length, 1, 'anonymous stops receiving')
|
||||
assert.equal(player.frames().length, 2, 'player keeps receiving')
|
||||
})
|
||||
|
||||
test("a subscriber's rung is frozen at subscribe time", async () => {
|
||||
const client = await subscribeAt('anonymous')
|
||||
// Even if the resolver would now say "admin", the open connection must not
|
||||
// gain privilege — its level was captured when it subscribed.
|
||||
visibility.viewerLevel = async () => 'admin'
|
||||
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
|
||||
assert.deepEqual(client.frames(), [])
|
||||
})
|
||||
|
||||
test('an unresolvable viewer subscribes as anonymous, not as privileged', async () => {
|
||||
const client = fakeClient()
|
||||
visibility.viewerLevel = async () => {
|
||||
throw new Error('session lookup exploded')
|
||||
}
|
||||
await broadcast.subscribe(client.req, client.res, 'public')
|
||||
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
|
||||
assert.deepEqual(client.frames(), [])
|
||||
|
||||
// ...but it still receives ordinary public traffic.
|
||||
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
|
||||
assert.equal(client.frames().length, 1)
|
||||
})
|
||||
|
||||
test('an unreadable visibility config withholds every public frame', async () => {
|
||||
const client = await subscribeAt('anonymous')
|
||||
visibility.getConfig = async () => {
|
||||
throw new Error('db down')
|
||||
}
|
||||
await broadcast.broadcast(GUILD_FRAME)
|
||||
assert.deepEqual(client.frames(), [])
|
||||
})
|
||||
|
||||
test('a disabled feature stops its kinds without touching others', async () => {
|
||||
model.listAll = async () => [
|
||||
{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} },
|
||||
]
|
||||
visibility.invalidate()
|
||||
|
||||
const client = await subscribeAt('anonymous')
|
||||
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
|
||||
await broadcast.broadcast({ kind: 'guild.update', id: 7 })
|
||||
|
||||
const kinds = client.frames().map((f) => f.kind)
|
||||
assert.deepEqual(kinds, ['guild.update'])
|
||||
})
|
||||
|
||||
test('a dead client is dropped rather than repeatedly retried', async () => {
|
||||
const client = fakeClient()
|
||||
visibility.viewerLevel = async () => 'anonymous'
|
||||
await broadcast.subscribe(client.req, client.res, 'public')
|
||||
assert.equal(broadcast.stats().publicClients, 1)
|
||||
|
||||
client.res.write = () => {
|
||||
throw new Error('EPIPE')
|
||||
}
|
||||
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
|
||||
assert.equal(broadcast.stats().publicClients, 0)
|
||||
})
|
||||
|
||||
test('broadcast is a no-op for a malformed event', async () => {
|
||||
const client = await subscribeAt('anonymous')
|
||||
await broadcast.broadcast(null)
|
||||
await broadcast.broadcast({})
|
||||
assert.deepEqual(client.frames(), [])
|
||||
})
|
||||
319
server/test/shardVisibility.test.js
Normal file
319
server/test/shardVisibility.test.js
Normal file
@@ -0,0 +1,319 @@
|
||||
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
||||
// Every DB call this suite would make is monkeypatched.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/utils/shardVisibility')
|
||||
const model = require('../src/model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
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('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('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']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
|
||||
assert.deepEqual(
|
||||
[...visibility.PUBLIC_KINDS].sort(),
|
||||
[...PRE_V3_PUBLIC_KINDS, ...V3_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')
|
||||
})
|
||||
Reference in New Issue
Block a user