- 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
86 lines
4.1 KiB
JavaScript
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]);
|
|
});
|
|
});
|