fix(backups): preserve canonical recovery integrity

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:44:29 +02:00
1 parent 5f4017d039
commit f8b0a64d69
11 files changed
+853 -615

No files matched your search

+112 -119
View File
@@ -27,6 +27,7 @@ import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import {
parseBackupPayload,
isCompatibleSchemaVersion,
summarizeBackup,
type BackupSummary,
} from "@/lib/validations/backup";
@@ -72,43 +73,6 @@ function decodeEncryptedBytes(encoded: string): Uint8Array<ArrayBuffer> {
return bytes;
}
/**
* Maps a measurement-type string from the backup back into the
* Prisma enum literal. Backups are written from `enum MeasurementType`
* (Postgres-mapped to lowercase column type), so the round-trip is
* normally identity. But the schema is a `passthrough` schema so a
* malformed type slips through Zod — guard it explicitly here so the
* restore fails fast instead of erroring deep in `prisma.create()`.
*/
const MEASUREMENT_TYPES = new Set([
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
// ── v1.4.23 Apple Health additions ──
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"ACTIVE_ENERGY_BURNED",
"FLIGHTS_CLIMBED",
"WALKING_RUNNING_DISTANCE",
"VO2_MAX",
"BODY_TEMPERATURE",
]);
const MEASUREMENT_SOURCES = new Set([
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
]);
const INTAKE_SOURCES = new Set(["WEB", "API", "REMINDER", "IMPORT"]);
const handler = apiHandler(
async (
@@ -185,7 +149,24 @@ const handler = apiHandler(
message: err instanceof Error ? err.message : String(err),
},
});
return apiError("Backup payload failed schema validation", 500);
return apiError("Backup payload failed schema validation", 422);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
reason: "incompatible_schema_version",
schemaVersion: payload.schemaVersion,
},
});
return apiError(
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
422,
);
}
// The payload declares its own owner, and the backup ROW records who the
@@ -250,55 +231,6 @@ const handler = apiHandler(
},
});
// Pre-validate enum values OUTSIDE the transaction so a malformed
// payload doesn't half-wipe the user before failing the create
// step. Cheap up-front guard.
for (const m of payload.measurements) {
if (!MEASUREMENT_TYPES.has(m.type)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "unknown_measurement_type",
type: m.type,
},
});
return apiError(`Unknown measurement type in backup: '${m.type}'`, 422);
}
if (m.source && !MEASUREMENT_SOURCES.has(m.source)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "unknown_measurement_source",
source: m.source,
},
});
return apiError(
`Unknown measurement source in backup: '${m.source}'`,
422,
);
}
}
for (const e of payload.intakeEvents) {
if (e.source && !INTAKE_SOURCES.has(e.source)) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId,
reason: "unknown_intake_source",
source: e.source,
},
});
return apiError(`Unknown intake source in backup: '${e.source}'`, 422);
}
}
// Portable exports intentionally omit document ciphertext. They remain
// valid upload/download artifacts, but cannot be used to manufacture an
@@ -398,27 +330,56 @@ const handler = apiHandler(
where: { userId: ownerId },
});
// ── re-create from payload ──
if (payload.measurements.length > 0) {
await tx.measurement.createMany({
data: payload.measurements.map((m) => ({
// Stable ids are authoritative for canonical snapshots. Legacy payloads
// without ids fall back to the historic natural measurement key.
for (const measurement of payload.measurements) {
const measuredAt = new Date(measurement.measuredAt);
const source = measurement.source ?? "MANUAL";
const restoredData = {
type: measurement.type,
value: measurement.value,
unit: measurement.unit,
source,
measuredAt,
notes: null,
notesEncrypted: encryptNote(measurement.notes ?? null),
deletedAt: measurement.deletedAt
? new Date(measurement.deletedAt)
: null,
};
if (measurement.id) {
await tx.measurement.upsert({
where: { id: measurement.id, userId: ownerId },
create: {
id: measurement.id,
userId: ownerId,
...restoredData,
},
update: restoredData,
});
continue;
}
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: m.type as never, // already enum-validated above
value: m.value,
unit: m.unit,
source: (m.source ?? "MANUAL") as never,
measuredAt: new Date(m.measuredAt),
// v1.23 — re-encrypt the note from the (decrypted) backup payload
// into the at-rest column; legacy plaintext column nulled.
notes: null,
notesEncrypted: encryptNote(m.notes ?? null),
})),
// Backups can replay the same data — the unique
// (userId, type, measuredAt, source) constraint catches
// accidental dupes. `skipDuplicates` keeps the restore
// idempotent across retries instead of throwing.
skipDuplicates: true,
type: measurement.type,
source,
measuredAt,
},
select: { id: true },
});
if (existing) {
await tx.measurement.update({
where: { id: existing.id, userId: ownerId },
data: restoredData,
});
} else {
await tx.measurement.create({
data: { userId: ownerId, ...restoredData },
});
}
}
// Medications + schedules — restore one at a time so we can
@@ -501,23 +462,55 @@ const handler = apiHandler(
}
for (const entry of payload.moodEntries) {
const created = await tx.moodEntry.create({
data: {
...(entry.id ? { id: entry.id } : {}),
userId: ownerId,
date: entry.date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ?? null,
source: entry.source ?? "MOODLOG",
externalId: entry.externalId ?? null,
moodLoggedAt: new Date(entry.loggedAt),
},
const moodLoggedAt = new Date(entry.loggedAt);
const restoredData = {
date: entry.date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ?? null,
source: entry.source ?? "MOODLOG",
externalId: entry.externalId ?? null,
moodLoggedAt,
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
};
const createData = { userId: ownerId, ...restoredData };
const restored = entry.id
? await tx.moodEntry.upsert({
where: { id: entry.id, userId: ownerId },
create: { id: entry.id, ...createData },
update: restoredData,
})
: entry.externalId
? await tx.moodEntry.upsert({
where: {
userId_source_externalId: {
userId: ownerId,
source: restoredData.source,
externalId: entry.externalId,
},
},
create: createData,
update: restoredData,
})
: await tx.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId: ownerId,
date: entry.date,
moodLoggedAt,
},
},
create: createData,
update: restoredData,
});
await tx.moodEntryTagLink.deleteMany({
where: { moodEntryId: restored.id },
});
if (entry.factors.length > 0) {
await tx.moodEntryTagLink.createMany({
data: entry.factors.map((factor) => ({
moodEntryId: created.id,
moodEntryId: restored.id,
moodTagId: factorByKey.get(factor.key)!,
rating: factor.rating,
})),
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMeasurementsPaged: vi.fn(),
buildCycleBackupSection: vi.fn(),
buildRecordsBackupSection: vi.fn(),
}));
vi.mock("@/lib/export/paged-measurements", () => ({
findMeasurementsPaged: mocks.findMeasurementsPaged,
}));
vi.mock("@/lib/cycle/backup", () => ({
buildCycleBackupSection: mocks.buildCycleBackupSection,
}));
vi.mock("@/lib/export/records-backup", () => ({
buildRecordsBackupSection: mocks.buildRecordsBackupSection,
countRecordsBackupSection: vi.fn(() => ({
labResults: 0,
biomarkers: 0,
illnessEpisodes: 0,
illnessDayLogs: 0,
allergies: 0,
familyHistory: 0,
workouts: 0,
documents: 0,
})),
}));
import { buildFullBackupPayload } from "../full-backup-payload";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
function makePrisma() {
return {
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: {
findMany: vi.fn().mockResolvedValue([
{
id: "mood-tombstone",
date: "2026-07-19",
mood: "OKAY",
score: 3,
tags: null,
source: "MOODLOG",
externalId: "moodlog-42",
moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"),
deletedAt,
tagLinks: [
{ rating: 4, moodTag: { key: "sleep_quality" } },
],
},
]),
},
};
}
function installSectionMocks() {
mocks.findMeasurementsPaged.mockResolvedValue([
{
id: "measurement-tombstone",
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: new Date("2026-07-19T07:00:00.000Z"),
source: "MANUAL",
notes: null,
notesEncrypted: null,
deletedAt,
},
]);
mocks.buildCycleBackupSection.mockResolvedValue({
cycleProfile: null,
cycles: [],
cycleDayLogs: [],
});
mocks.buildRecordsBackupSection.mockResolvedValue({
labResults: [],
biomarkers: [],
illnessEpisodes: [],
allergies: [],
familyHistory: [],
workouts: [],
documents: [],
manifest: {
documents: { included: "encrypted-content", note: "included" },
workouts: { included: "summary-only", note: "included" },
},
});
}
describe("buildFullBackupPayload disaster-recovery mode", () => {
it("preserves measurement and mood tombstones with stable ids", async () => {
installSectionMocks();
const prisma = makePrisma();
const { payload } = await buildFullBackupPayload(prisma as never, "user-1", {
purpose: "disaster-recovery",
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
});
expect(mocks.findMeasurementsPaged).toHaveBeenCalledWith(
prisma,
{ userId: "user-1" },
expect.objectContaining({ id: true, deletedAt: true }),
);
expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1" },
include: expect.any(Object),
}),
);
expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith(
prisma,
"user-1",
{ purpose: "disaster-recovery" },
);
expect(payload).toMatchObject({
schemaVersion: "1",
exportedAt: "2026-07-20T00:00:00.000Z",
measurements: [
{
id: "measurement-tombstone",
deletedAt: deletedAt.toISOString(),
},
],
moodEntries: [
{
id: "mood-tombstone",
externalId: "moodlog-42",
deletedAt: deletedAt.toISOString(),
factors: [{ key: "sleep_quality", rating: 4 }],
},
],
});
});
});
+34 -6
View File
@@ -35,14 +35,21 @@ export interface FullBackupResult {
counts: FullBackupCounts;
}
export interface FullBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
exportedAt?: Date;
}
/**
* Build the canonical full-backup payload for `userId`. Soft-deleted rows are
* excluded so a round-trip via admin restore never resurrects deleted records.
* Build the canonical full-backup payload for `userId`. Portable exports omit
* tombstones and document ciphertext; disaster-recovery payloads preserve
* both so weekly and off-host snapshots share one restorable wire format.
*/
export async function buildFullBackupPayload(
prisma: PrismaClient,
userId: string,
options: FullBackupOptions = {},
): Promise<FullBackupResult> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [measurements, medications, intakeEvents, moodEntries, cycle, records] =
await Promise.all([
// v1.28.25 — keyset-paginated read with a narrow select. The backup
@@ -55,7 +62,7 @@ export async function buildFullBackupPayload(
// note columns `readNote` decrypts + the `id` cursor key.
findMeasurementsPaged(
prisma,
{ userId, deletedAt: null },
disasterRecovery ? { userId } : { userId, deletedAt: null },
{
id: true,
type: true,
@@ -65,6 +72,7 @@ export async function buildFullBackupPayload(
source: true,
notes: true,
notesEncrypted: true,
deletedAt: true,
},
),
prisma.medication.findMany({
@@ -89,24 +97,37 @@ export async function buildFullBackupPayload(
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
},
}),
buildCycleBackupSection(prisma, userId),
buildRecordsBackupSection(prisma, userId),
buildRecordsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
]);
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
userId,
measurements: measurements.map((m) => ({
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
deletedAt: m.deletedAt?.toISOString() ?? null,
})),
medications: medications.map((m) => ({
name: m.name,
@@ -127,12 +148,19 @@ export async function buildFullBackupPayload(
source: e.source,
})),
moodEntries: moodEntries.map((e) => ({
id: e.id,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
externalId: e.externalId,
deletedAt: e.deletedAt?.toISOString() ?? null,
factors: e.tagLinks.map((link) => ({
key: link.moodTag.key,
rating: link.rating!,
})),
})),
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
+97 -53
View File
@@ -1,4 +1,16 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
BACKUP_SCHEMA_VERSION,
backupPayloadSchema,
} from "@/lib/validations/backup";
const mocks = vi.hoisted(() => ({
buildFullBackupPayload: vi.fn(),
}));
vi.mock("@/lib/export/full-backup-payload", () => ({
buildFullBackupPayload: mocks.buildFullBackupPayload,
}));
import {
encryptBackup,
decryptBackup,
@@ -6,7 +18,6 @@ import {
runOffhostBackup,
runOffhostRoundtripTest,
} from "../offhost-backup";
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
const ENC_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -89,12 +100,36 @@ function makeS3Mock() {
describe("runOffhostBackup", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example");
vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups");
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA");
vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret");
vi.stubEnv("BACKUP_S3_REGION", "auto");
vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY);
mocks.buildFullBackupPayload.mockImplementation(
async (_prisma: unknown, userId: string) => ({
payload: {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: "2026-05-08T03:00:00.000Z",
userId,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [
{
id: `mood-${userId}`,
date: "2026-05-08",
mood: "GUT",
score: 4,
loggedAt: "2026-05-08T20:00:00.000Z",
factors: [],
},
],
},
counts: {},
}),
);
});
it("uploads one object per user and never deletes existing ones", async () => {
@@ -137,56 +172,66 @@ describe("runOffhostBackup", () => {
const decoded = decryptBackup(ct, Buffer.from(ENC_KEY, "hex"));
const parsed = JSON.parse(decoded);
expect(parsed.userId).toBe("u1");
expect(() => backupPayloadSchema.parse(parsed)).not.toThrow();
expect(parsed.schemaVersion).toBe(BACKUP_SCHEMA_VERSION);
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(prisma, "u1", {
purpose: "disaster-recovery",
exportedAt: now,
});
});
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
it("uploads the canonical builder output without reshaping it", async () => {
const s3 = makeS3Mock();
// A full first page (=== page size) followed by a short page proves the
// reader walks the id cursor instead of one unbounded findMany.
const fullPage = Array.from(
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
(_, i) => ({
id: `e${String(i).padStart(5, "0")}`,
userId: "u1",
takenAt: new Date("2026-05-01T00:00:00Z"),
}),
);
const tailPage = [
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
];
const intakeFindMany = vi.fn(
async (args: { where?: { id?: { gt?: string } } }) =>
args?.where?.id?.gt ? tailPage : fullPage,
);
const prisma = {
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: intakeFindMany },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
const canonicalPayload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: "2026-05-08T00:00:00.000Z",
userId: "u1",
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
documents: [
{
id: "document-1",
kind: "LAB_RESULT",
mimeType: "application/pdf",
byteSize: 4,
status: "STORED",
contentEncrypted: Buffer.from([1, 2, 3, 4]).toString("base64"),
contentSha256: null,
contentCodec: "binary2",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
summaryEncrypted: null,
summaryGeneratedAt: null,
summaryState: "NONE",
createdAt: "2026-05-08T00:00:00.000Z",
updatedAt: "2026-05-08T00:00:00.000Z",
},
],
};
mocks.buildFullBackupPayload.mockResolvedValueOnce({
payload: canonicalPayload,
counts: {},
});
const report = await runOffhostBackup(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
prisma as never,
s3,
new Date("2026-05-08T00:00:00Z"),
);
expect(report.uploaded).toBe(1);
// Two round-trips: the full page, then the short tail that ends the walk.
expect(intakeFindMany).toHaveBeenCalledTimes(2);
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
orderBy: { id: "asc" },
take: MEASUREMENT_BACKUP_PAGE_SIZE,
});
// Every paged row lands in the decrypted payload — no rows dropped.
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
const ciphertext = s3.store.get("2026-05-08/user-u1.json.enc")!;
expect(
JSON.parse(decryptBackup(ciphertext, Buffer.from(ENC_KEY, "hex"))),
).toEqual(canonicalPayload);
expect(() => backupPayloadSchema.parse(canonicalPayload)).not.toThrow();
});
it("counts per-user failures without aborting the whole run", async () => {
@@ -195,25 +240,24 @@ describe("runOffhostBackup", () => {
user: {
findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]),
},
measurement: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new Error("db gone")),
},
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
mocks.buildFullBackupPayload
.mockResolvedValueOnce({
payload: {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: "2026-05-08T00:00:00.000Z",
userId: "u1",
},
counts: {},
})
.mockRejectedValueOnce(new Error("db gone"));
const report = await runOffhostBackup(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
prisma as never,
s3,
new Date("2026-05-08T00:00:00Z"),
);
expect(report.uploaded).toBe(1);
expect(report.failed).toBe(1);
});
+6 -144
View File
@@ -19,11 +19,8 @@
*/
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import type { PrismaClient } from "@/generated/prisma/client";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { getEvent } from "@/lib/logging/context";
import {
collectPagedMeasurements,
MEASUREMENT_BACKUP_PAGE_SIZE,
} from "@/lib/jobs/backup/measurement-page";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
@@ -218,147 +215,12 @@ export async function runOffhostBackup(
const evt = getEvent();
for (const user of users) {
try {
const [
measurements,
medications,
intakeEvents,
moodEntries,
cycleProfile,
cycles,
cycleDayLogs,
] = await Promise.all([
// includes soft-deleted rows because this is the DR snapshot,
// not a user-facing export — see
// `/api/export/full-backup/route.ts` for the symmetric exclusion.
//
// Keyset-paged narrow read instead of one full-set findMany: a
// heavy multi-year tenant otherwise loads every measurement ORM
// row into a single array alongside the giant JSON.stringify
// below, the worker's dominant heap spike. The DR dump keeps the
// FULL row shape, so the select lists every scalar column (no
// relations) — identical bytes to the prior default findMany —
// while the page reader bounds peak heap to the page size. No
// `deletedAt` filter: the tombstoned rows stay in the snapshot.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
select: {
id: true,
userId: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
unit: true,
source: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
deviceType: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
// The intake / mood / cycle tables scale with history depth the same
// way measurements do (multiple intakes + a mood + a cycle-day-log per
// day over years), so they get the same keyset-paged read: at most one
// page is resident per round-trip instead of the entire table landing
// in one array alongside the giant JSON.stringify below. The projection
// keeps the full row (no select), so payload bytes are identical to the
// prior default findMany — only peak heap during the read is bounded.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.moodEntry.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
// disaster-recovery dump, not the canonical user-facing backup);
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
// stays a direct read; the cycle + day-log tables are paged.
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.menstrualCycle.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.cycleDayLog.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
include: { symptomLinks: true },
orderBy: { id: "asc" },
take,
}),
project: (row) => row,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}),
]);
const payload = JSON.stringify({
exportedAt: now.toISOString(),
userId: user.id,
measurements,
medications,
intakeEvents,
moodEntries,
cycleProfile,
cycles,
cycleDayLogs,
const { payload } = await buildFullBackupPayload(prisma, user.id, {
purpose: "disaster-recovery",
exportedAt: now,
});
const ciphertext = encryptBackup(payload, cfg.encryptionKey);
const plaintext = JSON.stringify(payload);
const ciphertext = encryptBackup(plaintext, cfg.encryptionKey);
const key = `${dateKey}/user-${user.id}.json.enc`;
await s3.putObject(key, ciphertext);
uploaded++;
@@ -3,22 +3,14 @@ import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
buildRecordsBackupSection: vi.fn(),
buildFullBackupPayload: vi.fn(),
encrypt: vi.fn((value: string) => value),
getWorkerPrisma: vi.fn(),
upsert: vi.fn(),
}));
vi.mock("@/lib/export/records-backup", () => ({
buildRecordsBackupSection: mocks.buildRecordsBackupSection,
}));
vi.mock("@/lib/cycle/backup", () => ({
buildCycleBackupSection: vi.fn().mockResolvedValue({
cycleProfile: null,
cycles: [],
cycleDayLogs: [],
}),
vi.mock("@/lib/export/full-backup-payload", () => ({
buildFullBackupPayload: mocks.buildFullBackupPayload,
}));
vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt }));
@@ -50,26 +42,6 @@ function buildPrismaMock() {
{ id: "user-dr", username: "backup-owner" },
]),
},
measurement: { findMany: vi.fn().mockResolvedValue([]) },
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: {
findMany: vi.fn().mockResolvedValue([
{
id: "mood-dr",
date: "2026-07-01",
mood: "GUT",
score: 4,
tags: null,
source: "MOODLOG",
externalId: "mood-external-dr",
moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"),
tagLinks: [
{ rating: 5, moodTag: { key: "sleep_quality" } },
],
},
]),
},
dataBackup: { upsert: mocks.upsert },
};
}
@@ -79,51 +51,36 @@ describe("handleDataBackup canonical DR payload", () => {
vi.clearAllMocks();
mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock());
mocks.upsert.mockResolvedValue({});
mocks.buildRecordsBackupSection.mockResolvedValue({
labResults: [],
biomarkers: [],
illnessEpisodes: [],
allergies: [],
familyHistory: [],
workouts: [],
documents: [
{
id: "document-dr",
kind: "OTHER",
title: null,
filename: "record.pdf",
mimeType: "application/pdf",
byteSize: 4,
status: "STORED",
reportDate: null,
documentDate: null,
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
contentEncrypted: documentCiphertext,
contentSha256: null,
contentCodec: "binary2",
providerType: null,
errorReason: null,
summaryEncrypted: null,
summaryGeneratedAt: null,
summaryState: "NONE",
},
],
manifest: {
documents: {
included: "encrypted-content",
note: "Encrypted content included",
},
workouts: { included: "summary-only", note: "Summary only" },
mocks.buildFullBackupPayload.mockResolvedValue({
payload: {
schemaVersion: "1",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "user-dr",
moodEntries: [
{
id: "mood-dr",
externalId: "mood-external-dr",
factors: [{ key: "sleep_quality", rating: 5 }],
},
],
documents: [
{
id: "document-dr",
contentEncrypted: documentCiphertext,
contentCodec: "binary2",
},
],
},
counts: {},
});
});
it("requests canonical records and serializes rated mood factors", async () => {
it("serializes the shared canonical disaster-recovery payload", async () => {
await handleDataBackup([]);
expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith(
expect.anything(),
const prisma = mocks.getWorkerPrisma.mock.results[0]!.value;
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(
prisma,
"user-dr",
{ purpose: "disaster-recovery" },
);
+7 -141
View File
@@ -6,17 +6,10 @@
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { encrypt } from "@/lib/crypto";
import { withBackgroundEvent } from "@/lib/logging/background";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import { buildRecordsBackupSection } from "@/lib/export/records-backup";
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
import {
collectPagedMeasurements,
MEASUREMENT_BACKUP_PAGE_SIZE,
sortWeeklyMeasurementsDesc,
toWeeklyBackupMeasurement,
} from "@/lib/jobs/backup/measurement-page";
import { getWorkerPrisma } from "./shared";
export interface DataBackupPayload {
@@ -65,139 +58,12 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
let backed = 0;
for (const user of users) {
try {
const [
measurements,
medications,
intakeEvents,
moodEntries,
cycle,
records,
] = await Promise.all([
// Keyset-paged narrow read instead of one full-set
// findMany + map: a heavy multi-year tenant otherwise
// loads every measurement ORM row into a single array
// alongside the giant JSON.stringify below, the worker's
// dominant heap spike. The page reader projects each page
// to the compact backup shape before the next page loads,
// bounding peak heap to the page size. Row scope is
// unchanged — no `deletedAt` filter, so tombstoned rows
// still round-trip exactly as before. The order is
// restored to measuredAt-desc to match the prior output.
collectPagedMeasurements({
fetchPage: (afterId, take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
...(afterId ? { id: { gt: afterId } } : {}),
},
// Exactly the columns the admin restore recreates a
// row from, plus `id` for the keyset cursor.
select: {
id: true,
type: true,
value: true,
unit: true,
source: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
},
orderBy: { id: "asc" },
take,
}),
project: toWeeklyBackupMeasurement,
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
}).then(sortWeeklyMeasurementsDesc),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId: user.id },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId: user.id },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
},
}),
// v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim).
buildCycleBackupSection(prisma, user.id),
// Canonical DR mode includes InboundDocument ciphertext and the
// exact codec/hash metadata needed to re-create the row.
buildRecordsBackupSection(prisma, user.id, {
purpose: "disaster-recovery",
}),
]);
const backupJson = JSON.stringify({
// Bumped only when the on-disk shape changes incompatibly.
// Mirrors `BACKUP_SCHEMA_VERSION` in
// `src/lib/validations/backup.ts` — keep them in sync.
schemaVersion: "1",
exportedAt: new Date().toISOString(),
userId: user.id,
// Already projected + ordered by the paged reader above; the
// decrypted note rode into each row so an admin restore
// re-encrypts on re-insert (v1.23 contract).
measurements,
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
})),
})),
intakeEvents: intakeEvents.map((e) => ({
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
skipped: e.skipped,
source: e.source,
})),
moodEntries: moodEntries.map((e) => ({
id: e.id,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
externalId: e.externalId,
factors: e.tagLinks.map((link) => ({
key: link.moodTag.key,
rating: link.rating,
})),
})),
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
// Canonical disaster-recovery records. Documents carry encrypted
// bytes plus codec/hash metadata; workout route/sample sidecars
// remain outside the serialized workout-summary contract.
labResults: records.labResults,
biomarkers: records.biomarkers,
illnessEpisodes: records.illnessEpisodes,
allergies: records.allergies,
familyHistory: records.familyHistory,
workouts: records.workouts,
documents: records.documents,
manifest: records.manifest,
});
const { payload } = await buildFullBackupPayload(
prisma,
user.id,
{ purpose: "disaster-recovery" },
);
const backupJson = JSON.stringify(payload);
// Encrypt the backup data (contains sensitive health information)
const encryptedBackup = encrypt(backupJson);
@@ -383,3 +383,135 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
expect(summary.documents).toBe(0);
});
});
describe("backupPayloadSchema — database enum boundaries", () => {
const enumPayload = {
schemaVersion: "1",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "u1",
measurements: [
{
id: "measurement-1",
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: "2026-07-19T07:00:00.000Z",
source: "MANUAL",
},
],
intakeEvents: [
{
medication: "Example",
scheduledFor: "2026-07-19T08:00:00.000Z",
source: "WEB",
},
],
cycleProfile: {
goal: "GENERAL_HEALTH",
secondarySymptom: "MUCUS",
},
cycleDayLogs: [
{
date: "2026-07-19",
flow: "MEDIUM",
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
pregnancyTest: "NEGATIVE",
progesteroneTest: "INDETERMINATE",
contraceptive: "NONE",
source: "MANUAL",
},
],
illnessEpisodes: [
{
id: "illness-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
},
],
allergies: [
{
id: "allergy-1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
},
],
familyHistory: [
{
id: "family-1",
relationship: "MOTHER",
condition: "Diabetes",
},
],
workouts: [
{
id: "workout-1",
sportType: "RUNNING",
startedAt: "2026-07-19T06:00:00.000Z",
endedAt: "2026-07-19T06:30:00.000Z",
durationSec: 1800,
source: "APPLE_HEALTH",
},
],
documents: [
{
id: "document-1",
kind: "LAB_RESULT",
mimeType: "application/pdf",
byteSize: 42,
status: "STORED",
summaryState: "NONE",
},
],
};
const enumPaths: Array<[string, Array<string | number>]> = [
["measurement type", ["measurements", 0, "type"]],
["measurement source", ["measurements", 0, "source"]],
["intake source", ["intakeEvents", 0, "source"]],
["cycle goal", ["cycleProfile", "goal"]],
["cycle secondary symptom", ["cycleProfile", "secondarySymptom"]],
["cycle flow", ["cycleDayLogs", 0, "flow"]],
["cycle ovulation test", ["cycleDayLogs", 0, "ovulationTest"]],
["cycle cervical mucus", ["cycleDayLogs", 0, "cervicalMucus"]],
["cycle cervix position", ["cycleDayLogs", 0, "cervixPosition"]],
["cycle cervix firmness", ["cycleDayLogs", 0, "cervixFirmness"]],
["cycle cervix opening", ["cycleDayLogs", 0, "cervixOpening"]],
["cycle pregnancy test", ["cycleDayLogs", 0, "pregnancyTest"]],
["cycle progesterone test", ["cycleDayLogs", 0, "progesteroneTest"]],
["cycle contraceptive", ["cycleDayLogs", 0, "contraceptive"]],
["cycle source", ["cycleDayLogs", 0, "source"]],
["illness type", ["illnessEpisodes", 0, "type"]],
["illness lifecycle", ["illnessEpisodes", 0, "lifecycle"]],
["allergy category", ["allergies", 0, "category"]],
["allergy type", ["allergies", 0, "type"]],
["allergy severity", ["allergies", 0, "severity"]],
["allergy status", ["allergies", 0, "status"]],
["family relationship", ["familyHistory", 0, "relationship"]],
["workout source", ["workouts", 0, "source"]],
["document kind", ["documents", 0, "kind"]],
["document status", ["documents", 0, "status"]],
["document summary state", ["documents", 0, "summaryState"]],
];
it.each(enumPaths)("rejects an unsupported %s", (_label, path) => {
const candidate = structuredClone(enumPayload);
let cursor: Record<PropertyKey, unknown> = candidate;
for (const segment of path.slice(0, -1)) {
cursor = cursor[segment] as Record<PropertyKey, unknown>;
}
cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM";
const parsed = backupPayloadSchema.safeParse(candidate);
expect(parsed.success).toBe(false);
});
});
+55 -26
View File
@@ -20,6 +20,32 @@
* a useful summary for them.
*/
import { z } from "zod/v4";
import {
AllergyCategory,
AllergySeverity,
AllergyStatus,
AllergyType,
CervicalMucus,
CervixFirmness,
CervixOpening,
CervixPosition,
ContraceptiveKind,
CycleTrackingGoal,
DocumentSummaryState,
FamilyRelationship,
FlowLevel,
HomeTestResult,
IllnessLifecycle,
IllnessType,
InboundDocumentKind,
InboundDocumentStatus,
IntakeSource,
MeasurementSource,
MeasurementType,
OvulationTest,
SecondarySymptom,
} from "@/generated/prisma/enums";
export const BACKUP_SCHEMA_VERSION = "1" as const;
@@ -32,12 +58,14 @@ const isoDateTime = z
const measurementSchema = z
.object({
type: z.string().min(1),
id: z.string().min(1).optional(),
type: z.enum(MeasurementType),
value: z.number(),
unit: z.string().min(1),
measuredAt: isoDateTime,
source: z.string().min(1).optional(),
source: z.enum(MeasurementSource).optional(),
notes: z.string().nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
@@ -65,7 +93,7 @@ const intakeEventSchema = z
scheduledFor: isoDateTime,
takenAt: isoDateTime.nullable().optional(),
skipped: z.boolean().optional(),
source: z.string().min(1).optional(),
source: z.enum(IntakeSource).optional(),
})
.passthrough();
@@ -114,6 +142,7 @@ const moodEntrySchema = z
source: z.string().min(1).optional(),
externalId: z.string().nullable().optional(),
loggedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
factors: z.array(moodFactorSchema).default([]),
})
.passthrough();
@@ -148,23 +177,23 @@ const cycleSpanSchema = z
const cycleDayLogSchema = z
.object({
date: z.string().min(1),
flow: z.string().nullable().optional(),
flow: z.enum(FlowLevel).nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().nullable().optional(),
temperatureExcluded: z.boolean().optional(),
ovulationTest: z.string().nullable().optional(),
cervicalMucus: z.string().nullable().optional(),
cervixPosition: z.string().nullable().optional(),
cervixFirmness: z.string().nullable().optional(),
cervixOpening: z.string().nullable().optional(),
ovulationTest: z.enum(OvulationTest).nullable().optional(),
cervicalMucus: z.enum(CervicalMucus).nullable().optional(),
cervixPosition: z.enum(CervixPosition).nullable().optional(),
cervixFirmness: z.enum(CervixFirmness).nullable().optional(),
cervixOpening: z.enum(CervixOpening).nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: z.string().nullable().optional(),
progesteroneTest: z.string().nullable().optional(),
contraceptive: z.string().nullable().optional(),
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
sensitiveEncrypted: z.string().nullable().optional(),
notesEncrypted: z.string().nullable().optional(),
source: z.string().min(1).optional(),
source: z.enum(MeasurementSource).optional(),
externalId: z.string().nullable().optional(),
tz: z.string().nullable().optional(),
symptomKeys: z.array(z.string()).default([]),
@@ -174,12 +203,12 @@ const cycleDayLogSchema = z
/** Cycle-tracking preferences (one row per user). */
const cycleProfileSchema = z
.object({
goal: z.string().min(1).optional(),
goal: z.enum(CycleTrackingGoal).optional(),
cycleTrackingEnabled: z.boolean().nullable().optional(),
typicalCycleLength: z.number().int().nullable().optional(),
typicalPeriodLength: z.number().int().nullable().optional(),
lutealPhaseLength: z.number().int().nullable().optional(),
secondarySymptom: z.string().nullable().optional(),
secondarySymptom: z.enum(SecondarySymptom).nullable().optional(),
predictionEnabled: z.boolean().optional(),
rawChartMode: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
@@ -254,8 +283,8 @@ const illnessEpisodeBackupSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
type: z.string().min(1),
lifecycle: z.string().min(1),
type: z.enum(IllnessType),
lifecycle: z.enum(IllnessLifecycle),
onsetAt: isoDateTime,
resolvedAt: isoDateTime.nullable().optional(),
// Self-referencing flare/exacerbation link, carried as the exported
@@ -272,10 +301,10 @@ const allergyBackupSchema = z
.object({
id: z.string().min(1),
substance: z.string().min(1),
category: z.string().min(1),
type: z.string().min(1),
severity: z.string().nullable().optional(),
status: z.string().min(1),
category: z.enum(AllergyCategory),
type: z.enum(AllergyType),
severity: z.enum(AllergySeverity).nullable().optional(),
status: z.enum(AllergyStatus),
onsetAt: isoDateTime.nullable().optional(),
reaction: z.string().nullable().optional(),
note: z.string().nullable().optional(),
@@ -287,7 +316,7 @@ const allergyBackupSchema = z
const familyHistoryBackupSchema = z
.object({
id: z.string().min(1),
relationship: z.string().min(1),
relationship: z.enum(FamilyRelationship),
condition: z.string().min(1),
ageAtOnset: z.number().int().nullable().optional(),
note: z.string().nullable().optional(),
@@ -311,7 +340,7 @@ const workoutBackupSchema = z
stepCount: z.number().int().nullable().optional(),
elevationM: z.number().nullable().optional(),
pauseDurationSec: z.number().int().nullable().optional(),
source: z.string().min(1),
source: z.enum(MeasurementSource),
externalId: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
@@ -330,12 +359,12 @@ const base64BytesSchema = z
const documentBackupSchema = z
.object({
id: z.string().min(1),
kind: z.string().min(1),
kind: z.enum(InboundDocumentKind),
title: z.string().nullable().optional(),
filename: z.string().nullable().optional(),
mimeType: z.string().min(1),
byteSize: z.number().int(),
status: z.string().min(1),
status: z.enum(InboundDocumentStatus),
reportDate: z.string().nullable().optional(),
documentDate: z.string().nullable().optional(),
contentEncrypted: base64BytesSchema.optional(),
@@ -349,7 +378,7 @@ const documentBackupSchema = z
errorReason: z.string().nullable().optional(),
summaryEncrypted: base64BytesSchema.nullable().optional(),
summaryGeneratedAt: isoDateTime.nullable().optional(),
summaryState: z.string().min(1).optional(),
summaryState: z.enum(DocumentSummaryState).optional(),
summary: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
@@ -4,11 +4,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
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 { buildRecordsBackupSection } from "@/lib/export/records-backup";
import { backupPayloadSchema } from "@/lib/validations/backup";
import {
decryptBackup,
runOffhostBackup,
type S3Like,
} from "@/lib/jobs/offhost-backup";
import {
backupPayloadSchema,
parseBackupPayload,
} from "@/lib/validations/backup";
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
import { invalidateUserData } from "@/lib/cache/invalidate";
@@ -74,8 +89,32 @@ function makeRequest(id: string) {
});
}
function makeS3Store(): S3Like & { objects: Map<string, Buffer> } {
const objects = new Map<string, Buffer>();
return {
objects,
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 every serialized record class and document ciphertext verbatim", async () => {
it("restores an off-host payload with tombstones and encrypted documents", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const ownerId = admin.id;
@@ -91,6 +130,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
source: "MANUAL",
},
});
const tombstoneDeletedAt = new Date("2026-07-01T12:00:00.000Z");
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: {
@@ -129,6 +181,18 @@ describe("canonical disaster-recovery backup round-trip", () => {
},
},
});
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 biomarker = await prisma.biomarker.create({
data: {
@@ -265,48 +329,27 @@ describe("canonical disaster-recovery backup round-trip", () => {
},
});
const records = await buildRecordsBackupSection(prisma, ownerId, {
purpose: "disaster-recovery",
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 payload = backupPayloadSchema.parse({
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"),
),
);
expect(payload).toMatchObject({
schemaVersion: "1",
exportedAt: "2026-07-02T00:00:00.000Z",
userId: ownerId,
measurements: [
{
type: measurement.type,
value: measurement.value,
unit: measurement.unit,
measuredAt: measurement.measuredAt.toISOString(),
source: measurement.source,
},
],
medications: [],
intakeEvents: [],
moodEntries: [
{
id: mood.id,
date: mood.date,
mood: mood.mood,
score: mood.score,
tags: mood.tags,
source: mood.source,
loggedAt: mood.moodLoggedAt.toISOString(),
factors: mood.tagLinks.map((link) => ({
key: link.moodTag.key,
rating: link.rating,
})),
},
],
labResults: records.labResults,
biomarkers: records.biomarkers,
illnessEpisodes: records.illnessEpisodes,
allergies: records.allergies,
familyHistory: records.familyHistory,
workouts: records.workouts,
documents: records.documents,
manifest: records.manifest,
});
expect(payload.documents[0]).not.toHaveProperty("summary");
expect(JSON.stringify(payload.documents[0])).not.toContain(
@@ -345,25 +388,30 @@ describe("canonical disaster-recovery backup round-trip", () => {
expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1);
expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1);
expect(
await prisma.measurement.findMany({
where: { userId: ownerId },
select: {
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
},
await prisma.measurement.findUniqueOrThrow({
where: { id: measurement.id },
}),
).toEqual([
{
).toEqual(
expect.objectContaining({
userId: ownerId,
type: measurement.type,
value: measurement.value,
unit: measurement.unit,
measuredAt: measurement.measuredAt,
source: measurement.source,
},
]);
deletedAt: null,
}),
);
expect(
await prisma.measurement.findUniqueOrThrow({
where: { id: deletedMeasurement.id },
}),
).toEqual(
expect.objectContaining({
userId: ownerId,
deletedAt: tombstoneDeletedAt,
}),
);
const restoredLab = await prisma.labResult.findUniqueOrThrow({
where: { id: lab.id },
@@ -519,6 +567,16 @@ describe("canonical disaster-recovery backup round-trip", () => {
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(
@@ -546,6 +604,76 @@ describe("canonical disaster-recovery backup round-trip", () => {
}),
).toEqual([{ id: illness.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();
+63 -1
View File
@@ -70,7 +70,34 @@ async function seedAdminSession() {
return admin;
}
function buildPayload(userId: string) {
interface UploadPayloadFixture {
schemaVersion: string;
exportedAt: string;
userId: string;
measurements: Array<{
type: string;
value: number;
unit: string;
measuredAt: string;
source: string;
notes: null;
}>;
medications: Array<{
name: string;
dose: string;
active: boolean;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string;
dose: null;
}>;
}>;
intakeEvents: [];
moodEntries: [];
}
function buildPayload(userId: string): UploadPayloadFixture {
return {
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
@@ -211,6 +238,36 @@ describe("POST /api/admin/backups/upload", () => {
expect(denied[0]?.details ?? "").toContain("owner_not_found");
});
it.each([
["measurement type", (payload: UploadPayloadFixture) => {
payload.measurements[0]!.type = "NOT_A_MEASUREMENT";
}],
["measurement source", (payload: UploadPayloadFixture) => {
payload.measurements[0]!.source = "NOT_A_SOURCE";
}],
])("rejects an invalid %s with 422 and zero backup writes", async (_label, mutate) => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = buildPayload(admin.id);
mutate(payload);
const file = new File([JSON.stringify(payload)], "invalid-enum.json", {
type: "application/json",
});
const { POST } = await import("@/app/api/admin/backups/upload/route");
const res = await POST(
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
data: null,
error: "Backup payload failed schema validation",
meta: { issues: expect.any(Array) },
});
expect(await prisma.dataBackup.count()).toBe(0);
});
it("rejects an incompatible schemaVersion with 422", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
@@ -224,6 +281,11 @@ describe("POST /api/admin/backups/upload", () => {
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
);
expect(await res.json()).toMatchObject({
data: null,
error: expect.stringContaining("not supported"),
});
expect(await prisma.dataBackup.count()).toBe(0);
expect(res.status).toBe(422);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.upload.denied", userId: admin.id },