Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.
Server
- shard_points_boards: one row per system, keyed by the shard's PointsType
name. The top-N list stays inside `payload` — a fixed-size list read whole,
exactly like shard_governors.candidates. Normalizing into an entries table
buys nothing until something needs a per-character reverse lookup, and a
character's own standings already ride inside char.profile.
- shardIngest routes points.board to upsertPointsBoard and deliberately does
NOT log it: this is board state like guild.update, and the shard emits a
frame every time anyone's score moves a top ten.
- uoLinkSocket backfills /points through snapshot() with ingestEach rather
than a replace*: there is no points.remove and the system set is fixed, so
upserting IS the reconciliation, and a system the operator later excludes
keeps its last-known board rather than vanishing.
- GET /public/shard/points and /points/:system behind
requireFeature('leaderboards'), both projected per §3.6.1. :system is
constrained to an identifier before any query runs; 404 for a system never
published, distinct from a published board nobody has scored in (200, empty
top).
The leaderboards field rule now keys on `name`, not `characterName`
Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
projectValue matches on the LITERAL JSON key and the wire key is `name`. As
written the rule was inert: an admin tightening character names would have got
no enforcement and no error — precisely the failure §3.6.1 records for the
flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
back, and the admin panel's FIELD_LABEL carries the meaning instead.
Client
- routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
describes ONE system, so live frames merge over the fetched set by system
key rather than replacing it wholesale the way the ruleset does. Filter
matches board name, system key, or any ranked player — the last is what
makes it useful ("where do I appear?").
- A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
PlayerCharacter and AdminCharacter.
- Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
system key when nameString is null. Neither is defensive padding: on a real
shard uncapped and cliloc-only names are the majority case.
Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.
605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
424 lines
19 KiB
JavaScript
424 lines
19 KiB
JavaScript
// 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('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']
|
|
|
|
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')
|
|
})
|