Files
website/server/test/accountDeletion.test.js
wtclaude d1ae8f943f
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
feat(account): self-service account deletion, MODULE_API 1.20.0
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
2026-10-29 18:32:19 -05:00

390 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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')
})