Files
HealthLog/tests/integration/admin-backups-upload.test.ts
T
Marc-André BombeckandClaude Opus 4.7 30a74ed3b8 fix(mobile): bump chart controls + medication primary buttons + mood list icons to 44px
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:

1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
   chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
   2.5.5 / Apple HIG / Material 44px minimum. Bumped to
   `min-h-9 px-2.5` (36px) — that's still compact enough to fit in
   the chart header strip on a 393px viewport but raises tap-target
   pass rate substantially. Files: health-chart, mood-chart,
   medication-compliance-chart, compliance-line-chart,
   compliance-charts.

2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
   the dashboard charts used `scale-75` to shrink the Switch primitive
   to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
   Switch returns to its default size. Files: health-chart, mood-chart.

3. Medication card primary actions ("Taken" / "Skipped"): these are
   the most-tapped controls in HealthLog (every dose intake is one
   tap). Were `size="sm"` (32px). Bumped to default size + explicit
   `min-h-11`. The history + edit icon-buttons on the same card go
   from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
   16px so the visual weight doesn't change.

4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
   were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
   mobile card branch only — the desktop table keeps its denser
   `h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
   gains a `mobile` prop so the existing desktop callsite is
   unchanged.

Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons

Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.

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

234 lines
7.1 KiB
TypeScript

/**
* Integration test for `POST /api/admin/backups/upload`.
*
* Verifies:
* - 201 + a `DataBackup` row with type prefix `MANUAL_UPLOAD_` is
* created when the JSON file matches the schema.
* - the original payload round-trips through encrypt → decrypt.
* - 422 when the JSON is malformed.
* - 422 when the schema validation fails (missing required field).
* - 422 when the userId on the file doesn't exist.
* - audit-log entries cover both success and denial paths.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { decrypt } 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),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAdminSession() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "upload-admin",
email: "upload-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 buildPayload(userId: string) {
return {
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
userId,
measurements: [
{
type: "WEIGHT",
value: 80.0,
unit: "kg",
measuredAt: "2026-05-08T07:00:00.000Z",
source: "MANUAL",
notes: null,
},
],
medications: [
{
name: "Ramipril",
dose: "5mg",
active: true,
schedules: [
{
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: null,
},
],
},
],
intakeEvents: [],
moodEntries: [],
};
}
function buildMultipart(file: File): Request {
const fd = new FormData();
fd.append("file", file);
return new Request("http://localhost/api/admin/backups/upload", {
method: "POST",
body: fd,
});
}
describe("POST /api/admin/backups/upload", () => {
it("accepts a valid backup file and persists an encrypted DataBackup row", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload(admin.id);
const file = new File([JSON.stringify(payload)], "backup.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { id: string; valid: boolean } };
expect(body.data.valid).toBe(true);
const row = await prisma.dataBackup.findUnique({
where: { id: body.data.id },
});
expect(row).not.toBeNull();
expect(row?.type).toMatch(/^MANUAL_UPLOAD_\d+$/);
expect(row?.userId).toBe(admin.id);
// Round-trip the ciphertext to make sure we stored the same payload
// we received.
const decrypted = JSON.parse(decrypt(row!.data));
expect(decrypted.userId).toBe(admin.id);
expect(decrypted.measurements).toHaveLength(1);
const events = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload", userId: admin.id },
});
expect(events).toHaveLength(1);
});
it("rejects malformed JSON with 422 + audit-log denial", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const file = new File(["{not-json"], "broken.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
it("rejects schema-invalid JSON with 422 + audit-log denial", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const broken = { schemaVersion: "1", userId: admin.id }; // missing exportedAt
const file = new File([JSON.stringify(broken)], "missing.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
it("rejects payloads referencing a non-existent owner with 422", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload("ghost-user-id");
const file = new File([JSON.stringify(payload)], "ghost.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
expect(denied[0]?.details ?? "").toContain("owner_not_found");
});
it("rejects an incompatible schemaVersion with 422", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = { ...buildPayload(admin.id), schemaVersion: "999" };
const file = new File([JSON.stringify(payload)], "future.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
});