mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(export): carry an undecryptable note as a marker instead of as nothing
A lab note or a biomarker context written under a key this instance no longer holds came out of a portable export as `null` — byte-identical to a column that was never written. The restore importer then wrote nothing, so the text was gone and nothing said it had ever been there. The whole loss fit inside a fail-soft `catch` written for a list view, where returning null is the right answer. Both now export a short marker naming what happened, and the marker survives a restore. The Coach transcript already did this with a placeholder sentence and the doctor report with a rendered flag; the marker is now one constant the transcript shares, and its comment says why an empty string will not do. The unit test that required `note: null` here pinned the loss. It keeps the half that matters — one bad row must never abort the export — and asserts the marker instead. The round trip is proven end to end against a real restore.
This commit is contained in:
1 parent
c2c018aa08
commit
4d98a273b1
5 files changed
+184
-12
No files matched your search
@@ -19,6 +19,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import {
|
||||
buildRecordsBackupSection,
|
||||
countRecordsBackupSection,
|
||||
@@ -450,10 +451,14 @@ describe("buildRecordsBackupSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails soft (null, not a throw) on an undecryptable note", async () => {
|
||||
it("marks an undecryptable note (not a throw, and not a silent null)", async () => {
|
||||
const prisma = buildMockPrisma();
|
||||
// Corrupt the ciphertext so decrypt throws internally — the row must
|
||||
// still come back with `note: null`, never abort the whole backup.
|
||||
// Corrupt the ciphertext so decrypt throws internally. The row must still
|
||||
// come back and must never abort the whole backup — that part is
|
||||
// unchanged. What it must NOT come back as is `null`, which this test used
|
||||
// to require: in an export file a null note is byte-identical to a note
|
||||
// that was never written, so the restore writes nothing and the loss is
|
||||
// invisible. It comes back as a marker instead.
|
||||
prisma.labResult.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
panel: null,
|
||||
@@ -471,6 +476,6 @@ describe("buildRecordsBackupSection", () => {
|
||||
]);
|
||||
|
||||
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
|
||||
expect(section.labResults[0].note).toBeNull();
|
||||
expect(section.labResults[0].note).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
type RestoreSkipLog,
|
||||
@@ -169,7 +170,7 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null {
|
||||
try {
|
||||
return decryptFromBytes(bytes);
|
||||
} catch {
|
||||
return "[unreadable: encrypted with a key this instance no longer holds]";
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
* verbatim. The DR path never decrypts document content or summaries.
|
||||
*
|
||||
* Every read is scoped to the owner. Soft-deleted rows are excluded so a
|
||||
* restore cannot resurrect tombstones. Other encrypted free-text fields keep
|
||||
* the established fail-soft portable representation and are re-encrypted by
|
||||
* the restore importer.
|
||||
* restore cannot resurrect tombstones. Other encrypted free-text fields are
|
||||
* carried as plaintext and re-encrypted by the restore importer; one this
|
||||
* instance cannot decrypt is carried as a visible marker rather than as
|
||||
* nothing (see `readForExport` below).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { decryptNoteSoft } from "@/lib/labs/store";
|
||||
import { decryptContextSoft } from "@/lib/labs/biomarker-store";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
||||
import { decryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -44,6 +46,39 @@ export const WORKOUTS_MANIFEST_NOTE =
|
||||
"steps) are included. GPS routes and per-sample heart-rate/pace time " +
|
||||
"series are not included in this export.";
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted free-text column for a portable export, or say plainly
|
||||
* that this one could not be read.
|
||||
*
|
||||
* `decryptNoteSoft` / `decryptContextSoft` answer `null` on a wrong or dropped
|
||||
* key. In a list view that is right — one bad row must not take the page down.
|
||||
* In an export it is not: `null` is exactly what a column that was never
|
||||
* written looks like, so the restore importer writes nothing and a note the
|
||||
* person did write disappears without anyone being told. The Coach transcript
|
||||
* and the doctor report both refuse that trade, one with a placeholder
|
||||
* sentence and one with a rendered flag; this follows the same rule.
|
||||
*
|
||||
* The warning the soft helpers emitted is kept, so a systemic key gap still
|
||||
* shows up in the wide event rather than only in the file.
|
||||
*/
|
||||
function readForExport(
|
||||
buf: Uint8Array | null,
|
||||
label: string,
|
||||
decode: (bytes: Uint8Array) => string,
|
||||
): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decode(buf);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${label} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LabResultBackupEntry {
|
||||
/** Present in canonical DR payloads so rows keep a stable identity. */
|
||||
id?: string;
|
||||
@@ -339,7 +374,13 @@ export async function buildRecordsBackupSection(
|
||||
deletedAt: r.deletedAt?.toISOString() ?? null,
|
||||
note: null,
|
||||
}
|
||||
: { note: decryptNoteSoft(r.noteEncrypted) }),
|
||||
: {
|
||||
note: readForExport(
|
||||
r.noteEncrypted,
|
||||
"lab note",
|
||||
decryptNoteFromBytes,
|
||||
),
|
||||
}),
|
||||
panel: r.panel,
|
||||
analyte: r.analyte,
|
||||
value: r.value,
|
||||
@@ -369,7 +410,11 @@ export async function buildRecordsBackupSection(
|
||||
upperBound: b.upperBound,
|
||||
panel: b.panel,
|
||||
hidden: b.hidden,
|
||||
context: decryptContextSoft(b.contextEncrypted),
|
||||
context: readForExport(
|
||||
b.contextEncrypted,
|
||||
"biomarker context",
|
||||
decryptContextFromBytes,
|
||||
),
|
||||
}));
|
||||
|
||||
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* What an export writes where a row could not be decrypted.
|
||||
*
|
||||
* A portable export carries plaintext, so an encrypted column that this
|
||||
* instance can no longer read has to become something. The fail-soft decrypt
|
||||
* helpers answer `null`, which in an export file is indistinguishable from a
|
||||
* column that was never written — and a restore then writes nothing, so the
|
||||
* loss is complete and silent. It is not: the ciphertext was there, and the
|
||||
* person is entitled to know a key gap ate it rather than to find a note they
|
||||
* remember writing simply gone.
|
||||
*
|
||||
* The marker is a sentence, not an empty string, for the same reason the Coach
|
||||
* transcript's placeholder is: an empty string reads as "there was nothing
|
||||
* here", and that is not what happened. It survives a round trip through the
|
||||
* restore importer like any other text, so the gap stays visible in the
|
||||
* restored account instead of closing over.
|
||||
*/
|
||||
export const UNREADABLE_EXPORT_MARKER =
|
||||
"[unreadable: encrypted with a key this instance no longer holds]";
|
||||
@@ -63,6 +63,9 @@ import {
|
||||
encryptWaveformToBytes,
|
||||
} from "@/lib/withings/ecg-waveform-codec";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
|
||||
@@ -2619,4 +2622,103 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A note this instance can no longer decrypt leaves the export as a marker,
|
||||
* not as nothing.
|
||||
*
|
||||
* `decryptNoteSoft` answers `null` on a wrong or dropped key, and in a
|
||||
* portable file `null` is byte-for-byte what a note that was never written
|
||||
* looks like. The restore importer then writes no note, so a note the person
|
||||
* did write is gone and no one is told — the whole loss fits inside a
|
||||
* fail-soft `catch` written for a list view, where returning null is right.
|
||||
*
|
||||
* The Coach transcript and the doctor report both refuse that trade, one
|
||||
* with a placeholder sentence and one with a rendered flag. This asserts the
|
||||
* lab note now does the same, and that the marker is still there after the
|
||||
* file has been through a real restore: the ciphertext is unreadable either
|
||||
* way, but a person opening the restored account can see that something was
|
||||
* there rather than believing they never wrote it.
|
||||
*/
|
||||
it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
||||
});
|
||||
// Ciphertext under a key id this instance does not hold — the shape a row
|
||||
// written before a key rotation that dropped its key has. `decrypt` is
|
||||
// fail-closed on an unknown key id, so this throws rather than returning
|
||||
// garbage, which is the case the soft helper swallowed.
|
||||
const unreadable = new Uint8Array(
|
||||
Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"),
|
||||
);
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
biomarkerId: biomarker.id,
|
||||
analyte: "Ferritin",
|
||||
value: 91,
|
||||
unit: "ng/mL",
|
||||
takenAt: AT("2026-06-30T09:00:00.000Z"),
|
||||
noteEncrypted: unreadable,
|
||||
},
|
||||
});
|
||||
await prisma.biomarker.update({
|
||||
where: { id: biomarker.id },
|
||||
data: { contextEncrypted: unreadable },
|
||||
});
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "portable-export",
|
||||
});
|
||||
|
||||
const exported = payload.labResults as Array<{ note: string | null }>;
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].note).toContain("unreadable");
|
||||
const exportedBiomarkers = payload.biomarkers as Array<{
|
||||
context: string | null;
|
||||
}>;
|
||||
expect(exportedBiomarkers[0].context).toContain("unreadable");
|
||||
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
|
||||
const restoredLab = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
// Back in the column it came from, readable again because the restore
|
||||
// re-encrypted it under the CURRENT key — and saying what happened.
|
||||
expect(restoredLab.noteEncrypted).not.toBeNull();
|
||||
expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe(
|
||||
UNREADABLE_EXPORT_MARKER,
|
||||
);
|
||||
|
||||
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
|
||||
UNREADABLE_EXPORT_MARKER,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user