Adds a full GDPR Article 15 (right of access) workflow. Staff trigger an export from the client detail; a BullMQ worker assembles every row keyed to that client (profile, contacts, addresses, notes, tags, yachts, company memberships, interests, reservations, invoices, documents, last 500 audit events) into JSON + a self-contained HTML report, ZIPs them, uploads to MinIO, and optionally emails the client a 7-day signed download link. - New table gdpr_exports tracks lifecycle (pending → building → ready → sent / failed) with a 30-day cleanup target - Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant- scoped, with HTML escaping to block injection from rogue field values - Worker hook in export queue dispatches on job name 'gdpr-export' - New audit actions: 'request_gdpr_export', 'send_gdpr_export' - API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports rate-limit, Article-15 audit on POST); GET /:exportId returns a fresh signed URL - UI: <GdprExportButton> dialog on client detail header — admin-only, shows recent exports, supports email-to-client + override recipient, polls every 5s while open - Validation: refuses email-to-client when no primary email + no override (rather than silently dropping the send) Tests: 778/778 vitest (was 771) — +7 covering builder happy path, HTML escaping, tenant isolation, empty client, request-flow validation, and audit / queue interaction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
6.6 KiB
TypeScript
201 lines
6.6 KiB
TypeScript
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
clientAddresses,
|
|
clientContacts,
|
|
clientNotes,
|
|
clientTags,
|
|
tags,
|
|
gdprExports,
|
|
} from '@/lib/db/schema';
|
|
import { user } from '@/lib/db/schema/users';
|
|
import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder';
|
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
|
import { makePort, makeClient, makeYacht } from '../helpers/factories';
|
|
|
|
let TEST_USER_ID = '';
|
|
|
|
beforeAll(async () => {
|
|
// Pull any existing user — gdpr_exports.requested_by has an FK that needs
|
|
// to resolve. Tests don't need the user to be specific; they just need it
|
|
// to exist.
|
|
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
|
if (!u) {
|
|
throw new Error('No user available; run pnpm db:seed first');
|
|
}
|
|
TEST_USER_ID = u.id;
|
|
});
|
|
|
|
const META = (portId: string) => ({
|
|
userId: TEST_USER_ID,
|
|
portId,
|
|
ipAddress: '127.0.0.1',
|
|
userAgent: 'vitest',
|
|
});
|
|
|
|
describe('buildClientBundle', () => {
|
|
it('aggregates client + contacts + addresses + tags + yachts', async () => {
|
|
const port = await makePort();
|
|
const client = await makeClient({
|
|
portId: port.id,
|
|
overrides: { fullName: 'Alice Test', nationalityIso: 'GB' },
|
|
});
|
|
|
|
await db.insert(clientContacts).values({
|
|
clientId: client.id,
|
|
channel: 'email',
|
|
value: 'alice@example.com',
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(clientAddresses).values({
|
|
clientId: client.id,
|
|
portId: port.id,
|
|
label: 'Home',
|
|
streetAddress: '1 Pier Way',
|
|
countryIso: 'GB',
|
|
isPrimary: true,
|
|
});
|
|
await db.insert(clientNotes).values({
|
|
clientId: client.id,
|
|
authorId: 'tester',
|
|
content: 'Met at boat show',
|
|
});
|
|
const [tagRow] = await db
|
|
.insert(tags)
|
|
.values({ portId: port.id, name: 'VIP', color: '#ff0000' })
|
|
.returning();
|
|
await db.insert(clientTags).values({ clientId: client.id, tagId: tagRow!.id });
|
|
await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id });
|
|
|
|
const bundle = await buildClientBundle(client.id, port.id);
|
|
|
|
expect(bundle.client.fullName).toBe('Alice Test');
|
|
expect(bundle.contacts).toHaveLength(1);
|
|
expect(bundle.addresses).toHaveLength(1);
|
|
expect(bundle.notes).toHaveLength(1);
|
|
expect(bundle.tags).toHaveLength(1);
|
|
expect(bundle.tags[0]?.name).toBe('VIP');
|
|
expect(bundle.ownedYachts).toHaveLength(1);
|
|
expect(bundle.meta.clientId).toBe(client.id);
|
|
expect(bundle.meta.portId).toBe(port.id);
|
|
expect(bundle.meta.schemaVersion).toBe(1);
|
|
});
|
|
|
|
it('throws NotFoundError when accessed cross-tenant', async () => {
|
|
const portA = await makePort();
|
|
const portB = await makePort();
|
|
const client = await makeClient({ portId: portA.id });
|
|
|
|
await expect(buildClientBundle(client.id, portB.id)).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('returns empty arrays when the client has no related rows', async () => {
|
|
const port = await makePort();
|
|
const client = await makeClient({ portId: port.id });
|
|
|
|
const bundle = await buildClientBundle(client.id, port.id);
|
|
expect(bundle.contacts).toEqual([]);
|
|
expect(bundle.addresses).toEqual([]);
|
|
expect(bundle.tags).toEqual([]);
|
|
expect(bundle.notes).toEqual([]);
|
|
expect(bundle.ownedYachts).toEqual([]);
|
|
expect(bundle.companyMemberships).toEqual([]);
|
|
expect(bundle.invoices).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('renderBundleHtml', () => {
|
|
it('produces a self-contained HTML doc with section headings', async () => {
|
|
const port = await makePort();
|
|
const client = await makeClient({
|
|
portId: port.id,
|
|
overrides: { fullName: 'Render Test' },
|
|
});
|
|
const bundle = await buildClientBundle(client.id, port.id);
|
|
const html = renderBundleHtml(bundle);
|
|
expect(html.startsWith('<!doctype html>')).toBe(true);
|
|
expect(html).toContain('Render Test');
|
|
expect(html).toContain('Personal data export');
|
|
expect(html).toContain('Contacts');
|
|
expect(html).toContain('Addresses');
|
|
expect(html).toContain('Audit trail');
|
|
// No external requests.
|
|
expect(html).not.toMatch(/https?:\/\/[^"'\s]+\.(?:js|css)/);
|
|
});
|
|
|
|
it('escapes HTML in client field values to prevent injection', async () => {
|
|
const port = await makePort();
|
|
const client = await makeClient({
|
|
portId: port.id,
|
|
overrides: { fullName: '<script>alert(1)</script>' },
|
|
});
|
|
const bundle = await buildClientBundle(client.id, port.id);
|
|
const html = renderBundleHtml(bundle);
|
|
// The literal "<script>" must not appear unescaped anywhere in the output.
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
expect(html).toContain('<script>');
|
|
});
|
|
});
|
|
|
|
describe('requestGdprExport', () => {
|
|
it('creates a pending row and queues a job', async () => {
|
|
// Stub the BullMQ queue so we don't actually push jobs to Redis here.
|
|
const add = vi.fn().mockResolvedValue({ id: 'mock-job' });
|
|
vi.doMock('@/lib/queue', () => ({ getQueue: () => ({ add }) }));
|
|
const { requestGdprExport } = await import('@/lib/services/gdpr-export.service');
|
|
|
|
const port = await makePort();
|
|
const client = await makeClient({ portId: port.id });
|
|
await db.insert(clientContacts).values({
|
|
clientId: client.id,
|
|
channel: 'email',
|
|
value: 'p@example.com',
|
|
isPrimary: true,
|
|
});
|
|
|
|
const { export: row } = await requestGdprExport({
|
|
...META(port.id),
|
|
clientId: client.id,
|
|
requestedBy: TEST_USER_ID,
|
|
emailToClient: true,
|
|
});
|
|
expect(row.status).toBe('pending');
|
|
expect(row.clientId).toBe(client.id);
|
|
expect(add).toHaveBeenCalledWith(
|
|
'gdpr-export',
|
|
expect.objectContaining({ exportId: row.id, emailToClient: true }),
|
|
);
|
|
|
|
// Cleanup the mock so other tests don't see a stubbed queue.
|
|
vi.doUnmock('@/lib/queue');
|
|
|
|
const persisted = await db.query.gdprExports.findFirst({
|
|
where: eq(gdprExports.id, row.id),
|
|
});
|
|
expect(persisted?.requestedBy).toBe(TEST_USER_ID);
|
|
});
|
|
|
|
it('refuses when emailToClient=true but no primary email exists and no override', async () => {
|
|
vi.doMock('@/lib/queue', () => ({
|
|
getQueue: () => ({ add: vi.fn().mockResolvedValue({ id: 'mock' }) }),
|
|
}));
|
|
const { requestGdprExport } = await import('@/lib/services/gdpr-export.service');
|
|
|
|
const port = await makePort();
|
|
const client = await makeClient({ portId: port.id });
|
|
|
|
await expect(
|
|
requestGdprExport({
|
|
...META(port.id),
|
|
clientId: client.id,
|
|
requestedBy: TEST_USER_ID,
|
|
emailToClient: true,
|
|
}),
|
|
).rejects.toThrow(ValidationError);
|
|
|
|
vi.doUnmock('@/lib/queue');
|
|
});
|
|
});
|