From 97cb63a77f5e4d014a730128559f9ade861ddf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 06:14:13 +0200 Subject: [PATCH] Report every restored section, disclose export decrypt losses, re-fold on zone change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The restore summary counts practitioners, encounters, the three link tables, and vaccinations — restored in full since 08-01 but absent from the report, so a file carrying visits or an Impfpass read as if those sections were empty. - A portable export that cannot open a field's ciphertext still nulls it fail-soft, but now names the loss in the file's own decryptFailures manifest — an export with a lost emergency note is no longer byte-identical to one where the note was never written. - A timezone change enqueues the medication-compliance re-fold: the rollup keys its rows by the local day minted under the OLD zone, and the coverage probe compares counts, not keys, so nothing else could notice the stale bucketing. --- .../api/admin/backups/[id]/restore/route.ts | 5 ++ .../auth/me/timezone/__tests__/route.test.ts | 36 +++++++++ src/app/api/auth/me/timezone/route.ts | 19 +++++ .../profile-backup-decrypt-failures.test.ts | 81 +++++++++++++++++++ src/lib/export/profile-backup.ts | 25 ++++++ src/lib/validations/__tests__/backup.test.ts | 45 +++++++++++ src/lib/validations/backup.ts | 26 ++++++ 7 files changed, 237 insertions(+) create mode 100644 src/lib/export/__tests__/profile-backup-decrypt-failures.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 6bfb9b39b..13976d771 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -1532,6 +1532,11 @@ const handler = apiHandler( customMetrics: summary.customMetrics, customMetricEntries: summary.customMetricEntries, correlationPatterns: summary.correlationPatterns, + practitioners: summary.practitioners, + encounters: summary.encounters, + encounterLinks: summary.encounterLinks, + vaccinations: summary.vaccinations, + vaccinationLinks: summary.vaccinationLinks, }, }, }); diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index c8b42b530..8fa2a9216 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -23,6 +23,14 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +// v1.37.19 (A6-12) — the zone change enqueues a compliance re-fold. +const refoldMock = vi.hoisted(() => + vi.fn(async () => ({ enqueued: true, error: null })), +); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + enqueueUserMedicationComplianceBackfill: refoldMock, +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -96,6 +104,34 @@ describe("PUT /api/auth/me/timezone", () => { expect(res.status).toBe(422); }); + // Watched red: with the re-fold enqueue removed from the route (the + // pre-v1.37.19 write), the changed-zone case fails — the compliance + // rollup's day keys were minted under the old zone and nothing ever + // re-bucketed them (the coverage probe compares counts, not keys). + it("enqueues a compliance re-fold when the zone actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await PUT(mkReq({ timezone: "America/New_York" })); + expect(res.status).toBe(200); + expect(refoldMock).toHaveBeenCalledWith("user-1"); + }); + + it("does not re-fold when the zone is unchanged", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); + expect(res.status).toBe(200); + expect(refoldMock).not.toHaveBeenCalled(); + }); + it("trims whitespace before validating", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 1ac07297a..0bc0c0503 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -29,6 +29,7 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -60,6 +61,24 @@ export const PUT = apiHandler(async (request: NextRequest) => { invalidateUserTimezone(user.id); + // v1.37.19 (A6-12) — the medication-compliance rollup keys its rows by + // the LOCAL day the dose fell on, minted under the timezone in force at + // write time. A zone change re-buckets those days, so the stored keys go + // stale (and the coverage probe compares counts, not keys — it cannot + // notice). Re-fold the trailing window through the ordinary backfill + // job; best-effort, the singleton key collapses rapid re-picks. + if ((before?.timezone ?? null) !== tz) { + const refold = await enqueueUserMedicationComplianceBackfill(user.id); + annotate({ + meta: { + timezone_compliance_refold_enqueued: refold.enqueued, + ...(refold.error + ? { timezone_compliance_refold_error: refold.error } + : {}), + }, + }); + } + await auditLog("user.timezone.update", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/lib/export/__tests__/profile-backup-decrypt-failures.test.ts b/src/lib/export/__tests__/profile-backup-decrypt-failures.test.ts new file mode 100644 index 000000000..48b03e390 --- /dev/null +++ b/src/lib/export/__tests__/profile-backup-decrypt-failures.test.ts @@ -0,0 +1,81 @@ +/** + * v1.37.19 (A6-9) — the portable export's decryptFailures manifest. + * + * Watched red: with the collector dropped from `decryptProfileFieldSoft` + * (the pre-fix fail-soft that only nulled the field), the first case fails + * — an export with an unreadable emergency note was byte-identical to one + * where the note was never written, so the loss was invisible to the + * person restoring elsewhere. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (buf: Uint8Array) => { + const tag = Buffer.from(buf).toString("utf8"); + if (tag === "__bad__") throw new Error("unknown key id"); + return `dec:${tag}`; + }, + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(s)), +})); + +import { buildProfileBackupSection } from "../profile-backup"; + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +function makePrisma(profileRow: Record | null) { + return { + userHealthProfile: { findUnique: vi.fn(async () => profileRow) }, + customMetric: { findMany: vi.fn(async () => []) }, + healthProfileFactRevision: { findMany: vi.fn(async () => []) }, + correlationPattern: { findMany: vi.fn(async () => []) }, + } as never; +} + +const PROFILE = { + id: "hp-1", + aboutMeEncrypted: bytes("about"), + conditionsEncrypted: null, + allergiesEncrypted: null, + coachFocusEncrypted: null, + pendingQuestionsEncrypted: null, + aiIncludedSections: undefined, + emergencyBloodType: null, + organDonorStatus: null, + advanceDirectiveStatus: null, + emergencyContactsEncrypted: null, + emergencyImplantsEncrypted: null, + emergencyNoteEncrypted: bytes("__bad__"), + createdAt: new Date("2026-08-01T00:00:00Z"), + updatedAt: new Date("2026-08-01T00:00:00Z"), +}; + +describe("buildProfileBackupSection — decryptFailures manifest", () => { + it("discloses an unreadable field in the file instead of a silent null", async () => { + const section = await buildProfileBackupSection(makePrisma(PROFILE), "u1"); + + // The field is still fail-soft null (one unreadable column must not + // cost the rest of the backup) … + expect(section.healthProfile?.emergencyNote).toBeNull(); + expect(section.healthProfile?.aboutMe).toBe("dec:about"); + // … but the loss is named in the manifest. + expect(section.decryptFailures).toEqual(["healthProfile.emergencyNote"]); + }); + + it("reports an empty manifest when everything decrypts", async () => { + const section = await buildProfileBackupSection( + makePrisma({ ...PROFILE, emergencyNoteEncrypted: bytes("note") }), + "u1", + ); + expect(section.decryptFailures).toEqual([]); + }); + + it("never decrypts on the disaster-recovery path (empty manifest)", async () => { + const section = await buildProfileBackupSection(makePrisma(PROFILE), "u1", { + purpose: "disaster-recovery", + }); + expect(section.decryptFailures).toEqual([]); + expect(section.healthProfile?.emergencyNote).toBeNull(); + }); +}); diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 955b90c87..50253e6b0 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -175,6 +175,16 @@ export interface ProfileBackupSection { customMetrics: CustomMetricBackupEntry[]; healthProfileFacts: HealthProfileFactBackupEntry[]; correlationPatterns: CorrelationPatternBackupEntry[]; + /** + * v1.37.19 (A6-9) — field paths whose ciphertext this instance could not + * open while building a PORTABLE export (fail-soft nulls). Disclosed in + * the file itself so an export with a nulled emergency field no longer + * reads byte-identical to one where the field was never written — the + * person restoring elsewhere can see exactly what was lost. Always empty + * on the disaster-recovery path (DR carries ciphertext verbatim and + * never decrypts). + */ + decryptFailures: string[]; } export interface ProfileBackupCounts { @@ -198,6 +208,8 @@ export interface ProfileBackupCounts { function decryptProfileFieldSoft( buf: Uint8Array | null, field: string, + /** v1.37.19 (A6-9) — collector for the file's decryptFailures manifest. */ + failures?: string[], ): string | null { if (!buf || buf.byteLength === 0) return null; try { @@ -208,6 +220,7 @@ function decryptProfileFieldSoft( err instanceof Error ? err.message : String(err) }`, ); + failures?.push(`healthProfile.${field}`); return null; } } @@ -235,6 +248,9 @@ export async function buildProfileBackupSection( options: ProfileBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; + // v1.37.19 (A6-9) — every fail-soft decrypt below records its field path + // here; the list rides the exported file as its decryptFailures manifest. + const decryptFailures: string[] = []; const [profileRow, metricRows, factRows, patternRows] = await Promise.all([ prisma.userHealthProfile.findUnique({ where: { userId } }), @@ -296,18 +312,22 @@ export async function buildProfileBackupSection( aboutMe: decryptProfileFieldSoft( profileRow.aboutMeEncrypted, "aboutMe", + decryptFailures, ), conditions: decryptProfileFieldSoft( profileRow.conditionsEncrypted, "conditions", + decryptFailures, ), allergies: decryptProfileFieldSoft( profileRow.allergiesEncrypted, "allergies", + decryptFailures, ), coachFocus: decryptProfileFieldSoft( profileRow.coachFocusEncrypted, "coachFocus", + decryptFailures, ), aiIncludedSections: (profileRow.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? [ @@ -319,14 +339,17 @@ export async function buildProfileBackupSection( emergencyContacts: decryptProfileFieldSoft( profileRow.emergencyContactsEncrypted, "emergencyContacts", + decryptFailures, ), emergencyImplants: decryptProfileFieldSoft( profileRow.emergencyImplantsEncrypted, "emergencyImplants", + decryptFailures, ), emergencyNote: decryptProfileFieldSoft( profileRow.emergencyNoteEncrypted, "emergencyNote", + decryptFailures, ), } : null; @@ -353,6 +376,7 @@ export async function buildProfileBackupSection( const value = decryptProfileFieldSoft( fact.valueEncrypted, `fact.${fact.kind}`, + decryptFailures, ); const kind = fact.kind as HealthProfileFactKind; if ( @@ -456,6 +480,7 @@ export async function buildProfileBackupSection( healthProfileFacts, customMetrics, correlationPatterns, + decryptFailures, }; } diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index d65b46652..162251544 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -387,6 +387,51 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => { expect(summary.familyHistory).toBe(0); expect(summary.documents).toBe(0); }); + + // Watched red: with the v1.37.19 section counts removed from + // `summarizeBackup` (the pre-fix shape stopped at healthScoreRecords), + // every assertion below fails — a file carrying visits or an Impfpass + // was restored in full but reported as if those sections were empty. + it("summarizeBackup counts practitioners, encounters, links, and vaccinations", () => { + const ts = "2026-05-08T07:00:00.000Z"; + const parsed = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: ts, + userId: "u1", + practitioners: [ + { id: "pr-1", name: "Praxis", createdAt: ts, updatedAt: ts }, + ], + encounters: [ + { + id: "en-1", + occurredAt: ts, + status: "DONE", + kind: "ROUTINE", + createdAt: ts, + updatedAt: ts, + }, + ], + encounterDocumentLinks: [ + { encounterId: "en-1", targetId: "doc-1", createdAt: ts }, + ], + encounterLabLinks: [ + { encounterId: "en-1", targetId: "lab-1", createdAt: ts }, + ], + encounterConditionLinks: [], + vaccinations: [ + { id: "va-1", occurredAt: ts, createdAt: ts, updatedAt: ts }, + ], + vaccinationDocumentLinks: [ + { vaccinationId: "va-1", targetId: "doc-1", createdAt: ts }, + ], + }); + const summary = summarizeBackup(parsed); + expect(summary.practitioners).toBe(1); + expect(summary.encounters).toBe(1); + expect(summary.encounterLinks).toBe(2); + expect(summary.vaccinations).toBe(1); + expect(summary.vaccinationLinks).toBe(1); + }); }); describe("backupPayloadSchema — canonical v2 identity compatibility", () => { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 4b08b9ce0..2f1e696e1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1108,6 +1108,10 @@ export const backupPayloadSchema = z vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), + // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt + // (fail-soft nulls). Disclosed in the file so a nulled field is + // distinguishable from one never written. Empty/absent on DR payloads. + decryptFailures: z.array(z.string()).default([]), }) .passthrough() .superRefine((payload, ctx) => { @@ -1175,6 +1179,16 @@ export interface BackupSummary { intradayProfiles: number; /** Local days whose health score was written down as it was shown. */ healthScoreRecords: number; + /** v1.37.19 (A6-8) — the visit address book. */ + practitioners: number; + /** v1.37.19 (A6-8) — doctor visits, planned and past. */ + encounters: number; + /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ + encounterLinks: number; + /** v1.37.19 (A6-8) — immunization log entries. */ + vaccinations: number; + /** v1.37.19 (A6-8) — vaccination↔document links. */ + vaccinationLinks: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1214,6 +1228,18 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, healthScoreRecords: payload.healthScoreRecords.length, + // v1.37.19 (A6-8) — the sections restored since 08-01 were written and + // restored but absent from this report, so the admin's "what did I just + // restore" answer silently under-counted a file that carried visits or + // an Impfpass. + practitioners: payload.practitioners.length, + encounters: payload.encounters.length, + encounterLinks: + payload.encounterDocumentLinks.length + + payload.encounterLabLinks.length + + payload.encounterConditionLinks.length, + vaccinations: payload.vaccinations.length, + vaccinationLinks: payload.vaccinationDocumentLinks.length, }; }