Files
website/client/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

81 lines
4.3 KiB
JavaScript
Raw Permalink 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.

// The Account page's "Delete my account" section (client/src/lib/accountDeletion.js)
// and how a deleted account's name is marked for staff (lib/deletedAccount.js).
// docs/website/ACCOUNT_DELETION.md §4.1, §4.2, AD5, AD6.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
usernameMatches, isSharedDemoLogin, refusalMessage, isStaffRole, WHAT_GOES, WHAT_STAYS,
} from '../src/lib/accountDeletion.js'
import { DELETED_PLAYER, markDeleted, rowName } from '../src/lib/deletedAccount.js'
import { describeRequest, leadershipOf } from '../src/lib/teamAdmin.js'
import { threadSummary } from '../src/lib/teamForum.js'
// ── the confirm step ───────────────────────────────────────────────────────
test('Delete is live only once the typed username matches, ignoring case and spaces', () => {
assert.equal(usernameMatches('Thorne', 'Thorne'), true)
assert.equal(usernameMatches(' thorne ', 'Thorne'), true)
assert.equal(usernameMatches('Thorn', 'Thorne'), false)
assert.equal(usernameMatches('', 'Thorne'), false)
assert.equal(usernameMatches(' ', 'Thorne'), false)
assert.equal(usernameMatches(undefined, 'Thorne'), false)
})
test('the demo’s shared logins are recognised from the published list, and nothing else is', () => {
const settings = { demo: { logins: [{ username: 'demo-player' }, { username: 'play-review' }] } }
assert.equal(isSharedDemoLogin(settings, 'Demo-Player'), true)
assert.equal(isSharedDemoLogin(settings, 'alice'), false)
// Off the demo there is no `demo` block, so nobody is a shared login.
assert.equal(isSharedDemoLogin({}, 'demo-player'), false)
assert.equal(isSharedDemoLogin(undefined, 'demo-player'), false)
})
test('every refusal code the route answers has its own sentence', () => {
for (const code of ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin', 'DEMO_LOCKED']) {
const msg = refusalMessage({ status: 400, body: { code }, message: 'server words' })
assert.notEqual(msg, 'server words', code)
assert.ok(msg.length > 10, code)
}
assert.match(refusalMessage({ status: 429, body: null }), /Too many attempts/)
assert.equal(refusalMessage({ status: 500, message: 'Internal Server Error' }), 'Internal Server Error')
assert.match(refusalMessage(null), /Could not delete/)
})
test('the screen says what goes and what stays, and that staff names are kept forever', () => {
assert.ok(WHAT_GOES.some((l) => /game-account links/.test(l)))
assert.ok(WHAT_STAYS.some((l) => l.includes('[deleted player]')))
assert.equal(isStaffRole('moderator'), true)
assert.equal(isStaffRole('player'), false)
})
// ── staff see a deleted account's name, marked ─────────────────────────────
test('a deleted account’s stored name is marked; the placeholder is left alone', () => {
assert.equal(markDeleted('Ravenna', true), 'Ravenna (deleted account)')
assert.equal(markDeleted('Ravenna', false), 'Ravenna')
assert.equal(markDeleted(DELETED_PLAYER, true), DELETED_PLAYER)
assert.equal(markDeleted(null, true), null)
})
test('a raw ledger row is a deleted account only when its id is an explicit null', () => {
assert.equal(rowName(null, 'OldMod'), 'OldMod (deleted account)')
assert.equal(rowName(4, 'OldMod'), 'OldMod')
assert.equal(rowName(undefined, 'OldMod'), 'OldMod')
assert.equal(rowName(null, null), '—')
})
test('Admin → Teams marks a deleted requester and a deleted override author', () => {
assert.match(
describeRequest({ action: 'unhide', requested_by: null, requested_username: 'mod1', team_name: 'X' }),
/^mod1 \(deleted account\) asks/,
)
const l = leadershipOf({ isLeaderSynced: true, leaderOverride: { effect: 'deny', by: 'mod1', byDeleted: true } })
assert.match(l.note, /Denied by mod1 \(deleted account\)/)
})
test('a forum thread by a deleted account is marked in the list for staff', () => {
assert.match(threadSummary({ type: 'discussion', author: 'Ravenna', authorDeleted: true, postCount: 1 }), /^Ravenna \(deleted account\)/)
assert.equal(threadSummary({ type: 'discussion', author: DELETED_PLAYER, authorDeleted: true, postCount: 1 }), DELETED_PLAYER)
})