From f8b0a64d691c765dfbdcacf98c9e30aac803eadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:44:29 +0200 Subject: [PATCH] fix(backups): preserve canonical recovery integrity --- .../api/admin/backups/[id]/restore/route.ts | 231 +++++++++-------- .../__tests__/full-backup-payload.test.ts | 137 ++++++++++ src/lib/export/full-backup-payload.ts | 40 ++- src/lib/jobs/__tests__/offhost-backup.test.ts | 150 +++++++---- src/lib/jobs/offhost-backup.ts | 150 +---------- .../__tests__/backup-handlers.test.ts | 97 ++----- src/lib/jobs/reminder/backup-handlers.ts | 148 +---------- src/lib/validations/__tests__/backup.test.ts | 132 ++++++++++ src/lib/validations/backup.ts | 81 ++++-- .../admin-backups-canonical-roundtrip.test.ts | 238 ++++++++++++++---- .../integration/admin-backups-upload.test.ts | 64 ++++- 11 files changed, 853 insertions(+), 615 deletions(-) create mode 100644 src/lib/export/__tests__/full-backup-payload.test.ts diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 68772b223..86876f8e5 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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 { 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, })), diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts new file mode 100644 index 000000000..29b327804 --- /dev/null +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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 }], + }, + ], + }); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 85229e03c..211cdbf17 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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 { + 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, diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 307617c02..6918cd0ca 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -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); }); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index d0c2ee672..00cb3a2d7 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -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++; diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index f829ed0be..5ee8ff29d 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -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" }, ); diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index f289a3846..8b419ae2e 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -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[]) { 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); diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index 69317751f..595f3e375 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -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]> = [ + ["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 = candidate; + for (const segment of path.slice(0, -1)) { + cursor = cursor[segment] as Record; + } + cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM"; + + const parsed = backupPayloadSchema.safeParse(candidate); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 1b0980090..fb50d852b 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 3aa274805..ee2b12f1a 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -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 } { + const objects = new Map(); + 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) => { + 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) => { + 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 = { + 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[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(); diff --git a/tests/integration/admin-backups-upload.test.ts b/tests/integration/admin-backups-upload.test.ts index b5c692553..39c822251 100644 --- a/tests/integration/admin-backups-upload.test.ts +++ b/tests/integration/admin-backups-upload.test.ts @@ -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[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[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 },