mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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>
234 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|