mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
fix(admin): /admin/users mobile layout — card-list at < md, no truncation
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table truncated the role badge ("ADMI", "USE") and pushed the action column entirely off the right edge. The destructive force-logout action was unreachable on mobile, plus the card-header filter pills collided with the title's count badge. Fix: - Header switches from horizontal to vertical stack at `< sm` so the filter pills get their own row instead of fighting the title for space. - Card padding `p-4 sm:p-6` matches the api-tokens responsive template. - Desktop table is gated behind `hidden md:block` (instead of just letting it overflow). - New mobile card-list at `< md` renders each user as a self- contained card with the username + role badge + meta line + a flex-wrapping action row carrying all four buttons (toggle role, edit, reset password, force logout). Nothing hidden behind a horizontal scroll, every action stays tap-targetable. - Action buttons extracted into a single `renderUserActions()` helper so desktop + mobile stay in lock-step on future admin actions. Evidence: new `user-management-responsive.test.tsx` asserts the desktop table is `hidden md:block`, the mobile list is `md:hidden`, the role badge appears in both layouts (no column lost), all four ARIA-labelled action buttons are present in the mobile card, the header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests green. Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
41945b2120
commit
d8c549edf1
7 files changed
+509
-3
No files matched your search
+6
-1
@@ -1551,7 +1551,12 @@
|
||||
"colType": "Typ",
|
||||
"colSize": "Größe",
|
||||
"colCreatedAt": "Letzter Snapshot",
|
||||
"retentionLabel": "Aufbewahrung: {days} Tage"
|
||||
"colActions": "Aktionen",
|
||||
"retentionLabel": "Aufbewahrung: {days} Tage",
|
||||
"download": "Herunterladen",
|
||||
"downloadAria": "Backup für {username} herunterladen",
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+6
-1
@@ -1551,7 +1551,12 @@
|
||||
"colType": "Type",
|
||||
"colSize": "Size",
|
||||
"colCreatedAt": "Last snapshot",
|
||||
"retentionLabel": "Retention: {days} days"
|
||||
"colActions": "Actions",
|
||||
"retentionLabel": "Retention: {days} days",
|
||||
"download": "Download",
|
||||
"downloadAria": "Download backup for {username}",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* GET /api/admin/backups/[id]/download — admin-only download of a single
|
||||
* `DataBackup` snapshot as JSON.
|
||||
*
|
||||
* The encrypted blob in the DB is decrypted, parsed, then streamed back
|
||||
* with `Content-Disposition: attachment` so the browser saves it as
|
||||
* `healthlog-backup-<userId>-<isoDate>.json`. Auth gate mirrors the rest
|
||||
* of the `/api/admin/backups` family — `requireAdmin()` (cookie session
|
||||
* only; bearer tokens never elevate).
|
||||
*
|
||||
* Phase B1 / criterion 1 of the v1.4.15 backup-completeness work: today
|
||||
* admins can list backups and trigger a manual run, but never see the
|
||||
* payload. This endpoint closes the loop without breaking the encryption-
|
||||
* at-rest contract: the ciphertext stays in the DB, the plaintext is only
|
||||
* materialised inside the request handler and streamed to the admin.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
|
||||
|
||||
const backup = await prisma.dataBackup.findUnique({
|
||||
where: { id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
// Audit the denied attempt — an admin trying to download a
|
||||
// backup that no longer exists is interesting on its own.
|
||||
await auditLog("admin.backups.download.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "not_found", backupId: id },
|
||||
});
|
||||
throw new HttpError(404, "Backup not found");
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
} catch (err) {
|
||||
// Decryption failure is rare but real — a rotated/missing key, or a
|
||||
// corrupted blob from a partially-failed write. Surface it as a 500
|
||||
// and audit the event so the admin has something to investigate.
|
||||
await auditLog("admin.backups.download.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId: backup.userId,
|
||||
reason: err instanceof Error ? err.message : "decrypt_failed",
|
||||
},
|
||||
});
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
}
|
||||
|
||||
// Validate the payload BEFORE handing it back. This catches the
|
||||
// pathological case where the worker wrote something the upload
|
||||
// route would later reject — better to fail loudly here than to ship
|
||||
// a junk file that breaks an admin's restore plan.
|
||||
try {
|
||||
parseBackupPayload(plaintext);
|
||||
} catch (err) {
|
||||
await auditLog("admin.backups.download.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId: backup.userId,
|
||||
reason: "schema_invalid",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
return apiError("Backup payload failed schema validation", 500);
|
||||
}
|
||||
|
||||
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
|
||||
|
||||
await auditLog("admin.backups.download", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId: backup.userId,
|
||||
ownerUsername: backup.user.username,
|
||||
type: backup.type,
|
||||
sizeBytes: Buffer.byteLength(plaintext, "utf8"),
|
||||
},
|
||||
});
|
||||
|
||||
// Returning the plaintext as-is keeps the response a faithful copy of
|
||||
// what the worker wrote (formatting, key order). The file is the
|
||||
// canonical artefact admins will store / re-upload.
|
||||
return new NextResponse(plaintext, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="${filename}"`,
|
||||
// Backups contain sensitive health data — keep them out of any
|
||||
// shared cache (CDN, browser disk cache).
|
||||
"cache-control": "no-store, max-age=0",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Loader2, PlayCircle } from "lucide-react";
|
||||
import { Database, Download, Loader2, PlayCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -71,6 +72,46 @@ export function BackupsSection() {
|
||||
|
||||
const rows: BackupRow[] = data?.rows ?? [];
|
||||
|
||||
// Per-row download in-flight state — keyed by backup id so two
|
||||
// parallel clicks on different rows don't share a single spinner.
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
async function handleDownload(row: BackupRow) {
|
||||
setDownloadingId(row.id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/backups/${row.id}/download`);
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
// Use the server-provided Content-Disposition filename if present,
|
||||
// otherwise fall back to a deterministic client-side name. The
|
||||
// server filename is more accurate (uses the actual createdAt).
|
||||
const cd = res.headers.get("content-disposition") ?? "";
|
||||
const match = cd.match(/filename="?([^";]+)"?/i);
|
||||
const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`;
|
||||
const filename = match?.[1] ?? fallback;
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("admin.section.backups.downloadStarted"));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.downloadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -137,6 +178,9 @@ export function BackupsSection() {
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colCreatedAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.section.backups.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
@@ -154,6 +198,24 @@ export function BackupsSection() {
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(row.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
username: row.username,
|
||||
})}
|
||||
>
|
||||
{downloadingId === row.id ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.download")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -975,6 +975,10 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
|
||||
]);
|
||||
|
||||
const backupJson = JSON.stringify({
|
||||
// Bumped only when the on-disk shape changes incompatibly.
|
||||
// Mirrors `BACKUP_SCHEMA_VERSION` in
|
||||
// `src/lib/validations/backup.ts` — keep them in sync.
|
||||
schemaVersion: "1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
measurements: measurements.map((m) => ({
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Zod schemas + helpers for the per-user `DataBackup` JSON payload.
|
||||
*
|
||||
* Single source of truth shared by:
|
||||
* - the pg-boss `data-backup` worker that writes a backup,
|
||||
* - `GET /api/admin/backups/[id]/download` that streams it back as JSON,
|
||||
* - `POST /api/admin/backups/upload` that ingests an admin-supplied file,
|
||||
* - `POST /api/admin/backups/[id]/restore` that re-creates DB rows from it.
|
||||
*
|
||||
* The schema is intentionally permissive about *extra* fields (`.passthrough`)
|
||||
* so older snapshots written before a column was added still parse and so
|
||||
* future minor additions don't break old admins. Required fields are
|
||||
* deliberately tight — any drift surfaces as a validation error rather than
|
||||
* a silent data-loss restore.
|
||||
*
|
||||
* `schemaVersion` is the migration handle. Bump when the on-disk shape
|
||||
* changes incompatibly. The current writer (worker) historically did NOT
|
||||
* include this field; `parseBackupPayload()` defaults it to "1" so legacy
|
||||
* blobs continue to round-trip and the upload validator can still produce
|
||||
* a useful summary for them.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const BACKUP_SCHEMA_VERSION = "1" as const;
|
||||
|
||||
const isoDateTime = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), {
|
||||
message: "Expected ISO-8601 date-time string",
|
||||
});
|
||||
|
||||
const measurementSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
value: z.number(),
|
||||
unit: z.string().min(1),
|
||||
measuredAt: isoDateTime,
|
||||
source: z.string().min(1).optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const medicationScheduleSchema = z
|
||||
.object({
|
||||
windowStart: z.string().min(1),
|
||||
windowEnd: z.string().min(1),
|
||||
label: z.string().nullable().optional(),
|
||||
dose: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
dose: z.string(),
|
||||
active: z.boolean().optional(),
|
||||
schedules: z.array(medicationScheduleSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const intakeEventSchema = z
|
||||
.object({
|
||||
medication: z.string().min(1),
|
||||
scheduledFor: isoDateTime,
|
||||
takenAt: isoDateTime.nullable().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
source: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const moodEntrySchema = z
|
||||
.object({
|
||||
date: z.string().min(1),
|
||||
mood: z.string().min(1),
|
||||
score: z.number().int().min(0).max(10),
|
||||
tags: z.string().nullable().optional(),
|
||||
source: z.string().min(1).optional(),
|
||||
loggedAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* Wire shape — exactly what the pg-boss worker writes today, plus a
|
||||
* `schemaVersion` field that newer writers stamp explicitly. Older blobs
|
||||
* without the field default to v1 in `parseBackupPayload`.
|
||||
*/
|
||||
export const backupPayloadSchema = z
|
||||
.object({
|
||||
schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION),
|
||||
exportedAt: isoDateTime,
|
||||
userId: z.string().min(1),
|
||||
measurements: z.array(measurementSchema).default([]),
|
||||
medications: z.array(medicationSchema).default([]),
|
||||
intakeEvents: z.array(intakeEventSchema).default([]),
|
||||
moodEntries: z.array(moodEntrySchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type BackupPayload = z.infer<typeof backupPayloadSchema>;
|
||||
|
||||
/**
|
||||
* Numeric counts of each backed-up record kind. Returned in the
|
||||
* upload + restore API responses so the admin sees what they
|
||||
* uploaded/restored without having to download the file again.
|
||||
*/
|
||||
export interface BackupSummary {
|
||||
schemaVersion: string;
|
||||
userId: string;
|
||||
exportedAt: string;
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
}
|
||||
|
||||
export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
return {
|
||||
schemaVersion: payload.schemaVersion,
|
||||
userId: payload.userId,
|
||||
exportedAt: payload.exportedAt,
|
||||
measurements: payload.measurements.length,
|
||||
medications: payload.medications.length,
|
||||
intakeEvents: payload.intakeEvents.length,
|
||||
moodEntries: payload.moodEntries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON blob (string or already-parsed object) against
|
||||
* `backupPayloadSchema`, returning a typed payload. Throws ZodError on
|
||||
* mismatch — the admin route catches it and turns it into a 422 with a
|
||||
* field-level error list.
|
||||
*
|
||||
* Accepts both forms because:
|
||||
* - the upload route hands us the parsed object after `await req.text()`
|
||||
* - the restore route reads `DataBackup.data` (decrypted) which is
|
||||
* always a JSON string straight from the worker.
|
||||
*/
|
||||
export function parseBackupPayload(input: string | unknown): BackupPayload {
|
||||
const raw = typeof input === "string" ? JSON.parse(input) : input;
|
||||
return backupPayloadSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schemaVersion the system understands today. Used by the upload
|
||||
* route to reject inbound files written by a *future* HealthLog instance
|
||||
* — restoring them under the current code might silently drop data the
|
||||
* new shape carried.
|
||||
*/
|
||||
export function isCompatibleSchemaVersion(version: string): boolean {
|
||||
return version === BACKUP_SCHEMA_VERSION;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Integration test for `GET /api/admin/backups/[id]/download`.
|
||||
*
|
||||
* Verifies:
|
||||
* - 200 + content-type=application/json + content-disposition=attachment
|
||||
* - the streamed body is non-empty and round-trips through
|
||||
* `parseBackupPayload()` (proves we shipped the same shape we'll
|
||||
* accept via the upload route).
|
||||
* - 404 on unknown id (with audit-log denial entry).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Crypto module reads `ENCRYPTION_KEY` at first call. Vitest doesn't load
|
||||
// the dev `.env`, so seed a deterministic 32-byte test key BEFORE the
|
||||
// `@/lib/crypto` import below (Node binds module-level imports first, but
|
||||
// `loadKeys()` is lazy — runs on first `encrypt()` call — so an env set
|
||||
// here is observed in time).
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
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: "backup-admin",
|
||||
email: "backup-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;
|
||||
}
|
||||
|
||||
describe("GET /api/admin/backups/[id]/download", () => {
|
||||
it("streams a valid backup payload as JSON with Content-Disposition", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
|
||||
const payload = {
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-05-09T10:00:00.000Z",
|
||||
userId: admin.id,
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 82.4,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-08T07:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
};
|
||||
const encrypted = encrypt(JSON.stringify(payload));
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: "WEEKLY_AUTO",
|
||||
data: encrypted,
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/admin/backups/[id]/download/route");
|
||||
const req = new Request(
|
||||
`http://localhost/api/admin/backups/${backup.id}/download`,
|
||||
);
|
||||
const res = await GET(req as unknown as Parameters<typeof GET>[0], {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("application/json");
|
||||
expect(res.headers.get("content-disposition") ?? "").toMatch(
|
||||
/^attachment;\s*filename=".+\.json"$/i,
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
const parsed = parseBackupPayload(text);
|
||||
expect(parsed.userId).toBe(admin.id);
|
||||
expect(parsed.measurements).toHaveLength(1);
|
||||
|
||||
// Audit trail: a download event must be present.
|
||||
const events = await prisma.auditLog.findMany({
|
||||
where: { action: "admin.backups.download" },
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.userId).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown id and audits the denied attempt", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
|
||||
const { GET } = await import("@/app/api/admin/backups/[id]/download/route");
|
||||
const req = new Request(
|
||||
"http://localhost/api/admin/backups/does-not-exist/download",
|
||||
);
|
||||
const res = await GET(req as unknown as Parameters<typeof GET>[0], {
|
||||
params: Promise.resolve({ id: "does-not-exist" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const denied = await prisma.auditLog.findMany({
|
||||
where: { action: "admin.backups.download.denied", userId: admin.id },
|
||||
});
|
||||
expect(denied).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user