65023ce404
An email/SMS command holds PII: the recipient address (target) and the rendered
body (payload). RetentionService now snapshots outbound commands and, once aged,
redacts target->'[redacted]' and payload->{redacted:true} in place while KEEPING
the template provenance (key/version/locale/hash) — so 'which template version did
we send?' stays answerable after the PII is gone.
- ensureOutboundSnapshots: one snapshot per command, dataClass 'outbound',
archiveAfter==deleteAfter (PII goes straight to redact, no archive phase).
Nullable command scope uses an 'unscoped' tag so the global sweep still reaches it.
- applySweep redact branch switches on targetType; compliance holds honored; audit
'retention.redacted' resourceType 'outbound_command'.
Closes lever #2 of the PII-minimization plan. 3 new + 6 regression tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
9.0 KiB
TypeScript
176 lines
9.0 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
|
import { resetDb } from '../test-utils/reset-db';
|
|
import { ActorResolver } from '../identity/actor.resolver';
|
|
import { IngestService } from '../interactions/ingest.service';
|
|
import { RetentionService } from './retention.service';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
|
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
|
const asService = prisma as unknown as PrismaService;
|
|
const actors = new ActorResolver(asService);
|
|
const svc = () => new RetentionService(asService);
|
|
|
|
async function ingest(text: string, orgId = 'org_demo'): Promise<{ interactionId: string; scopeId: string }> {
|
|
const res = await new IngestService(asService, makeFakePorts(), actors).ingest(
|
|
{
|
|
scope: { orgId, appId: 'portal-demo' },
|
|
channel: { type: 'WEBHOOK', externalChannelId: 'webhook' },
|
|
source: { handleKind: 'EXTERNAL_VISITOR', externalId: 'ext-1' },
|
|
kind: 'MESSAGE',
|
|
thread: { externalThreadId: `wh-${randomUUID().slice(0, 6)}` },
|
|
parts: [{ kind: 'TEXT', bodyText: text }],
|
|
occurredAt: new Date().toISOString(),
|
|
providerEventId: `pe-${randomUUID()}`,
|
|
},
|
|
`ing-${randomUUID()}`,
|
|
);
|
|
const it = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId } });
|
|
return { interactionId: res.interactionId, scopeId: it.scopeId };
|
|
}
|
|
|
|
const past = new Date(Date.now() - 86_400_000);
|
|
const future = new Date(Date.now() + 86_400_000);
|
|
const setSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
|
|
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'interaction', targetId } }, data });
|
|
const bodyOf = async (interactionId: string) => (await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } })).bodyText;
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
describe('RetentionService (P9 — retention sweep)', () => {
|
|
it('ensureSnapshots captures one ACTIVE snapshot per interaction with archive < delete', async () => {
|
|
const { interactionId } = await ingest('hello');
|
|
await svc().ensureSnapshots();
|
|
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } });
|
|
expect(snap.status).toBe('ACTIVE');
|
|
expect(snap.dataClass).toBe('internal');
|
|
expect(snap.archiveAfter.getTime()).toBeLessThan(snap.deleteAfter.getTime());
|
|
});
|
|
|
|
it('redacts an interaction whose delete window has passed', async () => {
|
|
const { interactionId } = await ingest('my secret');
|
|
await svc().ensureSnapshots();
|
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
|
|
|
|
const res = await svc().applySweep();
|
|
expect(res.redacted).toBe(1);
|
|
expect(await bodyOf(interactionId)).toBe('[redacted]');
|
|
expect((await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: interactionId } })).status).toBe('REDACTED');
|
|
expect((await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } })).status).toBe('REDACTED');
|
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted' } })).toBe(1);
|
|
});
|
|
|
|
it('archives (not deletes) when only the archive window has passed, then deletes later', async () => {
|
|
const { interactionId } = await ingest('still needed');
|
|
await svc().ensureSnapshots();
|
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: future });
|
|
|
|
let res = await svc().applySweep();
|
|
expect(res).toMatchObject({ archived: 1, redacted: 0 });
|
|
expect(await bodyOf(interactionId)).toBe('still needed'); // content untouched by archive
|
|
|
|
await setSnapshot(interactionId, { deleteAfter: past });
|
|
res = await svc().applySweep();
|
|
expect(res.redacted).toBe(1);
|
|
expect(await bodyOf(interactionId)).toBe('[redacted]');
|
|
});
|
|
|
|
it('an active compliance hold blocks the sweep until released', async () => {
|
|
const { interactionId, scopeId } = await ingest('held content');
|
|
await svc().ensureSnapshots();
|
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
|
|
const hold = await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'message', targetId: interactionId, holdReason: 'legal' } });
|
|
|
|
let res = await svc().applySweep();
|
|
expect(res).toMatchObject({ redacted: 0, skippedHeld: 1 });
|
|
expect(await bodyOf(interactionId)).toBe('held content');
|
|
|
|
await prisma.iiosComplianceHold.update({ where: { id: hold.id }, data: { status: 'RELEASED' } });
|
|
res = await svc().applySweep();
|
|
expect(res.redacted).toBe(1);
|
|
expect(await bodyOf(interactionId)).toBe('[redacted]');
|
|
});
|
|
|
|
it('is idempotent — a REDACTED snapshot is terminal', async () => {
|
|
const { interactionId } = await ingest('x');
|
|
await svc().ensureSnapshots();
|
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
|
|
await svc().applySweep();
|
|
expect(await svc().applySweep()).toMatchObject({ archived: 0, redacted: 0, skippedHeld: 0 });
|
|
});
|
|
|
|
it('sweep is tenant-scoped', async () => {
|
|
const a = await ingest('tenant a', 'org_A');
|
|
const b = await ingest('tenant b', 'org_B');
|
|
await svc().ensureSnapshots();
|
|
await setSnapshot(a.interactionId, { archiveAfter: past, deleteAfter: past });
|
|
await setSnapshot(b.interactionId, { archiveAfter: past, deleteAfter: past });
|
|
|
|
const res = await svc().applySweep(a.scopeId);
|
|
expect(res.redacted).toBe(1);
|
|
expect(await bodyOf(a.interactionId)).toBe('[redacted]');
|
|
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
|
});
|
|
});
|
|
|
|
// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with
|
|
// their name). Once aged, redact those while KEEPING the template provenance for audit/replay.
|
|
describe('RetentionService — outbound command PII (T8)', () => {
|
|
const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
|
|
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data });
|
|
|
|
async function command(target: string): Promise<{ id: string; scopeId: string }> {
|
|
const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
|
|
const cmd = await prisma.iiosOutboundCommand.create({
|
|
data: {
|
|
channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`,
|
|
payload: { subject: 'Hi Dana', html: '<p>secret body</p>' },
|
|
templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123',
|
|
},
|
|
});
|
|
return { id: cmd.id, scopeId: scope.id };
|
|
}
|
|
|
|
it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => {
|
|
const { id } = await command('dana@acme.com');
|
|
const n = await svc().ensureOutboundSnapshots();
|
|
expect(n).toBe(1);
|
|
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } });
|
|
expect(snap.targetType).toBe('outbound_command');
|
|
expect(snap.dataClass).toBe('outbound');
|
|
});
|
|
|
|
it('redacts target + payload of an aged command but keeps template provenance', async () => {
|
|
const { id } = await command('dana@acme.com');
|
|
await svc().ensureOutboundSnapshots();
|
|
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
|
|
|
|
const res = await svc().applySweep();
|
|
expect(res.redacted).toBe(1);
|
|
const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } });
|
|
expect(after.target).toBe('[redacted]');
|
|
expect(after.payload).toEqual({ redacted: true });
|
|
// Provenance survives — you can still answer "which template version did we send?"
|
|
expect(after.templateKey).toBe('welcome');
|
|
expect(after.templateVersion).toBe(1);
|
|
expect(after.renderedHash).toBe('abc123');
|
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1);
|
|
});
|
|
|
|
it('an active compliance hold blocks the command sweep', async () => {
|
|
const { id, scopeId } = await command('held@acme.com');
|
|
await svc().ensureOutboundSnapshots();
|
|
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
|
|
await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } });
|
|
|
|
const res = await svc().applySweep();
|
|
expect(res.skippedHeld).toBe(1);
|
|
expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com');
|
|
});
|
|
});
|