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>
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user