Phase 5: the account-provisioning backend — link-only stays, plus hybrid self-signup, an admin email-invite tool, and site-side unlink. - uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the shard (hashed there) and never stored/logged; the end-user browser IP is passed for the shard's per-IP cap; actor is stamped server-side. - Hybrid signup: POST /player/shard/account provisions a game account (its own username + password) for the signed-in user and mirrors the link locally. Gated by the new game_account_signup setting AND the shard's own mode (mapped 403/409/ 429/400/503). Serves both self-serve signup and the invite-accept game step. - Email invites: user_invites table (sha256 token hash, single-use, expiring); invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) + mailer.sendInvite (falls back to returning the accept link if email is off); public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the user at the invite's preset role and logs them in, bypassing the registration gate. Accept is race-safe (atomic single-use; rolls back the user if it loses). - Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local mirror drop; account.unlinked ingest reconciles the mirror when a player runs [unlink in game. account.audit / account.unlinked are logged (admin channel only — never on the public SSE allowlist). Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest reconcile/visibility. Full suite 193/193; swagger regenerated. Refs .plans/protocol2-integration.md (Phase 5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
5.2 KiB
JavaScript
120 lines
5.2 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) },
|
|
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)
|
|
})
|