Files
website/server/test/userDeletingHook.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

88 lines
3.2 KiB
JavaScript

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