diff --git a/server/test/announceJobs.test.js b/server/test/announceJobs.test.js index c49b58d..73a1b46 100644 --- a/server/test/announceJobs.test.js +++ b/server/test/announceJobs.test.js @@ -4,62 +4,12 @@ const assert = require('node:assert/strict') const logic = require('../src/model/announceJobs/announceJobs.logic') // Each leg owns its own text-building and result classification since PR 4 — a // leg is a registration now, not a branch in the worker (MODULE_SYSTEM.md §1.8). -const townCrier = require('../src/utils/shardAnnounce') const discord = require('../src/utils/discordAnnounce') -// ── 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}`) - } -}) - +// The town-crier leg's tests left with module-uo in Phase 3 — it is a MODULE's +// leg, and its text building and status classification are its business. What +// stays here is core's announce pipeline: the backoff schedule every leg shares, +// the parent-status rollup over all of a job's legs, and core's own Discord leg. // ── classifyDiscord ────────────────────────────────────────────────────────── test('discord classify: ok is done, every failure retries', () => { assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done') diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index 9161b9a..c0ebe3a 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -503,11 +503,22 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered') const probe = JSON.parse(fs.readFileSync(seen, 'utf8')) + // API 1.1.0 added activity, users and site — each because module-uo's + // extraction needed it and none could be vendored: an admin action a module + // performs belongs in core's one audit log, the extension slot needs the user + // its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL. assert.deepEqual(probe.keys, [ - 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', - 'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator', + 'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', + 'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator', + ]) + // is core's limiter FACTORY, not a limiter: a module states its own + // window and cap and takes the plumbing, so there is one express-rate-limit in + // the process and one place a breach is logged. is + // handed over whole because it is shared policy — core's /auth/me and + // /player/account sit behind the same counter. + assert.deepEqual(probe.middleware, [ + 'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate', ]) - assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate']) assert.equal(probe.moduleId, 'probe') assert.equal(probe.mutable, false, 'ctx members must be frozen') }) diff --git a/server/test/moduleRegistries.test.js b/server/test/moduleRegistries.test.js index c2a9f26..bade2b5 100644 --- a/server/test/moduleRegistries.test.js +++ b/server/test/moduleRegistries.test.js @@ -41,17 +41,16 @@ function tryApply(owner, build) { // ── Core goes through the same door ──────────────────────────────────────── -test('registerCore registers core AND the not-yet-extracted shard content', () => { +test('registerCore registers exactly what core owns, and nothing else', () => { registries.registerCore() - const ids = registries.allStreams().map((s) => s.id) - // Core's one stream first, then the seven that leave with module-uo. - assert.equal(ids[0], 'news.post') - assert.equal(ids.length, 8) - assert.ok(ids.includes('vendor.sale')) - - assert.deepEqual(registries.announceLegIds(), ['discord', 'towncrier']) - assert.equal(registries.slotFilledBy('admin.users.detail'), 'core') + // One stream, one leg, no filled slot. Before Phase 3 this was eight streams, + // two legs and a core-filled `admin.users.detail` — core was holding shard + // CONTENT so the seam would be exercised on every boot before a module first + // used it. module-uo registers all of it now, through the same door. + assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post']) + assert.deepEqual(registries.announceLegIds(), ['discord']) + assert.equal(registries.slotFilledBy('admin.users.detail'), null) assert.equal(registries.isCoreRegistered(), true) }) @@ -63,15 +62,26 @@ test('registerCore is idempotent — a second call registers nothing twice', () }) test('the wire shape of a stream survives registration', () => { - registries.registerCore() - const personal = registries.allStreams().find((s) => s.id === 'vendor.sale') + // Asserted against a REGISTERED stream rather than a core one, because the + // shape is what a module hands over and core republishes. `vendor.sale` used + // to be the subject here and is module-uo's now; a synthetic registration + // tests the same contract without core needing a personal stream of its own. + assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([{ + id: 'uo.vendorsale', + label: 'Vendor sales', + description: 'One of your vendors sold something.', + personal: true, + requiresLinkedAccount: true, + }])), null) + + const personal = registries.allStreams().find((s) => s.id === 'uo.vendorsale') // Two booleans, not the contract's single `scope`: this object is the body of // GET /auth/me/notifications/streams and a shipped Android client reads both. assert.equal(personal.personal, true) assert.equal(personal.requiresLinkedAccount, true) assert.ok(personal.description.length > 0) - assert.ok(registries.personalStreams().has('vendor.sale')) - assert.equal(registries.isValidStream('vendor.sale'), true) + assert.ok(registries.personalStreams().has('uo.vendorsale')) + assert.equal(registries.isValidStream('uo.vendorsale'), true) assert.equal(registries.isValidStream('nope.nope'), false) }) @@ -185,10 +195,12 @@ test('only a declared slot can be filled, and only once', () => { assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/) assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/) - registries.registerCore() // core fills it + // Core no longer fills it — module-uo does. Two registrants racing for the + // same slot is still the case worth testing, so the first fill is a module's. + assert.equal(tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)), null) assert.match( - tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)), - /already filled by "core"/, + tryApply('rust', (a) => a.registerExtension('admin.users.detail', router)), + /already filled by "uo"/, ) }) @@ -208,9 +220,11 @@ test('the filled slot’s router is findable in the live app, at the resource pa const { findMountPrefix } = require('../swagger/slotSpecs') /* eslint-enable global-require */ - const [slot] = registries.filledSlots() - assert.ok(slot, 'app.js must have registered core, filling the slot') - assert.equal(slot.slot, 'admin.users.detail') - assert.ok(slot.specFile, 'core names the file its slot router is generated from') - assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id') + // The slot is DECLARED by core and filled by whichever module is installed — + // none, in core's own test run. What must keep working regardless is the + // recovery of its mount prefix from the live stack, because that is what + // slotSpecs.js needs and what fails silently when it breaks. + const router = registries.declaredSlotRouter('admin.users.detail') + assert.ok(router, 'core declares the slot at require time, filled or not') + assert.equal(findMountPrefix(app._router.stack, router), '/api/v1/admin/users/:id') }) diff --git a/server/test/playerRouteAccess.test.js b/server/test/playerRouteAccess.test.js index 6e451f2..33476ff 100644 --- a/server/test/playerRouteAccess.test.js +++ b/server/test/playerRouteAccess.test.js @@ -1,6 +1,6 @@ // Point the DB at a closed port BEFORE requiring anything that builds the pool, // so any stray query fails fast instead of holding the process open. The session -// service, users model, and shardLinks model are all stubbed, so no query runs. +// service, users model, and appeals model are all stubbed, so no query runs. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' @@ -11,7 +11,7 @@ const { startApp } = require('./_helper') const playerRouter = require('../src/router/v1/player') const sessionService = require('../src/auth/session.service') const users = require('../src/model/users/users.model') -const shardLinks = require('../src/model/shardLinks/shardLinks.model') +const appeals = require('../src/model/appeals/appeals.model') const db = require('../src/utils/db') after(() => db.close()) @@ -21,14 +21,14 @@ const originals = { isSessionRevoked: sessionService.isSessionRevoked, sessionMeta: sessionService.sessionMeta, getById: users.getById, - listForUser: shardLinks.listForUser, + listMine: appeals.listMine, } afterEach(() => { sessionService.validateSession = originals.validateSession sessionService.isSessionRevoked = originals.isSessionRevoked sessionService.sessionMeta = originals.sessionMeta users.getById = originals.getById - shardLinks.listForUser = originals.listForUser + appeals.listMine = originals.listMine }) // Sign every request in as the given DB user (role decides the gate outcome). @@ -44,28 +44,28 @@ function signInAs(user) { // handlers. Regression guard for the fix that dropped requireRole('player') so a // staff/admin account is no longer 403'd out of its own characters. for (const role of ['player', 'admin', 'editor', 'moderator']) { - test(`GET /player/shard/accounts is reachable by an authenticated ${role}`, async () => { + test(`GET /player/appeals is reachable by an authenticated ${role}`, async () => { signInAs({ id: 7, username: 'u', role, status: 'active' }) - shardLinks.listForUser = async (id) => { + appeals.listMine = async (id) => { assert.equal(id, 7) // self-scoped to the caller regardless of role - return [{ account: 'acctA' }] + return [{ id: 1 }] } const app = await startApp((a) => a.use('/api/v1/player', playerRouter)) try { - const res = await fetch(app.url + '/api/v1/player/shard/accounts') + const res = await fetch(app.url + '/api/v1/player/appeals') assert.equal(res.status, 200, `${role} should reach the handler, got ${res.status}`) - assert.deepEqual(await res.json(), [{ account: 'acctA' }]) + assert.deepEqual(await res.json(), [{ id: 1 }]) } finally { await app.close() } }) } -test('GET /player/shard/accounts still rejects an unauthenticated caller with 401', async () => { +test('GET /player/appeals still rejects an unauthenticated caller with 401', async () => { sessionService.validateSession = () => null const app = await startApp((a) => a.use('/api/v1/player', playerRouter)) try { - const res = await fetch(app.url + '/api/v1/player/shard/accounts') + const res = await fetch(app.url + '/api/v1/player/appeals') assert.equal(res.status, 401) } finally { await app.close() @@ -76,7 +76,7 @@ test('a disabled account is still rejected with 403 (status gate, not role)', as signInAs({ id: 8, username: 'banned', role: 'player', status: 'disabled' }) const app = await startApp((a) => a.use('/api/v1/player', playerRouter)) try { - const res = await fetch(app.url + '/api/v1/player/shard/accounts') + const res = await fetch(app.url + '/api/v1/player/appeals') assert.equal(res.status, 403) } finally { await app.close() diff --git a/server/test/pushDispatch.test.js b/server/test/pushDispatch.test.js index 795cfde..1b4542c 100644 --- a/server/test/pushDispatch.test.js +++ b/server/test/pushDispatch.test.js @@ -6,11 +6,9 @@ process.env.DB_PORT = '59999' const { test, after } = require('node:test') const assert = require('node:assert/strict') -const { mapShardEvent, createTracker } = require('../src/config/shardStreams') const pushDispatch = require('../src/utils/pushDispatch') // fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard // side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8). -const shardPush = require('../src/utils/shardPush') const db = require('../src/utils/db') after(() => db.close()) @@ -33,71 +31,11 @@ function withEnv(vars, fn) { } } -// ── 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), []) -}) - +// `mapShardEvent` and `fromShardEvent` left with module-uo in Phase 3: which +// shard event becomes which stream is the module's catalog, not core's +// infrastructure. What stays here is what core owns — the SSRF guard on the +// configured ntfy endpoint, and `publish()` delivering a content-free tickle to +// the right subscribers. // ── isAllowedEndpoint (SSRF guard) ────────────────────────────────────────── test('isAllowedEndpoint pins the configured ntfy origin and rejects everything else', () => { @@ -188,51 +126,3 @@ test('publish skips endpoints that fail the SSRF guard', async () => { assert.equal(calls[0].url, 'https://relay.test/ok') }) }) - -// ── fromShardEvent (owner resolution) ─────────────────────────────────────── - -test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => { - await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { - const { calls, fetchImpl } = captureFetch() - const userStreamCalls = [] - const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) } - const pushDevices = { - endpointsForStream: async () => [], - endpointsForUserStream: async (userId, s) => { - userStreamCalls.push([userId, s]) - return [{ endpoint: 'https://relay.test/UPc' }] - }, - } - const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() } - - await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps) - assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']]) - assert.equal(calls.length, 1) - - // Unlinked account → nobody to notify → no publish. - userStreamCalls.length = 0 - calls.length = 0 - await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps) - assert.equal(userStreamCalls.length, 0) - assert.equal(calls.length, 0) - }) -}) - -test('fromShardEvent fans a public shard event to the stream’s subscribers', async () => { - await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => { - const { fetchImpl } = captureFetch() - const publicCalls = [] - const pushDevices = { - endpointsForStream: async (s) => { - publicCalls.push(s) - return [] - }, - endpointsForUserStream: async () => [], - } - await shardPush.fromShardEvent( - { kind: 'server.hello', bootId: 'b1' }, - { pushDevices, fetchImpl, tracker: createTracker() }, - ) - assert.deepEqual(publicCalls, ['server.status']) - }) -}) diff --git a/server/test/routeManifest.test.js b/server/test/routeManifest.test.js index 312ac9d..acab903 100644 --- a/server/test/routeManifest.test.js +++ b/server/test/routeManifest.test.js @@ -59,7 +59,11 @@ test('every /admin, /player and /settings route still sits behind the shared aut // unambiguous. const AUTHENTICATED_GROUPS = ['/api/v1/admin/', '/api/v1/player/', '/api/v1/settings/'] const gated = collected.public.filter((r) => AUTHENTICATED_GROUPS.some((p) => r.path.startsWith(p))) - assert.ok(gated.length > 100, 'expected the gated surface to be found') + // A floor, not a count: it exists so a filter that silently matches nothing + // fails loudly rather than passing vacuously. It was >100 before Phase 3 moved + // 70 UO routes into module-uo, and there is no value in tracking core's exact + // total here — the per-route assertion below is what actually guards the gate. + assert.ok(gated.length > 50, 'expected the gated surface to be found') for (const route of gated) { assert.ok( route.gates.includes('requireAuth'),