Files
HealthLog/tests/integration/admin-backups-canonical-roundtrip.test.ts
T
Marc-André Bombeck 210becb28b feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
2026-10-06 18:39:50 +02:00

1391 lines
45 KiB
TypeScript

import { Buffer } from "node:buffer";
import type { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const OFFHOST_ENCRYPTION_KEY =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
process.env.BACKUP_S3_ENDPOINT = "https://s3.example.test";
process.env.BACKUP_S3_BUCKET = "healthlog-test";
process.env.BACKUP_S3_ACCESS_KEY = "test-access";
process.env.BACKUP_S3_SECRET_KEY = "test-secret";
process.env.BACKUP_ENCRYPTION_KEY = OFFHOST_ENCRYPTION_KEY;
import { encrypt, encryptBytes } from "@/lib/crypto";
import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
decryptBackup,
runOffhostBackup,
type S3Like,
} from "@/lib/jobs/offhost-backup";
import {
backupPayloadSchema,
parseBackupPayload,
} from "@/lib/validations/backup";
import { POST } from "./restore-job-driver";
import { invalidateUserData } from "@/lib/cache/invalidate";
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),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
vi.mocked(invalidateUserData).mockClear();
});
async function seedAdminSession() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "canonical-restore-admin",
email: "canonical-restore-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return admin;
}
function makeRequest(id: string) {
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
// The disaster-recovery case, so the instance-wide half of the payload is
// asked for explicitly. Without the flag the restore rebuilds the account
// and leaves the host's own settings alone; that arm is covered in
// `admin-backups-restore.test.ts`.
body: JSON.stringify({ confirm: "RESTORE", restoreInstanceSettings: true }),
});
}
function makeS3Store(): S3Like & { objects: Map<string, Buffer> } {
const objects = new Map<string, Buffer>();
return {
objects,
putStream: vi.fn(async (key: string, body: Readable) => {
const chunks: Buffer[] = [];
for await (const c of body) chunks.push(Buffer.from(c as Uint8Array));
objects.set(key, Buffer.concat(chunks));
}),
putObject: vi.fn(async (key, body) => {
objects.set(key, Buffer.from(body));
}),
getObject: vi.fn(async (key) => {
const body = objects.get(key);
if (!body) throw new Error(`Missing S3 object: ${key}`);
return body;
}),
headObject: vi.fn(async (key) => objects.has(key)),
listObjects: vi.fn(async (prefix) =>
[...objects.keys()]
.filter((key) => key.startsWith(prefix))
.map((key) => ({ key })),
),
deleteObject: vi.fn(async (key) => {
objects.delete(key);
}),
};
}
describe("canonical disaster-recovery backup round-trip", () => {
it("restores an off-host payload with tombstones and encrypted documents", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const ownerId = admin.id;
const settings = await prisma.appSettings.create({
data: {
id: "singleton",
registrationEnabled: false,
mfaRequired: true,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
webPushVapidPublicKey: "public-dr",
webPushVapidPrivateKeyEncrypted: "private-dr",
webPushVapidSubject: "mailto:dr@example.test",
apiGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.test/script.js",
umamiWebsiteId: "website-dr",
glitchtipEnabled: true,
glitchtipDsn: "https://glitchtip.example.test/1",
glitchtipEnvironment: "dr",
reminderLateMinutes: 47,
reminderMissedMinutes: 93,
adminAiKeyEncrypted: "ai-key-dr",
adminAiModel: "model-dr",
adminAiBaseUrl: "https://ai.example.test/v1",
adminCodexAccessTokenEncrypted: "access-dr",
adminCodexRefreshTokenEncrypted: "refresh-dr",
adminCodexAccountIdEncrypted: "account-dr",
adminCodexTokenExpiresAt: new Date("2026-07-03T00:00:00.000Z"),
adminCodexConnectedAt: new Date("2026-07-01T00:00:00.000Z"),
adminCodexConnectionStatus: "connected",
adminAiInsightsFeedbackSummary: { helpful: 8, total: 10 },
defaultUserTimezone: "Europe/London",
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: false,
aiReasoningEnabled: false,
aiReasoningMaxEffort: "medium",
moduleAvailabilityJson: { insights: true, nutrients: false },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: BigInt("9876543210"),
},
});
const measurement = await prisma.measurement.create({
data: {
id: "measurement-dr",
userId: ownerId,
type: "WEIGHT",
value: 72.4,
unit: "kg",
measuredAt: new Date("2026-07-01T07:00:00.000Z"),
source: "MANUAL",
},
});
// Recent, so the restore writes it back: a tombstone older than the
// tombstone retention is left out, as the nightly purge would drop it.
const tombstoneDeletedAt = new Date(
Math.floor(Date.now() / 1000) * 1000 - 86_400_000,
);
const deletedMeasurement = await prisma.measurement.create({
data: {
id: "measurement-deleted-dr",
userId: ownerId,
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: new Date("2026-07-01T08:00:00.000Z"),
source: "MANUAL",
deletedAt: tombstoneDeletedAt,
},
});
const factorCategory = await prisma.moodTagCategory.create({
data: {
id: "category-dr",
key: "dr_restore_factors",
labelKey: "mood.tagCategory.drRestore",
},
});
const factor = await prisma.moodTag.create({
data: {
id: "factor-dr",
categoryId: factorCategory.id,
key: "dr_sleep_quality",
labelKey: "mood.tag.drSleepQuality",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
const mood = await prisma.moodEntry.create({
data: {
id: "mood-dr",
userId: ownerId,
date: "2026-07-01",
mood: "GUT",
score: 4,
source: "MOODLOG",
moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"),
tagLinks: {
create: { moodTagId: factor.id, rating: 5 },
},
},
include: {
tagLinks: {
select: { rating: true, moodTag: { select: { key: true } } },
},
},
});
const deletedMood = await prisma.moodEntry.create({
data: {
id: "mood-deleted-dr",
userId: ownerId,
date: "2026-06-30",
mood: "OKAY",
score: 3,
source: "MOODLOG",
moodLoggedAt: new Date("2026-06-30T20:00:00.000Z"),
deletedAt: tombstoneDeletedAt,
},
});
const medication = await prisma.medication.create({
data: {
id: "medication-dr",
userId: ownerId,
name: "Canonical medicine",
dose: "2 mg",
treatmentClass: "GENERIC",
dosesPerUnit: 4,
unitsPerDose: "0.5000",
active: false,
notificationsEnabled: false,
pausedAt: new Date("2026-06-25T08:00:00.000Z"),
snoozedUntil: new Date("2026-07-05T08:00:00.000Z"),
startsOn: new Date("2026-06-01T00:00:00.000Z"),
endsOn: new Date("2026-08-01T00:00:00.000Z"),
asNeeded: false,
// Not the default, so the round trip proves it travels (#1033).
trackIntake: false,
deliveryForm: "INJECTION",
trackInjectionSites: true,
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
liveActivityEnabled: true,
criticalAlarmEnabled: true,
atcCode: "A10BX10",
rxNormCode: "12345",
lowStockNotifiedAt: new Date("2026-06-26T08:00:00.000Z"),
lowStockNotifiedThresholdDays: 7,
reorderLeadDays: 3,
externalSource: "APPLE_HEALTH",
externalId: "medication-external-dr",
schedules: {
create: {
id: "schedule-dr",
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: "1 mg",
daysOfWeek: "i2;1,3,5",
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: 90,
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:00" }],
},
},
},
include: { schedules: true },
});
const sideEffect = await prisma.medicationSideEffect.create({
data: {
id: "side-effect-dr",
userId: ownerId,
medicationId: medication.id,
occurredAt: new Date("2026-06-27T21:00:00.000Z"),
category: "INJECTION_SITE",
entry: "INJECTION_SWELLING",
severity: 4,
notesEncrypted: encryptToBytes("swollen for a day after the injection"),
createdAt: new Date("2026-06-27T21:10:00.000Z"),
},
});
const intake = await prisma.medicationIntakeEvent.create({
data: {
id: "intake-dr",
userId: ownerId,
medicationId: medication.id,
scheduledFor: new Date("2026-06-29T08:00:00.000Z"),
takenAt: new Date("2026-06-29T08:05:00.000Z"),
autoMissed: false,
attributionSource: "USER_PIN",
source: "APPLE_HEALTH",
idempotencyKey: "intake-idempotency-dr",
injectionSite: "THIGH_LEFT",
doseTaken: "1 mg",
inventoryConsumption: [{ itemId: "pen-dr", units: 0.5 }],
externalId: "intake-external-dr",
syncVersion: 5,
deletedAt: tombstoneDeletedAt,
},
});
const cycleProfile = await prisma.cycleProfile.create({
data: {
id: "cycle-profile-dr",
userId: ownerId,
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
secondarySymptom: "CERVIX",
predictionEnabled: false,
rawChartMode: true,
discreetNotifications: true,
sensitiveCategoryEncryption: true,
},
});
const cycle = await prisma.menstrualCycle.create({
data: {
id: "cycle-dr",
userId: ownerId,
startDate: "2026-06-01",
endDate: "2026-06-28",
periodEndDate: "2026-06-05",
lengthDays: 28,
ovulationDate: "2026-06-14",
ovulationConfirmed: true,
isPredicted: false,
tz: "Europe/London",
syncVersion: 6,
deletedAt: tombstoneDeletedAt,
},
});
const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow();
const cycleSymptom = await prisma.cycleSymptom.create({
data: {
id: "cycle-symptom-dr",
userId: ownerId,
categoryId: cycleCategory.id,
key: "cycle_cramps_dr",
labelKey: "cycle.symptom.crampsDr",
},
});
const cycleDay = await prisma.cycleDayLog.create({
data: {
id: "cycle-day-dr",
userId: ownerId,
cycleId: cycle.id,
date: "2026-06-02",
flow: "HEAVY",
intermenstrualBleeding: true,
basalBodyTempC: 36.7,
temperatureExcluded: true,
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
// Real ciphertext: the restore writes a sealed value back only when
// this host opens it.
sensitiveEncrypted: encrypt(JSON.stringify({ sexualActivity: false })),
notesEncrypted: encrypt("notes-cycle-dr"),
source: "APPLE_HEALTH",
externalId: "cycle-day-external-dr",
tz: "Europe/London",
syncVersion: 7,
deletedAt: tombstoneDeletedAt,
symptomLinks: { create: { symptomId: cycleSymptom.id } },
},
});
const biomarker = await prisma.biomarker.create({
data: {
id: "biomarker-dr",
userId: ownerId,
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 300,
panel: "Iron",
contextEncrypted: encryptToBytes("Iron storage context"),
},
});
const lab = await prisma.labResult.create({
data: {
id: "lab-dr",
userId: ownerId,
biomarkerId: biomarker.id,
panel: "Iron",
analyte: "Ferritin",
value: 88,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 300,
takenAt: new Date("2026-06-30T09:00:00.000Z"),
source: "MANUAL",
noteEncrypted: encryptToBytes("Fasted"),
deletedAt: tombstoneDeletedAt,
},
});
const illness = await prisma.illnessEpisode.create({
data: {
id: "illness-dr",
userId: ownerId,
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
resolvedAt: new Date("2026-06-24T00:00:00.000Z"),
noteEncrypted: encryptToBytes("Recovered fully"),
deletedAt: tombstoneDeletedAt,
},
});
const symptom = await prisma.illnessSymptom.create({
data: {
id: "symptom-dr",
key: "dr_cough",
labelKey: "illness.symptom.drCough",
},
});
const illnessDay = await prisma.illnessDayLog.create({
data: {
id: "illness-day-dr",
userId: ownerId,
episodeId: illness.id,
date: "2026-06-21",
functionalImpact: 2,
feverC: 38.1,
noteEncrypted: encryptToBytes("Rest day"),
tz: "Europe/London",
deletedAt: tombstoneDeletedAt,
symptomLinks: {
create: { symptomId: symptom.id, severity: 2 },
},
},
});
const allergy = await prisma.allergy.create({
data: {
id: "allergy-dr",
userId: ownerId,
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
reactionEncrypted: encryptToBytes("Hives"),
notesEncrypted: encryptToBytes("Avoid beta-lactams"),
deletedAt: tombstoneDeletedAt,
},
});
const family = await prisma.familyHistoryEntry.create({
data: {
id: "family-dr",
userId: ownerId,
relationship: "MOTHER",
condition: "Type 2 diabetes",
ageAtOnset: 55,
notesEncrypted: encryptToBytes("Diet controlled"),
},
});
const workout = await prisma.workout.create({
data: {
id: "workout-dr",
userId: ownerId,
sportType: "RUNNING",
startedAt: new Date("2026-06-29T06:00:00.000Z"),
endedAt: new Date("2026-06-29T06:30:00.000Z"),
durationSec: 1800,
totalEnergyKcal: 250,
totalDistanceM: 5000,
avgHeartRate: 145,
maxHeartRate: 170,
source: "IMPORT",
externalId: "run-external-dr",
},
});
const encryptedBuffer = encryptBytes(Buffer.from("private PDF bytes"));
const contentEncrypted = new Uint8Array(
new ArrayBuffer(encryptedBuffer.byteLength),
);
contentEncrypted.set(encryptedBuffer);
const summaryEncrypted = encryptToBytes("Encrypted summary");
const document = await prisma.inboundDocument.create({
data: {
id: "document-dr",
userId: ownerId,
kind: "LAB_RESULT",
title: "June labs",
filename: "june-labs.pdf",
mimeType: "application/pdf",
byteSize: 17,
contentEncrypted,
contentSha256:
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
contentCodec: "binary2",
status: "EXTRACTED",
providerType: "local",
reportDate: new Date("2026-06-30T00:00:00.000Z"),
documentDate: new Date("2026-06-30T00:00:00.000Z"),
summaryEncrypted,
summaryGeneratedAt: new Date("2026-07-01T08:00:00.000Z"),
summaryState: "READY",
createdAt: new Date("2026-07-01T07:30:00.000Z"),
updatedAt: new Date("2026-07-01T08:00:00.000Z"),
},
});
const s3 = makeS3Store();
const snapshotAt = new Date("2026-07-02T00:00:00.000Z");
const offhostReport = await runOffhostBackup(prisma, s3, snapshotAt);
expect(offhostReport).toMatchObject({
uploaded: 1,
failed: 0,
totalUsers: 1,
});
const offhostObject = s3.objects.get(`2026-07-02/user-${ownerId}.json.enc`);
expect(offhostObject).toBeDefined();
const payload = parseBackupPayload(
decryptBackup(
offhostObject!,
Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex"),
`2026-07-02/user-${ownerId}.json.enc`,
),
);
expect(payload).toMatchObject({
schemaVersion: "2",
userId: ownerId,
appSettings: { id: settings.id },
});
expect(payload.documents[0]).not.toHaveProperty("summary");
expect(JSON.stringify(payload.documents[0])).not.toContain(
"private PDF bytes",
);
await prisma.appSettings.deleteMany();
await prisma.medicationIntakeEvent.deleteMany({
where: { userId: ownerId },
});
await prisma.medication.deleteMany({ where: { userId: ownerId } });
await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } });
await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } });
await prisma.cycleProfile.deleteMany({ where: { userId: ownerId } });
await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } });
await prisma.workout.deleteMany({ where: { userId: ownerId } });
await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } });
await prisma.allergy.deleteMany({ where: { userId: ownerId } });
await prisma.illnessEpisode.deleteMany({ where: { userId: ownerId } });
await prisma.labResult.deleteMany({ where: { userId: ownerId } });
await prisma.biomarker.deleteMany({ where: { userId: ownerId } });
const backup = await prisma.dataBackup.create({
data: {
userId: ownerId,
type: "CANONICAL_DR_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const restore = async () =>
POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
expect((await restore()).status).toBe(200);
expect(
await prisma.appSettings.findUniqueOrThrow({
where: { id: settings.id },
}),
).toEqual(settings);
expect(
await prisma.medication.findUniqueOrThrow({
where: { id: medication.id },
include: { schedules: true },
}),
).toEqual(medication);
expect(
await prisma.medicationIntakeEvent.findUniqueOrThrow({
where: { id: intake.id },
}),
).toEqual(intake);
// Byte-for-byte, ciphertext included: a canonical payload carries the
// encrypted note verbatim, so the same instance's key reads back exactly
// what it wrote. Re-encrypting here would still decrypt to the same words
// and would still be a different row than the one the file described.
expect(
await prisma.medicationSideEffect.findUniqueOrThrow({
where: { id: sideEffect.id },
}),
).toEqual(sideEffect);
expect(
await prisma.cycleProfile.findUniqueOrThrow({
where: { id: cycleProfile.id },
}),
).toEqual(cycleProfile);
expect(
await prisma.menstrualCycle.findUniqueOrThrow({
where: { id: cycle.id },
}),
).toEqual(cycle);
expect(
await prisma.cycleDayLog.findUniqueOrThrow({
where: { id: cycleDay.id },
}),
).toEqual(cycleDay);
expect(
await prisma.cycleSymptomLink.findMany({
where: { dayLogId: cycleDay.id },
select: { symptomId: true },
}),
).toEqual([{ symptomId: cycleSymptom.id }]);
expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(
1,
);
expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe(
1,
);
expect(
await prisma.illnessEpisode.count({ where: { userId: ownerId } }),
).toBe(1);
expect(
await prisma.illnessDayLog.count({ where: { userId: ownerId } }),
).toBe(1);
expect(await prisma.allergy.count({ where: { userId: ownerId } })).toBe(1);
expect(
await prisma.familyHistoryEntry.count({ where: { userId: ownerId } }),
).toBe(1);
expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1);
expect(
await prisma.inboundDocument.count({ where: { userId: ownerId } }),
).toBe(1);
expect(
await prisma.measurement.findUniqueOrThrow({
where: { id: measurement.id },
}),
).toEqual(measurement);
expect(
await prisma.measurement.findUniqueOrThrow({
where: { id: deletedMeasurement.id },
}),
).toEqual(deletedMeasurement);
const restoredLab = await prisma.labResult.findUniqueOrThrow({
where: { id: lab.id },
});
expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted");
expect(restoredLab.biomarkerId).toBe(biomarker.id);
expect(restoredLab).toEqual(lab);
const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({
where: { id: biomarker.id },
});
expect(restoredBiomarker).toEqual(
expect.objectContaining({
name: biomarker.name,
unit: biomarker.unit,
lowerBound: biomarker.lowerBound,
upperBound: biomarker.upperBound,
panel: biomarker.panel,
hidden: biomarker.hidden,
}),
);
expect(decryptFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
"Iron storage context",
);
const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({
where: { id: illness.id },
});
expect(restoredIllness).toEqual(illness);
expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe(
"Recovered fully",
);
const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({
where: { id: illnessDay.id },
});
expect(restoredIllnessDay).toEqual(illnessDay);
expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe(
"Rest day",
);
expect(
await prisma.illnessSymptomLink.findMany({
where: { dayLogId: illnessDay.id },
}),
).toEqual([
expect.objectContaining({ symptomId: symptom.id, severity: 2 }),
]);
const restoredAllergy = await prisma.allergy.findUniqueOrThrow({
where: { id: allergy.id },
});
expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives");
expect(restoredAllergy).toEqual(allergy);
const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({
where: { id: family.id },
});
expect(decryptFromBytes(restoredFamily.notesEncrypted!)).toBe(
"Diet controlled",
);
expect(restoredFamily).toEqual(
expect.objectContaining({
relationship: family.relationship,
condition: family.condition,
ageAtOnset: family.ageAtOnset,
}),
);
expect(
await prisma.workout.findUniqueOrThrow({ where: { id: workout.id } }),
).toEqual(
expect.objectContaining({
sportType: workout.sportType,
startedAt: workout.startedAt,
endedAt: workout.endedAt,
durationSec: workout.durationSec,
totalEnergyKcal: workout.totalEnergyKcal,
totalDistanceM: workout.totalDistanceM,
avgHeartRate: workout.avgHeartRate,
maxHeartRate: workout.maxHeartRate,
source: workout.source,
externalId: workout.externalId,
}),
);
const restoredDocument = await prisma.inboundDocument.findUniqueOrThrow({
where: { id: document.id },
});
expect(Buffer.from(restoredDocument.contentEncrypted)).toEqual(
Buffer.from(document.contentEncrypted),
);
expect(Buffer.from(restoredDocument.summaryEncrypted!)).toEqual(
Buffer.from(document.summaryEncrypted!),
);
expect(restoredDocument).toEqual(
expect.objectContaining({
title: document.title,
filename: document.filename,
mimeType: document.mimeType,
byteSize: document.byteSize,
contentSha256: document.contentSha256,
contentCodec: document.contentCodec,
status: document.status,
providerType: document.providerType,
summaryState: document.summaryState,
reportDate: document.reportDate,
documentDate: document.documentDate,
errorReason: document.errorReason,
summaryGeneratedAt: document.summaryGeneratedAt,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
}),
);
const restoredMood = await prisma.moodEntry.findUniqueOrThrow({
where: { id: mood.id },
include: {
tagLinks: {
include: { moodTag: { select: { key: true } } },
},
},
});
expect(restoredMood).toEqual(
expect.objectContaining({
date: mood.date,
mood: mood.mood,
score: mood.score,
source: mood.source,
moodLoggedAt: mood.moodLoggedAt,
}),
);
expect(restoredMood.tagLinks).toEqual([
expect.objectContaining({
rating: 5,
moodTag: { key: "dr_sleep_quality" },
}),
]);
expect(
await prisma.moodEntry.findUniqueOrThrow({
where: { id: deletedMood.id },
}),
).toEqual(
expect.objectContaining({
userId: ownerId,
deletedAt: tombstoneDeletedAt,
}),
);
expect((await restore()).status).toBe(200);
expect(
await prisma.inboundDocument.findMany({
where: { userId: ownerId },
select: { id: true },
}),
).toEqual([{ id: document.id }]);
expect(
await prisma.workout.findMany({
where: { userId: ownerId },
select: { id: true },
}),
).toEqual([{ id: workout.id }]);
expect(
await prisma.labResult.findMany({
where: { userId: ownerId },
select: { id: true },
}),
).toEqual([{ id: lab.id }]);
expect(
await prisma.illnessEpisode.findMany({
where: { userId: ownerId },
select: { id: true },
}),
).toEqual([{ id: illness.id }]);
});
it("rejects a stable measurement id owned by another account without clearing either owner", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const other = await prisma.user.create({
data: { username: "stable-id-owner-other" },
});
const original = await prisma.measurement.create({
data: {
id: "owner-original-measurement",
userId: admin.id,
type: "PULSE",
value: 70,
unit: "bpm",
measuredAt: new Date("2026-07-08T10:00:00.000Z"),
},
});
const conflicting = await prisma.measurement.create({
data: {
id: "cross-owner-stable-id",
userId: other.id,
type: "PULSE",
value: 65,
unit: "bpm",
measuredAt: new Date("2026-07-08T11:00:00.000Z"),
},
});
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-12T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: conflicting.id,
type: "PULSE",
value: 80,
unit: "bpm",
measuredAt: "2026-07-08T12:00:00.000Z",
source: "IMPORT",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "STABLE_ID_OWNER_CONFLICT",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(500);
expect(
await prisma.measurement.findUnique({ where: { id: original.id } }),
).toEqual(original);
expect(
await prisma.measurement.findUnique({ where: { id: conflicting.id } }),
).toEqual(conflicting);
});
it("restores many stable-id measurements with a bounded number of writes", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const measurements = Array.from({ length: 250 }, (_, index) => ({
id: `stable-measurement-${index}`,
type: "PULSE" as const,
value: 60 + (index % 20),
unit: "bpm",
measuredAt: new Date(Date.UTC(2026, 6, 10, 0, 0, 0, index)).toISOString(),
source: "IMPORT" as const,
externalId: `external-${index}`,
syncVersion: 3,
// Recent: a tombstone past the retention is not written back.
deletedAt:
index % 10 === 0
? new Date(Date.now() - 86_400_000).toISOString()
: null,
}));
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-12T00:00:00.000Z",
userId: admin.id,
measurements,
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "BOUNDED_STABLE_ID_RESTORE",
data: encrypt(JSON.stringify(payload)),
},
});
let stableWriteQueries = 0;
const originalTransaction = prisma.$transaction.bind(prisma) as unknown as (
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
options?: { maxWait?: number; timeout?: number },
) => Promise<unknown>;
const transactionSpy = vi.spyOn(prisma, "$transaction");
transactionSpy.mockImplementation((async (
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
options?: { maxWait?: number; timeout?: number },
) =>
originalTransaction(async (tx) => {
const originalUpsert = tx.measurement.upsert.bind(tx.measurement);
const originalCreateMany = tx.measurement.createMany.bind(
tx.measurement,
);
vi.spyOn(tx.measurement, "upsert").mockImplementation((args) => {
stableWriteQueries += 1;
return originalUpsert(args);
});
vi.spyOn(tx.measurement, "createMany").mockImplementation((args) => {
stableWriteQueries += 1;
return originalCreateMany(args);
});
return callback(tx);
}, options)) as never);
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
transactionSpy.mockRestore();
expect(stableWriteQueries).toBeLessThanOrEqual(2);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { id: "asc" },
select: { id: true },
}),
).toEqual(
measurements
.map(({ id }) => ({ id }))
.sort((left, right) => left.id.localeCompare(right.id)),
);
});
it.each([
{
label: "invalid enum",
mutate: (payload: Record<string, unknown>) => {
payload.measurements = [
{
type: "INVALID_MEASUREMENT_TYPE",
value: 72,
unit: "kg",
measuredAt: "2026-07-03T07:00:00.000Z",
source: "MANUAL",
},
];
},
},
{
label: "unsupported schema version",
mutate: (payload: Record<string, unknown>) => {
payload.schemaVersion = "999";
},
},
])(
"rejects $label with 422 before writes or invalidation",
async ({ mutate }) => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const existing = await prisma.measurement.create({
data: {
id: "pre-validation-measurement",
userId: admin.id,
type: "WEIGHT",
value: 91,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-07-03T06:00:00.000Z"),
},
});
const payload: Record<string, unknown> = {
schemaVersion: "1",
exportedAt: "2026-07-03T08:00:00.000Z",
userId: admin.id,
measurements: [],
moodEntries: [],
};
mutate(payload);
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`,
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(422);
expect(await response.json()).toMatchObject({
data: null,
error: expect.any(String),
});
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
select: { id: true },
}),
).toEqual([{ id: existing.id }]);
expect(invalidateUserData).not.toHaveBeenCalled();
},
);
it("rejects metadata-only documents before mutation or invalidation", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
await prisma.measurement.create({
data: {
userId: admin.id,
type: "WEIGHT",
value: 91,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-07-03T07:00:00.000Z"),
},
});
const payload = backupPayloadSchema.parse({
schemaVersion: "1",
exportedAt: "2026-07-03T08:00:00.000Z",
userId: admin.id,
measurements: [],
moodEntries: [],
documents: [
{
id: "metadata-only-document",
kind: "OTHER",
title: null,
filename: "metadata.pdf",
mimeType: "application/pdf",
byteSize: 12,
status: "STORED",
reportDate: null,
documentDate: null,
summary: null,
createdAt: "2026-07-03T08:00:00.000Z",
},
],
manifest: {
documents: { included: "metadata-only", note: "Portable export" },
workouts: { included: "summary-only", note: "Summary only" },
},
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PORTABLE_METADATA_ONLY",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(422);
expect(
await prisma.measurement.count({ where: { userId: admin.id } }),
).toBe(1);
expect(invalidateUserData).not.toHaveBeenCalled();
});
/**
* The contract here is the rollback, not the trigger. The trigger used to be
* a mood factor key the instance did not know, and that is no longer a
* failure at all: a seeded catalogue legitimately drifts between the day a
* backup is written and the day it is read, so an unresolvable catalogue key
* now drops one link and names it in the response rather than costing the
* whole file.
*
* An intake event naming a medication the file does not carry is a different
* animal and still throws. That reference points at a row THIS FILE was
* supposed to contain, so a dangling one means the file contradicts itself,
* and writing a dose history against a medication that does not exist is not
* a recoverable restore. It is the honest trigger for the rollback contract.
*/
it("rolls back a failed full restore before invalidating caches", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const measurement = await prisma.measurement.create({
data: {
userId: admin.id,
type: "WEIGHT",
value: 91,
unit: "kg",
source: "MANUAL",
measuredAt: new Date("2026-07-04T07:00:00.000Z"),
},
});
const payload = backupPayloadSchema.parse({
schemaVersion: "1",
exportedAt: "2026-07-04T08:00:00.000Z",
userId: admin.id,
measurements: [],
medications: [],
intakeEvents: [
{
id: "failed-restore-intake",
medication: "a medication this file never carried",
scheduledFor: "2026-07-04T08:00:00.000Z",
takenAt: "2026-07-04T08:05:00.000Z",
},
],
moodEntries: [
{
id: "failed-restore-mood",
date: "2026-07-04",
mood: "OKAY",
score: 3,
tags: null,
loggedAt: "2026-07-04T08:00:00.000Z",
factors: [],
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "FAILED_FULL_RESTORE",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(500);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
select: { id: true },
}),
).toEqual([{ id: measurement.id }]);
expect(await prisma.moodEntry.count({ where: { userId: admin.id } })).toBe(
0,
);
expect(invalidateUserData).not.toHaveBeenCalled();
});
/**
* `aggregationProvenance` is what keeps an export.xml source-day estimate
* from overwriting a native HealthKit statistic. A backup that drops it
* restores a flattened authority ladder, so it has to survive the round
* trip — and a backup written before it rode the payload has to land in the
* documented repairable state rather than reproduce the ambiguity migration
* 0263 already repaired once.
*
* Mutation check: drop `aggregationProvenance` from the backup select or
* from the restore mapping and the first case goes red; drop the
* Apple-Health `stats:` fallback and the second goes red.
*/
it("round-trips the aggregation provenance of a stats aggregate", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "provenance-native",
type: "ACTIVITY_STEPS",
value: 8_600,
unit: "steps",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
aggregationProvenance: "HEALTHKIT_STATISTICS",
},
{
id: "provenance-xml",
type: "ACTIVITY_STEPS",
value: 8_526,
unit: "steps",
measuredAt: "2026-07-21T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-21",
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_ROUNDTRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { measuredAt: "asc" },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([
{
id: "provenance-native",
aggregationProvenance: "HEALTHKIT_STATISTICS",
},
{ id: "provenance-xml", aggregationProvenance: "EXPORT_XML_SOURCE_MAX" },
]);
});
it("marks an unlabelled Apple Health stats aggregate as legacy-unknown on restore", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
// A backup taken before the provenance rode the payload: no field at all.
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "legacy-apple-stats",
type: "ACTIVITY_STEPS",
value: 8_526,
unit: "steps",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
},
{
// Another provider's cumulative daily. It mints a `stats:` id too but
// has never carried provenance, so inventing one would be a lie.
id: "legacy-fitbit-stats",
type: "ACTIVITY_STEPS",
value: 7_100,
unit: "steps",
measuredAt: "2026-07-21T10:00:00.000Z",
source: "FITBIT",
externalId: "stats:steps",
},
{
// An ordinary point reading never had provenance either.
id: "legacy-point-row",
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: "2026-07-22T10:00:00.000Z",
source: "MANUAL",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_LEGACY",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { measuredAt: "asc" },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([
{ id: "legacy-apple-stats", aggregationProvenance: "LEGACY_UNKNOWN" },
{ id: "legacy-fitbit-stats", aggregationProvenance: null },
{ id: "legacy-point-row", aggregationProvenance: null },
]);
});
it("keeps a provenance the backup states as empty, rather than marking it legacy", async () => {
// The nightly daily-mean fold writes Apple Health `stats:` rows with no
// provenance (respiratory rate, walking speed, …). A current backup says
// so with an explicit null, which is not the same as a backup too old to
// carry the field. Restoring those rows as LEGACY_UNKNOWN changed 18 618
// rows of a 2.6 million-reading account on every restore (#1031).
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "daily-mean-stats",
type: "RESPIRATORY_RATE",
value: 14.2,
unit: "breaths/min",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId:
"stats:HKQuantityTypeIdentifierRespiratoryRate:2026-07-20",
aggregationProvenance: null,
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_EXPLICIT_NULL",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([{ id: "daily-mean-stats", aggregationProvenance: null }]);
});
});