Files
maaz519 a5cd8f0ec4 feat(iios): /v1/dsr erasure + compliance-hold REST + smoke (P9)
Tenant-scoped /v1/dsr: POST /erase (self-service right-to-erasure), POST/GET
/holds + POST /holds/:id/release. smoke-dsr.mjs proves PII present → hold
blocks erase (409) → release → erase redacts the ticket subject in place →
re-erase idempotent → cross-tenant cannot see the hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 06:27:42 +05:30

66 lines
3.1 KiB
JavaScript

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