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
This commit is contained in:
@@ -149,6 +149,8 @@ function fakeApi() {
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
|
||||
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
|
||||
312
server/test/accountDeletion.test.js
Normal file
312
server/test/accountDeletion.test.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// ── 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')
|
||||
})
|
||||
@@ -131,6 +131,8 @@ test('registers exactly what module.json declares', () => {
|
||||
assert.ok(api.record.streams.length > 0)
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||
// MODULE_API 1.20.0: a deleted account's game links are unlinked on the shard.
|
||||
assert.strictEqual(api.record.hooks.onUserDeleting, require('../utils/accountDeletion').onUserDeleting)
|
||||
})
|
||||
|
||||
test('every registered stream is namespaced or grandfathered', () => {
|
||||
|
||||
Reference in New Issue
Block a user