// ── 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') })