fix(doctor-report): mark an undecryptable allergy reaction instead of blanking it

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:46:54 +02:00
1 parent 982fe53b51
commit a53dd2b59c
9 files changed
+116 -9

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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,
});
});
});
+48 -8
View File
@@ -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;
+5 -1
View File
@@ -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}`),
]);