fix(measurements): shape the single-measurement note so ciphertext never leaves the server

Wrap GET /api/measurements/:id through shapeMeasurementNotes like the list,
create, and update paths already do, so the encrypted note column is decrypted
into notes and the raw ciphertext is stripped from the response. Harden the
rotation-coverage guard to require both the field literal and the model
delegate, so a same-named encrypted column whose table is not rotated is caught.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 19:46:07 +02:00
1 parent 4d3a02cc1a
commit 9399b77a74
2 files changed
+22 -5

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ export const GET = apiHandler(
},
});
return apiSuccess(measurement);
return apiSuccess(shapeMeasurementNotes(measurement));
},
);
@@ -147,12 +147,29 @@ describe("encrypted-column registry", () => {
it("is fully referenced by the key-rotation script", () => {
const script = readFileSync(ROTATION_SCRIPT_PATH, "utf8");
const unreferenced = ENCRYPTED_COLUMNS.filter(
(c: EncryptedColumn) => !script.includes(`"${c.field}"`),
).map(encryptedColumnKey);
// Field names are NOT unique across models (e.g. `noteEncrypted` lives on
// five models, `notesEncrypted` on two, `accessToken`/`refreshToken` on
// three connection tables each). A bare field-name substring match passes
// on the first occurrence and is blind to a same-named sibling whose model
// was never wired into rotation — exactly the "silently skipped →
// undecryptable after the old key is dropped" failure the registry exists
// to prevent. So require, per registry entry, BOTH the field literal AND
// the model's Prisma delegate (`prisma.<delegate>`) to appear in the script
// — the delegate proves that model's table is actually walked (some tables
// are rotated through a shared `for (const field of [...])` loop, where the
// field literal appears once but each delegate is named explicitly).
const delegate = (model: string) =>
`prisma.${model.charAt(0).toLowerCase()}${model.slice(1)}`;
const unreferenced = (ENCRYPTED_COLUMNS as EncryptedColumn[])
.filter(
(c) =>
!script.includes(`"${c.field}"`) ||
!script.includes(delegate(c.model)),
)
.map(encryptedColumnKey);
expect(
unreferenced,
"registry columns NOT referenced by scripts/rotate-encryption-key.ts",
"registry columns NOT fully referenced (field + model delegate) by scripts/rotate-encryption-key.ts",
).toEqual([]);
});
});