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
313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
// ── 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')
|
||
})
|