Files
HealthLog/tests/integration/cascade-delete.test.ts
T
accdb7d44d test(integration): Postgres testcontainers + 4 critical-path tests (rate-limit race, idempotency, cascade, session) (#137)
* 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>
2026-05-08 16:03:31 +02:00

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();
});
});