From a53dd2b59c10432c455763bd9a937fe83ebbe180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:46:54 +0200 Subject: [PATCH] fix(doctor-report): mark an undecryptable allergy reaction instead of blanking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clinician-facing export failed SOFT on an undecryptable allergy reaction (→ null, rendered as a bare '—' indistinguishable from 'no reaction recorded') while a sibling dose-change note fails CLOSED. Extract a pure decryptAllergyReaction helper that keeps 'unreadable' distinct from 'unset', log the swallowed decrypt, and render an honest localized marker in the PDF so a clinician is never shown a silent blank where an unreadable reaction exists. Read-path only — the fail-closed write posture is untouched. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../doctor-report-allergy-reaction.test.ts | 57 +++++++++++++++++++ src/lib/doctor-report-data.ts | 56 +++++++++++++++--- src/lib/doctor-report-pdf-core.ts | 6 +- 9 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 src/lib/__tests__/doctor-report-allergy-reaction.test.ts diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..c88ace188 100644 --- a/messages/de.json +++ b/messages/de.json @@ -189,6 +189,7 @@ "illnessColOnset": "Beginn", "illnessColResolved": "Abgeklungen", "allergiesTitle": "Allergien & Unverträglichkeiten", + "reactionUnreadable": "Reaktion erfasst, konnte aber nicht entschlüsselt werden", "allergiesColSubstance": "Substanz", "allergiesColCategory": "Kategorie", "allergiesColKind": "Art", diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..9d65503d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -189,6 +189,7 @@ "illnessColOnset": "Onset", "illnessColResolved": "Resolved", "allergiesTitle": "Allergies & intolerances", + "reactionUnreadable": "Reaction recorded but could not be decrypted", "allergiesColSubstance": "Substance", "allergiesColCategory": "Category", "allergiesColKind": "Kind", diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..afdabfc42 100644 --- a/messages/es.json +++ b/messages/es.json @@ -189,6 +189,7 @@ "illnessColOnset": "Inicio", "illnessColResolved": "Resuelta", "allergiesTitle": "Alergias e intolerancias", + "reactionUnreadable": "Reacción registrada pero no se pudo descifrar", "allergiesColSubstance": "Sustancia", "allergiesColCategory": "Categoría", "allergiesColKind": "Tipo", diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..90b18136a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -189,6 +189,7 @@ "illnessColOnset": "Début", "illnessColResolved": "Résolue", "allergiesTitle": "Allergies et intolérances", + "reactionUnreadable": "Réaction enregistrée mais impossible à déchiffrer", "allergiesColSubstance": "Substance", "allergiesColCategory": "Catégorie", "allergiesColKind": "Type", diff --git a/messages/it.json b/messages/it.json index 933289bb6..b4296d3bf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -189,6 +189,7 @@ "illnessColOnset": "Inizio", "illnessColResolved": "Risolta", "allergiesTitle": "Allergie e intolleranze", + "reactionUnreadable": "Reazione registrata ma impossibile da decifrare", "allergiesColSubstance": "Sostanza", "allergiesColCategory": "Categoria", "allergiesColKind": "Tipo", diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..71df1aca9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -189,6 +189,7 @@ "illnessColOnset": "Początek", "illnessColResolved": "Ustąpiła", "allergiesTitle": "Alergie i nietolerancje", + "reactionUnreadable": "Reakcja zapisana, ale nie można jej odszyfrować", "allergiesColSubstance": "Substancja", "allergiesColCategory": "Kategoria", "allergiesColKind": "Rodzaj", diff --git a/src/lib/__tests__/doctor-report-allergy-reaction.test.ts b/src/lib/__tests__/doctor-report-allergy-reaction.test.ts new file mode 100644 index 000000000..cff19fd89 --- /dev/null +++ b/src/lib/__tests__/doctor-report-allergy-reaction.test.ts @@ -0,0 +1,57 @@ +/** + * F-CRYPTO-3 — an undecryptable allergy reaction must be distinguishable from a + * genuinely-unset one on a clinician-facing export. The builder used to fail + * SOFT (→ null, silently omitted) where a sibling dose-change note fails CLOSED, + * so a reaction that existed but was unreadable rendered as a blank "—" that a + * clinician reads as "no reaction recorded". `decryptAllergyReaction` now flags + * the unreadable case so the PDF renders an honest marker. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const decryptMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: decryptMock, +})); + +import { decryptAllergyReaction } from "../doctor-report-data"; + +afterEach(() => vi.clearAllMocks()); + +describe("decryptAllergyReaction", () => { + it("returns null / not-unreadable for a genuinely-unset envelope (null)", () => { + expect(decryptAllergyReaction(null)).toEqual({ + reaction: null, + reactionUnreadable: false, + }); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it("returns null / not-unreadable for an empty envelope", () => { + expect(decryptAllergyReaction(new Uint8Array(0))).toEqual({ + reaction: null, + reactionUnreadable: false, + }); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it("returns the decrypted reaction on success", () => { + decryptMock.mockReturnValue("hives, swelling"); + expect(decryptAllergyReaction(new Uint8Array([1, 2, 3]))).toEqual({ + reaction: "hives, swelling", + reactionUnreadable: false, + }); + }); + + it("flags reactionUnreadable when the decrypt throws (key gap / GCM corruption)", () => { + decryptMock.mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + // The reaction WAS recorded (non-empty envelope) but cannot be decrypted: + // reaction stays null, but the honest unreadable flag is raised so the + // clinician sees a marker, not a silent blank. + expect(decryptAllergyReaction(new Uint8Array([9, 9, 9]))).toEqual({ + reaction: null, + reactionUnreadable: true, + }); + }); +}); diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index b4aa23537..c9fa20985 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -28,6 +28,7 @@ import type { MeasurementType, } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { getEvent } from "@/lib/logging/context"; import { readNote } from "@/lib/crypto/note-cipher"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; @@ -342,8 +343,15 @@ export interface DoctorReportData { severity: string | null; /** Status (ACTIVE / INACTIVE / RESOLVED). */ status: string; - /** Decrypted reaction description, or null (unset or key gap). */ + /** Decrypted reaction description, or null (genuinely unset). */ reaction: string | null; + /** + * True when a reaction description WAS stored but could not be decrypted + * (a key-rotation gap / GCM corruption). Distinct from `reaction: null` + * (nothing recorded) so the clinician-facing report shows an honest + * "unreadable" marker rather than a blank that reads as "no reaction". + */ + reactionUnreadable?: boolean; }> | null; /** * v1.27.x — structured family-history records. Reference data, not @@ -764,6 +772,32 @@ export function buildLedgerCompliance( return compliance; } +/** + * Decrypt an allergy reaction envelope for a clinician-facing export, keeping + * "unreadable" distinct from "unset". A genuinely-empty envelope yields + * `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable + * one (key-rotation gap / GCM corruption) yields + * `{ reaction: null, reactionUnreadable: true }` so the report can render an + * honest marker instead of a blank that reads as "no reaction recorded". Pure + * (no logging) so it is unit-testable; the caller logs on the unreadable flag. + */ +export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): { + reaction: string | null; + reactionUnreadable: boolean; +} { + if (!reactionEncrypted || reactionEncrypted.byteLength === 0) { + return { reaction: null, reactionUnreadable: false }; + } + try { + return { + reaction: decryptFromBytes(reactionEncrypted), + reactionUnreadable: false, + }; + } catch { + return { reaction: null, reactionUnreadable: true }; + } +} + /** * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. @@ -1473,13 +1507,18 @@ export async function collectDoctorReportData( }, }); const mapped = rows.map((r) => { - let reaction: string | null = null; - if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) { - try { - reaction = decryptFromBytes(r.reactionEncrypted); - } catch { - reaction = null; - } + const { reaction, reactionUnreadable } = decryptAllergyReaction( + r.reactionEncrypted, + ); + if (reactionUnreadable) { + // A reaction WAS recorded but is undecryptable (key gap / GCM + // corruption). It is flagged (not silently blanked) so the PDF renders + // an honest "unreadable" marker; log the swallowed decrypt so a + // systemic key-config failure is visible rather than masquerading as + // "no reaction recorded" on a clinician-facing export. + getEvent()?.addWarning( + `doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`, + ); } return { substance: r.substance, @@ -1488,6 +1527,7 @@ export async function collectDoctorReportData( severity: r.severity, status: r.status, reaction, + reactionUnreadable, }; }); allergies = mapped.length > 0 ? mapped : null; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 40784dd8d..208f28051 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1627,7 +1627,11 @@ export function buildDoctorReportPdfDocument( t(`records.allergies.category.${al.category}`), t(`records.allergies.type.${al.type}`), al.severity ? t(`records.allergies.severity.${al.severity}`) : "—", - al.reaction ?? "—", + // A reaction that WAS recorded but could not be decrypted renders an + // honest marker, never a blank "—" that reads as "no reaction recorded". + al.reactionUnreadable + ? t("doctorReport.reactionUnreadable") + : (al.reaction ?? "—"), t(`records.allergies.status.${al.status}`), ]);