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