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
390 lines
17 KiB
JavaScript
390 lines
17 KiB
JavaScript
// ── 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')
|
||
})
|