diff --git a/server/scripts/checkImports.js b/server/scripts/checkImports.js index 958c67c..b24aa4c 100644 --- a/server/scripts/checkImports.js +++ b/server/scripts/checkImports.js @@ -45,11 +45,18 @@ const { isBuiltin } = require('module') const MODULE_ROOT = path.resolve(__dirname, '..', '..') const SERVER_ROOT = path.join(MODULE_ROOT, 'server') -// Dependencies this half is allowed to resolve for itself. Empty, and that is -// the design: everything the server half needs comes from `ctx` (§2.3). A new -// entry here is a real decision — it becomes a package an operator's install -// has to carry — so it should be argued for in a PR, not added in passing. -const ALLOWED_PACKAGES = new Set([]) +// Packages the SHIPPED half may resolve for itself: this package's declared +// `dependencies`, and nothing else. Read from package.json rather than listed +// here, so adding one is a visible, reviewable edit to the manifest that also +// changes what CI installs and what the release tarball carries. +// +// Adding a dependency is a real decision. §2.7 permits a module its own, and the +// release tarball carries `server/node_modules` because an operator never builds +// — so every entry is weight in the artifact and a package the operator's +// deployment now runs. Anything core already owns must come from `ctx` instead: +// a second express is a second Router prototype, a second express-rate-limit is +// a second store, and a limit enforced by two independent counters is not the +// limit either of them states. const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git']) @@ -58,9 +65,9 @@ const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git']) const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')] const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep)) -const devDependencies = new Set( - Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).devDependencies || {}), -) +const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')) +const dependencies = new Set(Object.keys(manifest.dependencies || {})) +const devDependencies = new Set(Object.keys(manifest.devDependencies || {})) // `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would // need a dependency, and a specifier this pattern misses is a specifier written @@ -135,7 +142,7 @@ function* walk(dir) { * has never been shown to fail is a check nobody knows the state of — and this * one guards the acceptance criterion for the whole contract. */ -function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDependencies } = {}) { +function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) { const violations = [] for (const file of walk(root)) { const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8')) @@ -151,7 +158,7 @@ function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDe const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0] - const allowed = ALLOWED_PACKAGES.has(pkg) || (!shipped(file) && dev.has(pkg)) + const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg)) // The `node:` prefix can only ever name a builtin, so it never reaches // node_modules and is safe whatever this Node version enumerates. const builtin = isBuiltin(specifier) || specifier.startsWith('node:') diff --git a/server/test/_fakes.js b/server/test/_fakes.js index a971e05..3f13c62 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -28,6 +28,10 @@ function fakeLog() { } function fakeCtx(overrides = {}) { + // `freeze: false` is for _setup.js, which installs one process-wide ctx a test + // may adjust. Core always freezes; the unfrozen variant is a test seam and + // never a claim about what a module is handed in production. + const { freeze = true, ...rest } = overrides const logs = [] const ctx = { moduleId: 'uo', @@ -50,10 +54,19 @@ function fakeCtx(overrides = {}) { siteMode: (req, res, next) => next(), validate: (req, res, next) => next(), noindex: (req, res, next) => next(), + // API 1.1.0. The factory returns a pass-through rather than a real + // limiter: a test that tripped a rate limit would be a test whose result + // depended on how many times the suite had run. + rateLimit: (options) => Object.assign((req, res, next) => next(), { options }), + accountChangeLimiter: (req, res, next) => next(), }, uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} }, posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) }, - ...overrides, + // The three §2.3 members API 1.1.0 added for this extraction. + activity: { log: spy(Promise.resolve()) }, + users: { getById: spy(Promise.resolve(null)) }, + site: { baseUrl: 'http://localhost:5173' }, + ...rest, } // Non-enumerable, and that is not tidiness. Core freezes every object value on // `ctx` one level deep, so an enumerable recorder hung off it would be frozen @@ -62,6 +75,7 @@ function fakeCtx(overrides = {}) { // faithful: a module iterating `ctx` sees exactly §2.3's members and nothing // a test put there. Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false }) + if (!freeze) return ctx for (const value of Object.values(ctx)) { if (value && typeof value === 'object') Object.freeze(value) } diff --git a/server/test/_helper.js b/server/test/_helper.js new file mode 100644 index 0000000..b61aa1e --- /dev/null +++ b/server/test/_helper.js @@ -0,0 +1,32 @@ +// Test helper: start a throwaway Express app on an ephemeral port and return its +// base URL + a close(). Uses the built-in fetch (Node 18+) so tests need no +// extra HTTP dependency. Tests here exercise middleware in isolation and do NOT +// touch the database. +// The module takes express off ctx in shipped code; a test may resolve it +// directly, because test/ never runs inside core's process (checkImports.js +// allows devDependencies there). Same express either way — this repo pins the +// version core declares. +const express = require('express') + +async function startApp(configure) { + const app = express() + app.use(express.json()) + configure(app) + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + const { port } = server.address() + return { + url: `http://127.0.0.1:${port}`, + // `server.close()` stops accepting and waits for open connections to end on + // their own — and node's global fetch keeps its sockets alive, so nothing + // ever ends them. The listener then outlives the test that made it, which + // used to be invisible because the pool held the process open anyway. + close: () => new Promise((resolve) => { + server.closeAllConnections() + server.close(resolve) + }), + } +} + +module.exports = { startApp } diff --git a/server/test/_setup.js b/server/test/_setup.js new file mode 100644 index 0000000..f3fc1dc --- /dev/null +++ b/server/test/_setup.js @@ -0,0 +1,27 @@ +// Initialise `core` once, before any test file is required. +// +// Loaded via `node --test --require ./test/_setup.js`, the same arrangement +// core's own suite uses. It exists because of the one structural difference +// between testing this module and testing the code when it lived in core: +// nothing here can be stubbed by monkey-patching a core module, because there +// are no core modules to patch. `require('../utils/db')` does not exist. What a +// test controls instead is the `ctx` core would have handed over — which is a +// better seam anyway, since it is exactly the surface the contract promises and +// nothing wider. +// +// The ctx installed here is deliberately NOT frozen. Core freezes what it hands +// a module, and `entry.test.js` asserts the module behaves against a frozen one; +// but a test that needs `settings.get` to return a particular value has to be +// able to say so, and a frozen ctx would mean re-initialising core per test. The +// mutable copy is a test seam, not a claim about what core does. + +const core = require('../core') +const { fakeCtx } = require('./_fakes') + +const ctx = fakeCtx({ freeze: false }) +core.init(ctx) + +// Exposed so a test can reach the same object it is running against, e.g. +// `testCtx.settings.get = async () => '/srv/uo'`. There is one ctx per process, +// as there is in core, so a test that changes a member should put it back. +module.exports = { ctx } diff --git a/server/test/adminUserShard.test.js b/server/test/adminUserShard.test.js new file mode 100644 index 0000000..536707c --- /dev/null +++ b/server/test/adminUserShard.test.js @@ -0,0 +1,154 @@ +// 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 the pool, +// so any stray query fails fast instead of hanging the runner. These tests stub +// every model method the controller touches, so the DB is never actually hit. + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const ctrl = require('../router/admin/usersShard.controller') +const { ctx } = require('./_setup') +const users = ctx.users +const shardLinks = require('../model/shardLinks/shardLinks.model') +const shardState = require('../model/shardState/shardState.model') +const shardEvents = require('../model/shardEvents/shardEvents.model') +const { salesForAccounts } = require('../utils/shardSales') + + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +// Save/restore the originals so each test's monkeypatches don't leak. +const originals = { + getById: users.getById, + listForUser: shardLinks.listForUser, + listHousesForAccounts: shardState.listHousesForAccounts, + listOnlineForAccounts: shardState.listOnlineForAccounts, + eventsList: shardEvents.list, +} +afterEach(() => { + users.getById = originals.getById + shardLinks.listForUser = originals.listForUser + shardState.listHousesForAccounts = originals.listHousesForAccounts + shardState.listOnlineForAccounts = originals.listOnlineForAccounts + shardEvents.list = originals.eventsList +}) + +// ── salesForAccounts util ────────────────────────────────────────────────── +test('salesForAccounts returns [] for an empty account set without hitting the log', async () => { + let called = false + shardEvents.list = async () => { + called = true + return [] + } + assert.deepEqual(await salesForAccounts([]), []) + assert.equal(called, false) +}) + +test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => { + const events = [] + // 60 sales owned by "mine", plus some owned by "other". + for (let i = 0; i < 60; i++) { + events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } }) + } + events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } }) + shardEvents.list = async () => events + + const rows = await salesForAccounts(['mine']) + assert.equal(rows.length, 50) // capped + assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other" + assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't']) +}) + +// ── Controller: unknown user → 404 ───────────────────────────────────────── +for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) { + test(`${handler} returns 404 when the user does not exist`, async () => { + users.getById = async () => null + const res = mockRes() + await ctrl[handler]({ params: { id: '404' } }, res) + assert.equal(res.statusCode, 404) + }) +} + +// ── Controller: scoping to the user's accounts ───────────────────────────── +test('listAccounts returns the user’s linked accounts', async () => { + users.getById = async () => ({ id: 7, username: 'bob', role: 'player' }) + shardLinks.listForUser = async (id) => { + assert.equal(id, 7) + return [{ account: 'acctA' }, { account: 'acctB' }] + } + const res = mockRes() + await ctrl.listAccounts({ params: { id: '7' } }, res) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }]) +}) + +test('getHouses passes exactly the user’s accounts to the model', async () => { + users.getById = async () => ({ id: 7 }) + shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }] + let received = null + shardState.listHousesForAccounts = async (accounts) => { + received = accounts + return [{ serial: '0x1', isIdoc: true }] + } + const res = mockRes() + await ctrl.getHouses({ params: { id: '7' } }, res) + assert.deepEqual(received, ['acctA', 'acctB']) + assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }]) +}) + +test('getOnline passes exactly the user’s accounts to the model', async () => { + users.getById = async () => ({ id: 7 }) + shardLinks.listForUser = async () => [{ account: 'acctA' }] + let received = null + shardState.listOnlineForAccounts = async (accounts) => { + received = accounts + return [{ serial: '0x2', name: 'Zoe' }] + } + const res = mockRes() + await ctrl.getOnline({ params: { id: '7' } }, res) + assert.deepEqual(received, ['acctA']) + assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }]) +}) + +test('a user with no linked accounts yields empty sales/houses/online', async () => { + users.getById = async () => ({ id: 7 }) + shardLinks.listForUser = async () => [] + shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : []) + shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : []) + shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }] + + const sales = mockRes() + const houses = mockRes() + const online = mockRes() + await ctrl.getSales({ params: { id: '7' } }, sales) + await ctrl.getHouses({ params: { id: '7' } }, houses) + await ctrl.getOnline({ params: { id: '7' } }, online) + + assert.deepEqual(sales.body, []) + assert.deepEqual(houses.body, []) + assert.deepEqual(online.body, []) +}) + +// getUser is NOT here any more: reading a user is core semantics that had ended +// up in this controller by proximity, and PR 4 moved it back to +// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is +// covered by test/adminUsers.test.js. diff --git a/server/test/atlasController.test.js b/server/test/atlasController.test.js new file mode 100644 index 0000000..0ff5bc0 --- /dev/null +++ b/server/test/atlasController.test.js @@ -0,0 +1,242 @@ +// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through +// every moved test: core internals can no longer be stubbed by requiring them, +// because there are none to require — `../utils/db` and `../model/settings` do +// not exist here. What a test controls instead is the `ctx` core would have +// handed over, installed once by `test/_setup.js`, which is the seam the +// contract actually promises. + +// Point the DB at a closed port BEFORE requiring the controllers (their models +// build the pool). Every model call is monkeypatched, so no query runs; +// db.close() at the end releases the pool so the process exits cleanly. + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// The spawn atlas API, public and admin. What is worth asserting here is not the +// SQL (that is the parser suite's job) but the contracts the two surfaces make: +// +// • the public reads project through the visibility framework — v3.md §3.6.1's +// standing rule is that a read path returning shard data and not calling +// projectFeature is a bug, and `atlas` declaring no sensitive fields TODAY is +// exactly why the call has to be there before one does; +// • the public /meta route reports the game world only, never the operator's +// filesystem — the ServUO path, the per-file hashes and any pending refresh +// stay on the admin route; +// • a missing creature is a 404, not an empty 200; +// • an unreadable ServUO tree is a 200 carrying `status: 'unavailable'`, NOT a +// 500. The refresh contract reports outcomes rather than throwing (so boot is +// never blocked by a bad tree), and the admin needs to be told what is wrong +// with their path; +// • a model failure degrades to a 500 rather than a thrown/uncaught error. +const pub = require('../router/public/atlas.controller') +const admin = require('../router/admin/shardAtlas.controller') +const atlas = require('../model/shardAtlas/shardAtlas.model') +const { ctx } = require('./_setup') +const activity = ctx.activity +const visibility = require('../utils/shardVisibility') + + +// Stub the visibility MODEL rather than the util's exports: project() calls the +// module-internal getConfig, which an exports-level stub would not intercept — it +// would hit the closed DB port and cost a ~10s pool timeout per test before +// falling back to these same defaults. +const visibilityModel = require('../model/shardVisibility/shardVisibility.model') +visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults +visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous' + +// The admin controller logs every action; keep it off the DB. +activity.log = async () => {} + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +const originals = { + searchCreatures: atlas.searchCreatures, + getCreature: atlas.getCreature, + listRegions: atlas.listRegions, + listLandmarks: atlas.listLandmarks, + listChampions: atlas.listChampions, + publicMeta: atlas.publicMeta, + status: atlas.status, + refresh: atlas.refresh, + approvePending: atlas.approvePending, + rejectPending: atlas.rejectPending, + setServuoPath: atlas.setServuoPath, +} +afterEach(() => Object.assign(atlas, originals)) + +// ── Public reads ──────────────────────────────────────────────────────── +test('getCreatures passes the search through and returns the page shape', async () => { + let seen = null + atlas.searchCreatures = async (opts) => { + seen = opts + return { total: 1, limit: 50, offset: 0, creatures: [{ slug: 'lizardman', name: 'Lizardman' }] } + } + const res = mockRes() + await pub.getCreatures({ query: { q: ' lizard ', facet: 'Felucca', limit: '10', offset: '20' } }, res) + assert.deepEqual(seen, { q: 'lizard', facet: 'Felucca', limit: 10, offset: 20 }) + assert.equal(res.body.total, 1) + assert.equal(res.body.creatures[0].slug, 'lizardman') +}) + +test('getCreatures falls back to the documented defaults when nothing is passed', async () => { + let seen = null + atlas.searchCreatures = async (opts) => { + seen = opts + return { total: 0, limit: 50, offset: 0, creatures: [] } + } + await pub.getCreatures({ query: {} }, mockRes()) + assert.deepEqual(seen, { q: '', facet: '', limit: 50, offset: 0 }) +}) + +test('an unknown creature is a 404, not an empty 200', async () => { + atlas.getCreature = async () => null + const res = mockRes() + await pub.getCreature({ params: { slug: 'nosuchthing' }, query: {} }, res) + assert.equal(res.statusCode, 404) +}) + +test('getCreature returns places and spawners, and `points` stays the COUNT', async () => { + atlas.getCreature = async () => ({ + slug: 'lizardman', + name: 'Lizardman', + total: 214, + points: 62, + places: [{ facet: 'Trammel', label: 'Shrines', spawners: 7, maxAlive: 21 }], + spawners: [{ id: 1, facet: 'Trammel', label: 'Shrines', x: 1, y: 2 }], + spawnersTruncated: false, + alsoHere: [], + }) + const res = mockRes() + await pub.getCreature({ params: { slug: 'lizardman' }, query: {} }, res) + // The list route uses `points` as a number; the detail route must not quietly + // turn the same key into an array. + assert.equal(typeof res.body.points, 'number') + assert.ok(Array.isArray(res.body.spawners)) + assert.equal(res.body.places[0].label, 'Shrines') +}) + +// ── The projection rule (§3.6.1) ──────────────────────────────────────── +test('public reads run through projectFeature, so a locked field can never survive', async () => { + // `atlas` declares no sensitive fields, so nothing here is stripped by a + // FEATURE rule. acct/webId are stripped anyway — they are locked by meaning, + // for every feature, and this is what proves the read path projects at all. + atlas.searchCreatures = async () => ({ + total: 1, + limit: 50, + offset: 0, + creatures: [{ slug: 'lizardman', name: 'Lizardman', acct: 'someacct', ownerWebId: 7 }], + }) + const res = mockRes() + await pub.getCreatures({ query: {}, viewerLevel: 'anonymous' }, res) + const row = res.body.creatures[0] + assert.equal(row.name, 'Lizardman') + assert.ok(!('acct' in row), 'acct must never reach an anonymous caller') + assert.ok(!('ownerWebId' in row), 'a flattened webId spelling is locked too') +}) + +test('getMeta reports the game world only — never the operator’s filesystem', async () => { + // The model is what enforces this; the assertion documents the boundary so a + // future "just return status() here" shortcut fails loudly. + atlas.publicMeta = async () => ({ + importedAt: '2026-07-28T00:00:00.000Z', + generatedAt: '2026-07-28T00:00:00.000Z', + counts: { points: 6455, creatures: 800 }, + facets: ['Felucca', 'Trammel'], + }) + const res = mockRes() + await pub.getMeta({ query: {} }, res) + assert.deepEqual(Object.keys(res.body).sort(), ['counts', 'facets', 'generatedAt', 'importedAt']) + assert.ok(!('path' in res.body)) + assert.ok(!('pending' in res.body)) +}) + +test('a model failure degrades to a 500 rather than throwing', async () => { + atlas.listChampions = async () => { + throw new Error('table is gone') + } + const res = mockRes() + await pub.getChampions({ query: {} }, res) + assert.equal(res.statusCode, 500) +}) + +// ── Admin ─────────────────────────────────────────────────────────────── +test('an unreadable tree answers 200 with the reason, not a 500', async () => { + atlas.refresh = async () => ({ status: 'unavailable', reason: 'no Spawns directory', path: '/bad' }) + const res = mockRes() + await admin.importAtlas({ body: {}, user: { id: 1 } }, res) + assert.equal(res.statusCode, 200) + assert.equal(res.body.status, 'unavailable') + assert.equal(res.body.reason, 'no Spawns directory') +}) + +test('import passes `force` through and coerces it to a boolean', async () => { + let seen = null + atlas.refresh = async (opts) => { + seen = opts + return { status: 'unchanged' } + } + await admin.importAtlas({ body: { force: true }, user: { id: 1 } }, mockRes()) + assert.deepEqual(seen, { force: true }) +}) + +test('approve applies a staged refresh (facet loss included)', async () => { + let called = false + atlas.approvePending = async () => { + called = true + return { status: 'imported', removedFacets: ['Malas'], counts: { points: 6162 } } + } + const res = mockRes() + await admin.approve({ user: { id: 1 } }, res) + assert.ok(called) + assert.equal(res.body.status, 'imported') +}) + +test('rejecting when nothing is staged is a 404', async () => { + atlas.rejectPending = async () => ({ status: 'none' }) + const res = mockRes() + await admin.reject({ user: { id: 1 } }, res) + assert.equal(res.statusCode, 404) +}) + +test('setPath trims, persists, and answers with fresh status — it does not import', async () => { + let saved = null + let imported = false + atlas.setServuoPath = async (value) => { + saved = value + } + atlas.refresh = async () => { + imported = true + return { status: 'imported' } + } + atlas.status = async () => ({ configured: true, path: '/srv/servuo', treeReadable: true }) + const res = mockRes() + await admin.setPath({ body: { path: ' /srv/servuo ' }, user: { id: 3 } }, res) + assert.equal(saved, '/srv/servuo') + assert.equal(imported, false, 'changing the path must not reload the atlas as a side effect') + assert.equal(res.body.path, '/srv/servuo') +}) + +test('setPath accepts a blank path (clearing it turns the atlas off)', async () => { + let saved = 'unset' + atlas.setServuoPath = async (value) => { + saved = value + } + atlas.status = async () => ({ configured: false, path: '' }) + const res = mockRes() + await admin.setPath({ body: {}, user: { id: 3 } }, res) + assert.equal(saved, '') + assert.equal(res.statusCode, 200) +}) diff --git a/server/test/clilocParse.test.js b/server/test/clilocParse.test.js new file mode 100644 index 0000000..a428a37 --- /dev/null +++ b/server/test/clilocParse.test.js @@ -0,0 +1,227 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { + ClilocFormatError, + parseCliloc, + parseClilocBinary, + parseClilocText, + isCompressedCliloc, + displayText, + isPlaceholderOnly, +} = require('../utils/clilocParse') + +// These parsers are pure and fs-free precisely so this suite can run in CI, +// where there is no UO client and no converted cliloc file. Every fixture below +// is built from the real layout, and the strings are verbatim entries from a +// real Cliloc.enu (123,490 entries) rather than invented ones. + +// ── Fixture builders ─────────────────────────────────────────────────────── + +/** Build a plain-format cliloc buffer: 6-byte header, then records. */ +function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) { + const parts = [Buffer.alloc(6)] + parts[0].writeInt32LE(header1, 0) + parts[0].writeUInt16LE(header2, 4) + for (const e of entries) { + const text = Buffer.from(e.text, 'utf8') + const head = Buffer.alloc(7) + head.writeInt32LE(e.number, 0) + head.writeUInt8(e.flag ?? 0, 4) + head.writeUInt16LE(text.length, 5) + parts.push(head, text) + } + return Buffer.concat(parts) +} + +// ── Binary ───────────────────────────────────────────────────────────────── + +test('parseClilocBinary: reads a plain-format table', () => { + const buf = buildBinary([ + { number: 1015012, text: 'Greater Heal' }, + { number: 1023721, text: 'quarter staff' }, + { number: 1025913, flag: 1, text: 'bonnet' }, + ]) + assert.deepEqual(parseClilocBinary(buf), [ + { number: 1015012, flag: 0, text: 'Greater Heal' }, + { number: 1023721, flag: 0, text: 'quarter staff' }, + { number: 1025913, flag: 1, text: 'bonnet' }, + ]) +}) + +test('parseClilocBinary: length is UNSIGNED 16-bit', () => { + // ServUO's own SDK reads this field into a signed short, which turns any + // string over 32 KB into a negative length. Real tables top out around 12 KB + // so nothing is broken today, but the field is written unsigned and reading it + // that way costs nothing. + const text = 'x'.repeat(40000) + const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }])) + assert.equal(entry.text.length, 40000) +}) + +test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => { + const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }])) + assert.equal(entry.text, 'Ilshenar — Ver Lor Reg') +}) + +test('parseClilocBinary: a truncated record body throws rather than importing short', () => { + // The realistic corruption is a half-copied file. It must fail loudly: a + // silently short table renders as "some items named, some not", which is + // indistinguishable from having no table at all. + const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }]) + const truncated = buf.subarray(0, buf.length - 4) + assert.throws(() => parseClilocBinary(truncated), (err) => { + assert.ok(err instanceof ClilocFormatError) + assert.equal(err.code, 'TRUNCATED') + return true + }) +}) + +test('parseClilocBinary: a truncated record HEADER throws too', () => { + const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)]) + assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED') +}) + +test('parseClilocBinary: an empty table (header only) is valid', () => { + assert.deepEqual(parseClilocBinary(buildBinary([])), []) +}) + +// ── Compressed detection ─────────────────────────────────────────────────── + +test('isCompressedCliloc: recognises the Mythic marker', () => { + // Every cliloc the client ships opens with a DWORD whose high byte is 0x8E. + // Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e). + assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true) + assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true) + assert.equal(isCompressedCliloc(buildBinary([])), false) +}) + +test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => { + // This is the whole reason the marker check exists. Without it the plain + // parser reads compressed bytes as ~19k records of negative ids and 60 KB + // "strings" before dying somewhere in the middle — and the resulting error + // names truncation, which is the wrong problem to hand an operator. + const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)]) + assert.throws(() => parseCliloc(compressed), (err) => { + assert.equal(err.code, 'COMPRESSED') + assert.match(err.message, /CLILOCS\.md/) + return true + }) +}) + +// ── Text ─────────────────────────────────────────────────────────────────── + +test('parseClilocText: tab-delimited, skipping a header row', () => { + const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n') + assert.deepEqual(entries, [ + { number: 1023721, flag: 0, text: 'quarter staff' }, + { number: 1015012, flag: 0, text: 'Greater Heal' }, + ]) +}) + +test('parseClilocText: splits on the FIRST separator only', () => { + // Cliloc text is full of commas. Splitting on all of them would truncate every + // such entry at its first one. + const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n') + assert.equal(entry.text, 'a scroll of magery, unfinished') +}) + +test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => { + const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n') + assert.equal(entry.text, 'a "quarter" staff, plain') +}) + +test('parseClilocText: reads an optional flag column', () => { + const [entry] = parseClilocText('1025913\t1\tbonnet\n') + assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' }) +}) + +test('parseClilocText: text that is itself a number stays the text', () => { + // `number,text` where text is "100" is indistinguishable from `number,flag` + // with an empty text. Keeping it as the text is the safer miss — the other way + // silently deletes a real entry. + const [entry] = parseClilocText('1000000,100\n') + assert.equal(entry.text, '100') +}) + +test('parseClilocText: blank lines and # comments are ignored', () => { + const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n') + assert.equal(entries.length, 1) +}) + +test('parseClilocText: a file with no entries is an error, not an empty table', () => { + assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY') +}) + +test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => { + // `Number('')` is 0, not NaN, so a line that merely starts with a separator + // would otherwise become a bogus cliloc 0. + assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY') +}) + +test('parseClilocText: keeps entries whose text is EMPTY', () => { + // About half of a real table is empty strings (unused ids). They must survive + // parsing — the import layer decides whether to store them, and both input + // formats have to agree on what the file contained. + const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n') + assert.equal(entries.length, 2) + assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' }) +}) + +// ── Sniffing ─────────────────────────────────────────────────────────────── + +test('parseCliloc: sniffs binary vs text from the header, not the extension', () => { + assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff') + assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff') +}) + +test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => { + // The recoverable guess: a mis-sniffed text file says "no entries found", + // while a mis-sniffed binary yields plausible nonsense. + assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY') +}) + +// ── Display ──────────────────────────────────────────────────────────────── + +test('displayText: drops interpolated arguments we never receive', () => { + // The bridge sends a cliloc id, never the property packet that carries the + // arguments, so a name containing them has to be reduced to what is knowable. + assert.equal(displayText('cold damage ~1_val~%'), 'cold damage') + assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the') +}) + +test('displayText: a string that is nothing but arguments resolves to nothing', () => { + assert.equal(displayText('[~1_stuff~]'), '') + assert.equal(isPlaceholderOnly('[~1_stuff~]'), true) + assert.equal(isPlaceholderOnly('quarter staff'), false) +}) + +test('displayText: a trailing % is only stripped when a placeholder was removed', () => { + // "cold damage ~1_val~%" loses its % because that % was the unit belonging to + // the number we never had. A string that genuinely ends in one keeps it. + assert.equal(displayText('50%'), '50%') + assert.equal(displayText('cold damage ~1_val~%'), 'cold damage') +}) + +test('displayText: ordinary names pass through untouched', () => { + assert.equal(displayText('quarter staff'), 'quarter staff') + assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished') + assert.equal(displayText(' spiked collar '), 'spiked collar') +}) + +test('displayText: punctuation is only tidied when a placeholder was removed', () => { + // A shard's custom "Runic Gateway Sigil (v2)" came back as "(v2" while the + // bracket trim was unconditional. A string with no placeholder has no debris + // to clean, so it is left alone apart from whitespace. + assert.equal(displayText('Runic Gateway Sigil (v2)'), 'Runic Gateway Sigil (v2)') + assert.equal(displayText('scroll of power - greater'), 'scroll of power - greater') + assert.equal(displayText('[Companion] Great Dane'), '[Companion] Great Dane') + // …but the debris a placeholder leaves behind is still cleaned. + assert.equal(displayText('[~1_stuff~]'), '') + assert.equal(displayText('cold damage ~1_val~%'), 'cold damage') +}) + +test('displayText: null and undefined are empty, not "null"', () => { + assert.equal(displayText(null), '') + assert.equal(displayText(undefined), '') +}) diff --git a/server/test/clilocSource.test.js b/server/test/clilocSource.test.js new file mode 100644 index 0000000..6daf8b8 --- /dev/null +++ b/server/test/clilocSource.test.js @@ -0,0 +1,194 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { + ClilocSourceError, + CUSTOM_DIR, + resolveBase, + listCustom, + readSources, + hashSources, + sameSources, + missingSources, + readCliloc, +} = require('../utils/clilocSource') + +// The fs layer, exercised against real temp directories rather than mocks — +// the behaviours that matter here (which file wins, what a directory listing +// yields, what happens when one vanishes) are precisely the ones a mock would +// define away. + +function tmpdir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-')) + return dir +} + +const tsv = (entries) => entries.map(([n, t]) => `${n}\t${t}`).join('\n') + '\n' + +function write(dir, name, contents) { + const file = path.join(dir, name) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, contents) + return file +} + +// ── Resolution ───────────────────────────────────────────────────────────── + +test('resolveBase: a directory picks the most specific candidate', () => { + const dir = tmpdir() + // A converted file sitting next to the client's own compressed one must win — + // otherwise pointing at a client folder finds the file that will be rejected. + write(dir, 'cliloc.enu', 'ignored') + write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']])) + assert.equal(path.basename(resolveBase(dir).base), 'clilocs.tsv') +}) + +test('resolveBase: a file path roots overlays at its DIRECTORY', () => { + // An operator who pointed at a file should not have to re-point at its folder + // just to add a custom/ directory beside it. + const dir = tmpdir() + const file = write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + assert.deepEqual(resolveBase(file), { root: dir, base: file }) +}) + +test('resolveBase: a missing path and an empty path are different errors', () => { + assert.throws(() => resolveBase(''), (err) => err.code === 'NO_PATH') + assert.throws(() => resolveBase(path.join(tmpdir(), 'nope')), (err) => err.code === 'NOT_FOUND') +}) + +test('resolveBase: a directory with no cliloc file names what it looked for', () => { + assert.throws(() => resolveBase(tmpdir()), (err) => { + assert.ok(err instanceof ClilocSourceError) + assert.equal(err.code, 'NO_FILE') + assert.match(err.message, /clilocs\.tsv/) + return true + }) +}) + +// ── Overlays ─────────────────────────────────────────────────────────────── + +test('listCustom: no overlay directory is normal, not an error', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + assert.deepEqual(listCustom(dir), []) +}) + +test('listCustom: sorted, and only recognised extensions', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + write(dir, `${CUSTOM_DIR}/b.tsv`, tsv([[2, 'b']])) + write(dir, `${CUSTOM_DIR}/a.csv`, tsv([[3, 'c']])) + write(dir, `${CUSTOM_DIR}/notes.md`, 'ignore me') + assert.deepEqual(listCustom(dir).map((f) => path.basename(f)), ['a.csv', 'b.tsv']) +}) + +test('readSources: base first, then overlays, with root-relative labels', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']])) + const { files } = readSources(dir) + // Forward-slashed so the same directory read on Windows and Linux fingerprints + // identically — otherwise every boot on one of them looks like a change. + assert.deepEqual(files.map((f) => [f.label, f.kind]), [ + ['clilocs.tsv', 'base'], + ['custom/shard.tsv', 'custom'], + ]) +}) + +// ── Merging ──────────────────────────────────────────────────────────────── + +test('readCliloc: an overlay ADDS ids the base never had', () => { + // The whole point: shards add items, and those carry cliloc ids no stock + // client table has. + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']])) + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1180001, 'Runic Gateway Sigil']])) + const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text])) + assert.equal(byNumber.get(1023721), 'quarter staff') + assert.equal(byNumber.get(1180001), 'Runic Gateway Sigil') +}) + +test('readCliloc: an overlay OVERRIDES a stock id', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']])) + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1023721, 'gnarled staff of testing']])) + const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text])) + assert.equal(byNumber.get(1023721), 'gnarled staff of testing') +}) + +test('readCliloc: later overlays beat earlier ones, deterministically', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[7, 'base']])) + write(dir, `${CUSTOM_DIR}/01-first.tsv`, tsv([[7, 'first']])) + write(dir, `${CUSTOM_DIR}/02-second.tsv`, tsv([[7, 'second']])) + const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text])) + assert.equal(byNumber.get(7), 'second') +}) + +test('readCliloc: reports what each source contributed', () => { + // An operator who adds an overlay wants to see it took effect; "overrode: 0" + // on a file meant to re-label stock items says it did not. + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a'], [2, 'b']])) + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'B!'], [3, 'c']])) + const { source } = readCliloc(dir) + assert.deepEqual(source.sources, [ + { label: 'clilocs.tsv', kind: 'base', entries: 2, added: 2, overrode: 0 }, + { label: 'custom/shard.tsv', kind: 'custom', entries: 2, added: 1, overrode: 1 }, + ]) +}) + +test('readCliloc: a malformed overlay names the file it came from', () => { + // "Which of my six overlay files is broken" is otherwise a guessing game. + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + write(dir, `${CUSTOM_DIR}/broken.tsv`, 'no separators here\nnor here\n') + assert.throws(() => readCliloc(dir), (err) => { + assert.equal(err.code, 'EMPTY') + assert.match(err.message, /^custom\/broken\.tsv: /) + return true + }) +}) + +test('readCliloc: a compressed BASE is still rejected by name', () => { + const dir = tmpdir() + write(dir, 'cliloc.enu', Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(32, 0x41)])) + assert.throws(() => readCliloc(dir), (err) => err.code === 'COMPRESSED') +}) + +// ── Drift over the SET ───────────────────────────────────────────────────── + +test('hashSources: fingerprints every source, and counts the overlays', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']])) + const fp = hashSources(dir) + assert.deepEqual(Object.keys(fp.hashes).sort(), ['clilocs.tsv', 'custom/shard.tsv']) + assert.equal(fp.customCount, 1) +}) + +test('sameSources: adding or editing an overlay counts as drift', () => { + const dir = tmpdir() + write(dir, 'clilocs.tsv', tsv([[1, 'a']])) + const before = hashSources(dir).hashes + + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']])) + const added = hashSources(dir).hashes + assert.equal(sameSources(before, added), false, 'a new overlay is drift') + + write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b changed']])) + const edited = hashSources(dir).hashes + assert.equal(sameSources(added, edited), false, 'an edited overlay is drift') + assert.equal(sameSources(edited, hashSources(dir).hashes), true, 'an untouched set is not') +}) + +test('missingSources: a vanished source is detected, an added one is not "missing"', () => { + const loaded = { 'clilocs.tsv': 'aaa', 'custom/shard.tsv': 'bbb' } + assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, loaded), ['custom/shard.tsv']) + assert.deepEqual(missingSources({ ...loaded, 'custom/new.tsv': 'ccc' }, loaded), []) + // Nothing loaded yet (a first import) is not a vanished source. + assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, null), []) +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index f54f728..cc4ee2d 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -30,14 +30,59 @@ test('touches no database at registration time', () => { assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database') }) -test('registers nothing in slice 0', () => { +test('registers exactly what module.json declares', () => { + // The loader compares these two in BOTH directions and rejects a mismatch + // either way, so a prefix registered without being declared and a prefix + // declared without being registered are both module-breaking. Asserting + // against the manifest rather than a literal list means the test cannot drift + // from the file core actually reads. const api = fakeApi() register(fakeCtx(), api) - assert.strictEqual(api.record.routes, null) - assert.strictEqual(api.record.streams, null) - assert.deepStrictEqual(api.record.extensions, []) - assert.deepStrictEqual(api.record.legs, []) - assert.deepStrictEqual(api.record.hooks, {}) + + const manifest = require('../../module.json') + for (const tier of ['public', 'admin', 'player']) { + assert.deepStrictEqual( + Object.keys(api.record.routes[tier]).sort(), + [...manifest.mounts[tier]].sort(), + `${tier} mounts disagree with module.json`, + ) + for (const router of Object.values(api.record.routes[tier])) { + assert.strictEqual(typeof router, 'function', `${tier} router is not a router`) + } + } + + assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions) + assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier']) + assert.ok(api.record.streams.length > 0) + assert.strictEqual(typeof api.record.hooks.onBoot, 'function') + assert.strictEqual(typeof api.record.hooks.onShutdown, 'function') +}) + +test('every registered stream is namespaced or grandfathered', () => { + // Core rejects a stream id that carries neither this module's prefix nor a + // §6.5 grandfathered name. The seven legacy ids are stored in + // `notification_subs` and read by a shipped Android client, so they are + // allowlisted rather than renamed — but a NEW id must be namespaced, and this + // is where that is caught before an install refuses to load the module. + // Copied from core's loader (LEGACY_STREAM_IDS), deliberately rather than + // imported — this repo has no dependency on core's source, and a copy that + // drifts is caught by the module failing to load, which is the failure this + // test exists to move earlier. + const GRANDFATHERED = new Set([ + 'server.status', 'idoc.warning', 'champ.start', 'governor.election', + 'vendor.sale', 'house.idoc', 'account.login', + ]) + const api = fakeApi() + register(fakeCtx(), api) + for (const s of api.record.streams) { + assert.ok( + s.id.startsWith('uo.') || GRANDFATHERED.has(s.id), + `stream "${s.id}" is neither namespaced "uo." nor grandfathered`, + ) + assert.ok(s.label && s.description, `stream "${s.id}" is missing its wire shape`) + assert.strictEqual(typeof s.personal, 'boolean') + assert.strictEqual(typeof s.requiresLinkedAccount, 'boolean') + } }) test('takes a frozen ctx and does not try to write to it', () => { diff --git a/server/test/manifest.test.js b/server/test/manifest.test.js index 61697d3..557b483 100644 --- a/server/test/manifest.test.js +++ b/server/test/manifest.test.js @@ -71,11 +71,17 @@ test('declared mounts are single lowercase segments in known tiers', () => { } }) -test('notification stream ids and announce legs stay namespaced or grandfathered', () => { - // Nothing to check yet — slice 0 registers neither. The assertion that matters - // is that the manifest does not quietly claim capabilities the module does not - // serve, since `GET /api/v1/public/modules` publishes them to clients. - assert.deepStrictEqual(manifest.capabilities || [], []) - assert.deepStrictEqual(manifest.mounts || {}, {}) - assert.deepStrictEqual(manifest.extensions || [], []) +test('the manifest claims a coherent bundle', () => { + // `capabilities` is published by GET /api/v1/public/modules and is what a + // client feature-detects against — the SPA and the Android app both read it — + // so it must not claim something the module does not serve. Checked as a shape + // rather than a list: which capabilities exist is a product decision, that + // they are non-empty opaque strings is the contract. + for (const c of manifest.capabilities || []) { + assert.match(c, /^[a-z][a-z0-9-]*$/, `capability "${c}" is not an opaque lowercase id`) + } + // Declaring a mount is what makes the prefix this module's; the loader checks + // the declaration against what register() actually registers (entry.test.js). + assert.ok(Object.keys(manifest.mounts).length > 0, 'a module that mounts nothing serves nothing') + assert.deepStrictEqual(manifest.extensions, ['admin.users.detail']) }) diff --git a/server/test/newsGump.test.js b/server/test/newsGump.test.js new file mode 100644 index 0000000..b25e265 --- /dev/null +++ b/server/test/newsGump.test.js @@ -0,0 +1,77 @@ +// 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. + +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Exercise the News-gump sync decisions against a fake sidecar client by +// monkeypatching the shared modules newsGump require()s (same instance) — no DB, +// no network. +const uoLinkClient = require('../utils/uoLinkClient') +const { ctx } = require('./_setup') +const settings = ctx.settings +const newsGump = require('../utils/newsGump') + +let calls +const saved = {} + +beforeEach(() => { + calls = { post: [], del: [] } + saved.postNews = uoLinkClient.postNews + saved.deleteNews = uoLinkClient.deleteNews + saved.get = settings.get + uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } } + uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } } + settings.get = async () => null // no gump image configured +}) + +afterEach(() => { + uoLinkClient.postNews = saved.postNews + uoLinkClient.deleteNews = saved.deleteNews + settings.get = saved.get +}) + +const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over }) + +test('buildArticle centres the title, links the news list, and respects announce', async () => { + const a = await newsGump.buildArticle(newsPost(), { announce: false }) + assert.equal(a.id, '42') + assert.match(a.body, /
Double XP Weekend<\/CENTER>/) + assert.match(a.body, /Starts Friday\./) + assert.match(a.url, /\/site\/news$/) + assert.equal(a.announce, false) +}) + +test('a fresh publish into news pushes with announce=true', async () => { + await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 1) + assert.equal(calls.post[0].announce, true) + assert.equal(calls.del.length, 0) +}) + +test('an edit of already-published news refreshes silently (announce=false)', async () => { + await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true }) + assert.equal(calls.post.length, 1) + assert.equal(calls.post[0].announce, false) +}) + +test('unpublishing published news pulls the article from the gump', async () => { + await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true }) + assert.equal(calls.post.length, 0) + assert.deepEqual(calls.del, ['42']) +}) + +test('a draft never-published news post does nothing', async () => { + await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 0) + assert.equal(calls.del.length, 0) +}) + +test('a non-news post (e.g. screenshot) is never pushed', async () => { + await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 0) +}) diff --git a/server/test/publicShardOnline.test.js b/server/test/publicShardOnline.test.js new file mode 100644 index 0000000..41c92f8 --- /dev/null +++ b/server/test/publicShardOnline.test.js @@ -0,0 +1,109 @@ +// 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. + +// Staff-location visibility on GET /public/shard/online. The endpoint is +// token-free, so it inspects the caller's session (getUserFromRequest) and only +// includes each staff member's in-game location (map/x/y/z) for admins and +// moderators. Players and the public still see who is online, but not where. +// +// Point the DB at a closed port BEFORE requiring anything that builds the pool, +// so any stray query fails fast instead of hanging. The model + auth are stubbed, +// so the DB is never actually hit. + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const ctrl = require('../router/public/shard.controller') +const shardState = require('../model/shardState/shardState.model') +const { ctx } = require('./_setup') +const auth = ctx.auth + + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +// One online staff member with a location the model would return. +const ONLINE_ROW = { serial: '0x1', name: 'Lady Mod', map: 'Felucca', x: 1495, y: 1628, z: 10 } + +const originals = { + listOnlineLinked: shardState.listOnlineLinked, + getUserFromRequest: auth.getUserFromRequest, +} +afterEach(() => { + shardState.listOnlineLinked = originals.listOnlineLinked + auth.getUserFromRequest = originals.getUserFromRequest +}) + +// Stub the model to return the staff member, and the session to the given viewer. +function setup(viewer) { + shardState.listOnlineLinked = async () => [ONLINE_ROW] + auth.getUserFromRequest = () => viewer +} + +const LOCATION_KEYS = ['map', 'x', 'y', 'z'] + +for (const role of ['admin', 'moderator']) { + test(`getOnline includes location for a ${role}`, async () => { + setup({ id: 1, username: 'staff', role }) + const res = mockRes() + await ctrl.getOnline({}, res) + assert.equal(res.statusCode, 200) + assert.equal(res.body.length, 1) + const entry = res.body[0] + assert.equal(entry.name, 'Lady Mod') + assert.equal(entry.serial, '0x1') + assert.equal(entry.map, 'Felucca') + assert.equal(entry.x, 1495) + assert.equal(entry.y, 1628) + assert.equal(entry.z, 10) + }) +} + +test('getOnline omits location for a logged-in player', async () => { + setup({ id: 2, username: 'joe', role: 'player' }) + const res = mockRes() + await ctrl.getOnline({}, res) + assert.equal(res.statusCode, 200) + const entry = res.body[0] + // Still shows they are online… + assert.equal(entry.name, 'Lady Mod') + assert.equal(entry.serial, '0x1') + // …but the location fields are absent entirely (not null/placeholder). + for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`) +}) + +test('getOnline omits location for an unauthenticated request', async () => { + setup(null) // getUserFromRequest returns null for anon callers + const res = mockRes() + await ctrl.getOnline({}, res) + assert.equal(res.statusCode, 200) + const entry = res.body[0] + assert.equal(entry.name, 'Lady Mod') + assert.equal(entry.serial, '0x1') + for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`) +}) + +// An editor is staff but not admin/moderator — they should not see location. +test('getOnline omits location for an editor', async () => { + setup({ id: 3, username: 'ed', role: 'editor' }) + const res = mockRes() + await ctrl.getOnline({}, res) + const entry = res.body[0] + for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`) +}) diff --git a/server/test/shardBroadcast.visibility.test.js b/server/test/shardBroadcast.visibility.test.js new file mode 100644 index 0000000..d25bd20 --- /dev/null +++ b/server/test/shardBroadcast.visibility.test.js @@ -0,0 +1,227 @@ +// 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. + +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('../utils/shardBroadcast') +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, + 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(), []) +}) diff --git a/server/test/shardControllerPublic.test.js b/server/test/shardControllerPublic.test.js new file mode 100644 index 0000000..ae25f88 --- /dev/null +++ b/server/test/shardControllerPublic.test.js @@ -0,0 +1,415 @@ +// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through +// every moved test: core internals can no longer be stubbed by requiring them, +// because there are none to require — `../utils/db` and `../model/settings` do +// not exist here. What a test controls instead is the `ctx` core would have +// handed over, installed once by `test/_setup.js`, which is the seam the +// contract actually promises. + +// Point the DB at a closed port BEFORE requiring the controller (its models build +// the pool). Every model call is monkeypatched, so no query runs; db.close() at +// the end releases the pool so the process exits cleanly. + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the +// bits that decide what the anonymous public may and may not see: +// - getFeed serves only kinds on the public allowlist (staff audit / cheat / +// login events are stored for the admin channel and must never leak here); +// - getHouses exposes only IDOC houses and only their location — owner, price, +// co-owners and decay detail are staff-only and must be stripped; +// - getStatus assembles the connection/economy summary; +// - a model failure degrades to a 500, never a thrown/uncaught error. +const ctrl = require('../router/public/shard.controller') +const shardEvents = require('../model/shardEvents/shardEvents.model') +const shardState = require('../model/shardState/shardState.model') +const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') +const broadcast = require('../utils/shardBroadcast') +const visibility = require('../utils/shardVisibility') + + +// The controller now resolves the visibility config and the caller's rung on +// every read. Stub the MODEL rather than the util's exports: getConfig() and +// project() call the module-internal getConfig, which an exports-level stub does +// not intercept — it would still hit the closed DB port and cost a ~10s pool +// timeout per test before falling back to these same defaults. +const visibilityModel = require('../model/shardVisibility/shardVisibility.model') +visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults +visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous' + +const DEFAULTS = visibility.compileDefaults() + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +const originals = { + eventsList: shardEvents.list, + listIdoc: shardState.listIdoc, + onlineCount: shardState.onlineCount, + latestEconomy: shardState.latestEconomy, + getRuleset: shardState.getRuleset, + getSafe: uoLinkConfig.getSafe, +} +afterEach(() => { + shardEvents.list = originals.eventsList + shardState.listIdoc = originals.listIdoc + shardState.onlineCount = originals.onlineCount + shardState.latestEconomy = originals.latestEconomy + shardState.getRuleset = originals.getRuleset + uoLinkConfig.getSafe = originals.getSafe +}) + +// ── getFeed: the public-safe allowlist is a security boundary ─────────── +test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => { + let queried = false + shardEvents.list = async () => { + queried = true + return [{ kind: 'staff.audit' }] + } + const res = mockRes() + await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind + assert.deepEqual(res.body, []) + assert.equal(queried, false, 'a disallowed kind is rejected before any DB read') +}) + +test('getFeed serves a specific kind when it IS public-safe', async () => { + const publicKind = [...broadcast.PUBLIC_KINDS][0] + let seen + shardEvents.list = async (opts) => { + seen = opts + return [{ kind: publicKind }] + } + const res = mockRes() + await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res) + assert.equal(seen.kind, publicKind) + assert.equal(seen.limit, 5) + assert.equal(res.body[0].kind, publicKind) +}) + +test('getFeed with no kind restricts the query to the kinds THIS viewer may read', async () => { + let seen + shardEvents.list = async (opts) => { + seen = opts + return [] + } + await ctrl.getFeed({ query: {} }, mockRes()) + // Resolved from the LIVE config, not the module-load PUBLIC_KINDS constant, so + // an admin re-gating a feature takes effect on the stored history too. + assert.deepEqual(new Set(seen.kinds), new Set(visibility.visibleKinds('anonymous', DEFAULTS))) + // Sanity: a known admin-only kind is absent from what the public feed queries. + assert.ok(!seen.kinds.includes('staff.audit')) + // The `stream` flag governs SSE fan-out only, so a feature whose live firehose + // ships off is still readable from history — the one way this set is WIDER + // than PUBLIC_KINDS. + for (const kind of broadcast.PUBLIC_KINDS) assert.ok(seen.kinds.includes(kind)) + assert.ok(seen.kinds.includes('vendor.listing')) + assert.ok(!broadcast.PUBLIC_KINDS.has('vendor.listing')) +}) + +test('getFeed projects each row against ITS OWN kind\'s feature', async () => { + shardEvents.list = async () => [ + { + id: 1, + kind: 'player.death', + payload: { kind: 'player.death', actor: { serial: '0x1', name: 'Doomed', acct: 'secret', webId: 99 } }, + }, + { + id: 2, + kind: 'guild.join', + payload: { kind: 'guild.join', actor: { serial: '0x2', name: 'Joiner', acct: 'secret2', webId: 98 } }, + }, + ] + const res = mockRes() + await ctrl.getFeed({ query: {} }, res) + for (const row of res.body) { + assert.equal(row.payload.actor.acct, undefined, `${row.kind} leaked acct`) + assert.equal(row.payload.actor.webId, undefined, `${row.kind} leaked webId`) + assert.ok(row.payload.actor.name, 'the in-game name is still public') + } +}) + +test('getFeed serves nothing when the viewer may read no kinds at all', async () => { + let queried = false + shardEvents.list = async () => { + queried = true + return [{ kind: 'staff.audit' }] + } + const allGated = Object.fromEntries( + Object.entries(DEFAULTS).map(([name, f]) => [name, { ...f, enabled: false }]), + ) + visibility.getConfig = async () => allGated + const res = mockRes() + await ctrl.getFeed({ query: {} }, res) + visibility.getConfig = async () => DEFAULTS + assert.deepEqual(res.body, []) + // An empty allowlist must never fall through to an unfiltered "give me + // everything" query. + assert.equal(queried, false) +}) + +// ── getHouses: the public house view must strip owner/price ───────────── +test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => { + shardState.listIdoc = async () => [ + { + serial: 1, + name: 'Keep', + region: 'Britain', + map: 'Felucca', + x: 1, + y: 2, + z: 3, + // The following are staff-only and must NOT appear in the public payload: + ownerName: 'Lord British', + ownerAcct: 'secret', + price: 999999, + coOwners: 'a,b', + decay: 'IDOC', + }, + ] + const res = mockRes() + await ctrl.getHouses({}, res) + const [h] = res.body + assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z']) + assert.equal(h.isIdoc, true) + assert.equal(h.ownerName, undefined) + assert.equal(h.price, undefined) + assert.equal(h.coOwners, undefined) +}) + +// ── getIdoc: the flattened owner fields are a security boundary too ────── +test('getIdoc never serves the owner game account to a viewer below admin', async () => { + shardState.listIdoc = async () => [ + { + serial: '0x1', + name: 'Marble Tower', + region: 'Britain', + map: 'Felucca', + x: 1, + y: 2, + z: 3, + ownerSerial: '0x2A01', + ownerName: 'Sir Cadmus', + ownerAcct: 'cadmus_acct', // flattened spelling of the locked `acct` + price: 1250000, + isIdoc: true, + }, + ] + for (const level of ['anonymous', 'logged_in', 'player', 'staff']) { + const res = mockRes() + await ctrl.getIdoc({ viewerLevel: level }, res) + assert.equal(res.body[0].ownerAcct, undefined, `${level} saw the owner's game account`) + } + const res = mockRes() + await ctrl.getIdoc({ viewerLevel: 'admin' }, res) + assert.equal(res.body[0].ownerAcct, 'cadmus_acct', 'admin still sees it') +}) + +test('getIdoc gates owner identity and price at `staff`, but never the location', async () => { + shardState.listIdoc = async () => [ + { serial: '0x1', name: 'Marble Tower', region: 'Britain', map: 'Felucca', x: 1, y: 2, z: 3, + ownerSerial: '0x2A01', ownerName: 'Sir Cadmus', price: 1250000, isIdoc: true }, + ] + const anon = mockRes() + await ctrl.getIdoc({ viewerLevel: 'anonymous' }, anon) + assert.equal(anon.body[0].ownerName, undefined) + assert.equal(anon.body[0].ownerSerial, undefined) + assert.equal(anon.body[0].price, undefined) + // The public IDOC board still renders: name, region and location survive. + assert.equal(anon.body[0].name, 'Marble Tower') + assert.equal(anon.body[0].region, 'Britain') + assert.equal(anon.body[0].map, 'Felucca') + + const staff = mockRes() + await ctrl.getIdoc({ viewerLevel: 'staff' }, staff) + assert.equal(staff.body[0].ownerName, 'Sir Cadmus') + assert.equal(staff.body[0].price, 1250000) +}) + +test('getIdoc preserves Date columns rather than flattening them to {}', async () => { + const when = new Date('2026-07-06T19:32:29.000Z') + shardState.listIdoc = async () => [ + { serial: '0x1', name: 'Marble Tower', isIdoc: true, lastRefreshed: when, updatedAt: when }, + ] + const res = mockRes() + await ctrl.getIdoc({ viewerLevel: 'anonymous' }, res) + assert.ok(res.body[0].updatedAt instanceof Date, 'a Date must survive projection intact') + assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString()) +}) + +// ── getRuleset: "never published" is a real answer ────────────────────── +test('getRuleset serves null when the shard has never published a ruleset', async () => { + shardState.getRuleset = async () => null + const res = mockRes() + await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res) + // Deliberately null, not {} — the page says "not published yet" rather than + // rendering an empty ruleset as though the shard had no rules. + assert.equal(res.body, null) + assert.equal(res.statusCode, 200) +}) + +test('getRuleset serves the published ruleset whole, nested blocks intact', async () => { + shardState.getRuleset = async () => ({ + kind: 'world.ruleset', + rev: '1a2b3c4d', + shard: 'UOMysticmoon', + expansion: 'EJ', + systems: { cityLoyalty: true, vvv: true, factions: false }, + caps: { skill: 1000, totalSkill: 7000, stat: 225 }, + champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] }, + }) + const res = mockRes() + await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res) + assert.equal(res.body.expansion, 'EJ') + assert.equal(res.body.systems.vvv, true) + assert.equal(res.body.caps.totalSkill, 7000) + // Arrays must survive projection as arrays, not become objects. + assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13]) +}) + +// §3.6.1's rule: a read path that returns shard data and does not project is a +// bug. The ruleset frame carries no actor today, but it goes through the same +// gate — so a future block that does cannot leak. +test('getRuleset projects: acct/webId never survive below admin', async () => { + shardState.getRuleset = async () => ({ + expansion: 'EJ', + connect: 'play.example.com,2593', + owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 }, + }) + for (const level of ['anonymous', 'logged_in', 'player', 'staff']) { + const res = mockRes() + await ctrl.getRuleset({ viewerLevel: level }, res) + assert.equal(res.body.owner.acct, undefined, `${level} saw acct`) + assert.equal(res.body.owner.webId, undefined, `${level} saw webId`) + // `connect` defaults to the anonymous rung: an operator who published it + // meant it to be readable. + assert.equal(res.body.connect, 'play.example.com,2593') + } +}) + +// ── points boards ────────────────────────────────────────────────────── +const BOARD = { + system: 'QueensLoyalty', + nameString: "Queen's Loyalty", + nameNumber: 1114938, + maxPoints: 30000, + players: 842, + top: [ + { rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }, + { rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 }, + ], +} + +test('getPointsBoards serves every board with its ranked list intact', async () => { + shardState.listPointsBoards = async () => [BOARD] + const res = mockRes() + await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res) + assert.equal(res.body.length, 1) + assert.equal(res.body[0].system, 'QueensLoyalty') + // The ranked list is an ARRAY through projection, not an object keyed 0/1 — + // the same trap the ruleset's rankThresholds assertion guards. + assert.ok(Array.isArray(res.body[0].top)) + assert.equal(res.body[0].top[1].name, 'Mireille') +}) + +test('getPointsBoards serves an empty list before the shard has published any', async () => { + shardState.listPointsBoards = async () => [] + const res = mockRes() + await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res) + assert.deepEqual(res.body, []) + assert.equal(res.statusCode, 200) +}) + +// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry +// no actor today — they write entries inline as {serial, name} precisely so they +// never carry acct/webId — but the gate is what keeps that true if the shape grows. +test('getPointsBoard projects: acct/webId never survive below admin', async () => { + shardState.getPointsBoard = async () => ({ + system: 'QueensLoyalty', + top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }], + }) + for (const level of ['anonymous', 'logged_in', 'player', 'staff']) { + const res = mockRes() + await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res) + assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`) + assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`) + assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default') + } +}) + +// "No such system" and "a board nobody has scored in" are different answers. +test('getPointsBoard 404s for a system the shard has never published', async () => { + shardState.getPointsBoard = async () => null + const res = mockRes() + await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res) + assert.equal(res.statusCode, 404) +}) + +test('getPointsBoard rejects a malformed system name before touching the model', async () => { + let queried = false + shardState.getPointsBoard = async () => { queried = true; return null } + for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) { + const res = mockRes() + await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res) + assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`) + } + assert.equal(queried, false, 'a malformed name must never reach the query') +}) + +test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => { + shardState.listPointsBoards = async () => { + throw new Error('pool down') + } + const res = mockRes() + await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res) + assert.equal(res.statusCode, 500) + assert.equal(res.body.message, 'Internal Server Error') +}) + +test('getRuleset degrades to a 500 when the model fails, without throwing', async () => { + shardState.getRuleset = async () => { + throw new Error('pool down') + } + const res = mockRes() + await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res) + assert.equal(res.statusCode, 500) + assert.equal(res.body.message, 'Internal Server Error') +}) + +// ── getStatus assembles the summary ───────────────────────────────────── +test('getStatus merges the sidecar config with the online count and latest economy', async () => { + uoLinkConfig.getSafe = async () => ({ + enabled: true, + status: 'connected', + pluginConnected: true, + lastEventAt: 'ts', + }) + shardState.onlineCount = async () => 12 + shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 }) + const res = mockRes() + await ctrl.getStatus({}, res) + assert.equal(res.body.enabled, true) + assert.equal(res.body.onlineCount, 12) + assert.equal(res.body.economy.gold, 100) +}) + +test('getStatus degrades to a 500 when a model call fails, without throwing', async () => { + uoLinkConfig.getSafe = async () => { + throw new Error('pool down') + } + const res = mockRes() + await ctrl.getStatus({}, res) // must resolve, not reject + assert.equal(res.statusCode, 500) + assert.equal(res.body.message, 'Internal Server Error') +}) diff --git a/server/test/shardIngest.champsPages.test.js b/server/test/shardIngest.champsPages.test.js new file mode 100644 index 0000000..de3d93c --- /dev/null +++ b/server/test/shardIngest.champsPages.test.js @@ -0,0 +1,71 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../utils/shardIngest') + +// Build a set of stub deps that record the champ/page/state calls the dispatcher +// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested +// kinds touch need to be real; the rest are no-op async so ingest() never throws. +function makeDeps() { + const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + upsertChamp: async (ev) => { calls.champUpsert.push(ev) }, + removeChamp: async (serial) => { calls.champRemove.push(serial) }, + upsertPage: async (ev) => { calls.pageUpsert.push(ev) }, + removePage: async (id) => { calls.pageRemove.push(id) }, + // Unused by these kinds but present so any stray routing is a no-op. + clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop, + }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + // No-op push fan-out so ingest() stays hermetic (no real relay/DB). + pushDispatch: async () => {}, + log: { warn() {}, info() {}, error() {} }, + } +} + +beforeEach(() => shardIngest.reset()) + +test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => { + const deps = makeDeps() + const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 } + const r = await shardIngest.ingest(ev, deps) + assert.equal(deps.calls.champUpsert.length, 1) + assert.equal(deps.calls.champUpsert[0].serial, '0x1') + assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events + assert.equal(deps.calls.appended.length, 0) + assert.equal(deps.calls.broadcast.length, 1) // still broadcast live +}) + +test('champ.remove routes to shardState.removeChamp', async () => { + const deps = makeDeps() + await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps) + assert.deepEqual(deps.calls.champRemove, ['0x2']) +}) + +test('page.new and page.updated upsert the page; page.closed removes it', async () => { + const deps = makeDeps() + await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps) + await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps) + await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps) + assert.equal(deps.calls.pageUpsert.length, 2) + assert.deepEqual(deps.calls.pageRemove, ['0x24C']) +}) + +test('admin.audit is appended to the event log (moderation history)', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps) + assert.equal(r.logged, true) + assert.equal(deps.calls.appended.length, 1) + assert.equal(deps.calls.appended[0].kind, 'admin.audit') +}) + +test('champ.remove without a serial is a harmless no-op', async () => { + const deps = makeDeps() + await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps) + assert.deepEqual(deps.calls.champRemove, [undefined]) +}) diff --git a/server/test/shardIngest.market.test.js b/server/test/shardIngest.market.test.js new file mode 100644 index 0000000..58990af --- /dev/null +++ b/server/test/shardIngest.market.test.js @@ -0,0 +1,114 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../utils/shardIngest') + +// Protocol 3.0 vendor.listing / vendor.listing.remove routing. Same shape as +// shardIngest.points.test.js: stubbed deps, asserting where the dispatcher sends +// the frame and whether it is appended to the event log. +function makeDeps() { + const calls = { upserts: [], removes: [], appended: [], broadcast: [] } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + // Present so any stray routing is a harmless no-op rather than a crash. + clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, + addEconomySample: noop, setRuleset: noop, upsertPointsBoard: noop, + }, + shardMarket: { + upsertVendor: async (ev) => { calls.upserts.push(ev) }, + removeVendor: async (serial) => { calls.removes.push(serial) }, + }, + shardLinks: { removeByAccount: noop }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + pushDispatch: async () => {}, + log: { warn() {}, info() {}, error() {} }, + } +} + +const FRAME = { + kind: 'vendor.listing', + t: 1000, + serial: '0x40001234', + shopName: "Darrow's Bargains", + ownerSerial: '0x1A2B', + ownerName: 'Darrow', + location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" }, + count: 2, + total: 2, + truncated: false, + items: [ + { serial: '0x40012ABC', itemId: 3922, hue: 0, amount: 1, price: 25000, name: null, cliloc: 1023721 }, + { serial: '0x40012ABD', itemId: 7026, hue: 1157, amount: 3, price: 500, name: 'a shard sigil', cliloc: 1041243 }, + ], +} + +beforeEach(() => shardIngest.reset()) + +test('vendor.listing routes to the market model with the whole frame', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + assert.equal(deps.calls.upserts.length, 1) + const stored = deps.calls.upserts[0] + assert.equal(stored.serial, '0x40001234') + assert.equal(stored.location.region, 'Britain') + assert.equal(stored.items.length, 2) +}) + +test('vendor.listing.remove routes to removeVendor with the serial', async () => { + const deps = makeDeps() + await shardIngest.ingest({ kind: 'vendor.listing.remove', t: 2000, serial: '0x40001234' }, deps) + assert.deepEqual(deps.calls.removes, ['0x40001234']) + assert.equal(deps.calls.upserts.length, 0) +}) + +// The market IS the state. One frame carries up to 250 listings and the sweep +// re-emits a shop on any price change, so logging would turn shard_events into a +// price history nobody reads — the strongest case of the three v3 kinds. +test('neither market kind is appended to the event log', async () => { + const deps = makeDeps() + const a = await shardIngest.ingest(FRAME, deps) + const b = await shardIngest.ingest({ kind: 'vendor.listing.remove', serial: '0x40001234' }, deps) + assert.equal(a.logged, false) + assert.equal(b.logged, false) + assert.equal(deps.calls.appended.length, 0) + assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing'), false) + assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing.remove'), false) +}) + +// Broadcast is unconditional at this layer — whether it actually reaches anyone +// is shardBroadcast's call, and the market feature ships with its stream off. +test('vendor.listing is handed to the broadcaster', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + assert.equal(deps.calls.broadcast.length, 1) + assert.equal(deps.calls.broadcast[0].kind, 'vendor.listing') +}) + +test('a backfilled vendor.listing still stores but does not broadcast', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true }) + assert.equal(deps.calls.upserts.length, 1) + assert.equal(deps.calls.broadcast.length, 0) +}) + +// The reconnect backfill replays the whole index through this path, so a single +// bad vendor must not abort it. +test('an upsertVendor failure does not throw or stop the broadcast', async () => { + const deps = makeDeps() + deps.shardMarket.upsertVendor = async () => { throw new Error('db down') } + const r = await shardIngest.ingest(FRAME, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.broadcast.length, 1) +}) + +// Each vendor is its own row; the frame is authoritative for that vendor only. +test('two vendors are stored independently', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + await shardIngest.ingest({ ...FRAME, serial: '0x40009999', shopName: 'Second Shop' }, deps) + assert.deepEqual(deps.calls.upserts.map((v) => v.serial), ['0x40001234', '0x40009999']) +}) diff --git a/server/test/shardIngest.points.test.js b/server/test/shardIngest.points.test.js new file mode 100644 index 0000000..38c04ee --- /dev/null +++ b/server/test/shardIngest.points.test.js @@ -0,0 +1,111 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../utils/shardIngest') + +// Protocol 3.0 points.board routing. Same shape as shardIngest.ruleset.test.js: +// stubbed deps, asserting where the dispatcher sends the frame and whether it is +// appended to the event log. +function makeDeps() { + const calls = { boards: [], appended: [], broadcast: [] } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + upsertPointsBoard: async (ev) => { calls.boards.push(ev) }, + // Present so any stray routing is a harmless no-op. + clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, + addEconomySample: noop, setRuleset: noop, + }, + shardLinks: { removeByAccount: noop }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + pushDispatch: async () => {}, + log: { warn() {}, info() {}, error() {} }, + } +} + +const FRAME = { + kind: 'points.board', + t: 1000, + system: 'QueensLoyalty', + nameString: "Queen's Loyalty", + nameNumber: 1114938, + maxPoints: 30000, + showOnGump: true, + players: 842, + top: [ + { rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }, + { rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 }, + ], +} + +beforeEach(() => shardIngest.reset()) + +test('points.board routes to upsertPointsBoard with the whole frame', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + assert.equal(deps.calls.boards.length, 1) + const stored = deps.calls.boards[0] + assert.equal(stored.system, 'QueensLoyalty') + assert.equal(stored.nameNumber, 1114938) + assert.equal(stored.players, 842) + // The ranked list must survive intact — the read model serves it from the payload. + assert.equal(stored.top.length, 2) + assert.equal(stored.top[0].name, 'Darrow') +}) + +// A board is state, not an event. The shard emits a frame every time anyone's +// score moves the top ten, so logging would grow shard_events without bound for +// something whose only interesting value is its latest version — the same call +// guild.update already makes. +test('points.board is NOT appended to the event log', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest(FRAME, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.appended.length, 0) + assert.equal(shardIngest.LOGGED_KINDS.has('points.board'), false) +}) + +test('points.board is broadcast (the leaderboards page updates live)', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + assert.equal(deps.calls.broadcast.length, 1) + assert.equal(deps.calls.broadcast[0].kind, 'points.board') +}) + +test('a backfilled points.board still stores but does not broadcast', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true }) + assert.equal(deps.calls.boards.length, 1) + assert.equal(deps.calls.broadcast.length, 0) +}) + +// Each system is its own row, so two systems must not collide — this is the whole +// reason the frame is per-system rather than one board of everything. +test('two systems are stored independently', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + await shardIngest.ingest({ ...FRAME, system: 'CleanUpBritannia', nameString: null }, deps) + assert.deepEqual(deps.calls.boards.map((b) => b.system), ['QueensLoyalty', 'CleanUpBritannia']) +}) + +// A re-emitted board is an overwrite of one row, never an append. +test('a repeated points.board overwrites rather than accumulating', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + await shardIngest.ingest({ ...FRAME, t: 2000, players: 843 }, deps) + assert.equal(deps.calls.appended.length, 0) + assert.equal(deps.calls.boards.length, 2) // two writes... + assert.equal(deps.calls.boards[1].system, 'QueensLoyalty') // ...of the same row +}) + +// A model write that throws must not kill the feed. +test('an upsertPointsBoard failure does not throw or stop the broadcast', async () => { + const deps = makeDeps() + deps.shardState.upsertPointsBoard = async () => { throw new Error('db down') } + const r = await shardIngest.ingest(FRAME, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.broadcast.length, 1) +}) diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js new file mode 100644 index 0000000..aa238c4 --- /dev/null +++ b/server/test/shardIngest.protocol2.test.js @@ -0,0 +1,121 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../utils/shardIngest') + +// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the +// methods the tested kinds touch need to be real; the rest are no-op async so +// ingest() never throws on an unrelated kind. +function makeDeps() { + const calls = { + guildUpsert: [], guildRemove: [], + governorUpsert: [], + presenceSet: [], + houseRegistry: [], houseRemove: [], + linkRemove: [], + appended: [], broadcast: [], + } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + upsertGuild: async (ev) => { calls.guildUpsert.push(ev) }, + removeGuild: async (id) => { calls.guildRemove.push(id) }, + upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) }, + setPresence: async (ev) => { calls.presenceSet.push(ev) }, + upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) }, + removeHouse: async (serial) => { calls.houseRemove.push(serial) }, + // Present so any stray routing is a harmless no-op. + clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, + addEconomySample: noop, + }, + shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + // No-op push fan-out so ingest() stays hermetic (no real relay/DB). + pushDispatch: async () => {}, + log: { warn() {}, info() {}, error() {} }, + } +} + +beforeEach(() => shardIngest.reset()) + +test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps) + assert.equal(deps.calls.guildUpsert.length, 1) + assert.equal(deps.calls.guildUpsert[0].id, 1042) + assert.equal(r.logged, false) // board state, not appended to shard_events + await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps) + assert.deepEqual(deps.calls.guildRemove, [1042]) +}) + +test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps) + assert.equal(r.logged, true) + assert.equal(deps.calls.appended.length, 1) + assert.equal(deps.calls.appended[0].kind, 'guild.join') + assert.equal(deps.calls.broadcast.length, 1) +}) + +test('city.update routes to upsertGovernor (which also captures term history)', async () => { + const deps = makeDeps() + await shardIngest.ingest( + { kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps) + assert.equal(deps.calls.governorUpsert.length, 1) + assert.equal(deps.calls.governorUpsert[0].city, 'Britain') +}) + +test('presence.online routes to setPresence and is not logged', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps) + assert.equal(deps.calls.presenceSet.length, 1) + assert.equal(deps.calls.presenceSet[0].count, 42) + assert.equal(r.logged, false) +}) + +test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => { + const deps = makeDeps() + await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps) + assert.equal(deps.calls.houseRegistry.length, 1) + assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234') + await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps) + assert.deepEqual(deps.calls.houseRemove, ['0x40001234']) +}) + +test('region.enter is broadcast-only — not logged, no state side effect', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.appended.length, 0) + assert.equal(deps.calls.broadcast.length, 1) // still surfaced live +}) + +test('account.unlinked reconciles the local link mirror and is logged', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps) + assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped + assert.equal(r.logged, true) // provisioning audit trail + assert.equal(deps.calls.appended[0].kind, 'account.unlinked') +}) + +test('account.audit is logged (provisioning history) but has no state side effect', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps) + assert.equal(r.logged, true) + assert.equal(deps.calls.linkRemove.length, 0) + assert.equal(deps.calls.appended[0].kind, 'account.audit') +}) + +test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => { + const broadcast = require('../utils/shardBroadcast') + assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false) + assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false) +}) diff --git a/server/test/shardIngest.ruleset.test.js b/server/test/shardIngest.ruleset.test.js new file mode 100644 index 0000000..754074d --- /dev/null +++ b/server/test/shardIngest.ruleset.test.js @@ -0,0 +1,161 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../utils/shardIngest') + +// Protocol 3.0 world.ruleset routing. Same shape as shardIngest.protocol2.test.js: +// stubbed deps, asserting where the dispatcher sends the frame and whether it is +// appended to the event log. +function makeDeps() { + const calls = { rulesetSet: [], appended: [], broadcast: [] } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + setRuleset: async (ev) => { calls.rulesetSet.push(ev) }, + // Present so any stray routing is a harmless no-op. + clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, + addEconomySample: noop, + }, + shardLinks: { removeByAccount: noop }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + pushDispatch: async () => {}, + log: { warn() {}, info() {}, error() {} }, + } +} + +const FRAME = { + kind: 'world.ruleset', + t: 1000, + rev: '1a2b3c4d', + shard: 'UOMysticmoon', + expansion: 'EJ', + systems: { cityLoyalty: true, vvv: true, factions: false }, + caps: { skill: 1000, totalSkill: 7000 }, +} + +beforeEach(() => shardIngest.reset()) + +test('world.ruleset routes to setRuleset with the whole frame', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + assert.equal(deps.calls.rulesetSet.length, 1) + const stored = deps.calls.rulesetSet[0] + assert.equal(stored.rev, '1a2b3c4d') + assert.equal(stored.expansion, 'EJ') + // The nested blocks must survive intact — the read model serves the frame whole. + assert.equal(stored.systems.vvv, true) + assert.equal(stored.caps.totalSkill, 7000) +}) + +// The shard re-emits world.ruleset on EVERY sidecar connect. Logging it would put +// a duplicate row in shard_events per reconnect, and server.hello already marks +// each of those — so this assertion is the guard on that decision. +test('world.ruleset is NOT appended to the event log', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest(FRAME, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.appended.length, 0) + assert.equal(shardIngest.LOGGED_KINDS.has('world.ruleset'), false) +}) + +test('world.ruleset is broadcast (the rules page updates live)', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + assert.equal(deps.calls.broadcast.length, 1) + assert.equal(deps.calls.broadcast[0].kind, 'world.ruleset') +}) + +// A backfill replay must reach the store but must NOT re-animate the live ticker. +test('a backfilled world.ruleset still stores but does not broadcast', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true }) + assert.equal(deps.calls.rulesetSet.length, 1) + assert.equal(deps.calls.broadcast.length, 0) +}) + +// A re-emitted identical ruleset is an overwrite, not an append: two ingests of +// the same rev leave one stored frame's worth of state, never a growing log. +test('a repeated world.ruleset overwrites rather than accumulating', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, deps) + await shardIngest.ingest({ ...FRAME, t: 2000 }, deps) + assert.equal(deps.calls.appended.length, 0) + assert.equal(deps.calls.rulesetSet.length, 2) // two writes... + assert.equal(deps.calls.rulesetSet[1].rev, '1a2b3c4d') // ...of the same singleton +}) + +// A model write that throws must not kill the feed — ingest swallows it and the +// frame is still broadcast. +test('a setRuleset failure does not throw or stop the broadcast', async () => { + const deps = makeDeps() + deps.shardState.setRuleset = async () => { throw new Error('db down') } + const r = await shardIngest.ingest(FRAME, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.broadcast.length, 1) +}) + +// ── Shard name fallback ──────────────────────────────────────────────────── +// +// ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it +// publishes that verbatim, which says "unnamed" rather than naming anything — so +// the site answers with its own instance name instead of printing the stock +// default under a header carrying the real one. +// +// Applied at INGEST, not on read, because world.ruleset is also broadcast live: +// the same object goes to the SSE fan-out, so a read-time fix would be undone by +// the next reconnect's frame. These tests assert both halves. + +function withSettings(deps, name) { + return { ...deps, settings: { getInstanceName: async () => name } } +} + +test('the stock ServUO shard name is replaced with the instance name', async () => { + const deps = makeDeps() + await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon')) + assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon') +}) + +test('the substituted name reaches the live broadcast, not just the store', async () => { + const deps = makeDeps() + await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon')) + assert.equal(deps.calls.broadcast.length, 1) + assert.equal(deps.calls.broadcast[0].shard, 'UOMysticmoon') +}) + +test('a missing or blank shard name gets the same treatment', async () => { + for (const shard of [undefined, null, '', ' ']) { + const deps = makeDeps() + await shardIngest.ingest({ ...FRAME, shard }, withSettings(deps, 'UOMysticmoon')) + assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon') + } +}) + +// The match is on the whole value, case- and padding-insensitive. A shard that +// deliberately calls itself "My Shard Reborn" has named itself and keeps it. +test('a real name that merely contains the stock one is left alone', async () => { + const deps = makeDeps() + await shardIngest.ingest({ ...FRAME, shard: 'My Shard Reborn' }, withSettings(deps, 'UOMysticmoon')) + assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard Reborn') + + const padded = makeDeps() + await shardIngest.ingest({ ...FRAME, shard: ' MY SHARD ' }, withSettings(padded, 'UOMysticmoon')) + assert.equal(padded.calls.rulesetSet[0].shard, 'UOMysticmoon') +}) + +test('a shard that named itself is never overridden by the brand', async () => { + const deps = makeDeps() + await shardIngest.ingest(FRAME, withSettings(deps, 'Some Other Brand')) + assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon') +}) + +// The settings read is a DB call on a path that must never fail ingest. +test('a settings read failure leaves the frame storable', async () => { + const deps = makeDeps() + const boom = { ...deps, settings: { getInstanceName: async () => { throw new Error('db down') } } } + await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, boom) + assert.equal(deps.calls.rulesetSet.length, 1) + assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard') +}) diff --git a/server/test/shardMarket.model.test.js b/server/test/shardMarket.model.test.js new file mode 100644 index 0000000..70c18c9 --- /dev/null +++ b/server/test/shardMarket.model.test.js @@ -0,0 +1,263 @@ +// 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. +// Nothing here reaches the database: these are the model's PURE parts — the +// flatten/shape rules the frame passes through on the way in and out — plus the +// visibility projection over the shapes they produce. + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const market = require('../model/shardMarket/shardMarket.model') +const clilocs = require('../model/shardClilocs/shardClilocs.model') +const clilocDb = require('../model/shardClilocs/shardClilocs.db') +const visibility = require('../utils/shardVisibility') + + +// Stand in for the cliloc table. Without this each unresolved lookup waits out +// the pool's 10s acquire timeout against the dead port — the model swallows the +// failure exactly as it would in production (an operator who never converted a +// cliloc file is in a supported state), so the RESULT is the same either way; +// this only stops the suite spending half a minute proving it. +const TABLE = new Map([[1023721, 'quarter staff']]) +clilocDb.lookup = async (numbers) => + numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) })) + +const FRAME = { + kind: 'vendor.listing', + t: 1000, + serial: '0x40001234', + shopName: "Darrow's Bargains", + ownerSerial: '0x1A2B', + ownerName: 'Darrow', + location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" }, + count: 2, + total: 2, + truncated: false, + items: [], +} + +// ── flattenFrame ─────────────────────────────────────────────────────────── + +test('flattenFrame lifts the nested location into columns', () => { + const v = market.flattenFrame(FRAME) + assert.equal(v.serial, '0x40001234') + assert.equal(v.map, 'Trammel') + assert.equal(v.x, 1421) + assert.equal(v.region, 'Britain') + assert.equal(v.house, "Darrow's Villa") +}) + +// A vendor standing in the street has no house, and a frame from an older plugin +// may have no location at all. Neither is an error. +test('flattenFrame tolerates a missing location entirely', () => { + const v = market.flattenFrame({ serial: '0x1', shopName: null }) + assert.equal(v.map, null) + assert.equal(v.x, null) + assert.equal(v.region, null) + assert.equal(v.house, null) +}) + +// `total` is what the SHOP holds; `count` is what the frame carried. A truncated +// shop must not report its published slice as its size, or the page says +// "showing 250 of 250" for a vendor holding three thousand stacks. +test('flattenFrame keeps the shop total separate from the published count', () => { + const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true }) + assert.equal(v.itemTotal, 3104) + assert.equal(v.truncated, true) +}) + +// An older plugin sends no `total`. Falling back to `count` is right — it is the +// only number available and it is correct whenever nothing was truncated. +test('flattenFrame falls back to count when total is absent', () => { + const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined }) + assert.equal(v.itemTotal, 7) +}) + +test('flattenFrame clips over-length strings rather than letting the insert fail', () => { + const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) }) + assert.equal(v.ownerName.length, 64) +}) + +// ── shapeItems ───────────────────────────────────────────────────────────── +// +// resolveMany never throws and, with no cliloc table reachable, resolves nothing +// — which is exactly the state of a shard whose operator never converted one, so +// these run against the real function rather than a stub. + +test('shapeItems prefers the item\'s literal name over its cliloc', async () => { + const items = await market.shapeItems({ + items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }], + }) + assert.equal(items[0].displayName, 'a shard sigil') + // The cliloc is kept regardless, so a later import can still re-resolve it. + assert.equal(items[0].cliloc, 1023721) +}) + +test('shapeItems resolves the cliloc when the item has no literal name', async () => { + const items = await market.shapeItems({ + items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }], + }) + assert.equal(items[0].displayName, 'quarter staff') +}) + +// The supported state for a shard whose operator never converted a cliloc file: +// no name, not a fabricated one. Clients render the item id, exactly as they did +// before the table existed. +test('shapeItems leaves displayName null for an unknown cliloc', async () => { + const items = await market.shapeItems({ + items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }], + }) + assert.equal(items[0].displayName, null) +}) + +// Unpriced rows are inventory, not listings. The shard drops them too; enforcing +// it here as well means a plugin that stops doing so cannot put un-buyable rows +// on the market page. +test('shapeItems drops unpriced listings', async () => { + const items = await market.shapeItems({ + items: [ + { serial: '0x1', itemId: 1, price: 0 }, + { serial: '0x2', itemId: 2, price: -1 }, + { serial: '0x3', itemId: 3, price: 5 }, + ], + }) + assert.deepEqual(items.map((i) => i.serial), ['0x3']) +}) + +test('shapeItems caps a pathological frame', async () => { + const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({ + serial: `0x${i}`, + itemId: 1, + price: 1, + })) + const items = await market.shapeItems({ items: many }) + assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR) +}) + +test('shapeItems tolerates a frame with no items array', async () => { + assert.deepEqual(await market.shapeItems({}), []) +}) + +// ── Visibility projection ────────────────────────────────────────────────── +// +// The regression that matters. Part A pre-wired `market.ownerName` and +// `market.location` before the frame existed, and the sibling rule it pre-wired +// for leaderboards (`characterName`) turned out to be INERT because projectValue +// matches literal JSON keys. These assert the market rules actually bite — on the +// read model AND on the wire frame, which is why both carry the same key names. + +const config = visibility.compileDefaults() + +const listing = market.shapeListing({ + serial: '0x40012ABC', + item_id: 3922, + hue: 0, + amount: 1, + price: 25000, + name: null, + cliloc: 1023721, + display_name: 'quarter staff', + child: 0, + vendor_serial: '0x40001234', + shop_name: "Darrow's Bargains", + owner_serial: '0x1A2B', + owner_name: 'Darrow', + map: 'Trammel', + x: 1421, + y: 1699, + z: 0, + region: 'Britain', + house: "Darrow's Villa", + updated_at: new Date(0), +}) + +test('market defaults expose owner and location (they are already public in game)', () => { + const out = visibility.projectFeature('market', listing, 'anonymous', config) + assert.equal(out.vendor.ownerName, 'Darrow') + assert.equal(out.vendor.location.region, 'Britain') +}) + +test('tightening market.ownerName hides it from below that rung', () => { + const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } } + const anon = visibility.projectFeature('market', listing, 'anonymous', tightened) + const staff = visibility.projectFeature('market', listing, 'staff', tightened) + assert.equal('ownerName' in anon.vendor, false) + assert.equal(staff.vendor.ownerName, 'Darrow') + // The shop name is a separate field and must survive — hiding the owner is not + // the same as hiding the shop. + assert.equal(anon.vendor.shopName, "Darrow's Bargains") +}) + +// The whole reason `location` is one nested object: a single rule has to take the +// facet, the coordinates, the region and the house together. Five flat keys would +// be five rules that drift apart. +test('tightening market.location hides the whole location object at once', () => { + const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } } + const anon = visibility.projectFeature('market', listing, 'anonymous', tightened) + const player = visibility.projectFeature('market', listing, 'player', tightened) + assert.equal('location' in anon.vendor, false) + assert.equal(player.vendor.location.map, 'Trammel') +}) + +// The same rules must bite on the LIVE frame, not just the stored read model — +// the market's SSE stream is off by default but an admin can turn it on, and a +// field rule that only worked on one of the two paths is exactly the leak §3.6.1 +// records. +test('the same rules apply to the raw vendor.listing frame', () => { + const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } } + const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened) + assert.equal('ownerName' in out, false) + assert.equal('location' in out, false) + assert.equal(out.shopName, "Darrow's Bargains") +}) + +// Rule 1 is not configurable and does not depend on the market rules at all: a +// frame that somehow carried an account name must never publish it. +test('acct and webId are stripped from a market payload regardless of config', () => { + const out = visibility.projectFeature( + 'market', + { serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' }, + 'staff', + config, + ) + assert.equal('ownerAcct' in out, false) + assert.equal('webId' in out, false) + assert.equal(out.shopName, 'Shop') +}) + +// Both kinds must be attributed to a feature, or rule 2 makes them admin-only by +// omission — which would be a silent failure rather than a loud one. +test('both market kinds are mapped to the market feature', () => { + assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market') + assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market') +}) + +// The market's live firehose is off by default (a page of whole vendor +// inventories is the site's biggest bandwidth item and no page needs it live), +// but the REST reads are unaffected — which is what `visibleKinds` ignoring the +// stream flag encodes. +test('market kinds are stream-suppressed by default but still readable', () => { + assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true) + assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false) + assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false) + assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing')) +}) + +test('an admin who enables the stream gets the frames', () => { + const on = { ...config, market: { ...config.market, stream: true } } + assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true) +}) + +// Guards the stub above against silently doing nothing: if the model stopped +// going through db.lookup, every shapeItems assertion would still "pass" by +// resolving nothing, which is also what a real miss looks like. +test('the cliloc resolver is the path shapeItems resolves through', async () => { + const found = await clilocs.resolveMany([1023721]) + assert.equal(found.get(1023721), 'quarter staff') +}) diff --git a/server/test/shardState.governorTerms.test.js b/server/test/shardState.governorTerms.test.js new file mode 100644 index 0000000..22cc6a1 --- /dev/null +++ b/server/test/shardState.governorTerms.test.js @@ -0,0 +1,77 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +// Term capture lives in the model (shardState.model.upsertGovernor → +// recordGovernorTransition) and talks to the db module. We exercise the real +// logic against an in-memory fake by monkeypatching the shared db module object +// (same instance the model require()s) — no DB, no mocking library. +const db = require('../model/shardState/shardState.db') +const model = require('../model/shardState/shardState.model') + +let terms // in-memory shard_governor_terms +let nextId +const saved = {} + +beforeEach(() => { + terms = [] + nextId = 1 + for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) { + saved[k] = db[k] + } + db.currentGovernorTerm = async (city) => + terms.find((t) => t.city === city && t.ended_at === null) || null + db.closeGovernorTerm = async (id, endedAt) => { + const row = terms.find((t) => t.id === id) + if (row) row.ended_at = endedAt + } + db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => { + terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name, + governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null }) + } + db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture +}) + +function restore() { + for (const k of Object.keys(saved)) db[k] = saved[k] +} + +test('a repeated city.update with the same governor does NOT open a second term', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 }) + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 }) + const open = terms.filter((t) => t.ended_at === null) + assert.equal(terms.length, 1) + assert.equal(open.length, 1) + assert.equal(open[0].governor_serial, '0x1') + assert.equal(open[0].started_at, 100) + restore() +}) + +test('a governor change closes the old term and opens a new one', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 }) + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 }) + assert.equal(terms.length, 2) + const [first, second] = terms + assert.equal(first.governor_serial, '0x1') + assert.equal(first.ended_at, 300) // closed at the transition time + assert.equal(second.governor_serial, '0x2') + assert.equal(second.ended_at, null) // now current + assert.equal(second.started_at, 300) + restore() +}) + +test('a seat going vacant closes the term without opening a new one', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 }) + await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 }) + assert.equal(terms.length, 1) + assert.equal(terms[0].ended_at, 400) + restore() +}) + +test('terms are tracked independently per city', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 }) + await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 }) + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op + assert.equal(terms.length, 2) + assert.equal(terms.filter((t) => t.ended_at === null).length, 2) + restore() +}) diff --git a/server/test/shardState.model.test.js b/server/test/shardState.model.test.js new file mode 100644 index 0000000..1b439e7 --- /dev/null +++ b/server/test/shardState.model.test.js @@ -0,0 +1,253 @@ +// 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. + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +// Unit-test the shard-state model's mapping/derivation rules against a fake +// shardState.db (no DB). These are the transforms the ingest dispatcher and the +// public read endpoints both depend on: +// - a partial online refresh (char.vitals) only writes the keys it carries, so +// it never clobbers login-only fields with undefined; +// - is_idoc is DERIVED from the decay stage, not trusted from the wire; +// - the economy series is clamped, returned oldest→newest, and gold coerced to +// a JS number (mariadb hands back BigInt-ish strings for large gold totals); +// - an empty presence table reads as a well-formed zero snapshot, not null; +// - champ/guild/governor rows fall back to hoisted columns when payload is absent; +// - remove/upsert guard against missing identifiers instead of hitting the DB. +const db = require('../model/shardState/shardState.db') +const shardState = require('../model/shardState/shardState.model') + +// Records the (id, fields) the model hands to each db write, and serves canned +// rows back for reads. +let calls +const saved = {} +const DB_KEYS = [ + 'upsertOnline', 'removeOnline', 'clearOnline', 'insertEconomy', 'listEconomy', 'latestEconomy', + 'upsertHouse', 'removeHouse', 'listIdocHouses', 'listRegistryHouses', 'setPresence', 'latestPresence', + 'upsertChamp', 'removeChamp', 'listChamps', 'upsertGuild', 'removeGuild', 'listGuilds', + 'upsertGovernor', 'listGovernors', 'listGovernorTerms', 'listOnline', 'listOnlineLinked', 'listPages', +] + +beforeEach(() => { + calls = {} + for (const k of DB_KEYS) { + saved[k] = db[k] + calls[k] = [] + db[k] = async (...args) => { + calls[k].push(args) + } + } +}) + +afterEach(() => { + for (const k of DB_KEYS) db[k] = saved[k] +}) + +// ── partial online refresh must not clobber ───────────────────────────── +test('upsertOnline drops undefined keys so a vitals refresh keeps login fields', async () => { + // A char.vitals event carries hits but not name/acct — those must not be sent as + // undefined columns (which would overwrite the login row). + await shardState.upsertOnline({ serial: 5, hits: 40, hitsMax: 100 }) + const [serial, fields] = calls.upsertOnline[0] + assert.equal(serial, 5) + assert.deepEqual(fields, { hits: 40, hits_max: 100 }) + assert.ok(!('name' in fields), 'name not written when absent from the event') +}) + +test('upsertOnline maps camelCase vitals to snake_case columns', async () => { + await shardState.upsertOnline({ serial: 9, name: 'Bob', webId: 3, hitsMax: 90, manaMax: 50, stamMax: 70 }) + const [, fields] = calls.upsertOnline[0] + assert.equal(fields.web_id, 3) + assert.equal(fields.hits_max, 90) + assert.equal(fields.mana_max, 50) + assert.equal(fields.stam_max, 70) +}) + +test('upsertOnline ignores an event with no serial (never touches the DB)', async () => { + await shardState.upsertOnline({ name: 'Nobody' }) + await shardState.upsertOnline(null) + assert.equal(calls.upsertOnline.length, 0) +}) + +// ── is_idoc is derived, not trusted ───────────────────────────────────── +test('upsertHouse derives is_idoc=1 only for the IDOC stage (case-insensitive)', async () => { + await shardState.upsertHouse({ serial: 1, stage: 'IDOC' }) + await shardState.upsertHouse({ serial: 2, stage: 'idoc' }) + await shardState.upsertHouse({ serial: 3, stage: 'Slightly' }) + assert.equal(calls.upsertHouse[0][1].is_idoc, 1) + assert.equal(calls.upsertHouse[1][1].is_idoc, 1) + assert.equal(calls.upsertHouse[2][1].is_idoc, 0) +}) + +test('upsertHouseRegistry writes in_registry=1 and flattens the owner actor', async () => { + await shardState.upsertHouseRegistry({ serial: 7, name: 'Keep', owner: { serial: 20, acct: 'a', name: 'Liege' } }) + const [serial, fields] = calls.upsertHouse[0] + assert.equal(serial, 7) + assert.equal(fields.in_registry, 1) + assert.equal(fields.owner_serial, 20) + assert.equal(fields.owner_name, 'Liege') +}) + +test('upsertHouseRegistry tolerates an abandoned house (null owner)', async () => { + await shardState.upsertHouseRegistry({ serial: 8, name: 'Ruin', owner: null }) + const [, fields] = calls.upsertHouse[0] + assert.equal(fields.owner_serial, null) + assert.equal(fields.owner_name, null) + assert.equal(fields.in_registry, 1) +}) + +// ── economy series shaping ────────────────────────────────────────────── +test('listEconomy clamps the limit, reverses to oldest→newest, and coerces gold to Number', async () => { + // db.listEconomy returns newest-first; the model reverses for charting. + db.listEconomy = async (n) => { + assert.equal(n, 1000, 'limit is clamped to MAX_ECONOMY') + return [ + { accounts: 3, gold: '9000000000', t: 30 }, + { accounts: 2, gold: '20', t: 20 }, + { accounts: 1, gold: null, t: 10 }, + ] + } + const out = await shardState.listEconomy(999999) + assert.deepEqual(out.map((r) => r.t), [10, 20, 30], 'oldest first') + assert.equal(out[2].gold, 9000000000) + assert.equal(typeof out[2].gold, 'number') + assert.equal(out[0].gold, null, 'null gold stays null, not 0') +}) + +test('listEconomy floors a non-positive limit to the default', async () => { + let seen + db.listEconomy = async (n) => { + seen = n + return [] + } + await shardState.listEconomy(0) + assert.equal(seen, 100) +}) + +// ── presence defaults ─────────────────────────────────────────────────── +test('latestPresence returns a well-formed zero snapshot when nothing is stored', async () => { + db.latestPresence = async () => null + const out = await shardState.latestPresence() + assert.deepEqual(out, { count: 0, byFacet: {}, byRegion: {}, t: null }) +}) + +test('latestPresence parses JSON string columns from the DB', async () => { + db.latestPresence = async () => ({ count: '12', by_facet: '{"felucca":5}', by_region: '{"Britain":3}', t: '99' }) + const out = await shardState.latestPresence() + assert.equal(out.count, 12) + assert.deepEqual(out.byFacet, { felucca: 5 }) + assert.equal(out.t, 99) +}) + +// ── payload fallback shaping ──────────────────────────────────────────── +test('listChamps returns the stored payload verbatim when present', async () => { + const payload = { kind: 'champ.update', serial: 1, name: 'Barracoon', custom: 'field' } + db.listChamps = async () => [{ serial: 1, payload: JSON.stringify(payload) }] + const out = await shardState.listChamps() + assert.deepEqual(out[0], payload) +}) + +test('listChamps falls back to hoisted columns for a legacy row with no payload', async () => { + db.listChamps = async () => [{ serial: 2, name: 'Rikktor', active: 1, boss_up: 0, payload: null }] + const out = await shardState.listChamps() + assert.equal(out[0].kind, 'champ.update') + assert.equal(out[0].name, 'Rikktor') + assert.equal(out[0].active, true) + assert.equal(out[0].bossUp, false) +}) + +test('listGuilds falls back to a shaped leader object when payload is absent', async () => { + db.listGuilds = async () => [{ id: 1, name: 'Order', leader_serial: 5, leader_name: 'Cap', payload: null }] + const out = await shardState.listGuilds() + assert.equal(out[0].leader.serial, 5) + assert.equal(out[0].leader.name, 'Cap') +}) + +// ── guards against missing identifiers ────────────────────────────────── +test('remove helpers are no-ops on a falsy id (never call the DB)', async () => { + await shardState.removeChamp(undefined) + await shardState.removeHouse('') + await shardState.removeGuild(null) + assert.equal(calls.removeChamp.length, 0) + assert.equal(calls.removeHouse.length, 0) + assert.equal(calls.removeGuild.length, 0) +}) + +test('removeGuild treats id 0 as a real id (0 != null) but skips null/undefined', async () => { + await shardState.removeGuild(0) + assert.equal(calls.removeGuild.length, 1, 'guild id 0 is valid') +}) + +test('upsertChamp/upsertGuild/upsertGovernor ignore events missing their key', async () => { + await shardState.upsertChamp({ name: 'no serial' }) + await shardState.upsertGuild({ name: 'no id' }) + await shardState.upsertGovernor({ governor: {} }) // no city + assert.equal(calls.upsertChamp.length, 0) + assert.equal(calls.upsertGuild.length, 0) + assert.equal(calls.upsertGovernor.length, 0) +}) + +// ── read-shaping locks the camelCase API/app contract ─────────────────── +// A field-name regression in these serializers silently breaks the public site +// and the Android client, so pin the shapes the read endpoints emit. +test('listOnline maps snake_case columns to the camelCase player shape', async () => { + db.listOnline = async () => [ + { serial: 1, name: 'A', acct: 'acc', web_id: 7, hits: 10, hits_max: 100, mana_max: 50, stam_max: 60, updated_at: 'ts' }, + ] + const [p] = await shardState.listOnline() + assert.equal(p.webId, 7) + assert.equal(p.hitsMax, 100) + assert.equal(p.manaMax, 50) + assert.equal(p.stamMax, 60) + assert.equal(p.updatedAt, 'ts') + assert.ok(!('web_id' in p), 'no snake_case leaks into the API shape') +}) + +test('listIdoc shapes houses and coerces isIdoc/price', async () => { + db.listIdocHouses = async () => [{ serial: 3, is_idoc: 1, price: '5000', in_registry: 1, owner_serial: 2 }] + const [h] = await shardState.listIdoc() + assert.equal(h.isIdoc, true) + assert.equal(h.price, 5000) + assert.equal(typeof h.price, 'number') + assert.equal(h.inRegistry, true) +}) + +test('listPages folds the sender columns into a nested actor and coerces flags', async () => { + db.listPages = async () => [ + { page_id: 42, type: 'gm', sender_name: 'Help', sender_acct: 'x', web_id: 9, handled: 0, sent_ms: '1234', payload: null }, + ] + const [pg] = await shardState.listPages() + assert.equal(pg.pageId, 42) + assert.deepEqual(pg.sender, { serial: 42, name: 'Help', acct: 'x', webId: 9 }) + assert.equal(pg.handled, false) + assert.equal(pg.sentMs, 1234) +}) + +test('listGovernors falls back to a shaped governor object when payload is absent', async () => { + db.listGovernors = async () => [ + { city: 'Britain', governor_serial: 5, governor_name: 'Lord', governor_acct: 'a', election_phase: 'none', payload: null }, + ] + const [g] = await shardState.listGovernors() + assert.equal(g.kind, 'city.update') + assert.equal(g.city, 'Britain') + assert.equal(g.governor.name, 'Lord') + assert.equal(g.governorElect, null) +}) + +test('listGovernorHistory coerces started/ended timestamps to numbers and clamps the limit', async () => { + let seenLimit + db.listGovernorTerms = async (city, n) => { + seenLimit = n + return [{ city, governor_serial: 1, governor_name: 'X', started_at: '100', ended_at: null, votes: 3 }] + } + const out = await shardState.listGovernorHistory('Trinsic', 99999) + assert.equal(seenLimit, 500) // clamped to the 500 max + assert.equal(out[0].startedAt, 100) + assert.equal(typeof out[0].startedAt, 'number') + assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0 +}) diff --git a/server/test/shardStreams.test.js b/server/test/shardStreams.test.js new file mode 100644 index 0000000..a60c6c4 --- /dev/null +++ b/server/test/shardStreams.test.js @@ -0,0 +1,151 @@ +// `mapShardEvent` and the shard-event push fan-out, moved out of core's +// pushDispatch.test.js in Phase 3 (MODULE_SYSTEM.md §2.7.1). +// +// Core keeps the push INFRASTRUCTURE and its tests — the SSRF guard on an ntfy +// endpoint, and `publish()` sending a content-free tickle. What is here is the +// CATALOG and the mapping into it: which shard event becomes which stream, which +// kinds are owner-keyed, and which must never produce a public target. That last +// one is a security boundary and it is module-owned by design (MODULE_API.md +// §2.4) — the kinds, the streams and the public-safety filter are one file that +// moves together. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { fakeCtx } = require('./_fakes') +require('../core').init(fakeCtx()) + +const { mapShardEvent, createTracker } = require('../config/shardStreams') +const shardPush = require('../utils/shardPush') + +// Moved with the fromShardEvent tests. They set NTFY_* because the push +// dispatcher they hand results to reads them — core's variables, read by core's +// code, which is why this stays a plain env helper rather than becoming +// something on ctx: the module never reads these itself. +// ── mapShardEvent ─────────────────────────────────────────────────────────── + +test('server.hello / shutdown / crashed map to the public server.status stream', () => { + const t = createTracker() + assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [ + { streamId: 'server.status', ref: 'up:b1' }, + ]) + assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }]) + assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }]) +}) + +test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => { + const t = createTracker() + const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t) + assert.deepEqual(out, [ + { streamId: 'idoc.warning', ref: '0x40' }, + { streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' }, + ]) + // A non-IDOC decay stage produces nothing. + assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), []) +}) + +test('champ.update fires champ.start only on the inactive→active transition', () => { + const t = createTracker() + // First sight active → start. + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [ + { streamId: 'champ.start', ref: 'c1' }, + ]) + // Still active → no re-fire. + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), []) + // Goes inactive, then active again → fires again. + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), []) + assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [ + { streamId: 'champ.start', ref: 'c1' }, + ]) +}) + +test('city.update fires governor.election only on a real governor change, never on first sight', () => { + const t = createTracker() + // First sight of the city → no election (could be a reconnect snapshot). + assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), []) + // Same governor → nothing. + assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), []) + // New governor → election. + assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [ + { streamId: 'governor.election', ref: 'Britain' }, + ]) +}) + +test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => { + const t = createTracker() + const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t) + assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }]) + + const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t) + assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }]) + + // Every personal target carries an ownerAccount (never a bare public push). + for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed') + + // A truly sensitive, unmapped kind produces nothing at all. + assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), []) + assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), []) +}) + + +// ── fromShardEvent (owner resolution) ─────────────────────────────────────── +// +// **These two tests changed shape in the move, and the change is the boundary.** +// In core they asserted through `publish()` into `pushDevices` and a captured +// fetch — which endpoints were hit, how many requests went out. None of that is +// this module's any more: `publish` is `ctx.push.publish`, core's, and the device +// registry and the relay are behind it. Reaching for them from here would mean +// reaching past `ctx`, which is exactly what §5.1 forbids. +// +// What remains is what the module actually owns, and it is the part worth +// guarding: a game account resolves to a website user through `shardLinks`, a +// personal target that resolves to nobody is dropped rather than published, and +// a public target publishes with no owner. Core's own pushDispatch tests still +// cover the fan-out on the other side of the seam. + +/** Record what the module asked core to publish. */ +function capturePublish() { + const calls = [] + return { calls, publish: async (streamId, opts) => { calls.push({ streamId, ...opts }) } } +} + +test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => { + const { calls, publish } = capturePublish() + const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) } + const deps = { shardLinks, publish, tracker: createTracker() } + + await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps) + assert.equal(calls.length, 1) + assert.equal(calls[0].streamId, 'vendor.sale') + assert.equal(calls[0].ownerUserId, 42, 'the game account must resolve to the website user') + + // Unlinked account → nobody to notify → nothing published. Not an error: a + // player who never linked their account is the ordinary case, not a fault. + calls.length = 0 + await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps) + assert.equal(calls.length, 0) +}) + +test('fromShardEvent publishes a public shard event with no owner', async () => { + const { calls, publish } = capturePublish() + await shardPush.fromShardEvent( + { kind: 'server.hello', bootId: 'b1' }, + { publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } }, + ) + assert.equal(calls.length, 1) + assert.equal(calls[0].streamId, 'server.status') + assert.equal(calls[0].ownerUserId, undefined, 'a public target must not be owner-keyed') +}) + +test('a sensitive kind never reaches publish at all', async () => { + // The public-safety filter is module-internal by design (MODULE_API.md §2.4): + // the kinds, the streams and the filter are one file that moves together, so + // core never holds a rule about data only this module defines. Which makes + // this the right side of the boundary for the test too. + const { calls, publish } = capturePublish() + const deps = { publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } } + for (const kind of ['cheat.fastwalk', 'admin.audit']) { + await shardPush.fromShardEvent({ kind, acct: 'bob', actor: 'staff' }, deps) + } + assert.equal(calls.length, 0) +}) diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js new file mode 100644 index 0000000..6a49368 --- /dev/null +++ b/server/test/shardVisibility.test.js @@ -0,0 +1,426 @@ +// 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('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 `Acct` / `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') +}) diff --git a/server/test/spawnAtlas.parse.test.js b/server/test/spawnAtlas.parse.test.js new file mode 100644 index 0000000..76d4bbc --- /dev/null +++ b/server/test/spawnAtlas.parse.test.js @@ -0,0 +1,601 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { + parseXml, + parseObjects2, + parsePoints, + parseRegions, + parseLocations, + parseChampions, + buildPlacementIndex, + resolveRegion, + facetKey, + buildFacetIndex, + resolveFacetName, + slugify, + decodeEntities, +} = require('../utils/spawnAtlasParse') + +// These parsers are pure and fs-free precisely so this suite can run in CI, +// where there is no ServUO tree. Every fixture below is a literal excerpt of a +// real shard file, trimmed — not invented shapes. + +// ── parseObjects2 ────────────────────────────────────────────────────────── + +test('parseObjects2: single type', () => { + const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1') + assert.deepEqual(types, [{ type: 'Jacob', max: 1 }]) +}) + +test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => { + // Verbatim from trammel.xml — the case that a naive split(':') destroys. + const raw = + 'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + + ':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + + ':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + + ':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + + ':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + + ':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' + const types = parseObjects2(raw) + assert.equal(types.length, 6) + assert.deepEqual( + types.map((t) => t.type), + ['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'], + ) + // MX is per type, not per spawner: the lizardman entry carries 3. + assert.equal(types.find((t) => t.type === 'Lizardman').max, 3) + assert.equal(types.find((t) => t.type === 'Gazer').max, 1) +}) + +test('parseObjects2: empty and whitespace values yield no types', () => { + assert.deepEqual(parseObjects2(''), []) + assert.deepEqual(parseObjects2(' '), []) + assert.deepEqual(parseObjects2(null), []) + assert.deepEqual(parseObjects2(undefined), []) +}) + +test('parseObjects2: strips XmlSpawner property directives after "/"', () => { + // Left in place these become creatures that do not exist. + assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }]) + assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }]) + assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [ + { type: 'GargishRefugee', max: 1 }, + ]) +}) + +test('parseObjects2: strips argument lists after ","', () => { + assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }]) + assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }]) + assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }]) +}) + +test('parseObjects2: a directive-laden token slugs the same as the bare one', () => { + // The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed + // as two different creatures on the same page. + const bare = parseObjects2('Fairy:MX=1')[0] + const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0] + assert.equal(slugify(decorated.type), slugify(bare.type)) +}) + +test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => { + const raw = + 'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP//blessed/true/name/lord blackthorne/z/:MX=1' + assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }]) +}) + +test('parseObjects2: a token that is only a directive yields nothing', () => { + assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), []) + assert.deepEqual(parseObjects2(',1:MX=1'), []) +}) + +test('parseObjects2: a type with no MX token defaults to 1', () => { + assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }]) + assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }]) +}) + +// ── parsePoints ──────────────────────────────────────────────────────────── + +const POINTS_XML = ` + + CovetousSpawner26 + 001a34e5-0efa-46de-9c93-b6a163d96370 + Trammel + 5412 + 1970 + 10 + 10 + 5 + 3 + 5 + 10 + 500 + 0 + 0 + 0 + True + Lizardman:MX=3:SB=0 + + + Disabled + Felucca + 100 + 200 + 1 + False + Orc:MX=1 + +` + +test('parsePoints: reads the kept fields and drops the rest', () => { + const points = parsePoints(POINTS_XML) + assert.equal(points.length, 2) + const covetous = points[0] + assert.equal(covetous.name, 'CovetousSpawner26') + assert.equal(covetous.facet, 'Trammel') + assert.equal(covetous.x, 5412) + assert.equal(covetous.y, 1970) + assert.equal(covetous.width, 10) + assert.equal(covetous.range, 5) + assert.equal(covetous.maxCount, 3) + // Delays are normalised to seconds; this record carries no DelayInSec, which + // means minutes. + assert.equal(covetous.minDelay, 300) + assert.equal(covetous.maxDelay, 600) + assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }]) + // Dropped fields must not survive into the artifact — this is what keeps it + // under 1 MB. + assert.equal(covetous.uniqueId, undefined) + assert.equal(covetous.proximityTriggerSound, undefined) +}) + +test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => { + const points = parsePoints(POINTS_XML) + assert.equal(points[0].running, true) + assert.equal(points[1].running, false) +}) + +test('parsePoints: facet comes from , never the file name', () => { + // Eodon.xml holds TerMur points; a file-name assumption would mislabel every + // one of them. + const points = parsePoints( + 'aTerMur12', + ) + assert.equal(points[0].facet, 'TerMur') +}) + +test('parsePoints: a record with no is skipped rather than misfiled', () => { + const points = parsePoints('a12') + assert.deepEqual(points, []) +}) + +test('parsePoints: empty document yields no points', () => { + assert.deepEqual(parsePoints(''), []) + assert.deepEqual(parsePoints(''), []) +}) + +// ── parseRegions ─────────────────────────────────────────────────────────── + +const REGIONS_XML = ` + + + + + + + + + + + + + + + + + + + + + + + +` + +test('parseRegions: flattens nested regions and collects rects', () => { + const regions = parseRegions(REGIONS_XML) + const byName = new Map(regions.map((r) => [r.name, r])) + assert.ok(byName.has('Moongates')) + assert.ok(byName.has('Prism of Light')) + assert.equal(byName.get('Moongates').rects.length, 2) + assert.deepEqual(byName.get('Moongates').rects[0], { + x: 1330, + y: 1991, + width: 13, + height: 13, + }) + assert.equal(byName.get('Moongates').facet, 'Felucca') + assert.equal(byName.get('Moongates').type, 'GuardedRegion') +}) + +test('parseRegions: a nested child records its parent', () => { + const regions = parseRegions(REGIONS_XML) + const crystal = regions.find((r) => r.name === 'Crystal Field') + assert.ok(crystal, 'the nested named region should be indexed') + assert.equal(crystal.parent, 'Prism of Light') + assert.equal(crystal.facet, 'Felucca') +}) + +test('parseRegions: a child with no priority inherits its parent', () => { + // Defaulting to 0 instead would sort this specific room below every + // top-level region that contains it. + const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field') + assert.equal(crystal.priority, 50) +}) + +test('parseRegions: unnamed regions are skipped but still walked', () => { + const regions = parseRegions(REGIONS_XML) + // IcyRiver has a type but no name — it cannot label anything. + assert.equal(regions.some((r) => r.type === 'IcyRiver'), false) +}) + +test('parseRegions: a named region with no rects is not indexed', () => { + // It can never contain a point, so indexing it only costs scan time. + assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false) +}) + +// ── parseLocations ───────────────────────────────────────────────────────── + +const LOCATIONS_XML = ` + + + + + + + + + + + + +` + +test('parseLocations: flattens to points carrying their group', () => { + const landmarks = parseLocations(LOCATIONS_XML) + assert.equal(landmarks.length, 3) + const level1 = landmarks.find((l) => l.name === 'Level 1') + assert.equal(level1.x, 5456) + assert.equal(level1.y, 1863) + assert.equal(level1.z, 0) + assert.equal(level1.facet, 'Trammel') + // "Covetous" is the useful label, not "Level 1". + assert.equal(level1.group, 'Covetous') + // The facet-level parent is dropped from the path. + assert.deepEqual(level1.path, ['Dungeons', 'Covetous']) +}) + +// ── Facet canonicalisation ───────────────────────────────────────────────── + +// Facets are NOT a fixed list — a shard may add, replace or rename them when its +// maps are updated, so nothing may hardcode the stock six. Reconciliation is by +// matching against whatever the shard's own files declare. + +test('facetKey: collapses spelling differences to one key', () => { + assert.equal(facetKey('Ter Mur'), facetKey('TerMur')) + assert.equal(facetKey('ter-mur'), facetKey('TerMur')) + assert.equal(facetKey('Felucca'), 'felucca') + assert.equal(facetKey(''), '') + assert.equal(facetKey(null), '') +}) + +test('facetKey: distinct facets keep distinct keys', () => { + assert.notEqual(facetKey('Felucca'), facetKey('Trammel')) +}) + +test('resolveFacetName: matches a loose spelling to the discovered canonical', () => { + // The canonical set comes from the shard's own spawn/region data, not a table. + const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca']) + assert.equal(resolveFacetName('Ter Mur', index), 'TerMur') + assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno') + assert.equal(resolveFacetName('felucca', index), 'Felucca') +}) + +test('resolveFacetName: works for facets that do not exist in stock UO', () => { + // The whole point: a shard running its own maps gets the same treatment as + // the stock ones, with no entry anywhere naming them. + const index = buildFacetIndex(['Sosaria', 'The Underdark']) + assert.equal(resolveFacetName('sosaria', index), 'Sosaria') + assert.equal(resolveFacetName('The Underdark', index), 'The Underdark') + assert.equal(resolveFacetName('the-underdark', index), 'The Underdark') + // Same shape as the real `Tokuno Islands` → `Tokuno` case. + assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria') +}) + +test('resolveFacetName: a merely similar name is NOT forced to match', () => { + // "Underdark Isles" is not a prefix of "The Underdark" in either direction. + // Keeping its own name is right — a wrong match would silently file a real + // custom facet's landmarks under the wrong facet. + const index = buildFacetIndex(['The Underdark']) + assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles') +}) + +test('resolveFacetName: prefers the longer match when several could prefix', () => { + const index = buildFacetIndex(['Tokuno', 'TokunoDeep']) + assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep') +}) + +test('resolveFacetName: an unmatched facet keeps its own name', () => { + // Inventing a match would be worse than leaving a real custom facet alone. + const index = buildFacetIndex(['Felucca']) + assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar') + assert.equal(resolveFacetName('', index), '') + assert.equal(resolveFacetName(null, index), '') +}) + +test('buildFacetIndex: first spelling wins and is stable', () => { + const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur']) + assert.equal(index.size, 1) + assert.equal(resolveFacetName('Ter Mur', index), 'TerMur') +}) + +test('parsePoints and parseRegions report facet names verbatim', () => { + // and are the authority; they are never rewritten. + const points = parsePoints( + 'aSosaria12', + ) + assert.equal(points[0].facet, 'Sosaria') + const regions = parseRegions( + '' + + '', + ) + assert.equal(regions[0].facet, 'Sosaria') +}) + +test('placement index buckets two spellings of one facet together', () => { + // This is the bug the key exists to prevent: unreconciled, the landmark bucket + // is keyed apart from the points looking it up, the fallback never fires, and + // every unregioned spawn on that facet silently reads "Wilderness". + const index = buildPlacementIndex( + [], + [{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }], + ) + assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City') +}) + +// ── parseChampions ───────────────────────────────────────────────────────── + +const CHAMPIONS_XML = ` + + + + + + + + +` + +test('parseChampions: reads altar name, type and location', () => { + const champs = parseChampions(CHAMPIONS_XML) + assert.equal(champs.length, 2) + assert.deepEqual(champs[0], { + name: 'Deceit', + group: 'FelDungeons', + type: 'UnholyTerror', + randomType: false, + facet: 'Felucca', + x: 5178, + y: 708, + z: 20, + radius: 60, + }) +}) + +test('parseChampions: a spawn with no type is flagged random, not blank', () => { + const champs = parseChampions(CHAMPIONS_XML) + assert.equal(champs[1].randomType, true) + assert.equal(champs[1].type, '') +}) + +// ── resolveRegion ────────────────────────────────────────────────────────── + +function fixtureIndex() { + const regions = [ + { + facet: 'Felucca', + name: 'Britain', + type: 'TownRegion', + priority: 10, + parent: null, + rects: [{ x: 1000, y: 1000, width: 500, height: 500 }], + }, + { + facet: 'Felucca', + name: 'Britain Bank', + type: 'TownRegion', + priority: 50, + parent: 'Britain', + rects: [{ x: 1400, y: 1400, width: 20, height: 20 }], + }, + { + facet: 'Felucca', + name: 'Wide Low Priority', + type: 'TownRegion', + priority: 10, + parent: null, + rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }], + }, + ] + const landmarks = [ + { facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 }, + { facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 }, + ] + return buildPlacementIndex(regions, landmarks) +} + +test('resolveRegion: a contained point takes the region name', () => { + const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex()) + assert.equal(result.region, 'Britain') + assert.equal(result.label, 'Britain') + assert.equal(result.landmark, null) +}) + +test('resolveRegion: higher priority wins over a containing region', () => { + const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex()) + assert.equal(result.region, 'Britain Bank') +}) + +test('resolveRegion: equal priority breaks toward the smaller rect', () => { + // Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this + // point at priority 10; the specific one must win. + const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex()) + assert.equal(result.region, 'Britain') +}) + +test('resolveRegion: rects are half-open — the far edge is outside', () => { + const index = fixtureIndex() + // Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region. + assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain') + assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority') +}) + +test('resolveRegion: falls back to the nearest landmark group', () => { + const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex()) + assert.equal(result.region, null) + assert.equal(result.landmark, 'Covetous') + assert.equal(result.label, 'Covetous') +}) + +test('resolveRegion: a landmark beyond the radius yields Wilderness', () => { + // Without the radius cap the nearest landmark is always *some* landmark, and + // open countryside would get labelled with a dungeon across the map. + const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex()) + assert.equal(result.landmark, null) + assert.equal(result.label, 'Wilderness') +}) + +test('resolveRegion: the radius is configurable', () => { + const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 }) + assert.equal(wide.label, 'Covetous') +}) + +test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => { + const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex()) + assert.equal(result.label, 'Wilderness') + assert.equal(result.region, null) +}) + +test('resolveRegion: does not leak across facets', () => { + const index = buildPlacementIndex( + [ + { + facet: 'Trammel', + name: 'Britain', + type: 'TownRegion', + priority: 10, + parent: null, + rects: [{ x: 1000, y: 1000, width: 500, height: 500 }], + }, + ], + [], + ) + assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain') + assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null) +}) + +// ── Tokenizer edge cases ─────────────────────────────────────────────────── + +test('parseXml: skips comments, declarations and DOCTYPE', () => { + const root = parseXml( + '', + ) + assert.equal(root.name, 'r') + assert.equal(root.children.length, 1) + assert.equal(root.children[0].name, 'a') + assert.equal(root.children[0].attrs.x, '1') +}) + +test('parseXml: a ">" inside an attribute value does not end the tag', () => { + const root = parseXml('') + assert.equal(root.children[0].attrs.name, '1 > 0') + assert.equal(root.children[0].attrs.b, '2') +}) + +test('parseXml: single-quoted attributes are read', () => { + const root = parseXml("") + assert.equal(root.children[0].attrs.name, 'Mondain') +}) + +test('parseXml: a stray closing tag is ignored, not fatal', () => { + // Hand-maintained shard config: one malformed element should degrade to a + // missing element, not abort an otherwise good build. + const root = parseXml('') + assert.equal(root.name, 'r') + assert.deepEqual(root.children.map((n) => n.name), ['a', 'c']) +}) + +test('parseXml: empty or element-free input yields null', () => { + assert.equal(parseXml(''), null) + assert.equal(parseXml(''), null) +}) + +test('decodeEntities: named, numeric and hex refs', () => { + assert.equal(decodeEntities("Mondain's Legacy"), "Mondain's Legacy") + assert.equal(decodeEntities('a & b'), 'a & b') + assert.equal(decodeEntities('<tag>'), '') + assert.equal(decodeEntities('AB'), 'AB') + // An unknown entity is left alone rather than silently eaten. + assert.equal(decodeEntities('&nosuch;'), '&nosuch;') +}) + +test('parseXml: decodes entities in attribute values', () => { + const root = parseXml('') + assert.equal(root.children[0].attrs.name, "Mondain's Legacy") +}) + +// ── slugify ──────────────────────────────────────────────────────────────── + +test('slugify: produces URL-safe keys', () => { + assert.equal(slugify('Lizardman'), 'lizardman') + assert.equal(slugify('Giant Spider'), 'giant-spider') + assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy') + assert.equal(slugify(' Orc '), 'orc') +}) + +// ── Respawn delays: the unit is per record ────────────────────────────────── +// XmlSpawner writes minutes by default and switches to seconds only when a +// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore +// means five MINUTES on one spawner and five SECONDS on the next, and a reader +// assuming either unit is wrong about the other — silently, since both are +// plausible respawn times. +const DELAY_XML = ` + + Minutes + Sosaria + 11 + 5 + 10 + True + Orc:MX=1 + + + Seconds + Sosaria + 22 + True + 5 + 10 + True + Orc:MX=1 + +` + +test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => { + const [minutes, seconds] = parsePoints(DELAY_XML) + assert.equal(minutes.minDelay, 300) + assert.equal(minutes.maxDelay, 600) + assert.equal(seconds.minDelay, 5) + assert.equal(seconds.maxDelay, 10) +}) diff --git a/server/test/spawnAtlas.source.test.js b/server/test/spawnAtlas.source.test.js new file mode 100644 index 0000000..6e56b8e --- /dev/null +++ b/server/test/spawnAtlas.source.test.js @@ -0,0 +1,399 @@ +// 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. + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const { test, after, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const { + AtlasSourceError, + aggregateCreatures, + displayName, + sameSources, + hashSources, + buildAtlas, + PARSER_VERSION, +} = require('../utils/spawnAtlasSource') +const shardAtlas = require('../model/shardAtlas/shardAtlas.model') +const atlasDb = require('../model/shardAtlas/shardAtlas.db') +const { ctx } = require('./_setup') +const settings = ctx.settings + + +// ── A tiny synthetic ServUO tree ─────────────────────────────────────────── +// +// Deliberately uses facets that do NOT exist in stock UO. The atlas must not +// contain a built-in facet list anywhere: a shard may add facets, replace them +// outright, or rename them when its maps are updated, and everything has to keep +// working with no code change. + +function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) { + fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true }) + fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true }) + fs.mkdirSync(path.join(root, 'Config'), { recursive: true }) + + for (const facet of facets) { + fs.writeFileSync( + path.join(root, 'Spawns', `${facet}.xml`), + ` + ${facet}A${facet}11001100 + 3True + Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0 + ${facet}B${facet}90009000 + 1True + lizardman:MX=2:SB=0 + ${facet}Off${facet}11 + 1False + Ghost:MX=1 + `, + 'utf8', + ) + // The location file names its facet differently from , the real + // `Ter Mur` / `Tokuno Islands` drift. + fs.writeFileSync( + path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`), + ` + `, + 'utf8', + ) + } + + fs.writeFileSync( + path.join(root, 'Data', 'Regions.xml'), + `${facets + .map( + (facet) => ` + + + `, + ) + .join('')}`, + 'utf8', + ) + + if (includeChampions) { + fs.writeFileSync( + path.join(root, 'Config', 'ChampionSpawns.xml'), + ` + + `, + 'utf8', + ) + } +} + +function tempTree(options) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-')) + writeTree(root, options) + return root +} + +// ── buildAtlas against a custom-facet tree ───────────────────────────────── + +test('buildAtlas: works entirely on facets that do not exist in stock UO', () => { + const root = tempTree({ facets: ['Sosaria', 'Underdark'] }) + const atlas = buildAtlas(root) + assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark']) + assert.equal(atlas.meta.counts.facets, 2) +}) + +test('buildAtlas: reconciles a location file that spells the facet differently', () => { + // "Sosaria Isles" inside the file vs Sosaria — the same drift that + // silently emptied the Ter Mur / Tokuno landmark buckets. + const root = tempTree({ facets: ['Sosaria'] }) + const atlas = buildAtlas(root) + assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria']) + // And the fallback actually fires, rather than the point reading Wilderness. + const far = atlas.points.find((p) => p.name === 'SosariaB') + assert.equal(far.landmark, 'Deep Cave') + assert.equal(far.label, 'Deep Cave') +}) + +test('buildAtlas: resolves a contained point to its region', () => { + const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] })) + const inCity = atlas.points.find((p) => p.name === 'SosariaA') + assert.equal(inCity.region, 'Sosaria City') + assert.equal(inCity.label, 'Sosaria City') +}) + +test('buildAtlas: drops spawners that are switched off in-world', () => { + const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] })) + assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false) + assert.equal(atlas.meta.counts.pointsDisabled, 1) +}) + +test('buildAtlas: a champion altar resolves through the same placement index', () => { + const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] })) + assert.equal(atlas.champions[0].label, 'Sosaria City') + assert.equal(atlas.champions[0].facet, 'Sosaria') +}) + +test('buildAtlas: a tree with no champion file still builds', () => { + const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false })) + assert.deepEqual(atlas.champions, []) +}) + +test('buildAtlas: missing path and empty path raise typed errors', () => { + assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH') + assert.throws( + () => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')), + (err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND', + ) +}) + +test('buildAtlas: a directory with no spawn files raises rather than building empty', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-')) + fs.mkdirSync(path.join(root, 'Data'), { recursive: true }) + fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '', 'utf8') + assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS') +}) + +// ── Hashing ──────────────────────────────────────────────────────────────── + +test('hashSources: stable across reads, changes when a file changes', () => { + const root = tempTree({ facets: ['Sosaria'] }) + const first = hashSources(root) + assert.ok(sameSources(first, hashSources(root))) + + fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '', 'utf8') + assert.equal(sameSources(first, hashSources(root)), false) +}) + +test('sameSources: a missing or extra file is a difference', () => { + assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false) + assert.equal(sameSources({ a: '1' }, { a: '2' }), false) + assert.equal(sameSources({ a: '1' }, { a: '1' }), true) + assert.equal(sameSources(null, { a: '1' }), false) + assert.equal(sameSources({ a: '1' }, null), false) +}) + +// ── Aggregation ──────────────────────────────────────────────────────────── + +const POINTS = [ + { facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] }, + { facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] }, + { facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] }, +] + +test('aggregateCreatures: sums each type’s own max and counts per facet', () => { + const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman') + assert.equal(lizardman.total, 10) + assert.equal(lizardman.points, 3) + assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 }) +}) + +test('aggregateCreatures: differing case collapses to one creature', () => { + const creatures = aggregateCreatures(POINTS) + assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1) + assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc']) + assert.equal(Object.hasOwn(creatures[0], 'spellings'), false) +}) + +test('displayName: most common wins, ties break to the capitalised form', () => { + assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman') + assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman') + // Deterministic regardless of insertion order — a committed artifact is gone, + // but a spurious diff in the DB on every restart would be just as wrong. + assert.equal( + displayName(new Map([['abc', 1], ['abd', 1]])), + displayName(new Map([['abd', 1], ['abc', 1]])), + ) +}) + +test('pointTypeRows: collapses a repeated type to the larger max', () => { + // The primary key is (point_id, slug), so a duplicate would otherwise fail the + // insert and take the whole transaction with it. + const rows = shardAtlas.pointTypeRows([ + { types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] }, + ]) + assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort()) +}) + +test('pointTypeRows: point ids are 1-based and line up with insert order', () => { + const rows = shardAtlas.pointTypeRows([ + { types: [{ type: 'A', max: 1 }] }, + { types: [{ type: 'B', max: 1 }] }, + ]) + assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]]) +}) + +// ── The refresh decision ─────────────────────────────────────────────────── +// +// The boot path's two contracts: it never blocks startup, and it never applies a +// facet removal on its own. + +let applied +let pendingRow +let facetsInDb +let metaRow + +beforeEach(() => { + applied = null + pendingRow = null + facetsInDb = [] + metaRow = null + atlasDb.replaceAtlas = async (atlas) => { + applied = atlas + return { points: atlas.points.length, creatures: atlas.creatures.length } + } + atlasDb.getMeta = async () => metaRow + atlasDb.getFacets = async () => facetsInDb + atlasDb.getPending = async () => pendingRow + atlasDb.setPending = async (payload, status) => { + pendingRow = { ...payload, status } + } + atlasDb.clearPending = async () => { + pendingRow = null + } + settings.get = async () => '' + process.env.SERVUO_PATH = '' +}) + +test('refresh: no configured path is skipped, not an error', async () => { + const result = await shardAtlas.refresh() + assert.equal(result.status, 'skipped') +}) + +test('refresh: an unreadable tree reports unavailable rather than throwing', async () => { + const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') }) + assert.equal(result.status, 'unavailable') + assert.equal(result.code, 'NOT_FOUND') +}) + +test('refresh: a fresh database imports', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'imported') + assert.ok(applied) + assert.deepEqual(result.addedFacets, ['Sosaria']) +}) + +test('refresh: an unchanged tree parses nothing and writes nothing', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + metaRow = buildAtlas(root).meta + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'unchanged') + assert.equal(applied, null) +}) + +// The hash gate alone would strand an install whose maps never change on +// whatever an older build derived: a corrected parse would ship and never reach +// the data, because the only thing compared is the tree. +test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 } + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'imported') + assert.ok(applied) +}) + +test('refresh: an atlas imported before parser versions existed is stale', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + const meta = buildAtlas(root).meta + delete meta.parserVersion + metaRow = meta + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'imported') +}) + +test('refresh: --force reimports an unchanged tree', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + metaRow = buildAtlas(root).meta + const result = await shardAtlas.refresh({ path: root, force: true }) + assert.equal(result.status, 'imported') + assert.ok(applied) +}) + +test('refresh: a NEW facet applies straight away', async () => { + // Additions cannot destroy anything an operator would miss. + const root = tempTree({ facets: ['Sosaria', 'Underdark'] }) + facetsInDb = ['Sosaria'] + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'imported') + assert.deepEqual(result.addedFacets, ['Underdark']) +}) + +test('refresh: a REMOVED facet is staged, not applied', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + facetsInDb = ['Sosaria', 'Underdark'] + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'needsReview') + assert.deepEqual(result.removedFacets, ['Underdark']) + // The critical part: the existing atlas was left alone. + assert.equal(applied, null) + assert.equal(pendingRow.status, 'pending') +}) + +test('refresh: approving applies the removal', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + facetsInDb = ['Sosaria', 'Underdark'] + await shardAtlas.refresh({ path: root }) + assert.equal(applied, null) + + const result = await shardAtlas.approvePending({ path: root }) + assert.equal(result.status, 'imported') + assert.ok(applied) + assert.deepEqual(result.removedFacets, ['Underdark']) +}) + +test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + facetsInDb = ['Sosaria', 'Underdark'] + await shardAtlas.refresh({ path: root }) + await shardAtlas.rejectPending() + assert.equal(pendingRow.status, 'rejected') + + const again = await shardAtlas.refresh({ path: root }) + assert.equal(again.status, 'unchanged') + assert.equal(applied, null) +}) + +test('refresh: changing the tree asks again after a rejection', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + facetsInDb = ['Sosaria', 'Underdark'] + await shardAtlas.refresh({ path: root }) + await shardAtlas.rejectPending() + + fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '', 'utf8') + const again = await shardAtlas.refresh({ path: root }) + assert.equal(again.status, 'needsReview') +}) + +test('refreshOnBoot: never throws, whatever goes wrong', async () => { + atlasDb.getMeta = async () => { + throw new Error('database is on fire') + } + atlasDb.getFacets = async () => { + throw new Error('still on fire') + } + atlasDb.replaceAtlas = async () => { + throw new Error('and the import too') + } + process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] }) + + const result = await shardAtlas.refreshOnBoot() + assert.equal(result.status, 'failed') +}) + +test('refreshOnBoot: a missing tree is survivable, not fatal', async () => { + process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz') + const result = await shardAtlas.refreshOnBoot() + assert.equal(result.status, 'unavailable') +}) + +test('refresh: an explicit path overrides the configured one', async () => { + const configured = tempTree({ facets: ['Configured'] }) + const override = tempTree({ facets: ['Override'] }) + settings.get = async () => configured + + const result = await shardAtlas.refresh({ path: override }) + assert.equal(result.status, 'imported') + assert.deepEqual(result.addedFacets, ['Override']) +}) diff --git a/server/test/townCrier.test.js b/server/test/townCrier.test.js new file mode 100644 index 0000000..9f4cd0c --- /dev/null +++ b/server/test/townCrier.test.js @@ -0,0 +1,70 @@ +// The town-crier leg's own tests, moved out of core's announceJobs.test.js in +// Phase 3 (MODULE_SYSTEM.md §2.7.1). +// +// Core keeps what it owns there — the backoff schedule, the parent-status rollup +// and the Discord leg — because those are the announce PIPELINE. What is here is +// this module's LEG: how a post becomes town-crier lines, and how the sidecar's +// answers classify into done / retry / terminal. The split is the same one the +// registry makes. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const { fakeCtx } = require('./_fakes') +require('../core').init(fakeCtx()) + +const townCrier = require('../utils/shardAnnounce') + +// ── buildTownCrierText ─────────────────────────────────────────────────────── +test('buildTownCrierText produces title, excerpt, and URL lines', () => { + const lines = townCrier.buildTownCrierText( + { id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null }, + { baseUrl: 'https://uom.example' }, + ) + assert.deepEqual(lines, ['Server Update', 'Big things afoot.', 'https://uom.example/site/news']) +}) + +test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => { + const lines = townCrier.buildTownCrierText( + { id: 1, title: 'T', excerpt: '', body: '

Hello world

' }, + { baseUrl: 'https://uom.example' }, + ) + assert.equal(lines[1], 'Hello world') +}) + +test('buildTownCrierText clamps each line to the sidecar per-line cap', () => { + const longTitle = 'x'.repeat(500) + const lines = townCrier.buildTownCrierText( + { id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null }, + { baseUrl: 'https://uom.example' }, + ) + for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`) + assert.ok(lines[0].endsWith('…')) + assert.ok(lines.length <= townCrier.MAX_LINES) +}) + +test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => { + const lines = townCrier.buildTownCrierText( + { id: 1, title: 'Only a title', excerpt: null, body: null }, + { baseUrl: 'https://uom.example' }, + ) + assert.deepEqual(lines, ['Only a title', 'https://uom.example/site/news']) +}) + +// ── classifyTownCrier ──────────────────────────────────────────────────────── +test('town crier classify: 2xx is done', () => { + assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done') +}) + +test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => { + for (const status of [400, 401, 409]) { + assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`) + } +}) + +test('town crier classify: shard-transient and network errors retry', () => { + for (const status of [503, 504, 500, 0]) { + assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`) + } +}) + diff --git a/server/test/uoLinkClient.test.js b/server/test/uoLinkClient.test.js new file mode 100644 index 0000000..11f84a5 --- /dev/null +++ b/server/test/uoLinkClient.test.js @@ -0,0 +1,74 @@ +// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through +// every moved test: core internals can no longer be stubbed by requiring them, +// because there are none to require — `../utils/db` and `../model/settings` do +// not exist here. What a test controls instead is the `ctx` core would have +// handed over, installed once by `test/_setup.js`, which is the seam the +// contract actually promises. + +// Point the DB at a closed port BEFORE requiring the modules (they build the pool). +// The uoLinkConfig model is monkeypatched so no query runs. + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// The uo-link REST client's headline contract (see its module header and +// CLAUDE.md): it NEVER throws — every call resolves to { ok, data, status, error } +// so a public page or an admin poll degrades to "shard unavailable" instead of +// 500ing. The regression these tests lock down: resolveConfig() decrypts the +// stored auth token, and secretBox.decrypt THROWS when the ciphertext can't be +// authenticated (SECRET_ENC_KEY rotated, or a DB dump restored under a different +// key). It used to run OUTSIDE call()'s try, so that throw escaped the client and +// 500'd every live-shard route. +const uoLinkClient = require('../utils/uoLinkClient') +const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') + + +const origGetWithToken = uoLinkConfig.getWithToken +afterEach(() => { + uoLinkConfig.getWithToken = origGetWithToken + uoLinkClient.invalidateConfig() // drop the 5s config cache between cases +}) + +test('an undecryptable stored token resolves to { ok: false } instead of throwing', async () => { + uoLinkConfig.getWithToken = async () => { + // Exactly what crypto's Decipheriv.final() raises on a bad key / tampered blob. + throw new Error('Unsupported state or unable to authenticate data') + } + uoLinkClient.invalidateConfig() + + const result = await uoLinkClient.health() + + assert.equal(result.ok, false, 'must report failure, not throw') + assert.equal(result.status, 0) + assert.match(result.error, /unreadable/i, 'distinguishes config failure from a dead sidecar') +}) + +test('every read helper stays on the { ok:false } contract when config is unreadable', async () => { + uoLinkConfig.getWithToken = async () => { + throw new Error('Unsupported state or unable to authenticate data') + } + uoLinkClient.invalidateConfig() + + // The routes that regressed: character sheet, roster and vendor lookups, which + // are reachable from both /admin/shard/* and the player-facing /player/shard/*. + for (const call of [ + () => uoLinkClient.getCharBySerial('0x1'), + () => uoLinkClient.getRoster('someacct'), + () => uoLinkClient.getVendors('someacct'), + ]) { + const result = await call() + assert.equal(result.ok, false) + assert.equal(result.status, 0) + } +}) + +test('a missing/blank config still reports "not configured" (unchanged behaviour)', async () => { + uoLinkConfig.getWithToken = async () => null + uoLinkClient.invalidateConfig() + + const result = await uoLinkClient.health() + + assert.equal(result.ok, false) + assert.equal(result.status, 0) + assert.match(result.error, /not configured/i) +})