feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)
ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion, which runs the player's own unlinkOwned per link, logs rust.account.unlink with reason 'account deleted', and marks every server's permission sync dirty so the withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8); engagement manifest regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -173,6 +173,8 @@ function fakeApi() {
|
||||
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
|
||||
// MODULE_API 1.18.0 (the public demo's seed, DM15).
|
||||
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = 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
|
||||
|
||||
@@ -454,7 +454,11 @@ test('module.json locks the two rig-link routes and the player Steam unlink, and
|
||||
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
|
||||
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
|
||||
])
|
||||
assert.equal(manifest.coreApi, '^1.19.0')
|
||||
// A floor, not an exact value: later contract members raise it (1.20.0 for
|
||||
// account deletion), and what this test holds is that it never drops below the
|
||||
// core that asks demoRefuses.
|
||||
const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number)
|
||||
assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`)
|
||||
})
|
||||
|
||||
// ── The staff Steam unlink (W4) ────────────────────────────────────────────
|
||||
|
||||
@@ -92,6 +92,12 @@ test('registers both identity hooks, so a Steam sign-in link is a Rust link (D39
|
||||
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
|
||||
})
|
||||
|
||||
test('registers the account-deletion hook, so a deleted account is unlinked first (AD10)', () => {
|
||||
const { api } = register()
|
||||
const links = require('../model/links/links.model')
|
||||
assert.strictEqual(api.record.hooks.onUserDeleting, links.unlinkForDeletion)
|
||||
})
|
||||
|
||||
test('the manifest declares what the loader requires', () => {
|
||||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||
|
||||
@@ -472,6 +472,42 @@ test('the linked hook writes nothing for a link that is already there, or for an
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
// ── The website account is being deleted (MODULE_API 1.20.0, AD10) ─────────
|
||||
|
||||
test('a deleted account loses every Steam link through the player’s own unlink, each logged', async () => {
|
||||
const { ctx, queries } = withCore({ select: [[{ steamId: '7656', userId: 4 }, { steamId: STEAM, userId: 4 }]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
await links.unlinkForDeletion({ userId: 4, deletedBy: 'self' })
|
||||
|
||||
// The same statement `unlinkOwned` uses, scoped by user, once per link.
|
||||
const dels = queries.filter((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
|
||||
assert.deepEqual(dels.map((q) => q.params), [['7656', 4], [STEAM, 4]])
|
||||
|
||||
// What a cascade would skip: one entry per link, and the Teams re-sync.
|
||||
assert.deepEqual(ctx.activity.log.calls.map((c) => c[0]), [
|
||||
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: '7656', reason: 'account deleted', deletedBy: 'self' } },
|
||||
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: STEAM, reason: 'account deleted', deletedBy: 'self' } },
|
||||
])
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 2)
|
||||
|
||||
// Every server is marked dirty, so the withdrawn grants go on the next tick.
|
||||
const dirty = queries.filter((q) => /UPDATE rust_perm_sync SET dirty = 1/.test(q.sql))
|
||||
assert.equal(dirty.length, 1)
|
||||
assert.ok(!/WHERE/.test(dirty[0].sql), 'the whole fleet, not one server')
|
||||
})
|
||||
|
||||
test('an account with no Steam link deletes without touching the game or the sync', async () => {
|
||||
const { ctx, queries } = withCore({ select: [[]] })
|
||||
const links = require('../model/links/links.model')
|
||||
|
||||
await links.unlinkForDeletion({ userId: 4, deletedBy: 'staff' })
|
||||
|
||||
assert.equal(queries.filter((q) => !q.sql.trim().toUpperCase().startsWith('SELECT')).length, 0)
|
||||
assert.equal(ctx.activity.log.calls.length, 0)
|
||||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => {
|
||||
const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
|
||||
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })
|
||||
|
||||
Reference in New Issue
Block a user