Report every restored section, disclose export decrypt losses, re-fold on zone change

- 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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 06:14:13 +02:00
1 parent ac44b35867
commit 97cb63a77f
7 files changed
+237

No files matched your search

@@ -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,
},
},
});
@@ -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({
+19
View File
@@ -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),
@@ -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<string, unknown> | 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();
});
});
+25
View File
@@ -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<ProfileBackupSection> {
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,
};
}
@@ -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", () => {
+26
View File
@@ -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,
};
}