mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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>
This commit is contained in:
1 parent
ffa4aac974
commit
30a74ed3b8
5 files changed
+573
-4
No files matched your search
+7
-1
@@ -1556,7 +1556,13 @@
|
||||
"download": "Herunterladen",
|
||||
"downloadAria": "Backup für {username} herunterladen",
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet"
|
||||
"downloadStarted": "Download gestartet",
|
||||
"uploadTitle": "Backup hochladen",
|
||||
"uploadDescription": "Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.",
|
||||
"uploadButton": "Datei hochladen",
|
||||
"uploadHelp": "JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
|
||||
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
|
||||
"uploadFailed": "Upload fehlgeschlagen"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+7
-1
@@ -1556,7 +1556,13 @@
|
||||
"download": "Download",
|
||||
"downloadAria": "Download backup for {username}",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started"
|
||||
"downloadStarted": "Download started",
|
||||
"uploadTitle": "Upload backup",
|
||||
"uploadDescription": "Validate a backup file and store it as a manual snapshot. Restoring is a separate step.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadHelp": "JSON file matching the current backup schema. Max 10 MB.",
|
||||
"uploadSuccess": "Backup uploaded ({count} records)",
|
||||
"uploadFailed": "Upload failed"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* POST /api/admin/backups/upload — admin-only ingest of a backup file.
|
||||
*
|
||||
* Accepts a `multipart/form-data` body with a single `file` field that
|
||||
* carries a JSON backup matching `backupPayloadSchema`. The route:
|
||||
*
|
||||
* 1. Authenticates the caller as an admin (cookie session only).
|
||||
* 2. Reads the file (size capped — see `MAX_UPLOAD_BYTES`).
|
||||
* 3. Parses and validates the JSON against the canonical schema.
|
||||
* 4. Rejects files written by a future schema version (incompatible).
|
||||
* 5. Encrypts the plaintext and inserts a new `DataBackup` row of
|
||||
* type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
|
||||
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
|
||||
*
|
||||
* Crucially, this route does NOT execute a restore. It only stores the
|
||||
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
|
||||
* (criterion 3) actually replaces user data — keeping the two phases
|
||||
* apart is the whole reason an admin can review what they uploaded
|
||||
* before pulling the trigger.
|
||||
*
|
||||
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
backupPayloadSchema,
|
||||
isCompatibleSchemaVersion,
|
||||
summarizeBackup,
|
||||
type BackupSummary,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Cap on uploaded file size. The largest production backups today are
|
||||
* ~2 MB; 10 MB leaves headroom while keeping a malicious admin client
|
||||
* from blowing memory by streaming a 4 GB file. Enforced by reading the
|
||||
* `Content-Length` header AND the actual buffered size after decoding —
|
||||
* a missing/wrong content-length doesn't bypass the limit.
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
interface UploadResponse {
|
||||
id: string;
|
||||
valid: true;
|
||||
summary: BackupSummary;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.upload" } });
|
||||
|
||||
// Rate-limit identical to the manual-run endpoint. An admin uploading
|
||||
// a 10 MB blob 60× per minute would just be wasteful — three is plenty
|
||||
// for a deliberate restore-prep workflow.
|
||||
const rl = await checkRateLimit(
|
||||
`admin-backups-upload:${admin.id}`,
|
||||
3,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
throw new HttpError(429, "Too many backup uploads");
|
||||
}
|
||||
|
||||
// Cheap pre-flight on the declared content length. The actual buffer
|
||||
// size check below is the hard limit; this just rejects obvious abuse
|
||||
// before allocating the FormData parser.
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > MAX_UPLOAD_BYTES) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "content_length_exceeded", contentLength },
|
||||
});
|
||||
return apiError("Upload exceeds 10 MB limit", 413);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch (err) {
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "file_size_exceeded", size: file.size },
|
||||
});
|
||||
return apiError("Upload exceeds 10 MB limit", 413);
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = await file.text();
|
||||
} catch {
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
reason: "invalid_json",
|
||||
message: err instanceof Error ? err.message : "parse_failed",
|
||||
},
|
||||
});
|
||||
return apiError("Uploaded file is not valid JSON", 422);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(parsedJson);
|
||||
} catch (err) {
|
||||
const issues =
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [];
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "schema_invalid", issues },
|
||||
});
|
||||
return apiError("Backup payload failed schema validation", 422, {
|
||||
issues,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure the userId on the file points at a user that exists in
|
||||
// this DB. Restore would fail later anyway, but failing here gives
|
||||
// the admin a precise error before any side-effect.
|
||||
const owner = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!owner) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
reason: "owner_not_found",
|
||||
ownerId: payload.userId,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Backup is for user '${payload.userId}' which does not exist in this DB`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// `MANUAL_UPLOAD_<unix-ms>` keeps the (userId, type) unique-constraint
|
||||
// intact so multiple uploads can coexist for the same user without
|
||||
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
|
||||
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
|
||||
const encrypted = encrypt(JSON.stringify(payload));
|
||||
|
||||
const created = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: uploadType,
|
||||
data: encrypted,
|
||||
},
|
||||
});
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
|
||||
await auditLog("admin.backups.upload", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: created.id,
|
||||
ownerId: owner.id,
|
||||
ownerUsername: owner.username,
|
||||
type: uploadType,
|
||||
schemaVersion: summary.schemaVersion,
|
||||
counts: {
|
||||
measurements: summary.measurements,
|
||||
medications: summary.medications,
|
||||
intakeEvents: summary.intakeEvents,
|
||||
moodEntries: summary.moodEntries,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response: UploadResponse = {
|
||||
id: created.id,
|
||||
valid: true,
|
||||
summary,
|
||||
};
|
||||
return apiSuccess(response, 201);
|
||||
});
|
||||
@@ -12,8 +12,8 @@
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Download, Loader2, PlayCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Database, Download, Loader2, PlayCircle, Upload } from "lucide-react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -76,6 +76,60 @@ export function BackupsSection() {
|
||||
// parallel clicks on different rows don't share a single spinner.
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
// Upload state — single-file flow, drives the file input + button.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
return (await res.json()).data as {
|
||||
id: string;
|
||||
valid: true;
|
||||
summary: {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
};
|
||||
};
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const total =
|
||||
data.summary.measurements +
|
||||
data.summary.medications +
|
||||
data.summary.intakeEvents +
|
||||
data.summary.moodEntries;
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "backups"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.uploadFailed"),
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
// Reset the file input so the same file can be re-selected after a
|
||||
// rejected upload (browsers keep the previous selection otherwise).
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
});
|
||||
|
||||
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload.mutate(file);
|
||||
}
|
||||
|
||||
async function handleDownload(row: BackupRow) {
|
||||
setDownloadingId(row.id);
|
||||
try {
|
||||
@@ -143,6 +197,47 @@ export function BackupsSection() {
|
||||
{t("admin.section.backups.description")}
|
||||
</p>
|
||||
|
||||
{/* Upload card — separate from the table so admins can ingest a
|
||||
backup file independently of any existing rows. The visible
|
||||
button proxies a hidden file input so the layout stays clean
|
||||
while keyboard / screen-reader users keep a labelled control. */}
|
||||
<div className="bg-muted/30 border-border mt-4 flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.section.backups.uploadTitle")}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.uploadDescription")}{" "}
|
||||
<span className="opacity-80">
|
||||
{t("admin.section.backups.uploadHelp")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
aria-label={t("admin.section.backups.uploadButton")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={upload.isPending}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.uploadButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user