mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
* test(integration): Postgres testcontainers + 4 critical-path tests Adds an integration test suite that runs against a real Postgres booted in-process via testcontainers. Lives under tests/integration/, runs via `pnpm test:integration`, and is excluded from the default `pnpm test` loop so the 477-test unit suite stays fast (~1.2s). Covers four regression-prone behaviours that pure unit tests cannot validate against a mocked client: - rate-limit.test.ts — atomic UPSERT under concurrency (5/6 allowed) and window-reset semantics for src/lib/rate-limit.ts - idempotency-replay.test.ts — cached replay with X-Idempotent-Replay, expired-row purge, and the do-not-cache contract for 401/403/408/429/5xx on top of src/lib/idempotency.ts - cascade-delete.test.ts — GDPR Art. 17 erasure: user.delete wipes every onDelete:Cascade table and SetNulls AuditLog/Feedback - auth-flow.test.ts — createSession/getSession round-trip plus expired-session purge for src/lib/auth/session.ts Adds .github/workflows/integration.yml triggering on pull_request and push to main. Local Docker is not running in this worktree environment; the suite was verified by collecting all 10 tests via `vitest list` and will execute in CI. Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(integration): rebase onto v1.4 + exclude live agent worktrees from default test scope Rebase brings PR #137's testcontainers infrastructure on top of the user-locale migration drift fix landed in #140 — the integration suite should now bootstrap a fresh Postgres container with all 25 migrations applied cleanly. vitest.config.mts also gains a `.claude/worktrees/**` exclude so live agent worktrees (which mirror `src/` while their parent agents are running) don't double-execute the project's tests against possibly-stale copies. * test(integration): boot one Postgres container per run via vitest globalSetup The first iteration of the testcontainers suite started/stopped a fresh container in each test file's beforeAll/afterAll. That works in isolation but breaks when files share a worker (`pool: forks`, `fileParallelism: false`, `isolate: false`): 1. Test file A boots container 1, sets DATABASE_URL. 2. The application's Prisma singleton in src/lib/db.ts is built when test code dynamic-imports @/lib/auth/session — it captures container 1's URL. 3. afterAll stops container 1. 4. Test file B boots container 2, sets DATABASE_URL to a new port. 5. Test file B imports application code that resolves to the same singleton — still pointing at the dead container 1. Queries fail with a generic PrismaClientKnownRequestError. Fix: move the container lifecycle to vitest's globalSetup. One container, booted once, torn down at the end of the run. Tests still get isolation because beforeEach truncates every personal-data table in dependency-safe CASCADE order. - tests/integration/global-setup.ts boots postgres:16-alpine, runs `pnpm db:migrate:deploy`, exports a teardown for end-of-run. - tests/integration/setup.ts now exports getPrismaClient() (which delegates to the application singleton) and truncateAllTables(). No more startTestDb / stopTestDb. - All four test files lose their beforeAll/afterAll boilerplate. - vitest.integration.config.mts wires the new globalSetup. - truncate list adds refresh_tokens (added in 0025_refresh_tokens). Result: pnpm test:integration → 10/10 pass in 3.9s (was 6/10 with 4 cross-file failures). pnpm test → 658/658 unchanged. Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
5.2 KiB
TypeScript
191 lines
5.2 KiB
TypeScript
/**
|
|
* Integration regression guard for the GDPR Art. 17 erasure path.
|
|
*
|
|
* Deleting a user must wipe every personal-data row that references
|
|
* them. The schema declares `onDelete: Cascade` for personal data and
|
|
* `onDelete: SetNull` for audit-style rows (AuditLog, Feedback). This
|
|
* test verifies the actual database FKs match those declarations — a
|
|
* missing CASCADE would leave orphan rows behind, violating GDPR.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
describe("user.delete cascades to all personal-data tables", () => {
|
|
it("removes every personal-data row when the user is deleted", async () => {
|
|
const prisma = getPrismaClient();
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "erasure-target",
|
|
email: "erasure@example.test",
|
|
},
|
|
});
|
|
|
|
// Seed personal data across every cascading relation.
|
|
await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
|
|
await prisma.passkey.create({
|
|
data: {
|
|
userId: user.id,
|
|
credentialId: "cred-1",
|
|
credentialPublicKey: Buffer.from("pubkey"),
|
|
counter: BigInt(0),
|
|
credentialDeviceType: "singleDevice",
|
|
transports: ["internal"],
|
|
},
|
|
});
|
|
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const med = await prisma.medication.create({
|
|
data: { userId: user.id, name: "Aspirin", dose: "100mg" },
|
|
});
|
|
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId: user.id,
|
|
medicationId: med.id,
|
|
scheduledFor: new Date(),
|
|
},
|
|
});
|
|
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: user.id,
|
|
date: "2026-05-08",
|
|
mood: "GUT",
|
|
score: 4,
|
|
moodLoggedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await prisma.apiToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "test-token",
|
|
tokenHash: "hash-" + user.id,
|
|
},
|
|
});
|
|
|
|
await prisma.dataBackup.create({
|
|
data: { userId: user.id, type: "MANUAL", data: "{}" },
|
|
});
|
|
|
|
await prisma.userAchievement.create({
|
|
data: {
|
|
userId: user.id,
|
|
achievementId: "first-login",
|
|
unlockedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await prisma.idempotencyKey.create({
|
|
data: {
|
|
userId: user.id,
|
|
key: "abcdef12345678",
|
|
method: "POST",
|
|
path: "/api/test",
|
|
responseStatus: 200,
|
|
responseBody: "{}",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
|
|
await prisma.device.create({
|
|
data: {
|
|
userId: user.id,
|
|
platform: "ios",
|
|
token: "apns-token-" + user.id,
|
|
bundleId: "app.healthlog.ios",
|
|
},
|
|
});
|
|
|
|
await prisma.pushSubscription.create({
|
|
data: {
|
|
userId: user.id,
|
|
endpoint: "https://push.example/sub-" + user.id,
|
|
p256dh: "p256dh",
|
|
auth: "auth",
|
|
},
|
|
});
|
|
|
|
// Audit logs / feedback are intentionally SetNull, not Cascade —
|
|
// they survive deletion with a null userId for compliance triage.
|
|
const auditRow = await prisma.auditLog.create({
|
|
data: { userId: user.id, action: "auth.login" },
|
|
});
|
|
const feedbackRow = await prisma.feedback.create({
|
|
data: {
|
|
userId: user.id,
|
|
category: "BUG",
|
|
subject: "test",
|
|
description: "test",
|
|
},
|
|
});
|
|
|
|
// ── act ──
|
|
await prisma.user.delete({ where: { id: user.id } });
|
|
|
|
// ── assert: every cascading table is empty for that user id ──
|
|
expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0);
|
|
expect(await prisma.passkey.count({ where: { userId: user.id } })).toBe(0);
|
|
expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe(
|
|
0,
|
|
);
|
|
expect(await prisma.medication.count({ where: { userId: user.id } })).toBe(
|
|
0,
|
|
);
|
|
expect(
|
|
await prisma.medicationIntakeEvent.count({
|
|
where: { userId: user.id },
|
|
}),
|
|
).toBe(0);
|
|
expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(
|
|
0,
|
|
);
|
|
expect(await prisma.apiToken.count({ where: { userId: user.id } })).toBe(0);
|
|
expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe(
|
|
0,
|
|
);
|
|
expect(
|
|
await prisma.userAchievement.count({ where: { userId: user.id } }),
|
|
).toBe(0);
|
|
expect(
|
|
await prisma.idempotencyKey.count({ where: { userId: user.id } }),
|
|
).toBe(0);
|
|
expect(await prisma.device.count({ where: { userId: user.id } })).toBe(0);
|
|
expect(
|
|
await prisma.pushSubscription.count({ where: { userId: user.id } }),
|
|
).toBe(0);
|
|
|
|
// SetNull rows survive but lose their userId.
|
|
const auditAfter = await prisma.auditLog.findUnique({
|
|
where: { id: auditRow.id },
|
|
});
|
|
expect(auditAfter?.userId).toBeNull();
|
|
|
|
const feedbackAfter = await prisma.feedback.findUnique({
|
|
where: { id: feedbackRow.id },
|
|
});
|
|
expect(feedbackAfter?.userId).toBeNull();
|
|
});
|
|
});
|