test(backup): restore every release's own backup file

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 09:58:20 +01:00
1 parent 1340200cac
commit 9d27b6f103
5 files changed
+393

No files matched your search

+5
View File
@@ -30,6 +30,11 @@ scripts/bench/results
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly. # parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__ tests/integration/__golden__
# A release's backup file exactly as its exporter wrote it; the golden-backup
# test checks the bytes against the manifest's SHA-256, so it is never
# reformatted. The manifests are written by the same test.
tests/fixtures/golden-backups/*.json
# inlang writes its working files (cache, metadata, README) next to the # inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked. # project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/* project.inlang/*
+44
View File
@@ -0,0 +1,44 @@
# Golden backups
One disaster-recovery backup file per release, written by that release's own
exporter, and a manifest of what it held. `tests/integration/golden-backups.test.ts`
uploads every file here through the admin upload route, restores it into an
empty account through the restore route and job, exports the account again and
checks the counts section by section against the manifest. A backup a person
downloaded from an older release has to keep restoring after every later
change; this is where that is held still.
| File | What it is |
| -------------------------- | --------------------------------------------------------------------------- |
| `v<release>.json` | the file exactly as the exporter wrote it (compact JSON, never reformatted) |
| `v<release>.manifest.json` | release, schema version, owner id, `exportedAt`, SHA-256, byte size, counts |
The account is the shared backup seed (`tests/integration/backup-fixture.ts`:
one row of every two-ended model plus the column shapes), so every section the
backup writes holds at least one row. Ciphertext inside the file is sealed
under the integration suite's fixed key (`ENCRYPTION_KEY` in
`vitest.integration.config.mts`), so the file opens on every machine that runs
the suite and no real key is involved.
## Adding the next release's file
Once per release, on the release branch after the version bump, with Docker
running:
```bash
GOLDEN_BACKUP_WRITE=1 pnpm vitest run --config vitest.integration.config.mts tests/integration/golden-backups.test.ts
pnpm vitest run --config vitest.integration.config.mts tests/integration/golden-backups.test.ts
```
The first command writes `v<version from package.json>.json` and its manifest;
the second restores every file here, the new one included. Commit both files.
Never rewrite an existing release's file: the test checks each file against the
SHA-256 in its manifest, because a file regenerated by today's exporter would
prove only that today's exporter and today's restore agree. When a change means
an old file can no longer restore as it was written, that is a breaking change
to the backup format, and it is decided in the restore code, not by replacing
the fixture.
A count the restore is documented to drop by age (a measurement tombstone past
its retention) is listed in `AGES_OUT` in the test and may only go down.
+1
View File
@@ -0,0 +1 @@
{"schemaVersion":"2","exportedAt":"2026-10-29T08:54:55.373Z","userId":"round-trip-owner","appSettings":null,"accountSettings":{"heightCm":null,"dateOfBirth":null,"gender":null,"hasDiabetes":false,"displayName":null,"fullName":null,"insurerName":null,"insuranceNumber":null,"insuranceNumberEncrypted":null,"insurerIkNumber":null,"lastReportPracticeName":null,"avatarBytes":null,"avatarContentType":null,"avatarUpdatedAt":null,"homeTimezone":null,"homeSince":null,"homeLocationEncrypted":null,"environmentAirQualityEnabled":true,"timezone":"Europe/Berlin","locale":null,"unitPreference":null,"glucoseUnit":null,"timeFormat":"AUTO","dateFormat":"AUTO","modulePreferencesJson":null,"thresholdsJson":null,"healthScoreConfigJson":null,"sourcePriorityJson":null,"dashboardWidgetsJson":null,"insightsLayoutJson":null,"medicationListLayoutJson":null,"moodTagLayoutJson":null,"documentsLayoutJson":null,"moduleListLayoutJson":null,"activityGoalsJson":null,"reportSelectionJson":null,"globalExcludedInjectionSites":[],"healthKitConfigJson":null,"notificationPrefs":null,"moodReminderEnabled":false,"aiProvider":null,"aiModel":null,"aiBaseUrl":null,"aiCompatBaseUrl":null,"aiCompatModel":null,"aiProviderChain":null,"aiResponseTimeoutSeconds":null,"useCentralCodex":false,"insightsPrivacyMode":"aggregated","insightsExcludeMetrics":[],"disableCoach":false,"aiUsageLevel":null,"coachPrefsJson":null,"documentsAutoAiRead":false,"labsLocalOcrEnabled":false,"storeWorkoutRoutes":false,"onboardingCompletedAt":null,"onboardingTourCompleted":false,"onboardingTourProgressJson":null,"disclaimerAcknowledgedAt":null,"disclaimerAcknowledgedVersion":null,"passkeyUpgradeNudgeDismissed":false},"measurements":[{"id":"cmvtb53ls002rvdovt5ne4m0v","type":"BLOOD_GLUCOSE","value":5.4,"valueMin":null,"valueMax":null,"unit":"mmol/L","measuredAt":"2026-07-03T06:45:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":"FASTING","sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.360Z","updatedAt":"2026-10-29T08:54:55.360Z"},{"id":"cmvtb53lt002svdov5wtjes6d","type":"SLEEP_DURATION","value":95,"valueMin":null,"valueMax":null,"unit":"min","measuredAt":"2026-07-03T02:00:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deep-sleep","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":"DEEP","rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.361Z","updatedAt":"2026-10-29T08:54:55.361Z"},{"id":"cmvtb53gu0001vdovkhes3wde","type":"PULSE","value":61,"valueMin":null,"valueMax":null,"unit":"bpm","measuredAt":"2026-07-02T06:30:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deleted-sample","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":4,"deletedAt":"2026-10-19T08:54:54.000Z","createdAt":"2026-10-29T08:54:55.183Z","updatedAt":"2026-10-29T08:54:55.183Z"},{"id":"cmvtb53gq0000vdovpfeu4gu1","type":"WEIGHT","value":74.2,"valueMin":null,"valueMax":null,"unit":"kg","measuredAt":"2026-07-01T07:00:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.178Z","updatedAt":"2026-10-29T08:54:55.178Z"},{"id":"cmvtb53gx0002vdovwm23zudk","type":"IRREGULAR_RHYTHM_NOTIFICATION","value":1,"valueMin":null,"valueMax":null,"unit":"event","measuredAt":"2026-06-15T14:22:00.000Z","source":"WITHINGS","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":"IRREGULAR","deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-29T08:54:55.185Z","updatedAt":"2026-10-29T08:54:55.185Z"}],"customMedicationCategories":[{"key":"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10","label":null,"labelEncrypted":"djEueldXclgxVTZmcGhBaG9RUUFseFphVDloaWNLendKSWNtUGJZSmVTZkJ3RW5rbllZMm4zZklIZkYxc3ltamZ5VUx3PT0=","sortOrder":2,"isActive":true,"createdAt":"2026-10-29T08:54:55.214Z","updatedAt":"2026-10-29T08:54:55.214Z"}],"medications":[{"id":"cmvtb53hg0005vdovsxoh5965","treatmentClass":"GENERIC","dosesPerUnit":null,"unitsPerDose":"1","notificationsEnabled":true,"pausedAt":null,"snoozedUntil":null,"startsOn":"2026-06-10T00:00:00.000Z","endsOn":"2026-06-16T00:00:00.000Z","oneShot":false,"asNeeded":false,"deliveryForm":"ORAL","trackInjectionSites":false,"allowedInjectionSites":[],"liveActivityEnabled"Line truncated
+72
View File
@@ -0,0 +1,72 @@
{
"release": "1.46.1",
"schemaVersion": "2",
"userId": "round-trip-owner",
"exportedAt": "2026-10-29T08:54:55.373Z",
"sha256": "56d798b77d92a19a5048be1c364fdbb1b527c713a9f9dedc574b5d2c44bdc1dc",
"bytes": 41985,
"counts": {
"measurements": 5,
"medications": 1,
"intakeEvents": 1,
"medicationSideEffects": 1,
"medicationPauseEras": 2,
"medicationDoseChanges": 2,
"medicationInventoryItems": 2,
"medicationInventoryEvents": 2,
"reminderPhaseConfigs": 1,
"medicationEfficacyTargets": 1,
"medicationScheduleRevisions": 3,
"moodEntries": 1,
"customMoodTagCategories": 1,
"hiddenMoodTags": 1,
"cycles": 2,
"cycleDayLogs": 1,
"nutrientDays": 1,
"labResults": 1,
"biomarkers": 1,
"illnessEpisodes": 2,
"illnessDayLogs": 1,
"allergies": 1,
"familyHistory": 1,
"workouts": 1,
"documents": 1,
"healthProfile": 1,
"customMetrics": 1,
"healthProfileFactRevisions": 1,
"customMetricEntries": 2,
"correlationPatterns": 1,
"intradayProfiles": 1,
"healthScoreRecords": 1,
"onboardingRecords": 1,
"practitioners": 1,
"encounters": 1,
"encounterLinks": 3,
"customVaccines": 1,
"vaccinations": 1,
"vaccinationLinks": 1,
"measurementReminders": 1,
"measurementReminderEvents": 2,
"coachConversations": 2,
"coachMessages": 3,
"coachConversationDocuments": 1,
"coachFacts": 1,
"coachPlans": 1,
"coachReminders": 1,
"mentalHealthAssessments": 1,
"consentReceipts": 2,
"documentConditionLinks": 1,
"extractedFacts": 1,
"documentImportKeys": 1,
"personalRecords": 3,
"userAchievements": 2,
"environmentContexts": 2,
"environmentTravelLocations": 1,
"dawarichDays": 1,
"ecgRecordings": 1,
"symptomDefinitions": 2,
"symptomEvents": 3,
"lifeEvents": 2,
"relationLinks": 5
}
}
+271
View File
@@ -0,0 +1,271 @@
/**
* 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,
);
}
});