mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
982fe53b51
commit
a53dd2b59c
9 files changed
+116
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user