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
380 lines
17 KiB
JavaScript
380 lines
17 KiB
JavaScript
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
|
|
//
|
|
// Every row of §3.3 that core owns, the matcher's two express defaults, the
|
|
// conditions on the shared-login rows, the settings keys that are another door
|
|
// into a Tier A lock, the multipart rule, and the module locks. The whole-app
|
|
// wiring — mounted with DEMO_MODE on, absent with it off — is demoApp.test.js.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, afterEach, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const db = require('../src/utils/db')
|
|
const users = require('../src/model/users/users.model')
|
|
const sessionService = require('../src/auth/session.service')
|
|
const { compile, match } = require('../src/demo/paths')
|
|
const locks = require('../src/demo/locks')
|
|
const { isDemo, isDemoUsername, DEMO_ACCOUNTS } = require('../src/demo/mode')
|
|
const manifest = require('../routes.manifest.json')
|
|
const { startApp } = require('./_helper')
|
|
|
|
after(() => db.close())
|
|
|
|
const originalGetById = users.getById
|
|
const originalValidate = sessionService.validateSession
|
|
afterEach(() => {
|
|
users.getById = originalGetById
|
|
sessionService.validateSession = originalValidate
|
|
delete process.env.DEMO_MODE
|
|
})
|
|
|
|
// id 1-4 are the shared logins, 9 is somebody an admin created on the demo.
|
|
const ACCOUNTS = { 1: 'demo-admin', 2: 'demo-moderator', 3: 'demo-player', 4: 'play-review', 9: 'alice' }
|
|
function stubUsers() {
|
|
users.getById = async (id) => (ACCOUNTS[id] ? { id, username: ACCOUNTS[id], role: 'admin' } : null)
|
|
}
|
|
function signedInAs(userId) {
|
|
sessionService.validateSession = () => (userId ? { userId, username: ACCOUNTS[userId], role: 'admin' } : null)
|
|
}
|
|
|
|
async function withLock(moduleLocks, fn) {
|
|
const app = await startApp((a) => {
|
|
a.use(locks.refuseMultipart)
|
|
a.use('/api/v1', locks.createLock(moduleLocks))
|
|
a.all('*', (req, res) => res.json({ reached: true }))
|
|
})
|
|
try {
|
|
return await fn((method, path, body, headers = {}) =>
|
|
fetch(`${app.url}/api/v1${path}`, {
|
|
method,
|
|
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
}))
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
|
|
async function expectLocked(res) {
|
|
assert.equal(res.status, 403)
|
|
const body = await res.json()
|
|
assert.equal(body.code, 'DEMO_LOCKED')
|
|
assert.equal(body.message, 'This is turned off on the demo site.')
|
|
return body
|
|
}
|
|
async function expectReached(res) {
|
|
assert.equal(res.status, 200)
|
|
assert.deepEqual(await res.json(), { reached: true })
|
|
}
|
|
|
|
// ── The flag and the accounts ──────────────────────────────────────────────
|
|
|
|
test('only DEMO_MODE=true is the demo', () => {
|
|
for (const v of [undefined, '', '1', 'yes', 'TRUE', 'false']) {
|
|
if (v === undefined) delete process.env.DEMO_MODE
|
|
else process.env.DEMO_MODE = v
|
|
assert.equal(isDemo(), false, String(v))
|
|
}
|
|
process.env.DEMO_MODE = 'true'
|
|
assert.equal(isDemo(), true)
|
|
})
|
|
|
|
test('the four shared logins are recognised whatever their case', () => {
|
|
assert.deepEqual([...DEMO_ACCOUNTS], ['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
|
|
assert.equal(isDemoUsername('Demo-Admin'), true)
|
|
assert.equal(isDemoUsername('alice'), false)
|
|
assert.equal(isDemoUsername(null), false)
|
|
})
|
|
|
|
// ── The matcher ────────────────────────────────────────────────────────────
|
|
|
|
test('patterns match the way express routes: any case, optional trailing slash', () => {
|
|
const p = compile('/admin/users/:id')
|
|
assert.deepEqual(match(p, '/admin/users/7'), { id: '7' })
|
|
assert.deepEqual(match(p, '/Admin/USERS/7/'), { id: '7' })
|
|
assert.equal(match(p, '/admin/users'), null)
|
|
assert.equal(match(p, '/admin/users/7/mfa/reset'), null)
|
|
assert.deepEqual(match(compile('/a/:x'), '/a/b%20c'), { x: 'b c' })
|
|
})
|
|
|
|
test('a trailing * is the path and everything under it', () => {
|
|
const p = compile('/admin/modules/*')
|
|
for (const path of ['/admin/modules', '/admin/modules/', '/admin/modules/rust/enable', '/admin/modules/sources']) {
|
|
assert.ok(match(p, path), path)
|
|
}
|
|
assert.equal(match(p, '/admin/modulesx'), null)
|
|
})
|
|
|
|
test('a pattern that would compile to something else is refused', () => {
|
|
for (const bad of ['admin/x', '/admin//x', '/admin/*/x', '/admin/x y', '/admin/:']) {
|
|
assert.throws(() => compile(bad), /lock path/, bad)
|
|
}
|
|
})
|
|
|
|
// ── Core's list against the real routes ────────────────────────────────────
|
|
|
|
test("every row of core's list names a route the server actually serves", () => {
|
|
// A row that matched nothing would read as a lock and lock nothing. Held
|
|
// against routes.manifest.json, which `npm run routes:manifest` keeps exact.
|
|
const served = manifest.public
|
|
.filter((r) => r.path.startsWith('/api/v1/'))
|
|
.map((r) => ({ method: r.method, path: r.path.slice('/api/v1'.length) }))
|
|
for (const rule of locks.CORE_LOCKS) {
|
|
const hits = served.filter((r) => rule.methods.includes(r.method) && match(rule.compiled, r.path))
|
|
assert.ok(hits.length > 0, `${rule.methods.join('/')} ${rule.path} matches no served route`)
|
|
}
|
|
})
|
|
|
|
test('every upload route in the manifest is locked by name as well as by the multipart rule', () => {
|
|
const uploads = manifest.public.filter((r) => r.method === 'POST' && /upload|brand-asset/.test(r.path))
|
|
assert.ok(uploads.length >= 4)
|
|
for (const r of uploads) {
|
|
const path = r.path.slice('/api/v1'.length)
|
|
assert.ok(locks.CORE_LOCKS.some((l) => l.methods.includes('POST') && match(l.compiled, path)), path)
|
|
}
|
|
})
|
|
|
|
// ── Tier A, unconditional ──────────────────────────────────────────────────
|
|
|
|
test('the Tier A rows are refused, and reads of the same paths are not', async () => {
|
|
await withLock(undefined, async (req) => {
|
|
await expectLocked(await req('PUT', '/admin/site-mode', { mode: 'maintenance' }))
|
|
await expectLocked(await req('PUT', '/Admin/Site-Mode/', { mode: 'maintenance' }))
|
|
await expectLocked(await req('POST', '/admin/modules/rust/disable'))
|
|
await expectLocked(await req('DELETE', '/admin/modules/rust'))
|
|
await expectLocked(await req('POST', '/admin/modules/restart'))
|
|
await expectLocked(await req('POST', '/admin/auth/providers', { name: 'x' }))
|
|
await expectLocked(await req('PUT', '/admin/email/config', {}))
|
|
await expectLocked(await req('POST', '/admin/email/test'))
|
|
await expectLocked(await req('POST', '/admin/email/disconnect'))
|
|
await expectLocked(await req('PUT', '/admin/discord-bot/config', {}))
|
|
await expectLocked(await req('POST', '/admin/teams/voice/sync'))
|
|
await expectLocked(await req('POST', '/auth/password/forgot', { email: 'a@b.c' }))
|
|
await expectLocked(await req('POST', '/auth/password/reset/abc', { password: 'x' }))
|
|
await expectLocked(await req('POST', '/auth/register', { username: 'x' }))
|
|
await expectLocked(await req('POST', '/public/contact', { message: 'hi' }))
|
|
await expectLocked(await req('POST', '/admin/uploads'))
|
|
|
|
await expectReached(await req('GET', '/admin/site-mode'))
|
|
await expectReached(await req('GET', '/admin/modules'))
|
|
await expectReached(await req('GET', '/admin/email/config'))
|
|
await expectReached(await req('GET', '/auth/password/reset/abc'))
|
|
})
|
|
})
|
|
|
|
test('content, the rig and the rest of Tier C stay open', async () => {
|
|
await withLock(undefined, async (req) => {
|
|
await expectReached(await req('POST', '/admin/posts', { title: 'x' }))
|
|
await expectReached(await req('PUT', '/admin/wiki/guides', { title: 'x' }))
|
|
await expectReached(await req('POST', '/admin/events', {}))
|
|
await expectReached(await req('POST', '/admin/invites', {}))
|
|
await expectReached(await req('POST', '/admin/users', { username: 'bob' }))
|
|
await expectReached(await req('POST', '/auth/login', { username: 'demo-admin' }))
|
|
await expectReached(await req('POST', '/auth/mobile/logout', {}))
|
|
})
|
|
})
|
|
|
|
// ── Settings: open, except the keys that reach a Tier A lock ───────────────
|
|
|
|
test('PUT /admin/settings is open for theme and nav', async () => {
|
|
await withLock(undefined, async (req) => {
|
|
await expectReached(await req('PUT', '/admin/settings', { theme_visual: { preset: 'fantasy' }, nav_public: {} }))
|
|
await expectReached(await req('PUT', '/admin/settings', { player_registration: 'password' }))
|
|
})
|
|
})
|
|
|
|
// DM21: the hero editor's autosave and Preview write only the draft; Publish
|
|
// writes both, and the live key refuses the whole body.
|
|
test('the hero draft is open, publishing the hero is refused', async () => {
|
|
await withLock(undefined, async (req) => {
|
|
await expectReached(await req('PUT', '/admin/settings', { hero_layout_draft: '{"v":1}' }))
|
|
const body = await expectLocked(
|
|
await req('PUT', '/admin/settings', { hero_layout: '{"v":1}', hero_layout_draft: '{"v":1}' }),
|
|
)
|
|
assert.deepEqual(body.keys, ['hero_layout'])
|
|
})
|
|
})
|
|
|
|
test('PUT /admin/settings naming a protected key is refused whole, with the keys named', async () => {
|
|
await withLock(undefined, async (req) => {
|
|
for (const key of ['site_mode', 'site_mode_changed_by', 'module_source_hosts', 'teams_voice_enabled', 'demo_state']) {
|
|
const body = await expectLocked(await req('PUT', '/admin/settings', { theme_visual: {}, [key]: 'x' }))
|
|
assert.deepEqual(body.keys, [key])
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── The shared logins ──────────────────────────────────────────────────────
|
|
|
|
test('an admin cannot change, delete or reset a shared login, but can anybody else', async () => {
|
|
stubUsers()
|
|
await withLock(undefined, async (req) => {
|
|
for (const id of [1, 2, 3, 4]) {
|
|
await expectLocked(await req('PUT', `/admin/users/${id}`, { role: 'player' }))
|
|
await expectLocked(await req('DELETE', `/admin/users/${id}`))
|
|
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices`))
|
|
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices/5`))
|
|
await expectLocked(await req('POST', `/admin/users/${id}/mfa/reset`))
|
|
}
|
|
await expectReached(await req('PUT', '/admin/users/9', { role: 'player' }))
|
|
await expectReached(await req('DELETE', '/admin/users/9'))
|
|
await expectReached(await req('PUT', '/admin/users/404', { role: 'player' }))
|
|
await expectReached(await req('PUT', '/admin/users/not-a-number', {}))
|
|
})
|
|
})
|
|
|
|
const SELF_ROWS = [
|
|
['PATCH', '/auth/me/account/username'],
|
|
['PATCH', '/auth/me/account/password'],
|
|
['PATCH', '/auth/me/account/email'],
|
|
['POST', '/auth/me/account/totp/setup'],
|
|
['POST', '/auth/me/account/totp/enable'],
|
|
['POST', '/auth/me/account/totp/disable'],
|
|
['POST', '/auth/me/account/recovery-codes/generate'],
|
|
['DELETE', '/auth/me/account/identities/steam'],
|
|
['DELETE', '/auth/me/sessions/12'], // DM17
|
|
['DELETE', '/auth/me/account'], // ACCOUNT_DELETION.md §4.3
|
|
]
|
|
|
|
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
|
|
stubUsers()
|
|
signedInAs(4)
|
|
await withLock(undefined, async (req) => {
|
|
for (const [method, path] of SELF_ROWS) await expectLocked(await req(method, path, {}))
|
|
// Self-service that cannot lock anyone else out stays open.
|
|
await expectReached(await req('PUT', '/auth/me/notifications/channels', {}))
|
|
})
|
|
})
|
|
|
|
test('signed in as anyone else, or not at all, the same routes go to their handlers', async () => {
|
|
stubUsers()
|
|
signedInAs(9)
|
|
await withLock(undefined, async (req) => {
|
|
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
|
})
|
|
signedInAs(null) // the route's own requireAuth answers 401
|
|
await withLock(undefined, async (req) => {
|
|
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
|
|
})
|
|
})
|
|
|
|
test('a lock that cannot decide refuses', async () => {
|
|
users.getById = async () => {
|
|
throw new Error('pool down')
|
|
}
|
|
await withLock(undefined, async (req) => {
|
|
await expectLocked(await req('PUT', '/admin/users/9', {}))
|
|
})
|
|
})
|
|
|
|
// ── Uploads ────────────────────────────────────────────────────────────────
|
|
|
|
test('any multipart request is refused before a router sees it, wherever it is sent', async () => {
|
|
await withLock(undefined, async (req) => {
|
|
const multipart = { 'Content-Type': 'multipart/form-data; boundary=x' }
|
|
await expectLocked(await req('POST', '/admin/posts', undefined, multipart))
|
|
await expectLocked(await req('POST', '/rust/anything', undefined, { 'Content-Type': 'Multipart/Mixed; boundary=y' }))
|
|
await expectLocked(await req('GET', '/public/settings', undefined, multipart))
|
|
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine' }))
|
|
})
|
|
})
|
|
|
|
// DM25: a data: URI in any string of a write's body is an upload too.
|
|
async function withDataUriLock(fn) {
|
|
const app = await startApp((a) => {
|
|
a.use(locks.refuseDataUri)
|
|
a.all('*', (req, res) => res.json({ reached: true }))
|
|
})
|
|
try {
|
|
return await fn((method, path, body) =>
|
|
fetch(`${app.url}/api/v1${path}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}))
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
|
|
test('a data: URI anywhere in a write body is refused, on any route and any write method', async () => {
|
|
await withDataUriLock(async (req) => {
|
|
const img = '<p>hi</p><img src="data:image/png;base64,iVBORw0KGgo=">'
|
|
await expectLocked(await req('POST', '/admin/posts', { title: 't', body: img }))
|
|
await expectLocked(await req('PUT', '/admin/wiki/1', { content: img }))
|
|
await expectLocked(await req('PATCH', '/rust/anything', { deep: [{ x: { y: [img] } }] }))
|
|
await expectLocked(await req('PUT', '/admin/settings', { hero_layout_draft: JSON.stringify([{ src: 'data:image/webp;base64,UklG' }]) }))
|
|
await expectLocked(await req('DELETE', '/player/teams/x/forum/1', { reason: img }))
|
|
})
|
|
})
|
|
|
|
test('every data: URI shape a browser would load is refused', async () => {
|
|
const shapes = [
|
|
'DATA:image/png;base64,AAAA',
|
|
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
|
'data:text/html;base64,PHNjcmlwdD4=',
|
|
'data:,hello',
|
|
'data:;base64,AAAA',
|
|
'data: image/png;base64,AAAA',
|
|
'<div style="background:url(data:image/gif;base64,R0lG)">',
|
|
'<img src="data:image/png;base64,AAAA">',
|
|
'<img src="data:image/png;base64,AAAA">',
|
|
'<img src="data:image/png;base64,AAAA">',
|
|
'<img src="da\nta:image/png;base64,AAAA">',
|
|
'<img src="data:im\tage/png;base64,AAAA">',
|
|
]
|
|
for (const s of shapes) assert.equal(locks.hasDataUri({ body: s }), true, s)
|
|
})
|
|
|
|
test('prose that mentions data, reads, and bodies without strings pass', async () => {
|
|
for (const s of ['metadata: none', 'Data: none, yet', 'the data: it is fine', 'see https://example.com/data:x', 'mydata:image/png,x']) {
|
|
assert.equal(locks.hasDataUri({ body: s }), false, s)
|
|
}
|
|
assert.equal(locks.hasDataUri(undefined), false)
|
|
assert.equal(locks.hasDataUri({ n: 1, ok: true, list: [null, 2] }), false)
|
|
await withDataUriLock(async (req) => {
|
|
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine', body: '<p>metadata: none</p>' }))
|
|
})
|
|
// A read is never refused, whatever it carries.
|
|
const app = await startApp((a) => {
|
|
a.use(locks.refuseDataUri)
|
|
a.all('*', (req, res) => res.json({ reached: true }))
|
|
})
|
|
try {
|
|
await expectReached(await fetch(`${app.url}/api/v1/public/settings?x=data:image/png;base64,AAAA`))
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
// ── Module locks ───────────────────────────────────────────────────────────
|
|
|
|
test("a module's declared locks are refused alongside core's", async () => {
|
|
const declared = [{ methods: ['PUT'], path: '/admin/rust/servers/:id', compiled: compile('/admin/rust/servers/:id'), source: 'rust' }]
|
|
await withLock(() => declared, async (req) => {
|
|
await expectLocked(await req('PUT', '/admin/rust/servers/demo', {}))
|
|
await expectReached(await req('GET', '/admin/rust/servers/demo'))
|
|
await expectReached(await req('DELETE', '/admin/rust/servers/demo'))
|
|
await expectReached(await req('POST', '/admin/rust/servers/demo/test'))
|
|
})
|
|
})
|
|
|
|
// ── Search engines ─────────────────────────────────────────────────────────
|
|
|
|
test('noindex and robots keep the demo out of search engines', async () => {
|
|
const app = await startApp((a) => {
|
|
a.use(locks.noindex)
|
|
a.get('/robots.txt', locks.robots)
|
|
a.get('/', (req, res) => res.send('ok'))
|
|
})
|
|
try {
|
|
const page = await fetch(`${app.url}/`)
|
|
assert.equal(page.headers.get('x-robots-tag'), 'noindex, nofollow')
|
|
const robots = await fetch(`${app.url}/robots.txt`)
|
|
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|