Files
HealthLog/tests/integration/admin-data-wipe.test.ts
T
Marc-André BombeckandClaude Opus 4.7 3cacdf294e fix(tests): share next/headers cookieJar across integration suite
The integration suite runs with `vitest isolate: false` (one worker,
one container — migration cost demands it). Each test file declared
its own `vi.mock("next/headers", ...)` whose factory closed over a
file-local `cookieJar` Map. Vitest resolves a mock factory ONCE per
worker — whichever file loaded first won the registration, and the
other files' Map writes silently disappeared.

That made `admin-data-wipe.test.ts` flake intermittently (~12.5% per
empirical 8-run sample) when `idempotency-replay.test.ts` happened to
load first: its `vi.mock("@/lib/auth/session", ...)` then leaked,
returning a stub user without an admin role, so `requireAdmin()`
returned 401 and the wipe assertion failed with `expected 401 to be
200`.

Fix: extract cookieJar + headerJar into module-singleton Maps in
`tests/integration/mock-next-headers.ts`. All three integration files
import them and resolve them via dynamic-import inside the
`vi.mock("next/headers", ...)` factory body, so the factory always
sees the same backing Maps regardless of which file's registration
won the race. The `vi.mock("@/lib/auth/session", ...)` stub in
idempotency-replay was also removed; the test now seeds a real
Session row + cookie like the other two files.

After: 10/10 consecutive `pnpm test:integration` runs green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:08:11 +02:00

159 lines
5.2 KiB
TypeScript

/**
* Integration regression guard for `DELETE /api/admin/data`.
*
* The admin "wipe all data" path nulls personal columns on `User` and
* deletes a curated list of personal-data tables in one transaction.
* v1.4.6 left three notification-channel-style tables out of that
* list, leaving encrypted Telegram bot tokens (NotificationChannel.config)
* and Web Push endpoints behind. This test is the regression guard
* that the wipe now extends to:
*
* - notification_channels (Telegram/ntfy/web-push channel config)
* - push_subscriptions (raw browser push endpoints)
* - telegram_scheduled_deletions (queued message-delete tickets)
*
* Feedback rows are intentionally PRESERVED (T8 from v1.4.6 + admin
* i18n copy now states this explicitly) — a wipe must not erase
* user-submitted bug reports admins still need to triage.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
// next/headers cookie + ip headers stub for getSession() inside
// requireAdmin(). The admin route reads x-forwarded-for too; we stub
// both to keep the handler reachable. Maps live in
// `mock-next-headers.ts` — see that file for the rationale (suite
// runs with vitest `isolate: false`, so per-file Maps would leak).
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("DELETE /api/admin/data wipe scope", () => {
it("clears notification channels, push subscriptions, telegram deletions; preserves Feedback + AuditLog", async () => {
const prisma = getPrismaClient();
// ── arrange ──
const admin = await prisma.user.create({
data: {
username: "wipe-admin",
email: "wipe-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
// Seed the channels & adjacent rows the wipe must clear.
await prisma.notificationChannel.create({
data: {
userId: admin.id,
type: "TELEGRAM",
enabled: true,
// Pretend-encrypted blob — content is irrelevant, presence is.
config: "encrypted:test",
},
});
await prisma.pushSubscription.create({
data: {
userId: admin.id,
endpoint: "https://push.example/sub-" + admin.id,
p256dh: "p256dh-enc",
auth: "auth-enc",
},
});
await prisma.telegramScheduledDeletion.create({
data: {
userId: admin.id,
chatId: "1234",
messageId: 42,
deleteAfter: new Date(Date.now() + 60_000),
},
});
// Seed a feedback row that MUST survive the wipe (per i18n copy).
const feedbackRow = await prisma.feedback.create({
data: {
userId: admin.id,
category: "BUG",
subject: "survives wipe",
description: "feedback rows are preserved",
},
});
// ── act: invoke the route handler exactly as Next.js would ──
const { DELETE } = await import("@/app/api/admin/data/route");
const req = new Request("http://localhost/api/admin/data", {
method: "DELETE",
headers: {
"content-type": "application/json",
"x-forwarded-for": "127.0.0.1",
},
body: JSON.stringify({ confirm: "DELETE ALL" }),
});
const res = await DELETE(req as unknown as Parameters<typeof DELETE>[0]);
expect(res.status).toBe(200);
// ── assert: notification-related tables are empty ──
expect(
await prisma.notificationChannel.count({ where: { userId: admin.id } }),
).toBe(0);
expect(
await prisma.pushSubscription.count({ where: { userId: admin.id } }),
).toBe(0);
expect(
await prisma.telegramScheduledDeletion.count({
where: { userId: admin.id },
}),
).toBe(0);
// ── assert: feedback row survives, AuditLog rows survive ──
const feedbackAfter = await prisma.feedback.findUnique({
where: { id: feedbackRow.id },
});
expect(feedbackAfter).not.toBeNull();
expect(feedbackAfter?.subject).toBe("survives wipe");
// The wipe writes admin.data.clear.start AND admin.data.clear audit
// entries. They must outlive the operation (audit log is immune).
const auditCount = await prisma.auditLog.count({
where: { userId: admin.id },
});
expect(auditCount).toBeGreaterThanOrEqual(2);
});
});