Files
website/server/test/shardControllerPublic.test.js
wtclaude 26094459ae feat(shard): ingest points.board and publish the leaderboards
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>
2026-07-28 21:04:44 -05:00

413 lines
16 KiB
JavaScript

// 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.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
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('../src/router/v1/public/shard.controller')
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
const shardState = require('../src/model/shardState/shardState.model')
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../src/utils/shardBroadcast')
const visibility = require('../src/utils/shardVisibility')
const db = require('../src/utils/db')
after(() => db.close())
// 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('../src/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')
})