feat(provisioning): game-account signup, admin email invites, unlink (2.0)
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>
This commit is contained in:
78
server/test/invites.test.js
Normal file
78
server/test/invites.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Exercise invite create/lookup/single-use accept against an in-memory fake by
|
||||
// monkeypatching the shared db module the model require()s. No DB.
|
||||
const db = require('../src/model/invites/invites.db')
|
||||
const invites = require('../src/model/invites/invites.model')
|
||||
|
||||
let rows
|
||||
let nextId
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
rows = []
|
||||
nextId = 1
|
||||
for (const k of ['insert', 'getById', 'findByTokenHash', 'markAccepted', 'revoke']) saved[k] = db[k]
|
||||
db.insert = async ({ tokenHash, email, role, invitedBy, expiresAt }) => {
|
||||
const id = nextId++
|
||||
rows.push({ id, token_hash: tokenHash, email, role, status: 'pending', invited_by: invitedBy ?? null, accepted_user_id: null, expires_at: expiresAt, created_at: new Date(), accepted_at: null })
|
||||
return id
|
||||
}
|
||||
db.getById = async (id) => rows.find((r) => r.id === id) || null
|
||||
db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null
|
||||
db.markAccepted = async (id, userId) => {
|
||||
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
||||
if (!row) return 0
|
||||
row.status = 'accepted'
|
||||
row.accepted_user_id = userId
|
||||
return 1
|
||||
}
|
||||
db.revoke = async (id) => {
|
||||
const row = rows.find((r) => r.id === id && r.status === 'pending')
|
||||
if (!row) return 0
|
||||
row.status = 'revoked'
|
||||
return 1
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of Object.keys(saved)) db[k] = saved[k]
|
||||
})
|
||||
|
||||
test('create stores only the token hash, never the plaintext token', async () => {
|
||||
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
assert.ok(token && token.length >= 20)
|
||||
assert.equal(rows[0].token_hash, invites.hashToken(token))
|
||||
assert.notEqual(rows[0].token_hash, token) // hash, not the raw token
|
||||
assert.equal(invite.email, 'a@b.com')
|
||||
assert.equal(invite.role, 'player')
|
||||
assert.equal(invite.status, 'pending')
|
||||
})
|
||||
|
||||
test('findValidByToken resolves a pending token and rejects a wrong/used one', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'moderator', invitedBy: 1 })
|
||||
assert.ok(await invites.findValidByToken(token))
|
||||
assert.equal(await invites.findValidByToken('not-a-real-token'), null)
|
||||
})
|
||||
|
||||
test('accept is single-use — the second accept loses the race', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
const row = await invites.findValidByToken(token)
|
||||
assert.equal(await invites.accept(row.id, 55), true)
|
||||
assert.equal(await invites.accept(row.id, 66), false) // already consumed
|
||||
assert.equal(await invites.findValidByToken(token), null) // no longer pending
|
||||
})
|
||||
|
||||
test('an expired invite is not valid (exercises the expiry branch, not a bad token)', async () => {
|
||||
const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1, ttlDays: -1 })
|
||||
// The token itself is correct and the row is pending — only expires_at rejects it.
|
||||
assert.ok(rows[0] && rows[0].status === 'pending')
|
||||
assert.equal(await invites.findValidByToken(token), null)
|
||||
})
|
||||
|
||||
test('revoke makes a pending invite unusable', async () => {
|
||||
const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 })
|
||||
assert.equal(await invites.revoke(invite.id), 1)
|
||||
assert.equal(await invites.findValidByToken(token), null)
|
||||
})
|
||||
@@ -12,6 +12,7 @@ function makeDeps() {
|
||||
governorUpsert: [],
|
||||
presenceSet: [],
|
||||
houseRegistry: [], houseRemove: [],
|
||||
linkRemove: [],
|
||||
appended: [], broadcast: [],
|
||||
}
|
||||
const noop = async () => {}
|
||||
@@ -29,6 +30,7 @@ function makeDeps() {
|
||||
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() {} },
|
||||
@@ -91,3 +93,27 @@ test('region.enter is broadcast-only — not logged, no state side effect', asyn
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user