Files
Module-uo/server/test/shardStreams.test.js
wtclaude 6b99d7e220
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s
test(server): port core's UO suite onto the ctx harness
22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

One change runs through every moved test, and it is the boundary rather than a
chore: 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 a better seam anyway, since it is exactly the
surface the contract promises and nothing wider.

The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a
module and entry.test.js still asserts against a frozen one; but a test that
needs settings.get to return a path has to be able to say so.

Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to
assert 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, and the device registry and the relay are
behind it. Reaching for them from here would be reaching past ctx. What remains
is what the module owns and is the part worth guarding: a game account resolves
to a website user, a personal target that resolves to nobody is dropped rather
than published, and a sensitive kind never reaches publish at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:15 -05:00

152 lines
7.8 KiB
JavaScript

// `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)
})