mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Show what a backup contains before the operator types RESTORE
The restore dialog asked for its typed confirmation on nothing but a filename and a date; the counts only surfaced in the audit row after the rows were already replaced. A read-only summary endpoint now decrypts and schema-validates the stored file through the same helpers the restore itself runs and answers the summarizeBackup counts, and the dialog shows them — per section, with an other-records line so a future summary key can never silently vanish from the preview. A failed preview states its own absence and never blocks the restore.
This commit is contained in:
1 parent
90bad17cc8
commit
9d64eb6aa2
10 files changed
+403
No files matched your search
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.37.20 — restore preview: the summary read decrypts and validates the
|
||||
* stored file through the SAME helpers the restore uses, and only ever
|
||||
* reads. Watched red: written against the route before its annotate landed;
|
||||
* the not-found and decrypt-failure arms were asserted first.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const findUniqueMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
const decryptMock = vi.fn();
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (...a: unknown[]) => decryptMock(...a),
|
||||
}));
|
||||
|
||||
const parseMock = vi.fn();
|
||||
const summarizeMock = vi.fn();
|
||||
vi.mock("@/lib/validations/backup", () => ({
|
||||
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
|
||||
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
|
||||
isCompatibleSchemaVersion: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/admin/backups/b1/summary",
|
||||
);
|
||||
const params = { params: Promise.resolve({ id: "b1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUniqueMock.mockResolvedValue({
|
||||
id: "b1",
|
||||
userId: "u1",
|
||||
data: "ciphertext",
|
||||
createdAt: new Date("2026-08-01T00:00:00Z"),
|
||||
user: { id: "u1", username: "self-hoster" },
|
||||
});
|
||||
decryptMock.mockReturnValue("{}");
|
||||
parseMock.mockReturnValue({ schemaVersion: 3 });
|
||||
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
|
||||
});
|
||||
|
||||
describe("GET /api/admin/backups/[id]/summary", () => {
|
||||
it("answers the file's counts without writing anything", async () => {
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
|
||||
expect(body.data.owner).toBe("self-hoster");
|
||||
});
|
||||
|
||||
it("404s an unknown backup id", async () => {
|
||||
findUniqueMock.mockResolvedValue(null);
|
||||
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("degrades to an error response when the file cannot be decrypted", async () => {
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("bad key");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("422s a file that fails schema validation", async () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.37.20 — restore preview: what a backup file contains, BEFORE the
|
||||
* operator confirms a restore.
|
||||
*
|
||||
* The restore dialog used to ask for the typed confirmation on nothing but
|
||||
* a filename and a date; the counts only appeared in the audit row after
|
||||
* the rows were already replaced. This read decrypts and schema-validates
|
||||
* the stored file exactly the way the restore itself does (same decrypt,
|
||||
* same `parseBackupPayload`, same compatibility gate) and answers with the
|
||||
* `summarizeBackup` counts — so what the operator confirms is what the
|
||||
* restore will write, derived from the same code path. Read-only; nothing
|
||||
* is touched.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
parseBackupPayload,
|
||||
summarizeBackup,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
void admin;
|
||||
const { id } = await params;
|
||||
|
||||
const backup = await prisma.dataBackup.findUnique({
|
||||
where: { id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
});
|
||||
if (!backup) {
|
||||
throw new HttpError(404, "Backup not found");
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
} catch {
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = parseBackupPayload(plaintext);
|
||||
} catch {
|
||||
return apiError("Backup payload failed schema validation", 422);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
return apiError(
|
||||
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.backups.summary" },
|
||||
meta: { backupId: id, ownerId: backup.userId },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
summary,
|
||||
owner: backup.user?.username ?? null,
|
||||
createdAt: backup.createdAt.toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user