diff --git a/packages/iios-service/scripts/smoke-dsr.mjs b/packages/iios-service/scripts/smoke-dsr.mjs new file mode 100644 index 0000000..45232d4 --- /dev/null +++ b/packages/iios-service/scripts/smoke-dsr.mjs @@ -0,0 +1,65 @@ +// P9 slice-8 DSR smoke: right-to-erasure redacts a subject's PII in place (a ticket +// subject), an active compliance hold blocks erasure until released, erasure is +// idempotent, and holds are tenant-fenced (KG-03 / KG-02). Requires the service +// running with IIOS_DEV_TOKENS=1. +import 'dotenv/config'; + +const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200'; +const APP_ID = 'portal-demo'; +const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); }; + +async function devToken(userId, orgId) { + const r = await fetch(`${SERVICE}/v1/dev/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }), + }); + if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`); + return (await r.json()).token; +} +function call(token, path, method = 'GET', body) { + return fetch(`${SERVICE}${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: body ? JSON.stringify(body) : undefined, + }); +} +async function json(token, path, method = 'GET', body) { + const r = await call(token, path, method, body); + if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`); + return r.json(); +} +const ticketSubject = async (token, id) => (await json(token, '/v1/support/tickets')).find((t) => t.id === id)?.subject; + +const A = await devToken('dsr-a', 'org_dsr_A'); +const B = await devToken('dsr-b', 'org_dsr_B'); +const PII = 'my SSN is 123-45'; + +// A creates a ticket carrying PII. +const ticket = await json(A, '/v1/support/tickets', 'POST', { subject: PII }); +assert((await ticketSubject(A, ticket.id)) === PII, 'ticket carries the subject PII'); + +// A places a compliance hold on their own subject. +const hold = await json(A, '/v1/dsr/holds', 'POST', { targetType: 'data_subject', targetId: 'self', reason: 'litigation' }); +assert((await json(A, '/v1/dsr/holds')).some((h) => h.id === hold.id), 'compliance hold is listed'); + +// Erasure is blocked while the hold is active. +const blocked = await call(A, '/v1/dsr/erase', 'POST'); +assert(blocked.status === 409, `erase blocked by the hold → 409 (got ${blocked.status})`); +assert((await ticketSubject(A, ticket.id)) === PII, 'PII still present while held'); + +// Release the hold → erasure now redacts the PII in place. +await json(A, `/v1/dsr/holds/${hold.id}/release`, 'POST'); +const erased = await json(A, '/v1/dsr/erase', 'POST'); +assert(erased.status === 'ERASED', `erase succeeds after release (status ${erased.status})`); +assert((await ticketSubject(A, ticket.id)) === '[redacted]', 'ticket subject is redacted (erased in place, not deleted)'); + +// Idempotent: a second erase is a no-op. +const again = await json(A, '/v1/dsr/erase', 'POST'); +assert(again.status === 'ALREADY_ERASED', `re-erase is idempotent (status ${again.status})`); + +// Tenant fence: tenant B never sees A's holds. +assert(!(await json(B, '/v1/dsr/holds')).some((h) => h.id === hold.id), "tenant B's hold list excludes A's hold"); + +console.log('\nP9 DSR smoke: PASS'); +process.exit(0);