test(backup): pin seeded timestamps, tombstones, self-references and key ids

The parity harness normalised any timestamp written during the run to
<now>, and the seeded rows' createdAt and updatedAt were themselves written
during the run, so a restore that dropped a row's createdAt still matched
its golden copy. Every run-time timestamp of the seeded account is now
pinned to a fixed historical value, and the seeded snapshot is asserted to
hold none, so a lost createdAt shows as <now> against a historical value.

The account also carries a tombstone of every model that keeps them, a
flare under its condition and a superseded profile fact, so the second
restore pass resolves real pointers. The fixture is sealed under the
legacy key and a second key is active from the export on; sealed values
render as the key id they name and the text they open to, so a value the
restore re-encrypts instead of carrying over differs.

The golden copies are written from the backup code as it stood before the
export and restore moved into per-domain modules (64fa65b46), and the
current code matches them.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 18:55:56 +02:00
1 parent 6ab1f2d8a2
commit faedcbba93
9 files changed
+5078 -2674

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -9,47 +9,47 @@
"exportedAt": "2026-08-01T00:00:00.000Z",
"measurements": 5,
"medications": 1,
"intakeEvents": 1,
"intakeEvents": 2,
"medicationSideEffects": 1,
"medicationEfficacyTargets": 1,
"medicationScheduleRevisions": 3,
"moodEntries": 1,
"cycles": 2,
"cycleDayLogs": 1,
"moodEntries": 2,
"cycles": 3,
"cycleDayLogs": 2,
"nutrientDays": 1,
"labResults": 1,
"labResults": 2,
"biomarkers": 1,
"illnessEpisodes": 2,
"illnessDayLogs": 1,
"allergies": 1,
"illnessEpisodes": 4,
"illnessDayLogs": 2,
"allergies": 2,
"familyHistory": 1,
"workouts": 1,
"documents": 1,
"documentConditionLinks": 1,
"extractedFacts": 1,
"healthProfile": 1,
"healthProfileFactRevisions": 1,
"customMetrics": 1,
"customMetricEntries": 2,
"healthProfileFactRevisions": 2,
"customMetrics": 2,
"customMetricEntries": 3,
"correlationPatterns": 1,
"intradayProfiles": 1,
"healthScoreRecords": 1,
"onboardingRecords": 1,
"practitioners": 1,
"encounters": 1,
"practitioners": 2,
"encounters": 2,
"encounterLinks": 3,
"vaccinations": 1,
"vaccinations": 2,
"vaccinationLinks": 1,
"relationLinks": 5,
"customVaccines": 1,
"measurementReminders": 1,
"customVaccines": 2,
"measurementReminders": 2,
"measurementReminderEvents": 2,
"coachConversations": 2,
"coachMessages": 3,
"coachFacts": 1,
"coachPlans": 1,
"coachReminders": 1,
"mentalHealthAssessments": 1,
"coachFacts": 2,
"coachPlans": 2,
"coachReminders": 2,
"mentalHealthAssessments": 2,
"consentReceipts": 2,
"personalRecords": 3,
"userAchievements": 2,
@@ -65,47 +65,47 @@
"cleared": {
"measurements": 5,
"medications": 1,
"intakeEvents": 1,
"moodEntries": 1,
"intakeEvents": 2,
"moodEntries": 2,
"notificationChannels": 0,
"pushSubscriptions": 0,
"telegramScheduledDeletions": 0,
"cycles": 2,
"cycleDayLogs": 1,
"cycles": 3,
"cycleDayLogs": 2,
"cycleProfile": 1,
"labResults": 1,
"labResults": 2,
"biomarkers": 1,
"nutrientDays": 1,
"illnessEpisodes": 2,
"allergies": 1,
"illnessEpisodes": 4,
"allergies": 2,
"familyHistory": 1,
"workouts": 1,
"documents": 1,
"documentConditionLinks": 0,
"extractedFacts": 0,
"healthProfile": 1,
"healthProfileFactRevisions": 1,
"customMetrics": 1,
"healthProfileFactRevisions": 2,
"customMetrics": 2,
"correlationPatterns": 1,
"intradayProfiles": 1,
"healthScoreRecords": 1,
"onboardingRecords": 1,
"practitioners": 1,
"encounters": 1,
"practitioners": 2,
"encounters": 2,
"encounterLinks": 0,
"vaccinations": 1,
"vaccinations": 2,
"vaccinationLinks": 0,
"relationLinks": 0,
"customVaccines": 1,
"measurementReminders": 1,
"customVaccines": 2,
"measurementReminders": 2,
"measurementReminderEvents": 2,
"coachConversations": 2,
"coachMessages": 3,
"coachConversationDocuments": 0,
"coachFacts": 1,
"coachPlans": 1,
"coachReminders": 1,
"mentalHealthAssessments": 1,
"coachFacts": 2,
"coachPlans": 2,
"coachReminders": 2,
"mentalHealthAssessments": 2,
"consentReceipts": 2,
"personalRecords": 3,
"userAchievements": 2,
@@ -114,7 +114,7 @@
"dawarichDays": 1,
"ecgRecordings": 1,
"symptomDefinitions": 2,
"lifeEvents": 2
"lifeEvents": 3
}
}
},
@@ -281,47 +281,47 @@
"cleared": {
"measurements": 5,
"medications": 1,
"intakeEvents": 1,
"moodEntries": 1,
"intakeEvents": 2,
"moodEntries": 2,
"notificationChannels": 0,
"pushSubscriptions": 0,
"telegramScheduledDeletions": 0,
"cycles": 2,
"cycleDayLogs": 1,
"cycles": 3,
"cycleDayLogs": 2,
"cycleProfile": 1,
"labResults": 1,
"labResults": 2,
"biomarkers": 1,
"nutrientDays": 1,
"illnessEpisodes": 2,
"allergies": 1,
"illnessEpisodes": 4,
"allergies": 2,
"familyHistory": 1,
"workouts": 1,
"documents": 1,
"documentConditionLinks": 0,
"extractedFacts": 0,
"healthProfile": 1,
"healthProfileFactRevisions": 1,
"customMetrics": 1,
"healthProfileFactRevisions": 2,
"customMetrics": 2,
"correlationPatterns": 1,
"intradayProfiles": 1,
"healthScoreRecords": 1,
"onboardingRecords": 1,
"practitioners": 1,
"encounters": 1,
"practitioners": 2,
"encounters": 2,
"encounterLinks": 0,
"vaccinations": 1,
"vaccinations": 2,
"vaccinationLinks": 0,
"relationLinks": 0,
"customVaccines": 1,
"measurementReminders": 1,
"customVaccines": 2,
"measurementReminders": 2,
"measurementReminderEvents": 2,
"coachConversations": 2,
"coachMessages": 3,
"coachConversationDocuments": 0,
"coachFacts": 1,
"coachPlans": 1,
"coachReminders": 1,
"mentalHealthAssessments": 1,
"coachFacts": 2,
"coachPlans": 2,
"coachReminders": 2,
"mentalHealthAssessments": 2,
"consentReceipts": 2,
"personalRecords": 3,
"userAchievements": 2,
@@ -330,7 +330,7 @@
"dawarichDays": 1,
"ecgRecordings": 1,
"symptomDefinitions": 2,
"lifeEvents": 2
"lifeEvents": 3
},
"accountSettingsApplied": 62,
"skipped": {
@@ -340,32 +340,32 @@
"restored": {
"measurements": 5,
"medications": 1,
"intakeEvents": 1,
"moodEntries": 1,
"cycles": 2,
"cycleDayLogs": 1,
"labResults": 1,
"intakeEvents": 2,
"moodEntries": 2,
"cycles": 3,
"cycleDayLogs": 2,
"labResults": 2,
"biomarkers": 1,
"nutrientDays": 1,
"illnessEpisodes": 2,
"illnessDayLogs": 1,
"allergies": 1,
"illnessEpisodes": 4,
"illnessDayLogs": 2,
"allergies": 2,
"familyHistory": 1,
"workouts": 1,
"documents": 1,
"healthProfile": 1,
"healthProfileFactRevisions": 1,
"customMetrics": 1,
"customMetricEntries": 2,
"healthProfileFactRevisions": 2,
"customMetrics": 2,
"customMetricEntries": 3,
"correlationPatterns": 1,
"practitioners": 1,
"encounters": 1,
"practitioners": 2,
"encounters": 2,
"encounterLinks": 3,
"vaccinations": 1,
"vaccinations": 2,
"vaccinationLinks": 1,
"relationLinks": 5,
"customVaccines": 1,
"measurementReminders": 1,
"customVaccines": 2,
"measurementReminders": 2,
"measurementReminderEvents": 2
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -641,7 +641,7 @@
"scaleMax": 5,
"inverse": false,
"userId": "round-trip-owner",
"labelEncrypted": "<sealed:Quiet>"
"labelEncrypted": "<sealed:v2:Quiet>"
}
],
"LabResult": [],
@@ -726,7 +726,7 @@
"sortOrder": 0,
"isActive": true,
"userId": "round-trip-owner",
"labelEncrypted": "<sealed:Evenings>"
"labelEncrypted": "<sealed:v2:Evenings>"
}
],
"MoodTagHidden": [],
@@ -487,7 +487,7 @@
"source": "MANUAL",
"measuredAt": "2026-07-01T07:00:00.000Z",
"notes": null,
"notesEncrypted": "<sealed:after the long ride>",
"notesEncrypted": "<sealed:v2:after the long ride>",
"externalId": null,
"externalSourceVersion": null,
"aggregationProvenance": null,
@@ -668,7 +668,7 @@
"stabilityA5": null,
"tags": null,
"note": null,
"noteEncrypted": "<sealed:slept well>",
"noteEncrypted": "<sealed:v2:slept well>",
"source": "MOODLOG",
"externalId": null,
"moodLoggedAt": "2026-07-02T20:00:00.000Z",
@@ -726,7 +726,7 @@
"takenAt": "2026-07-05T08:00:00.000Z",
"biomarkerId": "<id:14>",
"source": "MANUAL",
"noteEncrypted": "<sealed:morning draw>",
"noteEncrypted": "<sealed:v2:morning draw>",
"createdAt": "<now>",
"updatedAt": "<now>",
"deletedAt": null
@@ -740,7 +740,7 @@
"unit": "ng/mL",
"lowerBound": 30,
"upperBound": 400,
"contextEncrypted": "<sealed:fasted>",
"contextEncrypted": "<sealed:v2:fasted>",
"panel": "Iron",
"hidden": false,
"analyteKey": null,
@@ -758,8 +758,8 @@
"severity": "SEVERE",
"status": "ACTIVE",
"onsetAt": null,
"reactionEncrypted": "<sealed:rash>",
"notesEncrypted": "<sealed:childhood>",
"reactionEncrypted": "<sealed:v2:rash>",
"notesEncrypted": "<sealed:v2:childhood>",
"createdAt": "<now>",
"updatedAt": "<now>",
"deletedAt": null
@@ -772,7 +772,7 @@
"relationship": "FATHER",
"condition": "Diabetes",
"ageAtOnset": 55,
"notesEncrypted": "<sealed:type 2>",
"notesEncrypted": "<sealed:v2:type 2>",
"createdAt": "<now>",
"updatedAt": "<now>",
"deletedAt": null
@@ -789,7 +789,7 @@
"resolvedAt": "2026-07-02T00:00:00.000Z",
"parentConditionId": "legacy-episode-parent",
"noteEncrypted": null,
"bodySiteEncrypted": "<sealed:forehead>",
"bodySiteEncrypted": "<sealed:v2:forehead>",
"laterality": null,
"createdAt": "<now>",
"updatedAt": "<now>",
@@ -804,7 +804,7 @@
"onsetAt": "2025-01-01T00:00:00.000Z",
"resolvedAt": null,
"parentConditionId": null,
"noteEncrypted": "<sealed:since last year>",
"noteEncrypted": "<sealed:v2:since last year>",
"bodySiteEncrypted": null,
"laterality": null,
"createdAt": "<now>",
@@ -820,7 +820,7 @@
"date": "2026-07-01",
"functionalImpact": 2,
"feverC": null,
"noteEncrypted": "<sealed:afternoon>",
"noteEncrypted": "<sealed:v2:afternoon>",
"tz": null,
"createdAt": "<now>",
"updatedAt": "<now>",
@@ -1350,7 +1350,7 @@
"measuredAt": "2026-07-01T07:00:00.000Z",
"source": "MANUAL",
"notes": null,
"notesEncrypted": "<sealed:after the long ride>",
"notesEncrypted": "<sealed:v2:after the long ride>",
"externalId": null,
"externalSourceVersion": null,
"aggregationProvenance": null,
@@ -1555,7 +1555,7 @@
},
{
"note": null,
"noteEncrypted": "<sealed:slept well>",
"noteEncrypted": "<sealed:v2:slept well>",
"syncedAt": "<now>",
"syncVersion": 0,
"createdAt": "<now>",
@@ -1591,7 +1591,7 @@
{
"id": "<id:12>",
"biomarkerId": "<id:13>",
"noteEncrypted": "<sealed:morning draw>",
"noteEncrypted": "<sealed:v2:morning draw>",
"createdAt": "<now>",
"updatedAt": "<now>",
"deletedAt": null,
@@ -1642,7 +1642,7 @@
"note": null,
"noteEncrypted": null,
"bodySite": null,
"bodySiteEncrypted": "<sealed:forehead>",
"bodySiteEncrypted": "<sealed:v2:forehead>",
"laterality": null,
"createdAt": "<now>",
"updatedAt": "<now>",
@@ -1655,7 +1655,7 @@
"functionalImpact": 2,
"feverC": null,
"note": null,
"noteEncrypted": "<sealed:afternoon>",
"noteEncrypted": "<sealed:v2:afternoon>",
"tz": null,
"createdAt": "<now>",
"updatedAt": "<now>",
@@ -1673,7 +1673,7 @@
"resolvedAt": null,
"parentConditionId": null,
"note": null,
"noteEncrypted": "<sealed:since last year>",
"noteEncrypted": "<sealed:v2:since last year>",
"bodySite": null,
"bodySiteEncrypted": null,
"laterality": null,
@@ -1694,8 +1694,8 @@
"onsetAt": null,
"reaction": null,
"note": null,
"reactionEncrypted": "<sealed:rash>",
"notesEncrypted": "<sealed:childhood>",
"reactionEncrypted": "<sealed:v2:rash>",
"notesEncrypted": "<sealed:v2:childhood>",
"createdAt": "<now>",
"updatedAt": "<now>",
"deletedAt": null
File diff suppressed because it is too large. Load diff
+436 -11
View File
@@ -22,15 +22,29 @@
* 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 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.
* 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.
* `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";
@@ -39,8 +53,16 @@ import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { decrypt, decryptBytes, encrypt } from "@/lib/crypto";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
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";
@@ -68,6 +90,7 @@ import {
syntheticValue,
} from "./backup-column-audit";
import {
AT,
COLUMN_PRESENCE_ONLY,
FILL_OVERRIDES,
OWNER_ID,
@@ -187,6 +210,34 @@ function openBytes(value: Uint8Array): string | null {
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);
@@ -234,11 +285,18 @@ class Normaliser {
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:${value.length}>`
: `<sealed:${this.text(opened)}>`;
? `<sealed:${kid}:${value.length}>`
: `<sealed:${kid}:${this.text(opened)}>`;
}
/** A JSON value, key order kept. */
@@ -267,7 +325,7 @@ class Normaliser {
const opened = openBytes(value);
return opened === null
? `<bytes:${Buffer.from(value).toString("base64")}>`
: `<sealed:${this.text(opened)}>`;
: `<sealed:${bytesKeyId(value)}:${this.text(opened)}>`;
}
if (typeof value === "bigint") return `bigint:${value}`;
if (
@@ -515,7 +573,369 @@ async function seedMaximalAccount(prisma: PrismaClient) {
acceptedAt: new Date("2026-07-02T09:00:00.000Z"),
},
});
return { admin, unfillable: unfillable.sort() };
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. */
@@ -705,15 +1125,20 @@ describe("backup parity", () => {
}, SLOW);
afterAll(async () => {
restoreKeyEnv();
await truncateAllTables(prisma);
}, SLOW);
it(
"exports the maximal account as it did",
async () => {
const { admin, unfillable } = await seedMaximalAccount(prisma);
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;