Files
joshuaandClaude Opus 5.5 a1ecbb9617 POPIA toolkit part 3: retention, breach register, readiness
- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
  `retention` job: inactive accounts anonymised after 5 years with a
  30-day warning (on by default; never staff roles or people with an
  upcoming registration), form answers, login history, payer details in
  payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
  and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
  the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 20:25:58 +02:00

86 lines
4.1 KiB
JavaScript

jest.mock('../src/config/db', () => ({
appSetting: { findUnique: jest.fn(), upsert: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
$queryRawUnsafe: jest.fn(),
}));
const prisma = require('../src/config/db');
const retention = require('../src/utils/retention');
function saved(value) {
prisma.appSetting.findUnique.mockResolvedValue(value === undefined ? null : { value: JSON.stringify(value) });
}
beforeEach(() => jest.clearAllMocks());
describe('policies', () => {
test('defaults: inactive accounts after 5 years, on', async () => {
saved(undefined);
const { policies, reviewedAt } = await retention.getPolicies();
expect(policies.inactive_accounts).toEqual({ enabled: true, months: 60 });
expect(policies.form_answers).toEqual({ enabled: true, months: 12 });
expect(reviewedAt).toBeNull();
});
test('saved values override the defaults, one policy at a time', async () => {
saved({ policies: { form_answers: { enabled: false, months: 6 } }, reviewedAt: '2026-10-23T00:00:00.000Z' });
const { policies, reviewedAt } = await retention.getPolicies();
expect(policies.form_answers).toEqual({ enabled: false, months: 6 });
expect(policies.login_history).toEqual({ enabled: true, months: 24 });
expect(reviewedAt).toBe('2026-10-23T00:00:00.000Z');
});
test('saving refuses periods below the minimum or silly ones', async () => {
const ok = Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { ...d.defaults }]));
await expect(retention.savePolicies({ ...ok, inactive_accounts: { enabled: true, months: 6 } })).rejects.toThrow(/Inactive accounts: choose between 12 and 240/);
await expect(retention.savePolicies({ ...ok, form_answers: { enabled: true, months: 'x' } })).rejects.toThrow(/Attendee form answers/);
saved(undefined);
await retention.savePolicies(ok, { reviewedBy: 'Ada' });
const value = JSON.parse(prisma.appSetting.upsert.mock.calls[0][0].create.value);
expect(value.reviewedBy).toBe('Ada');
expect(value.policies.audit_logs).toEqual({ enabled: true, months: 60 });
});
});
describe('describing periods', () => {
test('whole years read as years', () => {
expect(retention.describeMonths(60)).toBe('5 years');
expect(retention.describeMonths(12)).toBe('1 year');
expect(retention.describeMonths(18)).toBe('18 months');
expect(retention.describeMonths(1)).toBe('1 month');
});
test('monthsAgo goes back calendar months', () => {
expect(retention.monthsAgo(24, new Date('2026-10-23T12:00:00Z')).toISOString()).toBe('2024-10-23T12:00:00.000Z');
});
test('the notice lines follow the policies, and say so when one is off', async () => {
saved({ policies: { inactive_accounts: { enabled: false, months: 60 }, form_answers: { enabled: true, months: 24 } } });
const lines = await retention.noticeLines();
expect(lines[0]).toBe('• Accounts are kept until you ask us to delete them.');
expect(lines[1]).toBe('• Answers to event forms are deleted 2 years after the event.');
expect(lines.every(l => l.startsWith('• '))).toBe(true);
});
});
describe('inactive accounts query', () => {
test('not-yet-warned: a warning older than the last activity no longer counts', async () => {
prisma.$queryRawUnsafe.mockResolvedValue([{ id: 'u1' }]);
const before = new Date('2021-10-23');
expect(await retention.inactiveAccounts(before, { warned: false, limit: 10 })).toEqual(['u1']);
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
expect(sql).toMatch(/warned_at IS NULL OR warned_at < last_active/);
expect(sql).toMatch(/u\."role" = 'user'/);
expect(params).toEqual([before]);
});
test('due for deletion: warned since the last activity, long enough ago', async () => {
prisma.$queryRawUnsafe.mockResolvedValue([]);
const before = new Date('2021-10-23');
const warnedBefore = new Date('2026-09-23');
await retention.inactiveAccounts(before, { warnedBefore });
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
expect(sql).toMatch(/warned_at >= last_active AND warned_at <= \$2/);
expect(params).toEqual([before, warnedBefore]);
});
});