feat(account): self-service account deletion, MODULE_API 1.20.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 6m14s

ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.

- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
  and a 2FA or recovery code when 2FA is on. A wrong password or code is
  400, not 401 (AD12), so the app's refresh-on-401 never replays it.
  409 password_required and last_admin; the demo's shared logins are
  locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
  reserves a staff username (reserved_usernames, AD9) and deletes the row,
  then a Teams reconcile, the audit entry (no username) and a notice to
  the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
  delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
  "[deleted player]" to members and the stored name, marked, to staff
  (AD5, AD6). Used by the forum, the Reports queue, the guest list and
  the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
  views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-29 18:32:19 -05:00
parent 222bcb02fc
commit d1ae8f943f
50 changed files with 2103 additions and 69 deletions

View File

@@ -0,0 +1,389 @@
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
//
// The service's procedure, the member's route and its refusals, the admin
// route going through the same procedure, and reserved staff usernames. Every
// model call is stubbed; the DB pool points at a dead port (test/_setup.js).
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const users = require('../src/model/users/users.model')
const usersDb = require('../src/model/users/users.db')
const activity = require('../src/model/activity/activity.model')
const teamSync = require('../src/model/teams/teamSync.model')
const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
const loader = require('../src/modules/loader')
const mailer = require('../src/utils/mailer')
const totp = require('../src/utils/totp')
const loginProtection = require('../src/middleware/loginProtection')
const botScore = require('../src/middleware/botScore')
const accountDeletion = require('../src/auth/accountDeletion')
const account = require('../src/router/v1/auth/account.controller')
const admin = require('../src/router/v1/admin/admin.controller')
const db = require('../src/utils/db')
after(() => db.close())
// ── stubbing ───────────────────────────────────────────────────────────────
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
delete process.env.DEMO_MODE
})
// What the procedure did, in order.
let events
let logged
let mailed
const ACCOUNTS = {
7: { id: 7, username: 'Thorne', role: 'player', email: 'thorne@example.com' },
8: { id: 8, username: 'Ravenna', role: 'moderator', email: null },
9: { id: 9, username: 'boss', role: 'admin', email: 'boss@example.com' },
3: { id: 3, username: 'demo-player', role: 'player', email: null },
}
beforeEach(() => {
events = []
logged = []
mailed = []
patch(users, 'getById', async (id) => (ACCOUNTS[id] ? { ...ACCOUNTS[id] } : null))
patch(users, 'countAdmins', async () => 2)
patch(users, 'removeReserving', async (id, name) => {
events.push(['delete', id, name])
return ACCOUNTS[id] ? 1 : 0
})
patch(loader, 'userDeletingHooks', () => [])
patch(teamSync, 'request', (opts) => events.push(['teams', opts.reason]))
patch(activity, 'log', async (entry) => { logged.push(entry) })
patch(mailer, 'sendAccountDeleted', async (args) => {
mailed.push(args)
return { sent: true }
})
})
function mockRes() {
return {
statusCode: 200,
body: null,
cleared: [],
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
clearCookie(name) { this.cleared.push(name); return this },
set() { return this },
}
}
// ── the service ────────────────────────────────────────────────────────────
test('modules are asked BEFORE the row goes, with who deleted it (AD10)', async () => {
patch(loader, 'userDeletingHooks', () => [
{ id: 'rust', hook: async (payload) => { events.push(['hook', payload]) } },
])
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1' } })
await result.settled
assert.equal(result.ok, true)
assert.deepEqual(events.slice(0, 2), [
['hook', { userId: 7, deletedBy: 'self' }],
['delete', 7, null],
])
})
test('the hook payload is frozen, so a module cannot rewrite what the next one hears', async () => {
let seen
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async (p) => { seen = p } }])
await (await accountDeletion.deleteAccount(7, { by: 'staff' })).settled
assert.equal(Object.isFrozen(seen), true)
assert.deepEqual(seen, { userId: 7, deletedBy: 'staff' })
})
test('a module hook that throws never stops the deletion (AD11)', async () => {
patch(loader, 'userDeletingHooks', () => [
{ id: 'uo', hook: async () => { throw new Error('sidecar down') } },
])
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
await result.settled
assert.equal(result.ok, true)
assert.deepEqual(result.modules, [{ module: 'uo', ok: false, error: 'sidecar down' }])
assert.deepEqual(events.find((e) => e[0] === 'delete'), ['delete', 7, null])
})
test('a module hook that hangs is cut off at the time limit and the deletion goes ahead', async () => {
patch(loader, 'userDeletingHooks', () => [{ id: 'uo', hook: () => new Promise(() => {}) }])
const result = await accountDeletion.deleteAccount(7, { by: 'self', hookTimeoutMs: 20 })
await result.settled
assert.equal(result.ok, true)
assert.equal(result.modules[0].ok, false)
assert.match(result.modules[0].error, /no answer within 20 ms/)
assert.equal(accountDeletion.HOOK_TIMEOUT_MS, 10_000)
})
test('a staff account’s username is reserved in the deletion’s transaction; a player’s is not (AD9)', async () => {
const staff = await accountDeletion.deleteAccount(8, { by: 'staff', req: { user: { id: 9 } } })
await staff.settled
assert.equal(staff.reserved, true)
const player = await accountDeletion.deleteAccount(7, { by: 'self' })
await player.settled
assert.equal(player.reserved, false)
assert.deepEqual(events.filter((e) => e[0] === 'delete'), [['delete', 8, 'Ravenna'], ['delete', 7, null]])
})
test('the last admin is refused before any module is asked', async () => {
let asked = false
patch(users, 'countAdmins', async () => 1)
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async () => { asked = true } }])
const result = await accountDeletion.deleteAccount(9, { by: 'self' })
assert.deepEqual(result, { ok: false, reason: 'last_admin' })
assert.equal(asked, false)
assert.equal(events.length, 0)
})
test('the demo’s shared logins are refused by the service too, behind the demo lock', async () => {
process.env.DEMO_MODE = 'true'
assert.deepEqual(await accountDeletion.deleteAccount(3, { by: 'staff' }), { ok: false, reason: 'demo' })
assert.equal(events.length, 0)
// Off the demo the same username is just a name.
delete process.env.DEMO_MODE
assert.equal((await accountDeletion.deleteAccount(3, { by: 'staff' })).ok, true)
})
test('an account that is already gone is not_found and nothing runs after it', async () => {
assert.deepEqual(await accountDeletion.deleteAccount(404, { by: 'self' }), { ok: false, reason: 'not_found' })
// Gone between the read and the delete: somebody else's deletion won.
patch(users, 'removeReserving', async () => 0)
assert.deepEqual(await accountDeletion.deleteAccount(7, { by: 'self' }), { ok: false, reason: 'not_found' })
assert.equal(logged.length, 0)
assert.equal(mailed.length, 0)
})
test('after commit: a Teams reconcile, an activity entry with no username, and the notice', async () => {
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1', user: { id: 7 } } })
await result.settled
assert.deepEqual(events.find((e) => e[0] === 'teams'), ['teams', 'account deleted'])
// The member is gone, so the entry carries no user id — only the IP (AD6).
assert.equal(logged.length, 1)
assert.equal(logged[0].action, 'user.self_delete')
assert.deepEqual(logged[0].detail, { id: 7 })
assert.equal(logged[0].req.user, undefined)
assert.equal(logged[0].req.ip, '10.0.0.1')
assert.ok(!JSON.stringify(logged[0]).includes('Thorne'), 'the username is never logged')
assert.equal(mailed.length, 1)
assert.equal(mailed[0].to, 'thorne@example.com')
assert.equal(mailed[0].username, 'Thorne')
assert.equal(mailed[0].by, 'self')
assert.match(mailed[0].deletedOn, /^\d{1,2} [A-Z][a-z]+ \d{4}$/)
})
test('a staff deletion is logged as user.delete against the acting admin', async () => {
const req = { ip: '10.0.0.2', user: { id: 9 } }
await (await accountDeletion.deleteAccount(8, { by: 'staff', req })).settled
assert.equal(logged[0].action, 'user.delete')
assert.equal(logged[0].req, req)
assert.deepEqual(logged[0].detail, { id: 8, usernameReserved: true })
// No address on file, so no notice.
assert.equal(mailed.length, 0)
})
test('a failing mail or audit step after commit never rejects', async () => {
patch(mailer, 'sendAccountDeleted', async () => { throw new Error('smtp down') })
patch(activity, 'log', async () => { throw new Error('db gone') })
patch(teamSync, 'request', () => { throw new Error('sync broken') })
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
await result.settled // resolves
assert.equal(result.ok, true)
})
test('by must say who deleted it', async () => {
await assert.rejects(() => accountDeletion.deleteAccount(7, {}), /by must be one of/)
})
// ── DELETE /auth/me/account ────────────────────────────────────────────────
const RAW = {
id: 7, username: 'Thorne', role: 'player', password_hash: '$2a$hash', totp_enabled: 0, totp_secret: null,
}
let failures
let deleted
function stubSelf(raw = RAW, { passwordOk = true } = {}) {
failures = 0
deleted = null
patch(users, 'getRawById', async () => (raw ? { ...raw } : null))
patch(users, 'validatePassword', async () => passwordOk)
patch(loginProtection, 'recordFailure', () => { failures += 1 })
patch(botScore, 'recordLoginFailure', () => {})
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
deleted = { id, by: opts.by }
return { ok: true, id, settled: Promise.resolve() }
})
}
const selfReq = (body) => ({ body, user: { id: 7, username: 'Thorne' }, ip: '10.0.0.1', headers: {}, cookies: {} })
test('the typed username must match, ignoring case, before the password is even checked', async () => {
let checked = false
stubSelf()
patch(users, 'validatePassword', async () => { checked = true; return true })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorn', password: 'pw' }), res)
assert.equal(res.statusCode, 400)
assert.equal(res.body.code, 'username_mismatch')
assert.equal(checked, false)
assert.equal(deleted, null)
const ok = mockRes()
await account.deleteAccount(selfReq({ username: ' thorne ', password: 'pw' }), ok)
assert.equal(ok.statusCode, 200)
assert.deepEqual(ok.body, { deleted: true })
assert.deepEqual(deleted, { id: 7, by: 'self' })
})
test('an account with no password is told to set one first (AD7, 409 password_required)', async () => {
stubSelf({ ...RAW, password_hash: null })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne' }), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.code, 'password_required')
assert.equal(deleted, null)
})
test('a wrong password is a 400, never a 401, and counts as a failed login (AD12)', async () => {
stubSelf(RAW, { passwordOk: false })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'nope' }), res)
assert.equal(res.statusCode, 400)
assert.equal(res.body.code, 'wrong_password')
assert.equal(failures, 1)
assert.equal(deleted, null)
})
test('with two-factor on, a code is required, and a wrong one is a 400', async () => {
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
patch(totp, 'verifyCode', (secret, code) => secret === 'SECRET' && code === '123456')
const none = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), none)
assert.equal(none.statusCode, 400)
assert.equal(none.body.code, 'code_required')
const wrong = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '000000' }), wrong)
assert.equal(wrong.statusCode, 400)
assert.equal(wrong.body.code, 'wrong_code')
assert.equal(failures, 1)
assert.equal(deleted, null)
const right = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '123456' }), right)
assert.equal(right.statusCode, 200)
assert.deepEqual(deleted, { id: 7, by: 'self' })
})
test('a recovery code stands in for the authenticator, and is spent', async () => {
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
const spent = []
patch(recoveryCodes, 'consumeForUser', async (id, code) => { spent.push([id, code]); return code === 'ABCD-EFGH' })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', recoveryCode: 'ABCD-EFGH' }), res)
assert.equal(res.statusCode, 200)
assert.deepEqual(spent, [[7, 'ABCD-EFGH']])
})
test('success clears this browser’s session and trust cookies', async () => {
stubSelf()
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
const { COOKIE_NAME, TRUST_COOKIE_NAME } = require('../src/auth/token')
assert.deepEqual(res.cleared.sort(), [COOKIE_NAME, TRUST_COOKIE_NAME].sort())
})
test('the last admin deleting themself is a 409 with a code the screen can explain', async () => {
stubSelf({ ...RAW, role: 'admin' })
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.code, 'last_admin')
})
test('a demo login reaching the handler gets the demo’s own refusal', async () => {
stubSelf()
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'demo' }))
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
assert.equal(res.statusCode, 403)
assert.equal(res.body.code, 'DEMO_LOCKED')
})
// ── DELETE /admin/users/:id ────────────────────────────────────────────────
test('the admin route runs the same procedure, by staff, and keeps its 400s', async () => {
let calledWith = null
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
calledWith = { id, by: opts.by }
return { ok: true, id, settled: Promise.resolve() }
})
const res = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 }, ip: '10.0.0.2' }, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, { id: 8 })
assert.deepEqual(calledWith, { id: 8, by: 'staff' })
const self = mockRes()
await admin.deleteUser({ params: { id: '9' }, user: { id: 9 } }, self)
assert.equal(self.statusCode, 400)
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
const last = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, last)
assert.equal(last.statusCode, 400)
assert.match(last.body.message, /last admin/)
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'not_found' }))
const missing = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, missing)
assert.equal(missing.statusCode, 404)
})
// ── reserved usernames (AD9) ───────────────────────────────────────────────
test('a reserved name reads as a taken one wherever a username is written', async () => {
patch(usersDb, 'isUsernameReserved', async (name) => name.toLowerCase() === 'ravenna')
patch(usersDb, 'insertUser', async () => { throw new Error('must not insert') })
patch(usersDb, 'updateUser', async () => { throw new Error('must not update') })
const created = await users.createUser({ username: 'RAVENNA', password: 'pw12345678', role: 'player' }).catch((e) => e)
assert.equal(created.code, users.USERNAME_RESERVED)
assert.equal(users.isDuplicateUsername(created), true)
assert.equal(users.isDuplicateEmail(created), false)
const renamed = await users.update(7, { username: 'Ravenna' }).catch((e) => e)
assert.equal(users.isDuplicateUsername(renamed), true)
})
test('a self-service rename onto a reserved name answers the taken-name 409', async () => {
patch(usersDb, 'isUsernameReserved', async () => true)
const res = mockRes()
await account.changeUsername({ body: { username: 'Ravenna' }, user: { id: 7, username: 'Thorne' }, headers: {} }, res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.message, 'That username is already taken.')
})
test('a name that is not reserved goes through untouched', async () => {
patch(usersDb, 'isUsernameReserved', async () => false)
let inserted = null
patch(usersDb, 'insertUser', async (row) => { inserted = row; return 70 })
patch(usersDb, 'findById', async () => ({ id: 70, username: 'Newcomer', role: 'player' }))
const user = await users.createUser({ username: 'Newcomer', role: 'player' })
assert.equal(user.id, 70)
assert.equal(inserted.username, 'Newcomer')
})

View File

@@ -192,7 +192,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [{
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
id: 3, team_id: 1, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}])
@@ -201,6 +201,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
// load-bearing rather than bookkeeping.
assert.equal(item.target.kind, 'upload')
assert.equal(item.target.uploader, 'someone')
assert.equal(item.target.uploaderDeleted, false)
assert.equal(item.target.byteSize, 184320)
assert.equal(item.target.mimetype, 'image/png')
assert.equal(item.target.url, '/uploads/a1b2.png')
@@ -234,17 +235,64 @@ test('a report whose target is already gone still lists, with a null target', as
assert.equal(item.target, null)
})
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
test('a deleted reporter with no snapshot still shows as somebody, and is marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
assert.equal(item.reporter, '[deleted account]')
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.reporter, '[deleted player]')
assert.equal(item.reporterDeleted, true)
})
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
// moderation, and the flag says which it is. Every snapshot in a report row goes
// through the one rule — reporter, handler, and the target's author.
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({
reporter_user_id: null, reporter_username: 'Ravenna',
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
for (const role of ['admin', 'editor', 'moderator']) {
// eslint-disable-next-line no-await-in-loop
const [item] = await reports.queue({ viewer: { role } })
assert.equal(item.reporter, 'Ravenna', role)
assert.equal(item.reporterDeleted, true)
assert.equal(item.handledBy, 'OldMod')
assert.equal(item.handledByDeleted, true)
assert.equal(item.target.author, 'Thorne')
assert.equal(item.target.authorDeleted, true)
}
// Nothing but a staff viewer sees those names, so a caller that forgot to say
// who is reading cannot leak them.
const [blind] = await reports.queue({})
assert.equal(blind.reporter, '[deleted player]')
assert.equal(blind.handledBy, '[deleted player]')
assert.equal(blind.target.author, '[deleted player]')
})
test('an open report has no handler, which is not a deleted handler', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'admin' } })
assert.equal(item.handledBy, null)
assert.equal(item.handledByDeleted, false)
assert.equal(item.reporter, 'wanderer')
assert.equal(item.reporterDeleted, false)
})
// ── handling ───────────────────────────────────────────────────────────────
test('handling records who decided, when, and why', async () => {

View File

@@ -234,6 +234,7 @@ const SELF_ROWS = [
['POST', '/auth/me/account/recovery-codes/generate'],
['DELETE', '/auth/me/account/identities/steam'],
['DELETE', '/auth/me/sessions/12'], // DM17
['DELETE', '/auth/me/account'], // ACCOUNT_DELETION.md §4.3
]
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {

View File

@@ -0,0 +1,159 @@
// ── Who a deleted account's snapshots show as (ACCOUNT_DELETION.md AD5, AD6) ──
//
// The rule itself, then each place a member can read a snapshot: forum threads
// and posts, the leader's guest list, and the Team digest mail. The Reports
// queue (staff-only) is covered in contentReports.test.js.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const { displayAuthor, DELETED_PLAYER, seesDeletedNames, hasActor } = require('../src/utils/displayAuthor')
const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const accessDb = require('../src/model/teams/teamAccess.db')
const teamsDb = require('../src/model/teams/teams.db')
const db = require('../src/utils/db')
after(() => db.close())
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
})
const MEMBER = { role: 'player' }
const STAFF = ['admin', 'editor', 'moderator'].map((role) => ({ role }))
// ── the rule ───────────────────────────────────────────────────────────────
test('a live account shows its name to everyone', () => {
for (const viewer of [null, MEMBER, ...STAFF]) {
assert.deepEqual(displayAuthor({ userId: 4, username: 'Thorne' }, viewer), { name: 'Thorne', deleted: false })
}
})
test('a deleted account reads "[deleted player]" to members and visitors (AD5)', () => {
assert.equal(DELETED_PLAYER, '[deleted player]')
for (const viewer of [null, undefined, MEMBER, { role: 'leader' }, {}]) {
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: DELETED_PLAYER, deleted: true })
}
})
test('staff — admin, editor and moderator — see the stored name, marked deleted (AD6)', () => {
for (const viewer of STAFF) {
assert.equal(seesDeletedNames(viewer), true)
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: 'Ravenna', deleted: true })
}
// With no snapshot at all there is no name to show anybody.
assert.deepEqual(displayAuthor({ userId: null, username: null }, STAFF[0]), { name: DELETED_PLAYER, deleted: true })
})
test('an absent actor is not a deleted one', () => {
assert.equal(hasActor(null, null), false)
assert.equal(hasActor(null, ''), false)
assert.equal(hasActor(null, 'OldMod'), true)
assert.equal(hasActor(5, null), true)
})
// ── forum threads and posts ────────────────────────────────────────────────
const threadRow = (over = {}) => ({
id: 5, type: 'discussion', title: 'Raid night', created_by: null, created_username: 'Ravenna',
created_at: new Date(), last_post_at: new Date(), post_count: 2, pinned: 0, locked: 0, status: 'visible', ...over,
})
const postRow = (over = {}) => ({
id: 80, thread_id: 5, author_user_id: null, author_username: 'Ravenna', body_html: '<p>Guide</p>',
created_at: new Date(), edited_at: null, status: 'visible', ...over,
})
test('a forum thread by a deleted account: "[deleted player]" to a member, the name to staff', () => {
assert.equal(forum.publicThread(threadRow(), MEMBER).author, DELETED_PLAYER)
assert.equal(forum.publicThread(threadRow()).author, DELETED_PLAYER) // no viewer said
assert.equal(forum.publicThread(threadRow(), MEMBER).authorDeleted, true)
for (const viewer of STAFF) {
const t = forum.publicThread(threadRow(), viewer)
assert.equal(t.author, 'Ravenna')
assert.equal(t.authorDeleted, true)
}
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).author, 'Ravenna')
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).authorDeleted, false)
})
test('a forum post by a deleted account follows the same rule, and is nobody’s to edit', () => {
const asMember = forum.renderPost(postRow(), 'disabled', { userId: 9, role: 'player', isStaff: false })
assert.equal(asMember.author, DELETED_PLAYER)
assert.equal(asMember.authorDeleted, true)
assert.equal(asMember.mine, false)
assert.equal(asMember.canEdit, false)
const asEditor = forum.renderPost(postRow(), 'disabled', { userId: 2, role: 'editor', isStaff: false })
assert.equal(asEditor.author, 'Ravenna')
assert.equal(asEditor.authorDeleted, true)
})
test('the thread list and a thread pass the viewer down to every row', async () => {
const forumDb = require('../src/model/teams/teamForum.db')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
patch(forumDb, 'threadsByTeam', async () => [threadRow()])
patch(forumDb, 'threadById', async () => ({ ...threadRow(), team_id: 1 }))
patch(forumDb, 'postsByThread', async () => [postRow()])
patch(forumSettings, 'imageMode', async () => 'disabled')
const [listedForMember] = await forum.listThreads(1, { viewer: MEMBER })
assert.equal(listedForMember.author, DELETED_PLAYER)
const [listedForStaff] = await forum.listThreads(1, { viewer: { role: 'moderator' } })
assert.equal(listedForStaff.author, 'Ravenna')
const thread = await forum.getThread(1, 5, { viewer: { userId: 9, role: 'player' } })
assert.equal(thread.author, DELETED_PLAYER)
assert.equal(thread.posts[0].author, DELETED_PLAYER)
const staffThread = await forum.getThread(1, 5, { canModerate: true, viewer: { userId: 2, role: 'admin', isStaff: true } })
assert.equal(staffThread.author, 'Ravenna')
assert.equal(staffThread.posts[0].author, 'Ravenna')
})
// ── the leader's guest list ────────────────────────────────────────────────
test('a deleted account is not a forum guest, and a deleted granter reads "[deleted player]" (AD13)', async () => {
patch(accessDb, 'activeGrants', async () => [
{ user_id: 21, username: 'Guest', granted_by: null, granted_username: 'OldLeader', granted_at: new Date(), reason: null },
{ user_id: null, username: 'Ravenna', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
{ user_id: 22, username: 'Member', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
])
patch(teamsDb, 'membersByTeam', async () => [{ user_id: 22 }])
const guests = await grants.forumGuests(1, MEMBER)
assert.deepEqual(guests.map((g) => g.username), ['Guest'])
assert.equal(guests[0].grantedBy, DELETED_PLAYER)
const forStaff = await grants.forumGuests(1, { role: 'moderator' })
assert.equal(forStaff[0].grantedBy, 'OldLeader')
})
// ── the Team digest ────────────────────────────────────────────────────────
test('the digest mail names a deleted author "[deleted player]"', async () => {
const worker = require('../src/utils/teamDigestWorker')
const teamNotify = require('../src/model/teams/teamNotify.model')
const templates = require('../src/engagement/templates')
let items = null
patch(teamNotify, 'digestPostsSince', async () => [
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },
])
patch(templates, 'renderByKey', async (key, values) => {
items = values.items
return null // stop before anything is sent
})
await worker.sendOne({ id: 1, slug: 'wolves', name: 'Iron Wolves' }, { user_id: 9, last_digest_at: null }, null, new Date())
assert.deepEqual(items.map((i) => i.heading), ['Raid night — [deleted player]', 'Raid night — Thorne'])
})

View File

@@ -299,12 +299,13 @@ test('every shipped template validates against the email registry', () => {
assert.equal(typeof seed.seedVersion, 'number')
assert.equal(['email', 'inapp'].includes(seed.channel), true)
}
// The five transactional bodies the system itself depends on are protected.
// The six transactional bodies the system itself depends on are protected.
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
assert.deepEqual(guarded, [
'auth.password-reset',
'auth.invite',
'auth.email-verify',
'auth.account-deleted',
'admin.contact-message',
'admin.test',
])

View File

@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.19.0')
assert.equal(MODULE_API_VERSION, '1.20.0')
})
test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -0,0 +1,87 @@
// ── api.onUserDeleting (MODULE_API 1.20.0, ACCOUNT_DELETION.md AD10) ───────
//
// Registration and collection only; what the deletion does with the hook is in
// accountDeletion.test.js. Each test builds a throwaway modules directory and
// re-requires the loader, as moduleLoader.test.js does.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
after(() => db.close())
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
function loadOne(server) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-deleting-'))
const dir = path.join(root, 'rust')
fs.mkdirSync(dir)
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
id: 'rust',
name: 'Rust',
version: '1.0.0',
coreApi: '^1.20.0',
server: 'index.js',
mounts: { admin: ['/rust'] },
}))
fs.writeFileSync(path.join(dir, 'index.js'), server)
process.env.MODULES_DIR = root
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader
}
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
test('onUserDeleting is collected once the module is started, and not before', async () => {
const loader = loadOne(`module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onUserDeleting(async (p) => { global.__deleting = p })
}`)
assert.equal(stateOf(loader).state, 'registered')
// A registered module has not booted: it holds no link it could undo yet.
assert.deepEqual(loader.userDeletingHooks(), [])
loader.setState('rust', 'started')
const hooks = loader.userDeletingHooks()
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
await hooks[0].hook({ userId: 7, deletedBy: 'self' })
assert.deepEqual(global.__deleting, { userId: 7, deletedBy: 'self' })
delete global.__deleting
loader.setState('rust', 'disabled')
assert.deepEqual(loader.userDeletingHooks(), [])
})
test('a module that registers no hook adds nothing', () => {
const loader = loadOne(`module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
}`)
loader.setState('rust', 'started')
assert.deepEqual(loader.userDeletingHooks(), [])
})
test('onUserDeleting twice, or with no function, is a registration error', () => {
for (const [body, expected] of [
['api.onUserDeleting(() => {}); api.onUserDeleting(() => {})', /onUserDeleting\(\) called twice/],
['api.onUserDeleting("later")', /onUserDeleting: expected a function/],
]) {
const loader = loadOne(`module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
${body}
}`)
assert.equal(stateOf(loader).state, 'startup_failed')
assert.match(stateOf(loader).reason, expected)
}
})