Files
website/server/test/shardIngest.protocol2.test.js
wtclaude 416761f8f7
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s
feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:13:48 -05:00

122 lines
5.3 KiB
JavaScript

const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../src/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('../src/utils/shardBroadcast')
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
})