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
160 lines
8.0 KiB
JavaScript
160 lines
8.0 KiB
JavaScript
// ── Who a deleted account's snapshots show as (ACCOUNT_DELETION.md AD5, AD6) ──
|
||
//
|
||
// The rule itself, then each place a member can read a snapshot: forum threads
|
||
// and posts, the leader's guest list, and the Team digest mail. The Reports
|
||
// queue (staff-only) is covered in contentReports.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 { displayAuthor, DELETED_PLAYER, seesDeletedNames, hasActor } = require('../src/utils/displayAuthor')
|
||
const forum = require('../src/model/teams/teamForum.model')
|
||
const grants = require('../src/model/teams/teamGrants.model')
|
||
const accessDb = require('../src/model/teams/teamAccess.db')
|
||
const teamsDb = require('../src/model/teams/teams.db')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
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
|
||
}
|
||
})
|
||
|
||
const MEMBER = { role: 'player' }
|
||
const STAFF = ['admin', 'editor', 'moderator'].map((role) => ({ role }))
|
||
|
||
// ── the rule ───────────────────────────────────────────────────────────────
|
||
|
||
test('a live account shows its name to everyone', () => {
|
||
for (const viewer of [null, MEMBER, ...STAFF]) {
|
||
assert.deepEqual(displayAuthor({ userId: 4, username: 'Thorne' }, viewer), { name: 'Thorne', deleted: false })
|
||
}
|
||
})
|
||
|
||
test('a deleted account reads "[deleted player]" to members and visitors (AD5)', () => {
|
||
assert.equal(DELETED_PLAYER, '[deleted player]')
|
||
for (const viewer of [null, undefined, MEMBER, { role: 'leader' }, {}]) {
|
||
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: DELETED_PLAYER, deleted: true })
|
||
}
|
||
})
|
||
|
||
test('staff — admin, editor and moderator — see the stored name, marked deleted (AD6)', () => {
|
||
for (const viewer of STAFF) {
|
||
assert.equal(seesDeletedNames(viewer), true)
|
||
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: 'Ravenna', deleted: true })
|
||
}
|
||
// With no snapshot at all there is no name to show anybody.
|
||
assert.deepEqual(displayAuthor({ userId: null, username: null }, STAFF[0]), { name: DELETED_PLAYER, deleted: true })
|
||
})
|
||
|
||
test('an absent actor is not a deleted one', () => {
|
||
assert.equal(hasActor(null, null), false)
|
||
assert.equal(hasActor(null, ''), false)
|
||
assert.equal(hasActor(null, 'OldMod'), true)
|
||
assert.equal(hasActor(5, null), true)
|
||
})
|
||
|
||
// ── forum threads and posts ────────────────────────────────────────────────
|
||
|
||
const threadRow = (over = {}) => ({
|
||
id: 5, type: 'discussion', title: 'Raid night', created_by: null, created_username: 'Ravenna',
|
||
created_at: new Date(), last_post_at: new Date(), post_count: 2, pinned: 0, locked: 0, status: 'visible', ...over,
|
||
})
|
||
const postRow = (over = {}) => ({
|
||
id: 80, thread_id: 5, author_user_id: null, author_username: 'Ravenna', body_html: '<p>Guide</p>',
|
||
created_at: new Date(), edited_at: null, status: 'visible', ...over,
|
||
})
|
||
|
||
test('a forum thread by a deleted account: "[deleted player]" to a member, the name to staff', () => {
|
||
assert.equal(forum.publicThread(threadRow(), MEMBER).author, DELETED_PLAYER)
|
||
assert.equal(forum.publicThread(threadRow()).author, DELETED_PLAYER) // no viewer said
|
||
assert.equal(forum.publicThread(threadRow(), MEMBER).authorDeleted, true)
|
||
for (const viewer of STAFF) {
|
||
const t = forum.publicThread(threadRow(), viewer)
|
||
assert.equal(t.author, 'Ravenna')
|
||
assert.equal(t.authorDeleted, true)
|
||
}
|
||
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).author, 'Ravenna')
|
||
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).authorDeleted, false)
|
||
})
|
||
|
||
test('a forum post by a deleted account follows the same rule, and is nobody’s to edit', () => {
|
||
const asMember = forum.renderPost(postRow(), 'disabled', { userId: 9, role: 'player', isStaff: false })
|
||
assert.equal(asMember.author, DELETED_PLAYER)
|
||
assert.equal(asMember.authorDeleted, true)
|
||
assert.equal(asMember.mine, false)
|
||
assert.equal(asMember.canEdit, false)
|
||
|
||
const asEditor = forum.renderPost(postRow(), 'disabled', { userId: 2, role: 'editor', isStaff: false })
|
||
assert.equal(asEditor.author, 'Ravenna')
|
||
assert.equal(asEditor.authorDeleted, true)
|
||
})
|
||
|
||
test('the thread list and a thread pass the viewer down to every row', async () => {
|
||
const forumDb = require('../src/model/teams/teamForum.db')
|
||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||
patch(forumDb, 'threadsByTeam', async () => [threadRow()])
|
||
patch(forumDb, 'threadById', async () => ({ ...threadRow(), team_id: 1 }))
|
||
patch(forumDb, 'postsByThread', async () => [postRow()])
|
||
patch(forumSettings, 'imageMode', async () => 'disabled')
|
||
|
||
const [listedForMember] = await forum.listThreads(1, { viewer: MEMBER })
|
||
assert.equal(listedForMember.author, DELETED_PLAYER)
|
||
const [listedForStaff] = await forum.listThreads(1, { viewer: { role: 'moderator' } })
|
||
assert.equal(listedForStaff.author, 'Ravenna')
|
||
|
||
const thread = await forum.getThread(1, 5, { viewer: { userId: 9, role: 'player' } })
|
||
assert.equal(thread.author, DELETED_PLAYER)
|
||
assert.equal(thread.posts[0].author, DELETED_PLAYER)
|
||
const staffThread = await forum.getThread(1, 5, { canModerate: true, viewer: { userId: 2, role: 'admin', isStaff: true } })
|
||
assert.equal(staffThread.author, 'Ravenna')
|
||
assert.equal(staffThread.posts[0].author, 'Ravenna')
|
||
})
|
||
|
||
// ── the leader's guest list ────────────────────────────────────────────────
|
||
|
||
test('a deleted account is not a forum guest, and a deleted granter reads "[deleted player]" (AD13)', async () => {
|
||
patch(accessDb, 'activeGrants', async () => [
|
||
{ user_id: 21, username: 'Guest', granted_by: null, granted_username: 'OldLeader', granted_at: new Date(), reason: null },
|
||
{ user_id: null, username: 'Ravenna', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
|
||
{ user_id: 22, username: 'Member', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
|
||
])
|
||
patch(teamsDb, 'membersByTeam', async () => [{ user_id: 22 }])
|
||
|
||
const guests = await grants.forumGuests(1, MEMBER)
|
||
assert.deepEqual(guests.map((g) => g.username), ['Guest'])
|
||
assert.equal(guests[0].grantedBy, DELETED_PLAYER)
|
||
|
||
const forStaff = await grants.forumGuests(1, { role: 'moderator' })
|
||
assert.equal(forStaff[0].grantedBy, 'OldLeader')
|
||
})
|
||
|
||
// ── the Team digest ────────────────────────────────────────────────────────
|
||
|
||
test('the digest mail names a deleted author "[deleted player]"', async () => {
|
||
const worker = require('../src/utils/teamDigestWorker')
|
||
const teamNotify = require('../src/model/teams/teamNotify.model')
|
||
const templates = require('../src/engagement/templates')
|
||
let items = null
|
||
patch(teamNotify, 'digestPostsSince', async () => [
|
||
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
|
||
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },
|
||
])
|
||
patch(templates, 'renderByKey', async (key, values) => {
|
||
items = values.items
|
||
return null // stop before anything is sent
|
||
})
|
||
await worker.sendOne({ id: 1, slug: 'wolves', name: 'Iron Wolves' }, { user_id: 9, last_digest_at: null }, null, new Date())
|
||
assert.deepEqual(items.map((i) => i.heading), ['Raid night — [deleted player]', 'Raid night — Thorne'])
|
||
})
|