Files
HealthLog/tests/integration/cascade-delete.test.ts
T
Marc-André BombeckandMarc-André Bombeck 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46: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();
});
});