// 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);