mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Every backup test exports and restores with the same code, so a change that moves both ends at once passes all of them and still breaks a file a self-hoster downloaded last month. Commit the disaster-recovery file the 1.46.1 exporter writes for the shared backup seed, with a manifest of its counts and SHA-256, and restore each committed file through the admin upload and restore routes into an empty account, comparing the re-exported counts section by section. The folder takes one file per release (GOLDEN_BACKUP_WRITE=1 writes the current one; the README says when). The seed's own illness-symptom catalogue key is swapped for a shipped one so every link resolves on a fresh instance. Ciphertext is sealed under the suite's fixed key. A measurement tombstone that ages past retention may only lower its count.
272 lines
9.7 KiB
TypeScript
272 lines
9.7 KiB
TypeScript
/**
|
|
* A backup an older release wrote keeps restoring.
|
|
*
|
|
* Every other backup test exports with today's code and restores with
|
|
* today's code, so a change that moves both ends at once (a renamed field, a
|
|
* section that changes shape, a new required value) passes every one of them
|
|
* and still breaks the file a self-hoster downloaded last month. This file
|
|
* holds the other end still: each release commits the disaster-recovery file
|
|
* its own exporter wrote (`tests/fixtures/golden-backups/v<release>.json`),
|
|
* with a manifest of what that file held, and every later release has to
|
|
* restore each committed file into an empty account and get the same counts
|
|
* back, section by section.
|
|
*
|
|
* The files go in through the admin routes an operator uses, the upload and
|
|
* then the restore (`restore-job-driver.ts` runs the queued job in-process),
|
|
* so the upload's own checks of an old file are part of the claim. The counts
|
|
* are read back through the current exporter, which is the reading a person
|
|
* gets when they export again.
|
|
*
|
|
* Ciphertext inside the files is sealed under the suite's fixed key
|
|
* (`ENCRYPTION_KEY` in `vitest.integration.config.mts`), so they open on any
|
|
* machine that runs the suite.
|
|
*
|
|
* `GOLDEN_BACKUP_WRITE=1` writes the current release's file instead of
|
|
* restoring; `tests/fixtures/golden-backups/README.md` says when and how.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
|
|
import {
|
|
OWNER_ID,
|
|
createOwner,
|
|
seedAdminSession,
|
|
seedColumnShapes,
|
|
seedEveryTwoEndedModel,
|
|
} from "./backup-fixture";
|
|
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),
|
|
}));
|
|
|
|
const FIXTURE_DIR = resolve(__dirname, "../fixtures/golden-backups");
|
|
const WRITE = process.env.GOLDEN_BACKUP_WRITE === "1";
|
|
const SLOW = 240_000;
|
|
|
|
/** What a committed file held when its release wrote it. */
|
|
interface GoldenManifest {
|
|
release: string;
|
|
schemaVersion: string;
|
|
userId: string;
|
|
exportedAt: string;
|
|
sha256: string;
|
|
bytes: number;
|
|
counts: Record<string, number>;
|
|
}
|
|
|
|
/**
|
|
* Rows the restore is documented to drop by age rather than carry, so a file
|
|
* restored long after it was written comes back without them. Each entry is
|
|
* a count key and the reason; the count may only go down, never up.
|
|
*/
|
|
const AGES_OUT: Readonly<Record<string, string>> = {
|
|
measurements:
|
|
"a measurement tombstone older than TOMBSTONE_RETENTION_DAYS is not written back (`measurements-restore.ts`)",
|
|
};
|
|
|
|
function sha256(bytes: Buffer): string {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function goldenFiles(): string[] {
|
|
return readdirSync(FIXTURE_DIR)
|
|
.filter((name) => /^v\d+\.\d+\.\d+\.json$/.test(name))
|
|
.sort();
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
describe.runIf(WRITE)("golden backups: write this release's file", () => {
|
|
it(
|
|
"writes the disaster-recovery file of the seeded account",
|
|
async () => {
|
|
const prisma = getPrismaClient();
|
|
const release = JSON.parse(
|
|
readFileSync(resolve(__dirname, "../../package.json"), "utf8"),
|
|
).version as string;
|
|
await createOwner(prisma);
|
|
await seedEveryTwoEndedModel(prisma);
|
|
await seedColumnShapes(prisma);
|
|
// The shared seed files its illness symptom under a catalogue key of
|
|
// its own. A file restored on another instance resolves catalogue keys
|
|
// against what that instance ships, so the committed file names a
|
|
// shipped symptom instead and every link has somewhere to land.
|
|
const shipped = await prisma.illnessSymptom.findFirstOrThrow({
|
|
where: { key: { not: "round_trip_cough" } },
|
|
orderBy: { key: "asc" },
|
|
});
|
|
const own = await prisma.illnessSymptom.findUniqueOrThrow({
|
|
where: { key: "round_trip_cough" },
|
|
});
|
|
await prisma.illnessSymptomLink.updateMany({
|
|
where: { symptomId: own.id },
|
|
data: { symptomId: shipped.id },
|
|
});
|
|
await prisma.illnessSymptom.delete({ where: { id: own.id } });
|
|
|
|
const exportedAt = new Date();
|
|
let json = "";
|
|
const counts = await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
json += chunk;
|
|
},
|
|
{ purpose: "disaster-recovery", exportedAt },
|
|
);
|
|
const bytes = Buffer.from(json, "utf8");
|
|
const payload = JSON.parse(json) as { schemaVersion: string };
|
|
const manifest: GoldenManifest = {
|
|
release,
|
|
schemaVersion: payload.schemaVersion,
|
|
userId: OWNER_ID,
|
|
exportedAt: exportedAt.toISOString(),
|
|
sha256: sha256(bytes),
|
|
bytes: bytes.length,
|
|
counts: { ...counts },
|
|
};
|
|
writeFileSync(resolve(FIXTURE_DIR, `v${release}.json`), bytes);
|
|
writeFileSync(
|
|
resolve(FIXTURE_DIR, `v${release}.manifest.json`),
|
|
JSON.stringify(manifest, null, 2) + "\n",
|
|
);
|
|
},
|
|
SLOW,
|
|
);
|
|
});
|
|
|
|
describe.skipIf(WRITE)("golden backups: every committed file restores", () => {
|
|
it("has at least one committed file, each with its manifest", () => {
|
|
const files = goldenFiles();
|
|
expect(files.length).toBeGreaterThan(0);
|
|
for (const file of files) {
|
|
expect(readdirSync(FIXTURE_DIR)).toContain(
|
|
file.replace(/\.json$/, ".manifest.json"),
|
|
);
|
|
}
|
|
});
|
|
|
|
for (const file of goldenFiles()) {
|
|
it(
|
|
`restores ${file} into an empty account with the counts it was written with`,
|
|
async () => {
|
|
const prisma = getPrismaClient();
|
|
const bytes = readFileSync(resolve(FIXTURE_DIR, file));
|
|
const manifest = JSON.parse(
|
|
readFileSync(
|
|
resolve(FIXTURE_DIR, file.replace(/\.json$/, ".manifest.json")),
|
|
"utf8",
|
|
),
|
|
) as GoldenManifest;
|
|
// The file is the release's own bytes. Regenerating it from today's
|
|
// exporter would turn this into one more same-code round trip.
|
|
expect(sha256(bytes), `${file} was rewritten`).toBe(manifest.sha256);
|
|
// A file that held nearly nothing would agree with nearly nothing.
|
|
const nonEmpty = Object.values(manifest.counts).filter((n) => n > 0);
|
|
expect(nonEmpty.length).toBeGreaterThan(40);
|
|
|
|
await seedAdminSession(prisma);
|
|
// The empty account the file is restored into: the restore writes
|
|
// into the account the file names, which has to exist.
|
|
await prisma.user.create({
|
|
data: {
|
|
id: manifest.userId,
|
|
username: `golden-${manifest.release}`,
|
|
email: `golden-${manifest.release}@example.test`,
|
|
},
|
|
});
|
|
|
|
const { POST: upload } =
|
|
await import("@/app/api/admin/backups/upload/route");
|
|
const uploaded = await upload(
|
|
new Request("http://localhost/api/admin/backups/upload", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: bytes,
|
|
}) as unknown as Parameters<typeof upload>[0],
|
|
);
|
|
const uploadBody = (await uploaded.json()) as {
|
|
data: { id: string } | null;
|
|
error: string | null;
|
|
};
|
|
expect(uploaded.status, JSON.stringify(uploadBody)).toBe(201);
|
|
const backupId = uploadBody.data!.id;
|
|
|
|
const { POST: restore } = await import("./restore-job-driver");
|
|
const restored = await restore(
|
|
new Request(
|
|
`http://localhost/api/admin/backups/${backupId}/restore`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
},
|
|
) as unknown as Parameters<typeof restore>[0],
|
|
{ params: Promise.resolve({ id: backupId }) },
|
|
);
|
|
const restoreBody = (await restored.json()) as {
|
|
data: { skipped: { links: number } } | null;
|
|
error: string | null;
|
|
meta?: unknown;
|
|
};
|
|
expect(restored.status, JSON.stringify(restoreBody)).toBe(200);
|
|
expect(
|
|
restoreBody.data!.skipped.links,
|
|
JSON.stringify(restoreBody.data!.skipped),
|
|
).toBe(0);
|
|
|
|
const { counts } = await buildFullBackupPayload(
|
|
prisma,
|
|
manifest.userId,
|
|
{ purpose: "disaster-recovery" },
|
|
);
|
|
const now: Record<string, number> = { ...counts };
|
|
for (const [section, expected] of Object.entries(manifest.counts)) {
|
|
if (section in AGES_OUT) {
|
|
expect(now[section], `${section} grew`).toBeLessThanOrEqual(
|
|
expected,
|
|
);
|
|
expect(now[section], `${section} came back empty`).toBeGreaterThan(
|
|
0,
|
|
);
|
|
continue;
|
|
}
|
|
expect(now[section], `${section} after restoring ${file}`).toBe(
|
|
expected,
|
|
);
|
|
}
|
|
},
|
|
SLOW,
|
|
);
|
|
}
|
|
});
|