Files
pn-new-crm/tests/integration/gdpr-export.test.ts
Matt Ciaccio a3305a94f3 feat(gdpr): staff-triggered client-data export bundle (Article 15)
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>
2026-04-28 20:06:31 +02:00

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('&lt;script&gt;');
});
});
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');
});
});