Files

1869 lines
57 KiB
TypeScript

/**
* The backup file and the restored account, pinned against golden copies.
*
* The other backup tests each prove one property: every model comes back
* (`backup-round-trip.test.ts`), every column does, a refusal happens before
* the first delete. None of them says the whole file and the whole restored
* account are what they were yesterday, and that is the claim a change to the
* backup code has to keep when it means to change nothing. This file makes it:
*
* 1. Fill one account as far as the schema allows: a row of every
* two-ended model, every column of those rows, every column of the
* account row, the instance settings, and a sharing grant beside it.
* 2. Export it both ways (disaster recovery, portable) and pin the files,
* their counts, the parsed payload and the preview.
* 3. Restore each file into a fresh account, and the disaster-recovery file
* once more over the live account, and pin what the restore answered,
* the phases it reported, the audit rows and wide-event fields it wrote,
* every row the account holds afterwards, and the file it exports then.
* 4. Restore a version-1 file in the old shapes (no ids, references by
* name, the retired heart-rate variability filing, the Coach switch in
* module availability) over an account that already holds rows.
* 5. Hand the restore malformed and unresolvable files and pin each refusal
* and each skip report, and the schema's issue list for a bad file.
*
* The account carries a tombstone of every model that keeps them and both
* self-references (a flare under its condition, a superseded profile fact),
* so the second restore pass has something to resolve. Every timestamp the
* database stamped while the fixture ran is pinned to a fixed historical
* value, and the seeded account is asserted to hold none of this run's: a
* restore that drops a row's `createdAt` then shows up as `<now>` against a
* historical value instead of hiding behind a seeded `<now>`. The fixture is
* sealed under the legacy key `v1`, and a second key is active from the
* export on, so a value the restore re-encrypts instead of carrying over
* comes back under `v2`.
*
* Values that legitimately differ between two runs are normalised, never
* dropped: ids become `<id:N>` in order of first appearance, ciphertext
* becomes the key id it names and the text it opens to (or its length when no
* codec opens it), and a timestamp written during the run becomes `<now>`.
* Key order is kept, so a file that carries the same values in another order
* differs.
*
* `BACKUP_PARITY_WRITE=1` rewrites the golden copies. Only do that for a
* change that means to alter the backup, and review the diff of
* `tests/integration/__golden__/backup-parity/` as part of it. A change to
* this harness itself writes them from the backup code as it was before the
* change under test, never from the code being checked.
*/
import { Buffer } from "node:buffer";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
_resetCryptoCacheForTests,
decrypt,
decryptBytes,
encrypt,
encryptBytes,
extractKeyId,
extractKeyIdFromBytes,
} from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { openDayCountry } from "@/lib/dawarich/connection";
import { openLocation } from "@/lib/environment/location-cipher";
import { decryptFactData, decryptFactProvenance } from "@/lib/documents/store";
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
import { decryptNoteFromBytes } from "@/lib/labs/store";
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
import { TWO_ENDED_MODELS } from "@/lib/export/backup-plan";
import {
restoreBackup,
type RestoreBackupOutcome,
} from "@/lib/export/restore-backup";
import { STORED_BACKUP_SELECT } from "@/lib/export/stored-backup";
import { parseBackupPayload } from "@/lib/validations/backup";
import { summarizeBackup } from "@/lib/validations/backup-summary";
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
import {
type AuditModel,
enumValuesFromSchema,
isDefaultValue,
ownerWhere,
readAuditModels,
syntheticValue,
} from "./backup-column-audit";
import {
AT,
COLUMN_PRESENCE_ONLY,
FILL_OVERRIDES,
OWNER_ID,
TOMBSTONE_DELETED_AT,
USER_FILL_OVERRIDES,
comparison,
createOwner,
delegateOf,
primaryKeyWhere,
seedAdminSession,
seedColumnShapes,
seedEveryTwoEndedModel,
} from "./backup-fixture";
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),
})),
};
});
/** What the restore asked of the world outside its transaction. */
const effects = vi.hoisted(() => ({
invalidated: [] as string[],
unitRefresh: [] as string[],
foldRepair: [] as string[],
annotations: [] as unknown[],
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn((userId: string) => {
effects.invalidated.push(userId);
}),
}));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn(async (userId: string) => {
effects.unitRefresh.push(userId);
}),
}));
vi.mock("@/lib/jobs/measurement-fold-repair", async (importOriginal) => ({
...(await importOriginal<object>()),
enqueueMeasurementFoldRepair: vi.fn(async (userId: string) => {
effects.foldRepair.push(userId);
}),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn((...args: Parameters<typeof actual.annotate>) => {
effects.annotations.push(args[0]);
return actual.annotate(...args);
}),
};
});
const GOLDEN_DIR = resolve(__dirname, "__golden__/backup-parity");
const WRITE = process.env.BACKUP_PARITY_WRITE === "1";
const EXPORTED_AT = new Date("2026-08-01T00:00:00.000Z");
const RUN_STARTED = Date.now();
const DELEGATE_ID = "parity-delegate";
const SLOW = 240_000;
// ── Normalisation ────────────────────────────────────────────────────────────
const CUID = /\bc[a-z0-9]{24}\b/g;
const ISO = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z\b/g;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
const VERSIONED = /^[A-Za-z0-9_-]{1,32}\.[A-Za-z0-9+/=]{24,}$/;
const SEALED_KEY = /(Encrypted|Sealed)$/;
/** Every codec a sealed byte value is written with, tried in order. */
const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
decryptFromBytes,
decryptNoteFromBytes,
decryptContextFromBytes,
(value) => JSON.stringify(openLocation(value)),
(value) => {
const code = openDayCountry(value);
if (code === null) throw new Error("not a sealed day country");
return `country:${code}`;
},
(value) => JSON.stringify(decryptWaveformFromBytes(value)),
(value) => JSON.stringify(decryptFactData(Buffer.from(value))),
(value) => JSON.stringify(decryptFactProvenance(Buffer.from(value))),
(value) => `bytes:${decryptBytes(Buffer.from(value)).toString("base64")}`,
];
function openBytes(value: Uint8Array): string | null {
for (const open of BYTE_OPENERS) {
try {
const text = open(value);
if (text !== undefined && text !== "null") return text;
} catch {
continue;
}
}
return null;
}
/**
* The key id of a sealed byte value: the header of the binary codec, or the
* `<id>.` prefix of a string-codec ciphertext stored as its UTF-8 bytes.
* `-` for neither.
*/
function bytesKeyId(value: Uint8Array): string {
const buffer = Buffer.from(value);
return (
extractKeyIdFromBytes(buffer) ??
extractKeyId(buffer.toString("utf8")) ??
"-"
);
}
/**
* The key id of a sealed string: the `<id>.` prefix of the string codec, or
* the header of a sealed byte value carried as base64. `-` for neither (the
* unversioned legacy string form, or another codec).
*/
function stringKeyId(value: string): string {
const id = extractKeyId(value);
if (id !== null) return id;
if (value.length % 4 === 0 && BASE64.test(value)) {
return bytesKeyId(Buffer.from(value, "base64"));
}
return "-";
}
function openString(value: string): string | null {
try {
return decrypt(value);
} catch {
// not the string codec; try it as base64 of a sealed byte value
}
if (value.length % 4 === 0 && BASE64.test(value)) {
return openBytes(Buffer.from(value, "base64"));
}
return null;
}
/**
* One normalisation pass. Ids are numbered in the order this pass first meets
* them, so two documents normalised by two passes compare by shape.
*/
class Normaliser {
private readonly ids = new Map<string, string>();
private readonly windowFrom = RUN_STARTED - 5 * 60_000;
private readonly days: ReadonlySet<string>;
constructor() {
const today = (t: number) => new Date(t).toISOString().slice(0, 10);
this.days = new Set([today(RUN_STARTED), today(Date.now())]);
}
private alias(id: string): string {
let alias = this.ids.get(id);
if (!alias) {
alias = `<id:${this.ids.size + 1}>`;
this.ids.set(id, alias);
}
return alias;
}
text(value: string): string {
const windowTo = Date.now() + 5 * 60_000;
const out = value
.replace(CUID, (id) => this.alias(id))
.replace(ISO, (iso) => {
if (iso === TOMBSTONE_DELETED_AT) return "<tombstone>";
const at = Date.parse(iso);
return at >= this.windowFrom && at <= windowTo ? "<now>" : iso;
});
return DAY.test(out) && this.days.has(out) ? "<today>" : out;
}
/**
* A sealed string, as the key it was sealed under and the text it opens to.
* The key id is part of the comparison: a restore that re-encrypted a value
* the file carried as ciphertext opens to the same text under another key,
* and only the id shows it.
*/
sealed(value: string): string {
const opened = openString(value);
const kid = stringKeyId(value);
return opened === null
? `<sealed:${kid}:${value.length}>`
: `<sealed:${kid}:${this.text(opened)}>`;
}
/** A JSON value, key order kept. */
json(value: unknown, key = ""): unknown {
if (typeof value === "string") {
if (SEALED_KEY.test(key) || VERSIONED.test(value)) {
const opened = openString(value);
if (opened !== null || SEALED_KEY.test(key)) return this.sealed(value);
}
return this.text(value);
}
if (Array.isArray(value)) return value.map((item) => this.json(item, key));
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, this.json(v, k)]),
);
}
return value;
}
/** A database value: dates, bytes, decimals and big integers made JSON. */
db(value: unknown, key = ""): unknown {
if (value === null || value === undefined) return null;
if (value instanceof Date) return this.text(value.toISOString());
if (value instanceof Uint8Array) {
const opened = openBytes(value);
return opened === null
? `<bytes:${Buffer.from(value).toString("base64")}>`
: `<sealed:${bytesKeyId(value)}:${this.text(opened)}>`;
}
if (typeof value === "bigint") return `bigint:${value}`;
if (
typeof value === "object" &&
!Array.isArray(value) &&
"toFixed" in value
) {
return `decimal:${String(value)}`;
}
if (Array.isArray(value)) return value.map((item) => this.db(item, key));
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value as object).map(([k, v]) => [k, this.db(v, k)]),
);
}
return this.json(value, key);
}
}
/** Rows in an order that does not depend on their ids or on the heap. */
function sortRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const keyOf = (row: Record<string, unknown>) =>
JSON.stringify(row, (_k, v) => {
if (typeof v === "bigint") return `bigint:${v}`;
if (v instanceof Uint8Array) return `bytes:${v.byteLength}`;
if (typeof v === "string") return v.replace(CUID, "<id>");
return v;
});
return [...rows].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
}
// ── Golden copies ────────────────────────────────────────────────────────────
/** Paths where two JSON values differ, key order included, first 40. */
function differences(expected: unknown, actual: unknown, path = "$"): string[] {
const out: string[] = [];
const walk = (e: unknown, a: unknown, at: string) => {
if (out.length >= 40) return;
if (Array.isArray(e) && Array.isArray(a)) {
if (e.length !== a.length) {
out.push(`${at}: length ${e.length} -> ${a.length}`);
}
for (let i = 0; i < Math.min(e.length, a.length); i++) {
walk(e[i], a[i], `${at}[${i}]`);
}
return;
}
if (
e &&
a &&
typeof e === "object" &&
typeof a === "object" &&
!Array.isArray(e) &&
!Array.isArray(a)
) {
const ek = Object.keys(e);
const ak = Object.keys(a);
for (const k of ek) if (!ak.includes(k)) out.push(`${at}.${k}: removed`);
for (const k of ak) if (!ek.includes(k)) out.push(`${at}.${k}: added`);
const shared = ek.filter((k) => ak.includes(k));
const order = ak.filter((k) => ek.includes(k));
if (shared.join() !== order.join()) out.push(`${at}: key order changed`);
for (const k of shared) {
walk(
(e as Record<string, unknown>)[k],
(a as Record<string, unknown>)[k],
`${at}.${k}`,
);
}
return;
}
if (JSON.stringify(e) !== JSON.stringify(a)) {
out.push(
`${at}: ${JSON.stringify(e)?.slice(0, 120)} -> ${JSON.stringify(a)?.slice(0, 120)}`,
);
}
};
walk(expected, actual, path);
return out;
}
function matchGolden(name: string, actual: unknown): void {
const text = `${JSON.stringify(actual, null, 2)}\n`;
const path = join(GOLDEN_DIR, `${name}.json`);
if (WRITE) {
mkdirSync(GOLDEN_DIR, { recursive: true });
writeFileSync(path, text);
return;
}
expect(
existsSync(path),
`${path} is missing; write it with BACKUP_PARITY_WRITE=1`,
).toBe(true);
const expected = readFileSync(path, "utf8");
if (expected === text) return;
expect(
differences(JSON.parse(expected), actual),
`${name} differs from its golden copy`,
).toEqual([]);
expect(text, `${name} differs from its golden copy`).toBe(expected);
}
/** How many leaf values a document holds, so a near-empty one cannot pass. */
function leafCount(value: unknown): number {
if (Array.isArray(value)) return value.reduce((n, v) => n + leafCount(v), 0);
if (value && typeof value === "object") {
return Object.values(value).reduce<number>((n, v) => n + leafCount(v), 0);
}
return 1;
}
// ── The account ──────────────────────────────────────────────────────────────
const SNAPSHOT_EXTRA_MODELS = ["AccountGrant"] as const;
function auditModels(prisma: PrismaClient): AuditModel[] {
return readAuditModels(prisma, TWO_ENDED_MODELS);
}
/**
* Give every column of the fixture's rows a value that is not its default,
* the way the column round trip does, and say which ones would not take one.
*/
async function fillDefaultedColumns(prisma: PrismaClient): Promise<string[]> {
const models = auditModels(prisma);
const byName = new Map(models.map((m) => [m.name, m]));
const enumValues = enumValuesFromSchema();
const unfillable: string[] = [];
for (const model of models) {
// In key order, so the row that takes the fill is the same on every run:
// a table read without an order comes back in whatever order its last
// updates left on disk.
const rows = await delegateOf(prisma, model).findMany({
where: ownerWhere(byName, prisma, model.name, OWNER_ID),
orderBy: model.primaryKey.map((key) => ({ [key]: "asc" as const })),
});
if (rows.length === 0) continue;
for (const column of model.columns) {
if (
comparison(column) !== "value" &&
!(`${model.name}.${column.name}` in COLUMN_PRESENCE_ONLY)
) {
continue;
}
if (column.name === "deletedAt") continue;
if (rows.some((row) => !isDefaultValue(column, row[column.name]))) {
continue;
}
try {
await delegateOf(prisma, model).update({
where: primaryKeyWhere(model, rows[0]),
data: {
[column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES),
},
});
} catch {
unfillable.push(`${model.name}.${column.name}`);
}
}
}
return unfillable;
}
/** Every column of one row, through its delegate, with the given overrides. */
async function fillRow(
prisma: PrismaClient,
modelName: string,
where: Record<string, unknown>,
overrides: Readonly<Record<string, unknown>>,
skip: ReadonlySet<string>,
): Promise<string[]> {
const [model] = readAuditModels(prisma, [modelName]);
const enumValues = enumValuesFromSchema();
const unfillable: string[] = [];
for (const column of model.columns) {
if (column.isId || column.isUpdatedAt || skip.has(column.name)) continue;
try {
await delegateOf(prisma, model).update({
where,
data: { [column.name]: syntheticValue(column, enumValues, overrides) },
});
} catch {
unfillable.push(`${modelName}.${column.name}`);
}
}
return unfillable;
}
const APP_SETTINGS_OVERRIDES: Readonly<Record<string, unknown>> = {
"AppSettings.defaultLocale": "fr",
"AppSettings.defaultUserTimezone": "America/Chicago",
"AppSettings.moduleAvailabilityJson": { mood: "off" },
"AppSettings.adminAiInsightsFeedbackSummary": { helpful: 3 },
"AppSettings.aiReasoningMaxEffort": "low",
};
/** The whole account, and the host around it. */
async function seedMaximalAccount(prisma: PrismaClient) {
const admin = await seedAdminSession(prisma);
await createOwner(prisma);
await prisma.illnessSymptom.deleteMany({
where: { key: "round_trip_cough" },
});
await seedEveryTwoEndedModel(prisma);
await seedColumnShapes(prisma);
const unfillable = await fillDefaultedColumns(prisma);
unfillable.push(
...(await fillRow(
prisma,
"User",
{ id: OWNER_ID },
USER_FILL_OVERRIDES,
new Set(["username", "role", "email"]),
)),
);
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton" },
update: {},
});
unfillable.push(
...(await fillRow(
prisma,
"AppSettings",
{ id: "singleton" },
APP_SETTINGS_OVERRIDES,
new Set(),
)),
);
// A grant the owner gave, which the file never carries and a restore into
// the same account never touches.
await prisma.user.create({
data: {
id: DELEGATE_ID,
username: "parity-delegate",
email: "parity-delegate@example.test",
},
});
await prisma.accountGrant.create({
data: {
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access: "WRITE",
scopeJson: { sections: ["measurements"] },
acceptedAt: new Date("2026-07-02T09:00:00.000Z"),
},
});
await seedTombstones(prisma);
await seedSelfReferences(prisma);
const unpinned = await pinRunTimeTimestamps(prisma);
rotateActiveKey();
return { admin, unfillable: unfillable.sort(), unpinned };
}
/**
* One soft-deleted row of every model that keeps tombstones, beside the live
* rows the fixture seeds. A disaster-recovery file carries the tombstones a
* device sync needs to keep a deleted row deleted, and the golden copies pin
* which models' tombstones the file carries and that each comes back deleted.
* Seeded after the column fill so the fill still lands on the live rows.
*/
async function seedTombstones(prisma: PrismaClient) {
const deletedAt = AT(TOMBSTONE_DELETED_AT);
const userId = OWNER_ID;
const medication = await prisma.medication.findFirstOrThrow({
where: { userId, name: "Round-trip tablet" },
});
const episode = await prisma.illnessEpisode.findFirstOrThrow({
where: { userId, label: "Cold" },
});
const cycle = await prisma.menstrualCycle.findFirstOrThrow({
where: { userId, startDate: "2026-06-01" },
});
const metric = await prisma.customMetric.findFirstOrThrow({
where: { userId, name: "Grip strength" },
});
const thread = await prisma.coachConversation.findFirstOrThrow({
where: { userId, documentScoped: false },
});
await prisma.medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor: AT("2026-07-02T08:00:00.000Z"),
takenAt: AT("2026-07-02T08:03:00.000Z"),
deletedAt,
},
});
await prisma.mentalHealthAssessment.create({
data: {
userId,
instrument: "GAD7",
locale: "en",
version: "standard",
responsesEncrypted: encryptToBytes(
JSON.stringify({ items: [1, 1, 0, 1, 0, 1, 0], schema: 1 }),
),
totalScore: 4,
severityBand: "minimal",
takenAt: AT("2026-06-05T18:00:00.000Z"),
tz: "Europe/Berlin",
source: "WEB",
deletedAt,
},
});
await prisma.moodEntry.create({
data: {
userId,
date: "2026-07-03",
mood: "SCHLECHT",
score: 2,
moodLoggedAt: AT("2026-07-03T20:00:00.000Z"),
deletedAt,
},
});
await prisma.measurementReminder.create({
data: {
userId,
label: "Gewicht (deleted)",
measurementType: "WEIGHT",
intervalDays: 14,
deletedAt,
},
});
await prisma.labResult.create({
data: {
userId,
analyte: "TSH",
value: 2.1,
unit: "mU/L",
takenAt: AT("2026-05-30T09:00:00.000Z"),
deletedAt,
},
});
await prisma.customMetric.create({
data: { userId, name: "Plank (deleted)", unit: "s", deletedAt },
});
await prisma.customMetricEntry.create({
data: {
userId,
customMetricId: metric.id,
value: 40,
unit: "kg",
measuredAt: AT("2026-06-28T18:00:00.000Z"),
deletedAt,
},
});
await prisma.coachFact.create({
data: {
userId,
factEncrypted: encryptToBytes("a fact the person deleted"),
category: "preference",
sourceConversationId: thread.id,
source: "user",
deletedAt,
},
});
const plan = await prisma.coachPlan.create({
data: {
userId,
metric: "WEIGHT",
ifCueEncrypted: encryptToBytes("if the scale reads over 75"),
thenActionEncrypted: encryptToBytes("then skip the second helping"),
status: "active",
deletedAt,
},
});
await prisma.coachReminder.create({
data: {
userId,
noteEncrypted: encryptToBytes("a reminder the person deleted"),
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: AT("2026-09-15T07:00:00.000Z"),
status: "active",
source: "extractor",
deletedAt,
},
});
await prisma.menstrualCycle.create({
data: { userId, startDate: "2026-05-04", deletedAt },
});
await prisma.cycleDayLog.create({
data: {
userId,
cycleId: cycle.id,
date: "2026-06-03",
flow: "LIGHT",
deletedAt,
},
});
await prisma.illnessEpisode.create({
data: {
userId,
label: "Sprained ankle (deleted)",
type: "INJURY",
onsetAt: AT("2026-04-10T00:00:00.000Z"),
deletedAt,
},
});
await prisma.illnessDayLog.create({
data: { userId, episodeId: episode.id, date: "2026-06-22", deletedAt },
});
await prisma.allergy.create({
data: { userId, substance: "Latex (deleted)", deletedAt },
});
await prisma.familyHistoryEntry.create({
data: {
userId,
relationship: "FATHER",
condition: "Hypertension (deleted)",
notesEncrypted: encryptToBytes("diagnosed at fifty"),
deletedAt,
},
});
const bytes = encryptBytes(Buffer.from("a page the person deleted"));
const contentEncrypted = new Uint8Array(new ArrayBuffer(bytes.byteLength));
contentEncrypted.set(bytes);
await prisma.inboundDocument.create({
data: {
userId,
kind: "OTHER",
title: "Deleted scan",
filename: "deleted-scan.pdf",
mimeType: "application/pdf",
byteSize: bytes.byteLength,
contentEncrypted,
contentCodec: "binary2",
deletedAt,
},
});
const practitioner = await prisma.practitioner.create({
data: { userId, name: "Closed practice (deleted)", deletedAt },
});
await prisma.encounter.create({
data: {
userId,
occurredAt: AT("2026-04-02T08:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
practitionerId: practitioner.id,
deletedAt,
},
});
await prisma.customVaccine.create({
data: { userId, name: "Deleted combo", components: ["rabies"], deletedAt },
});
await prisma.vaccinationRecord.create({
data: {
userId,
occurredAt: AT("2026-04-14T00:00:00.000Z"),
antigenSlug: "influenza",
vaccineName: "Influenza (deleted)",
deletedAt,
},
});
await prisma.lifeEvent.create({
data: {
userId,
category: "OTHER",
startDate: "2025-12-01",
precision: "DAY",
titleEncrypted: encryptToBytes("an event the person deleted"),
deletedAt,
},
});
}
/**
* The two models that point at another row of themselves. The restore writes
* those pointers in a second pass once every row of the model is back; with
* the pointers null, nothing in the file exercised it.
*/
async function seedSelfReferences(prisma: PrismaClient) {
const userId = OWNER_ID;
const chronic = await prisma.illnessEpisode.findFirstOrThrow({
where: { userId, label: "Iron deficiency" },
});
await prisma.illnessEpisode.create({
data: {
userId,
label: "Iron deficiency flare",
type: "CHRONIC",
lifecycle: "FLARE",
onsetAt: AT("2026-06-25T00:00:00.000Z"),
parentConditionId: chronic.id,
},
});
const first = await prisma.healthProfileFactRevision.findFirstOrThrow({
where: { userId, kind: "SMOKING_STATUS" },
});
const correction = await prisma.healthProfileFactRevision.create({
data: {
userId,
kind: "SMOKING_STATUS",
valueEncrypted: encryptToBytes("former"),
validFrom: AT("2026-02-01T00:00:00.000Z"),
provenance: "USER_REPORTED",
},
});
await prisma.healthProfileFactRevision.update({
where: { id: first.id },
data: { supersededByRevisionId: correction.id },
});
}
/**
* Give every timestamp the database stamped while the fixture ran a fixed
* historical value. A restore that loses a row's `createdAt` writes the
* restore's own time; while the seeded value was itself "now", both read as
* `<now>` after normalisation and the loss could not show. Pinned, the seeded
* account holds no run-time timestamp at all (asserted below), so a `<now>` in
* a restored row is the restore's own doing and is pinned as such. Returns the
* columns that would not take a value.
*/
async function pinRunTimeTimestamps(prisma: PrismaClient): Promise<string[]> {
const from = RUN_STARTED - 5 * 60_000;
const runTime = (value: unknown) =>
value instanceof Date && value.getTime() >= from;
const base = Date.UTC(2026, 4, 1, 8, 0, 0);
const unpinned: string[] = [];
const pin = async (
model: AuditModel,
rows: Record<string, unknown>[],
modelIndex: number,
) => {
for (const [rowIndex, row] of rows.entries()) {
const data: Record<string, Date> = {};
model.columns.forEach((column, columnIndex) => {
if (column.type !== "DateTime" || !runTime(row[column.name])) return;
data[column.name] = new Date(
base + (modelIndex * 100 + columnIndex) * 60_000 + rowIndex * 1000,
);
});
if (Object.keys(data).length === 0) continue;
try {
await delegateOf(prisma, model).update({
where: primaryKeyWhere(model, row),
data,
});
} catch {
unpinned.push(`${model.name}:${Object.keys(data).join(",")}`);
}
}
};
const models = auditModels(prisma);
const byName = new Map(models.map((m) => [m.name, m]));
for (const [index, model] of models.entries()) {
const rows = await delegateOf(prisma, model).findMany({
where: ownerWhere(byName, prisma, model.name, OWNER_ID),
orderBy: model.primaryKey.map((key) => ({ [key]: "asc" as const })),
});
await pin(model, rows, index);
}
const extra = readAuditModels(prisma, [
"User",
"AppSettings",
"AccountGrant",
]);
const [user, settings, grant] = extra;
await pin(
user,
[await prisma.user.findUniqueOrThrow({ where: { id: OWNER_ID } })],
models.length,
);
await pin(
settings,
[
await prisma.appSettings.findUniqueOrThrow({
where: { id: "singleton" },
}),
],
models.length + 1,
);
await pin(
grant,
(await prisma.accountGrant.findMany({
where: { grantorId: OWNER_ID },
orderBy: { id: "asc" },
})) as Record<string, unknown>[],
models.length + 2,
);
return unpinned.sort();
}
/**
* Everything above was sealed under the legacy key, which the environment
* names `v1`. From here on a second key is the active one, so a value the
* restore re-encrypts instead of carrying over comes back under `v2`, and the
* key id in the normalised copies shows it.
*/
const ORIGINAL_KEY_ENV = {
keys: process.env.ENCRYPTION_KEYS,
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
};
function rotateActiveKey() {
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "11".repeat(32) });
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
_resetCryptoCacheForTests();
}
function restoreKeyEnv() {
for (const [name, value] of [
["ENCRYPTION_KEYS", ORIGINAL_KEY_ENV.keys],
["ENCRYPTION_ACTIVE_KEY_ID", ORIGINAL_KEY_ENV.active],
] as const) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
_resetCryptoCacheForTests();
}
/** Every row the account holds, model by model, normalised. */
async function snapshotAccount(
prisma: PrismaClient,
userId: string,
): Promise<Record<string, unknown>> {
const n = new Normaliser();
const models = auditModels(prisma);
const byName = new Map(models.map((m) => [m.name, m]));
const out: Record<string, unknown> = {};
for (const model of models) {
const rows = await delegateOf(prisma, model).findMany({
where: ownerWhere(byName, prisma, model.name, userId),
});
out[model.name] = sortRows(rows).map((row) => n.db(row));
}
for (const name of SNAPSHOT_EXTRA_MODELS) {
const rows = await prisma.accountGrant.findMany({
where: { OR: [{ grantorId: userId }, { granteeId: userId }] },
});
out[name] = sortRows(rows as Record<string, unknown>[]).map((row) =>
n.db(row),
);
}
out.User = n.db(await prisma.user.findUnique({ where: { id: userId } }));
out.AppSettings = n.db(
await prisma.appSettings.findUnique({ where: { id: "singleton" } }),
);
return out;
}
async function exportFile(
prisma: PrismaClient,
purpose: "disaster-recovery" | "portable-export",
) {
const built = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose,
exportedAt: EXPORTED_AT,
});
let json = "";
const preview = await createBackupPreviewCollector();
const counts = await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
json += chunk;
},
{ purpose, exportedAt: EXPORTED_AT, observe: preview.observe },
);
// The two writers must write the same bytes; everything below is then
// about both.
expect(json).toBe(JSON.stringify(built.payload));
return {
json,
builtCounts: built.counts,
streamedCounts: counts,
preview: preview.finish(counts),
};
}
function normalisedExport(file: Awaited<ReturnType<typeof exportFile>>) {
const n = new Normaliser();
const payload = JSON.parse(file.json);
const parsed = parseBackupPayload(payload);
return {
payload: n.json(payload),
builtCounts: file.builtCounts,
streamedCounts: file.streamedCounts,
parsed: n.json(JSON.parse(JSON.stringify(parsed))),
summary: summarizeBackup(parsed),
preview: n.json(JSON.parse(JSON.stringify(file.preview))),
};
}
async function storeCopy(
prisma: PrismaClient,
userId: string,
type: string,
json: string,
) {
const row = await prisma.dataBackup.create({
data: {
userId,
type,
data: await legacyStreamedBlobFrom(async (write) => {
await write(json);
}),
},
select: STORED_BACKUP_SELECT,
});
return row;
}
interface RestoreRun {
outcome: unknown;
progress: unknown[];
audit: unknown[];
effects: unknown;
}
/** Restore one stored copy and record everything it reported. */
async function runRestore(
prisma: PrismaClient,
backup: Awaited<ReturnType<typeof storeCopy>>,
actorUserId: string,
restoreInstanceSettings: boolean,
): Promise<{ run: RestoreRun; raw: RestoreBackupOutcome }> {
effects.invalidated.length = 0;
effects.unitRefresh.length = 0;
effects.foldRepair.length = 0;
effects.annotations.length = 0;
const progress: Array<Record<string, unknown>> = [];
const raw = await restoreBackup({
backup,
actorUserId,
ipAddress: null,
restoreInstanceSettings,
progress: (phase, p) => {
const entry = {
phase,
sectionsDone: p.sectionsDone,
sectionsTotal: p.sectionsTotal,
measurementsTotal: p.measurementsTotal,
};
const last = progress[progress.length - 1];
if (!last || JSON.stringify(last) !== JSON.stringify(entry)) {
progress.push(entry);
}
},
});
const audit = await prisma.auditLog.findMany({
// By the copy's id rather than by time: the database clock and this
// process's clock do not agree to the millisecond.
where: {
action: { startsWith: "admin." },
details: { contains: backup.id },
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { action: true, userId: true, details: true },
});
const n = new Normaliser();
return {
raw,
run: {
outcome: n.json(JSON.parse(JSON.stringify(raw))),
progress,
audit: audit.map((row) => ({
action: row.action,
userId: row.userId === actorUserId ? "<actor>" : row.userId,
details: n.json(row.details ? JSON.parse(row.details) : null),
})),
effects: n.json({
invalidated: [...effects.invalidated],
unitRefresh: [...effects.unitRefresh],
foldRepair: [...effects.foldRepair],
annotations: JSON.parse(JSON.stringify(effects.annotations)),
}),
},
};
}
async function recreateOwner(prisma: PrismaClient) {
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
await prisma.accountGrant.create({
data: {
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access: "READ",
acceptedAt: new Date("2026-07-03T09:00:00.000Z"),
},
});
}
// ── The cases ────────────────────────────────────────────────────────────────
// One account through the numbered steps above, in order: each restore and
// export reads what the step before it left. The order holds under a
// shuffled run.
describe("backup parity", { shuffle: false }, () => {
const prisma = getPrismaClient();
let adminId = "";
let drJson = "";
let portableJson = "";
let drRestoredSnapshot: Record<string, unknown> = {};
beforeAll(async () => {
await truncateAllTables(prisma);
}, SLOW);
afterAll(async () => {
restoreKeyEnv();
await truncateAllTables(prisma);
}, SLOW);
it(
"exports the maximal account as it did",
async () => {
const { admin, unfillable, unpinned } = await seedMaximalAccount(prisma);
adminId = admin.id;
const before = await snapshotAccount(prisma, OWNER_ID);
// The seeded account holds no timestamp of this run, so every `<now>`
// further down is one a restore or an export wrote.
expect(unpinned).toEqual([]);
expect(JSON.stringify(before)).not.toContain("<now>");
const dr = await exportFile(prisma, "disaster-recovery");
const portable = await exportFile(prisma, "portable-export");
drJson = dr.json;
portableJson = portable.json;
const drNormalised = normalisedExport(dr);
// A near-empty file would agree with a near-empty golden copy.
expect(leafCount(drNormalised.payload)).toBeGreaterThan(1200);
expect(
Object.values(before).filter(
(rows) => Array.isArray(rows) && rows.length > 0,
).length,
).toBeGreaterThanOrEqual(TWO_ENDED_MODELS.length);
matchGolden("seeded-account", { unfillable, snapshot: before });
matchGolden("export-disaster-recovery", drNormalised);
matchGolden("export-portable", normalisedExport(portable));
},
SLOW,
);
it(
"restores the disaster-recovery file into a fresh account as it did",
async () => {
expect(drJson).not.toBe("");
await recreateOwner(prisma);
const backup = await storeCopy(prisma, OWNER_ID, "PARITY_DR", drJson);
const { run, raw } = await runRestore(prisma, backup, adminId, true);
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
drRestoredSnapshot = await snapshotAccount(prisma, OWNER_ID);
const reexport = await exportFile(prisma, "disaster-recovery");
matchGolden("restore-disaster-recovery", {
...run,
snapshot: drRestoredSnapshot,
reexport: new Normaliser().json(JSON.parse(reexport.json)),
});
},
SLOW,
);
it(
"restores the disaster-recovery file over the live account as it did",
async () => {
expect(drJson).not.toBe("");
const backup = await storeCopy(
prisma,
OWNER_ID,
"PARITY_DR_LIVE",
drJson,
);
const { run, raw } = await runRestore(prisma, backup, adminId, false);
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
const snapshot = await snapshotAccount(prisma, OWNER_ID);
matchGolden("restore-disaster-recovery-over-live", {
...run,
snapshotChanges: differences(drRestoredSnapshot, snapshot),
});
},
SLOW,
);
it(
"restores the portable file into a fresh account as it did",
async () => {
expect(portableJson).not.toBe("");
await recreateOwner(prisma);
// A portable file carries document metadata only, which the restore
// refuses to turn into documents; the file of an account without any
// is the portable file a restore takes.
const backup = await storeCopy(
prisma,
OWNER_ID,
"PARITY_PORTABLE",
JSON.stringify({ ...JSON.parse(portableJson), documents: [] }),
);
const { run, raw } = await runRestore(prisma, backup, adminId, false);
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
const snapshot = await snapshotAccount(prisma, OWNER_ID);
const reexport = await exportFile(prisma, "portable-export");
matchGolden("restore-portable", {
...run,
snapshot,
reexport: new Normaliser().json(JSON.parse(reexport.json)),
});
},
SLOW,
);
it(
"restores a version-1 file in the old shapes over a live account as it did",
async () => {
await recreateOwner(prisma);
await seedLiveRowsForLegacyRestore(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "PARITY_V1",
data: encrypt(JSON.stringify(legacyVersionOneFile())),
},
select: STORED_BACKUP_SELECT,
});
const { run, raw } = await runRestore(prisma, backup, adminId, true);
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
const snapshot = await snapshotAccount(prisma, OWNER_ID);
const reexport = await exportFile(prisma, "disaster-recovery");
matchGolden("restore-version-1", {
...run,
snapshot,
reexport: new Normaliser().json(JSON.parse(reexport.json)),
});
},
SLOW,
);
it(
"names what it skips in a file it can only partly place, as it did",
async () => {
await recreateOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "PARITY_SKIPS",
data: encrypt(JSON.stringify(partlyPlaceableFile())),
},
select: STORED_BACKUP_SELECT,
});
const { run, raw } = await runRestore(prisma, backup, adminId, false);
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
const snapshot = await snapshotAccount(prisma, OWNER_ID);
matchGolden("restore-skips", { ...run, snapshot });
},
SLOW,
);
it(
"refuses each malformed or unplaceable file as it did",
async () => {
await recreateOwner(prisma);
await prisma.measurement.create({
data: {
userId: OWNER_ID,
type: "WEIGHT",
value: 70,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-07-05T07:00:00.000Z"),
},
});
const before = await snapshotAccount(prisma, OWNER_ID);
const results: Record<string, unknown> = {};
for (const [name, body] of Object.entries(refusedFiles())) {
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: `PARITY_REFUSE_${name}`,
data:
typeof body === "string" ? body : encrypt(JSON.stringify(body)),
},
select: STORED_BACKUP_SELECT,
});
const { run, raw } = await runRestore(prisma, backup, adminId, false);
expect(raw.ok, `${name} was restored`).toBe(false);
results[name] = run;
}
// Every refusal leaves the account exactly as it was.
expect(
differences(before, await snapshotAccount(prisma, OWNER_ID)),
).toEqual([]);
matchGolden("refusals", results);
},
SLOW,
);
it("reports the schema's issues for a malformed file as it did", () => {
const issues: Record<string, unknown> = {};
for (const [name, body] of Object.entries(schemaInvalidFiles())) {
try {
parseBackupPayload(body);
issues[name] = "parsed";
} catch (error) {
issues[name] = JSON.parse(
JSON.stringify((error as { issues?: unknown }).issues ?? null),
);
}
}
expect(Object.values(issues)).not.toContain("parsed");
matchGolden("schema-issues", new Normaliser().json(issues));
});
});
// ── Hand-built files ─────────────────────────────────────────────────────────
/** Rows the version-1 file meets: a reading it reconciles by natural key. */
async function seedLiveRowsForLegacyRestore(prisma: PrismaClient) {
await prisma.measurement.create({
data: {
userId: OWNER_ID,
type: "WEIGHT",
value: 99,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-07-01T07:00:00.000Z"),
},
});
await prisma.moodEntry.create({
data: {
userId: OWNER_ID,
date: "2026-07-02",
mood: "BAD",
score: 2,
moodLoggedAt: new Date("2026-07-02T20:00:00.000Z"),
},
});
}
function legacyVersionOneFile(): Record<string, unknown> {
return {
schemaVersion: "1",
exportedAt: "2026-07-20T08:00:00.000Z",
userId: OWNER_ID,
appSettings: {
id: "singleton",
registrationEnabled: false,
mfaRequired: true,
defaultLocale: "de",
telegramGlobal: true,
ntfyGlobal: false,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidPrivateKeyEncrypted: null,
webPushVapidSubject: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
reminderLateMinutes: 30,
reminderMissedMinutes: 120,
adminAiKeyEncrypted: null,
adminAiModel: "gpt-legacy",
adminAiBaseUrl: "https://api.example.com/v1",
adminCodexAccessTokenEncrypted: null,
adminCodexRefreshTokenEncrypted: null,
adminCodexAccountIdEncrypted: null,
adminCodexTokenExpiresAt: null,
adminCodexConnectedAt: null,
adminCodexConnectionStatus: "disconnected",
adminAiInsightsFeedbackSummary: null,
defaultUserTimezone: "Europe/Berlin",
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
moduleAvailabilityJson: { coach: "off", mood: "on" },
documentMaxFileBytes: 10_000_000,
documentQuotaBytes: "500000000",
},
measurements: [
// Reconciled onto the live reading of the same type, source and instant.
{
type: "WEIGHT",
value: 74.5,
unit: "kg",
measuredAt: "2026-07-01T07:00:00.000Z",
source: "MANUAL",
notes: "after the long ride",
},
{
type: "BLOOD_PRESSURE_SYS",
value: 128,
unit: "mmHg",
measuredAt: "2026-07-01T07:05:00.000Z",
},
// The retired filing: Fitbit RMSSD written as the SDNN series.
{
type: "HEART_RATE_VARIABILITY",
value: 41,
unit: "ms",
measuredAt: "2026-07-02T04:00:00.000Z",
source: "FITBIT",
externalId: "fitbit-hrv-2026-07-02",
},
{
type: "HEART_RATE_VARIABILITY",
value: 39,
unit: "ms",
measuredAt: "2026-07-03T04:00:00.000Z",
source: "FITBIT",
externalId: "fitbit-hrv-2026-07-03",
},
{
type: "HRV_RMSSD",
value: 40,
unit: "ms",
measuredAt: "2026-07-03T04:00:00.000Z",
source: "FITBIT",
externalId: "fitbit-hrv-2026-07-03",
},
],
medications: [
{
name: "Ramipril",
dose: "5 mg",
active: true,
schedules: [{ windowStart: "07:00", windowEnd: "09:00", label: null }],
},
{
name: "Vitamin D",
dose: "1000 IU",
endsOn: "2026-08-01T00:00:00.000Z",
schedules: [],
},
],
intakeEvents: [
{
medication: "Ramipril",
scheduledFor: "2026-07-01T07:00:00.000Z",
takenAt: "2026-07-01T07:10:00.000Z",
skipped: false,
},
{
medication: "Vitamin D",
scheduledFor: "2026-07-01T08:00:00.000Z",
takenAt: null,
skipped: true,
},
],
moodEntries: [
// Upserted onto the live entry of the same day and instant.
{
date: "2026-07-02",
mood: "GOOD",
score: 4,
tags: null,
note: "slept well",
loggedAt: "2026-07-02T20:00:00.000Z",
factors: [],
},
{
date: "2026-07-03",
mood: "OKAY",
score: 3,
tags: null,
loggedAt: "2026-07-03T20:00:00.000Z",
source: "MOODLOG",
externalId: "moodlog-2026-07-03",
factors: [],
},
],
biomarkers: [
{
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 400,
panel: "Iron",
hidden: false,
context: "fasted",
},
],
labResults: [
{
panel: "Iron",
analyte: "Ferritin",
value: 91,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
takenAt: "2026-07-05T08:00:00.000Z",
source: "MANUAL",
biomarkerName: "Ferritin",
note: "morning draw",
},
],
illnessEpisodes: [
{
id: "legacy-episode-parent",
label: "Hypertension",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "2025-01-01T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: "since last year",
dayLogs: [],
},
{
id: "legacy-episode-child",
label: "Headache",
type: "OTHER",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
resolvedAt: "2026-07-02T00:00:00.000Z",
parentConditionId: "legacy-episode-parent",
note: null,
bodySite: "forehead",
dayLogs: [
{
date: "2026-07-01",
functionalImpact: 2,
feverC: null,
note: "afternoon",
symptoms: [],
},
],
},
],
allergies: [
{
id: "legacy-allergy",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: null,
reaction: "rash",
note: "childhood",
},
],
familyHistory: [
{
id: "legacy-family",
relationship: "FATHER",
condition: "Diabetes",
ageAtOnset: 55,
note: "type 2",
},
],
workouts: [
{
sportType: "RUNNING",
startedAt: "2026-07-04T06:00:00.000Z",
endedAt: "2026-07-04T06:40:00.000Z",
durationSec: 2400,
totalEnergyKcal: 410,
totalDistanceM: 6500,
avgHeartRate: 148,
maxHeartRate: 171,
minHeartRate: 98,
stepCount: 6200,
elevationM: 40,
pauseDurationSec: null,
source: "MANUAL",
externalId: null,
},
],
nutrientDays: [
{
day: "2026-07-04",
nutrient: "WATER",
amount: 2100,
unit: "mL",
source: "MANUAL",
},
],
};
}
/** A current-format file whose references this instance can only partly place. */
function partlyPlaceableFile(): Record<string, unknown> {
return {
schemaVersion: "2",
exportedAt: "2026-07-20T08:00:00.000Z",
userId: OWNER_ID,
accountSettings: {
locale: "xx-unknown",
timezone: "Not/AZone",
unitPreference: "metric",
thresholdsJson: {
WEIGHT: { min: 60, max: 80 },
NOT_A_METRIC: { min: 1, max: 2 },
},
globalExcludedInjectionSites: ["THIGH_LEFT", "NOWHERE"],
aiProvider: "OPENAI",
aiBaseUrl: "https://llm.example.com/v1",
},
measurements: [
{
id: "skips-measurement",
type: "WEIGHT",
value: 72,
unit: "kg",
measuredAt: "2026-07-01T07:00:00.000Z",
source: "MANUAL",
},
],
medications: [
{
id: "skips-medication",
name: "Metformin",
dose: "500 mg",
schedules: [],
scheduleRevisions: [
{
validFrom: "2026-06-01T00:00:00.000Z",
validUntil: "2026-06-15T00:00:00.000Z",
payload: { schedules: [] },
supersededByIndex: 7,
},
{
validFrom: "2026-06-15T00:00:00.000Z",
validUntil: "2026-07-01T00:00:00.000Z",
payload: { schedules: [] },
supersededByIndex: 1,
},
],
efficacyTargets: [
{ biomarkerName: "HbA1c that is not in the file", primary: true },
{ measurementType: "BLOOD_GLUCOSE", primary: false },
],
},
],
hiddenMoodTags: [{ key: "a_tag_nobody_has" }],
customMoodTagCategories: [
{
id: "skips-mood-category",
key: "custom:skips-mood-category",
labelKey: "custom",
sortOrder: 0,
isActive: true,
label: "Evenings",
},
],
customMoodTags: [
{
key: "custom:skips-mood-tag",
labelKey: "custom",
categoryId: "skips-mood-category",
kind: "RATED",
isActive: true,
sortOrder: 0,
label: "Quiet",
scaleMin: 1,
scaleMax: 5,
inverse: false,
},
],
moodEntries: [
{
id: "skips-mood",
date: "2026-07-02",
mood: "GOOD",
score: 4,
tags: null,
loggedAt: "2026-07-02T20:00:00.000Z",
factors: [{ key: "a_factor_nobody_has", rating: 3 }],
structuredTags: ["a_binary_tag_nobody_has"],
},
],
illnessEpisodes: [
{
id: "skips-episode",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: null,
dayLogs: [
{
date: "2026-07-01",
functionalImpact: 1,
feverC: 38.1,
note: null,
symptoms: [{ key: "a_symptom_nobody_has", severity: 2 }],
},
],
},
],
};
}
/** Files every one of which the restore refuses before or by rolling back. */
function refusedFiles(): Record<string, unknown> {
const base = (extra: Record<string, unknown>) => ({
schemaVersion: "2",
exportedAt: "2026-07-20T08:00:00.000Z",
userId: OWNER_ID,
...extra,
});
return {
undecryptable: "v1.bm90IGEgYmFja3VwIGF0IGFsbA==",
schemaInvalid: base({
measurements: [
{
id: "bad-measurement",
type: "NOT_A_TYPE",
value: "high",
unit: "",
measuredAt: "yesterday",
},
],
}),
stableIdMissing: base({
measurements: [
{
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: "2026-07-01T07:00:00.000Z",
},
],
}),
qualitativeMarkerWithNumbers: base({
biomarkers: [
{ name: "Urine colour", unit: "", lowerBound: null, upperBound: null },
],
labResults: [
{
analyte: "Urine colour",
value: 3,
unit: "",
takenAt: "2026-07-01T07:00:00.000Z",
source: "MANUAL",
biomarkerName: "Urine colour",
},
],
}),
incompatibleVersion: { ...base({}), schemaVersion: "99" },
ownerMismatch: { ...base({}), userId: "someone-else" },
sectionMissing: base({
manifest: {
documents: { included: "encrypted-content", note: "carried" },
workouts: { included: "summary-only", note: "summaries" },
},
workouts: [],
}),
metadataOnlyDocument: base({
documents: [
{
id: "metadata-only-document",
kind: "OTHER",
title: null,
filename: "scan.pdf",
mimeType: "application/pdf",
byteSize: 12,
status: "STORED",
reportDate: null,
documentDate: null,
summary: null,
createdAt: "2026-07-03T08:00:00.000Z",
},
],
}),
keyMissing: base({
measurements: [
{
id: "sealed-under-a-key-this-host-lacks",
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: "2026-07-01T07:00:00.000Z",
notesEncrypted: Buffer.from(
`v9.${Buffer.from("x".repeat(48)).toString("base64")}`,
).toString("base64"),
},
],
}),
intakeForUnknownMedication: base({
intakeEvents: [
{
id: "orphan-intake",
medication: "a medication this file never carried",
scheduledFor: "2026-07-04T08:00:00.000Z",
takenAt: "2026-07-04T08:05:00.000Z",
},
],
}),
labForUnknownMarker: base({
labResults: [
{
id: "orphan-lab",
analyte: "Ferritin",
value: 91,
unit: "ng/mL",
takenAt: "2026-07-01T07:00:00.000Z",
source: "MANUAL",
biomarkerId: "a-marker-the-file-never-carried",
biomarkerName: "Ferritin",
},
],
}),
illnessWithUnknownParent: base({
illnessEpisodes: [
{
id: "orphan-episode",
label: "Cough",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
resolvedAt: null,
parentConditionId: "an-episode-the-file-never-carried",
note: null,
dayLogs: [],
},
],
}),
};
}
/** Files the schema refuses, for the issue list an upload is answered with. */
function schemaInvalidFiles(): Record<string, unknown> {
return {
everySectionWrong: {
schemaVersion: "2",
exportedAt: "not a date",
userId: "",
measurements: [{ type: "WEIGHT" }],
medications: [{ name: 3, schedules: "none" }],
moodEntries: [{ date: "2026-07-01", mood: "GOOD", score: "four" }],
labResults: [{ analyte: null }],
documents: [{ id: "d" }],
accountSettings: { heightCm: "tall", hasDiabetes: "yes" },
coachConversations: [{ id: "c", messages: [{ role: 1 }] }],
environmentContexts: [{ day: "July" }],
ecgRecordings: [{ id: "e" }],
vaccinations: [{ id: "v" }],
},
};
}