mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The backup schedule case assumed the shared database had no queue schema, but files that drive the queue create it and one leaves a finished backup run behind, so the case failed whenever it ran after them. It now clears that queue's history first. The delegate refusal case scanned the raw response for a digit run that the random request id can contain; the id is taken out before the scan.
275 lines
9.7 KiB
TypeScript
275 lines
9.7 KiB
TypeScript
/**
|
|
* Integration test consolidating the audit-log contract for every
|
|
* write-side backup endpoint:
|
|
*
|
|
* - POST /api/admin/backups/run → admin.backups.run
|
|
* - GET /api/admin/backups/[id]/download → admin.backups.download
|
|
* - POST /api/admin/backups/upload → admin.backups.upload
|
|
* - POST /api/admin/backups/[id]/restore → admin.backups.restore
|
|
*
|
|
* Each handler must emit an `AuditLog` row carrying:
|
|
* - `userId` (the actor — the admin running the op)
|
|
* - `action` (one of the strings above)
|
|
* - `details` referencing the target backup id when applicable
|
|
*
|
|
* The denied / failed counterparts are covered by the per-endpoint
|
|
* suites; this file is the single contract test confirming the four
|
|
* happy paths together so regressions in any one of them surface
|
|
* loudly.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
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),
|
|
}));
|
|
|
|
// Stub `getGlobalBoss` so the run endpoint can audit-log its happy
|
|
// path without booting an actual pg-boss worker against the test DB.
|
|
// The fake boss returns a deterministic job id so the audit row can be
|
|
// asserted to contain it.
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => ({
|
|
send: vi.fn().mockResolvedValue("test-job-123"),
|
|
})),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function seedAdminSession() {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "audit-admin",
|
|
email: "audit-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);
|
|
return admin;
|
|
}
|
|
|
|
function payloadFor(userId: string) {
|
|
return {
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-05-09T10:00:00.000Z",
|
|
userId,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [],
|
|
};
|
|
}
|
|
|
|
describe("backup endpoints — AuditLog contract", () => {
|
|
it("admin.backups.run writes an audit row carrying the boss job id", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
|
|
const { POST } = await import("@/app/api/admin/backups/run/route");
|
|
const req = new Request("http://localhost/api/admin/backups/run", {
|
|
method: "POST",
|
|
});
|
|
const res = await POST(req as unknown as Parameters<typeof POST>[0]);
|
|
expect(res.status).toBe(200);
|
|
|
|
const events = await prisma.auditLog.findMany({
|
|
where: { action: "admin.backups.run", userId: admin.id },
|
|
});
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.details ?? "").toContain("test-job-123");
|
|
});
|
|
|
|
it("admin.backups.upload + admin.backups.download + admin.backups.restore all emit audit rows", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
|
|
// ── upload ──
|
|
const { POST: UPLOAD } =
|
|
await import("@/app/api/admin/backups/upload/route");
|
|
const fd = new FormData();
|
|
fd.append(
|
|
"file",
|
|
new File([JSON.stringify(payloadFor(admin.id))], "b.json", {
|
|
type: "application/json",
|
|
}),
|
|
);
|
|
const uploadRes = await UPLOAD(
|
|
new Request("http://localhost/api/admin/backups/upload", {
|
|
method: "POST",
|
|
body: fd,
|
|
}) as unknown as Parameters<typeof UPLOAD>[0],
|
|
);
|
|
expect(uploadRes.status).toBe(201);
|
|
const uploadBody = (await uploadRes.json()) as { data: { id: string } };
|
|
|
|
// Sanity: AND a directly-seeded backup so download has something
|
|
// independent of the upload route's success.
|
|
const seeded = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "MANUAL_AUDIT_TEST",
|
|
data: encrypt(JSON.stringify(payloadFor(admin.id))),
|
|
},
|
|
});
|
|
|
|
// ── download ──
|
|
const { GET: DOWNLOAD } =
|
|
await import("@/app/api/admin/backups/[id]/download/route");
|
|
const downloadRes = await DOWNLOAD(
|
|
new Request(
|
|
`http://localhost/api/admin/backups/${seeded.id}/download`,
|
|
) as unknown as Parameters<typeof DOWNLOAD>[0],
|
|
{ params: Promise.resolve({ id: seeded.id }) },
|
|
);
|
|
expect(downloadRes.status).toBe(200);
|
|
|
|
// ── restore ──
|
|
const { POST: RESTORE } = await import("./restore-job-driver");
|
|
const restoreRes = await RESTORE(
|
|
new Request(`http://localhost/api/admin/backups/${seeded.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as unknown as Parameters<typeof RESTORE>[0],
|
|
{ params: Promise.resolve({ id: seeded.id }) },
|
|
);
|
|
expect(restoreRes.status).toBe(200);
|
|
|
|
// ── assert: each action has exactly one success row tied to the
|
|
// admin and the right backup id ──
|
|
const rows = await prisma.auditLog.findMany({
|
|
where: { userId: admin.id },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
const actions = rows.map((r) => r.action);
|
|
expect(actions).toContain("admin.backups.upload");
|
|
expect(actions).toContain("admin.backups.download");
|
|
expect(actions).toContain("admin.backups.restore");
|
|
expect(actions).toContain("admin.backups.restore.start");
|
|
|
|
const upload = rows.find((r) => r.action === "admin.backups.upload");
|
|
expect(upload?.details ?? "").toContain(uploadBody.data.id);
|
|
|
|
const download = rows.find((r) => r.action === "admin.backups.download");
|
|
expect(download?.details ?? "").toContain(seeded.id);
|
|
|
|
const restore = rows.find((r) => r.action === "admin.backups.restore");
|
|
expect(restore?.details ?? "").toContain(seeded.id);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The listing route is what an operator looks at to answer "are my backups
|
|
* still being made". It could not answer that: it returned rows and a
|
|
* retention hint, and a copy from six weeks ago carries a timestamp exactly
|
|
* like Sunday's. It now carries the schedule's own health, and the size comes
|
|
* from `octet_length` in the database rather than from pulling every blob into
|
|
* the web process to measure it.
|
|
*/
|
|
describe("GET /api/admin/backups — schedule health", () => {
|
|
it("calls a stalled weekly schedule out, and a fresh manual upload does not hide it", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedAdminSession();
|
|
const blob = encrypt(JSON.stringify(payloadFor(admin.id)));
|
|
|
|
const stalled = new Date(Date.now() - 46 * 24 * 60 * 60 * 1000);
|
|
await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "WEEKLY_AUTO",
|
|
data: blob,
|
|
createdAt: stalled,
|
|
},
|
|
});
|
|
// Uploaded by hand today. It must not make the dead cron look alive.
|
|
await prisma.dataBackup.create({
|
|
data: { userId: admin.id, type: "MANUAL_UPLOAD_1", data: blob },
|
|
});
|
|
|
|
// Files that drive the queue create the `pgboss` schema in the shared
|
|
// database, and one of them leaves a finished backup run behind. Clear
|
|
// that queue's history so this case reads the same in every file order.
|
|
const [queueTable] = await prisma.$queryRaw<Array<{ t: string | null }>>`
|
|
SELECT to_regclass('pgboss.job')::text AS t
|
|
`;
|
|
if (queueTable?.t) {
|
|
await prisma.$executeRaw`DELETE FROM pgboss.job WHERE name = 'data-backup'`;
|
|
}
|
|
|
|
// The listing handler declares no parameters; `apiHandler` reads the
|
|
// request defensively out of its arguments, so calling it bare is what a
|
|
// zero-argument handler supports.
|
|
const { GET } = await import("@/app/api/admin/backups/route");
|
|
const res = await GET();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
rows: Array<{ type: string; sizeBytes: number; username: string }>;
|
|
schedule: {
|
|
stale: boolean;
|
|
lastSuccessAgeDays: number | null;
|
|
lastRun: unknown;
|
|
lastRunFailed: boolean;
|
|
};
|
|
};
|
|
};
|
|
|
|
expect(body.data.rows).toHaveLength(2);
|
|
// Sizes are read in SQL now; a wrong column or a wrong cast shows up here
|
|
// as a zero or a NaN rather than as a plausible-looking number.
|
|
expect(body.data.rows.every((row) => row.sizeBytes === blob.length)).toBe(
|
|
true,
|
|
);
|
|
expect(body.data.rows.every((row) => row.username === admin.username)).toBe(
|
|
true,
|
|
);
|
|
|
|
expect(body.data.schedule.lastSuccessAgeDays).toBe(46);
|
|
expect(body.data.schedule.stale).toBe(true);
|
|
// No remembered run, or no `pgboss` schema at all — the run reader fails
|
|
// soft to null rather than taking the whole page down, which is the arm a
|
|
// web-only deployment lives on permanently.
|
|
expect(body.data.schedule.lastRun).toBeNull();
|
|
expect(body.data.schedule.lastRunFailed).toBe(false);
|
|
});
|
|
});
|