Files
Module-uo/server/test/accountDeletion.test.js
wtclaude 582a55dde5
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / client-build (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m20s
feat(account): unlink game accounts on the shard before an account is deleted (MODULE_API 1.20.0)
ACCOUNT_DELETION.md §4.4 (AD10, AD11). api.onUserDeleting queues every linked
account in the new shard_pending_unlinks table, then calls the sidecar's
DELETE /link/{account} (actor web:account-deletion) and drops the mirror on 200/404.
A shard that is away leaves the row pending, retried on WS reconnect and on a live
server.hello; a protected account (403) is refused and listed on Admin -> Shard as
still linked on the shard. A live mob.login without the deleted user's webId clears
the row. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 20:38:33 -05:00

313 lines
11 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.

// ── A website account is being deleted (MODULE_API 1.20.0) ──────────────────
//
// ACCOUNT_DELETION.md §4.4, AD10 and AD11. What these hold:
//
// • the shard is asked to unlink every game account, through the same sidecar
// call the staff unlink makes, and the mirror goes only when it said yes;
// • every account is queued BEFORE the first call, so a hook core abandons at
// its ten-second limit still leaves the rest in the queue;
// • a shard that is away leaves the row pending; a protected account (403)
// leaves it refused and is never asked again;
// • a live login without the deleted user's id clears the row, and a replayed
// one never does.
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const { ctx } = require('./_setup')
const accountDeletion = require('../utils/accountDeletion')
const shardIngest = require('../utils/shardIngest')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const pendingDb = require('../model/shardPendingUnlinks/shardPendingUnlinks.db')
const uoLinkClient = require('../utils/uoLinkClient')
const originals = {
listForUser: shardLinks.listForUser,
removeByAccount: shardLinks.removeByAccount,
unlinkAccount: uoLinkClient.unlinkAccount,
pending: { ...pendingDb },
activityLog: ctx.activity.log,
}
afterEach(() => {
shardLinks.listForUser = originals.listForUser
shardLinks.removeByAccount = originals.removeByAccount
uoLinkClient.unlinkAccount = originals.unlinkAccount
Object.assign(pendingDb, originals.pending)
ctx.activity.log = originals.activityLog
accountDeletion._reset()
})
/** An in-memory `shard_pending_unlinks`, and a journal of everything asked of it. */
function fakeQueue(rows = []) {
const table = new Map(rows.map((r) => [r.account, { attempts: 0, state: 'pending', ...r }]))
const journal = []
pendingDb.queue = async ({ account, webUserId }) => {
journal.push(['queue', account])
table.set(account, { account, web_user_id: webUserId, state: 'pending', attempts: 0 })
}
pendingDb.remove = async (account) => {
journal.push(['remove', account])
table.delete(account)
}
pendingDb.recordAttempt = async ({ account, state, error }) => {
journal.push(['attempt', account, state])
const row = table.get(account)
if (row) Object.assign(row, { state, attempts: row.attempts + 1, last_error: error })
}
pendingDb.listPending = async () => [...table.values()].filter((r) => r.state === 'pending')
pendingDb.listAll = async () => [...table.values()]
pendingDb.clearIfNotAttributed = async (account, webId) => {
const row = table.get(account)
if (!row || (webId !== null && row.web_user_id === webId)) return 0
table.delete(account)
return 1
}
return { table, journal }
}
/** The sidecar, answering per account from a script. */
function fakeShard(replies) {
const asked = []
uoLinkClient.unlinkAccount = async ({ actor, account }) => {
asked.push({ actor, account })
return replies[account]
}
return asked
}
function trackMirror() {
const removed = []
shardLinks.removeByAccount = async (account) => { removed.push(account) }
return removed
}
function trackActivity() {
const entries = []
ctx.activity.log = async (entry) => { entries.push(entry) }
return entries
}
const OK = { ok: true, status: 200, data: { ok: true } }
const ABSENT = { ok: false, status: 404 }
const PROTECTED = { ok: false, status: 403, error: 'protected' }
const AWAY = { ok: false, status: 503, error: 'plugin not connected' }
test('every game account is unlinked on the shard, then dropped from the mirror and logged', async () => {
shardLinks.listForUser = async () => [{ account: 'thorne' }, { account: 'thorne2' }]
const { table } = fakeQueue()
const asked = fakeShard({ thorne: OK, thorne2: OK })
const mirror = trackMirror()
const entries = trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
// The staff unlink's sidecar call, with an actor that says why.
assert.deepEqual(asked, [
{ actor: 'web:account-deletion', account: 'thorne' },
{ actor: 'web:account-deletion', account: 'thorne2' },
])
assert.deepEqual(mirror, ['thorne', 'thorne2'])
assert.equal(table.size, 0, 'nothing left queued')
// No `userId` column: on a retry the user is already gone.
assert.deepEqual(entries, [
{ action: 'shard.account.unlink', detail: { account: 'thorne', webUserId: 7, reason: 'account deleted', deletedBy: 'self' } },
{ action: 'shard.account.unlink', detail: { account: 'thorne2', webUserId: 7, reason: 'account deleted', deletedBy: 'self' } },
])
})
test('every account is queued before the shard is asked anything', async () => {
// A hook core abandons at ten seconds keeps running, but core deletes the user
// meanwhile; one sidecar call can take twelve. Queued first, nothing is lost.
shardLinks.listForUser = async () => [{ account: 'a' }, { account: 'b' }]
const { journal } = fakeQueue()
fakeShard({ a: OK, b: OK })
trackMirror()
trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'staff' })
assert.deepEqual(journal.slice(0, 2), [['queue', 'a'], ['queue', 'b']])
})
test('a shard that is away leaves the account pending, and the mirror to the cascade', async () => {
shardLinks.listForUser = async () => [{ account: 'thorne' }]
const { table } = fakeQueue()
fakeShard({ thorne: AWAY })
const mirror = trackMirror()
const entries = trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.equal(table.get('thorne').state, 'pending')
assert.equal(table.get('thorne').web_user_id, 7)
assert.equal(table.get('thorne').last_error, 'plugin not connected')
assert.deepEqual(mirror, [])
assert.deepEqual(entries, [])
})
test('a protected staff account is refused, and a retry never asks again', async () => {
shardLinks.listForUser = async () => [{ account: 'gm' }]
const { table } = fakeQueue()
const asked = fakeShard({ gm: PROTECTED })
trackMirror()
trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.equal(table.get('gm').state, 'refused')
await accountDeletion.retryPending()
assert.equal(asked.length, 1, 'the retry skipped the refused row')
// Admin → Shard sees it.
const listed = await accountDeletion.listForAdmin()
assert.equal(listed.length, 1)
assert.equal(listed[0].account, 'gm')
assert.equal(listed[0].state, 'refused')
})
test('a 404 means the shard has nothing to clear: the row and the mirror go, with no unlink entry', async () => {
shardLinks.listForUser = async () => [{ account: 'thorne' }]
const { table } = fakeQueue()
fakeShard({ thorne: ABSENT })
const mirror = trackMirror()
const entries = trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.equal(table.size, 0)
assert.deepEqual(mirror, ['thorne'])
assert.deepEqual(entries, [])
})
test('an account with no game links asks the shard nothing', async () => {
shardLinks.listForUser = async () => []
const { journal } = fakeQueue()
const asked = fakeShard({})
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.deepEqual(journal, [])
assert.deepEqual(asked, [])
})
test('a call that throws leaves the account queued and the hook still resolves', async () => {
shardLinks.listForUser = async () => [{ account: 'a' }, { account: 'b' }]
const { table } = fakeQueue()
uoLinkClient.unlinkAccount = async ({ account }) => {
if (account === 'a') throw new Error('socket hang up')
return OK
}
trackMirror()
trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.deepEqual([...table.keys()], ['a'])
})
test('a retry finishes what the shard can take and keeps the rest', async () => {
const { table } = fakeQueue([
{ account: 'a', web_user_id: 7 },
{ account: 'b', web_user_id: 7 },
])
// One account failing does not hold up the other.
const asked = fakeShard({ a: AWAY, b: OK })
trackMirror()
trackActivity()
await accountDeletion.retryPending()
assert.deepEqual(asked.map((x) => x.account), ['a', 'b'])
assert.deepEqual([...table.keys()], ['a'])
assert.equal(table.get('a').attempts, 1)
})
test('two triggers at once share one retry pass', async () => {
fakeQueue([{ account: 'a', web_user_id: 7 }])
let calls = 0
uoLinkClient.unlinkAccount = async () => {
calls += 1
await new Promise((resolve) => setTimeout(resolve, 10))
return OK
}
trackMirror()
trackActivity()
await Promise.all([accountDeletion.retryPending(), accountDeletion.retryPending()])
assert.equal(calls, 1)
})
test('a retry that cannot read the queue resolves anyway', async () => {
fakeQueue()
pendingDb.listPending = async () => { throw new Error('pool closed') }
await accountDeletion.retryPending()
})
// ── Through the ingest dispatcher ──────────────────────────────────────────
function ingestDeps(pendingUnlinks) {
const noop = async () => {}
return {
shardEvents: { append: noop },
shardState: { upsertOnline: noop, clearOnline: noop, setOffline: noop },
shardLinks: {},
shardMarket: {},
uoLinkConfig: { recordStatus: noop },
settings: { getInstanceName: async () => 'Rig' },
broadcast: () => {},
pushDispatch: () => {},
engagement: () => {},
eventsReconcile: () => {},
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
pendingUnlinks,
}
}
function spyUnlinks() {
const calls = { retry: 0, seen: [] }
return {
calls,
retryPending: () => { calls.retry += 1; return Promise.resolve() },
seenLogin: async (arg) => { calls.seen.push(arg) },
}
}
test('a live server.hello retries the queue; a replayed one does not', async () => {
const spy = spyUnlinks()
await shardIngest.ingest({ kind: 'server.hello', bootId: 'b1', t: 1 }, ingestDeps(spy))
assert.equal(spy.calls.retry, 1)
await shardIngest.ingest({ kind: 'server.hello', bootId: 'b1', t: 2 }, { ...ingestDeps(spy), fromBackfill: true })
assert.equal(spy.calls.retry, 1)
})
test('a live login is checked against the queue; a replayed one never is', async () => {
const spy = spyUnlinks()
const login = { kind: 'mob.login', who: { serial: 1, name: 'Thorne', acct: 'thorne' }, t: 1 }
await shardIngest.ingest(login, ingestDeps(spy))
assert.deepEqual(spy.calls.seen, [{ account: 'thorne', webId: undefined }])
await shardIngest.ingest({ ...login, webId: 7 }, { ...ingestDeps(spy), fromBackfill: true })
assert.equal(spy.calls.seen.length, 1)
})
test('a login without the deleted user’s id clears the row; one still carrying it does not', async () => {
const { table } = fakeQueue([
{ account: 'thorne', web_user_id: 7, state: 'refused' },
{ account: 'gm', web_user_id: 9, state: 'refused' },
])
await accountDeletion.seenLogin({ account: 'gm', webId: 9 })
await accountDeletion.seenLogin({ account: 'gm', webId: '9' })
assert.ok(table.has('gm'), 'still tagged with the deleted user')
await accountDeletion.seenLogin({ account: 'thorne', webId: undefined })
assert.ok(!table.has('thorne'), 'the tag is gone, whoever cleared it')
await accountDeletion.seenLogin({ account: 'gm', webId: 12 })
assert.ok(!table.has('gm'), 'relinked to someone else')
})