mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(crypto,sleep): register the ECG column for rotation and update the sleep-debt route test
The canonical rotation registry (src/lib/crypto/encrypted-columns.ts) is the source of truth its guard test cross-checks; add EcgRecording.waveformEncrypted there (the script already references it) and drop the redundant parallel guard. Update the sleep/rhythm route test to the v1.19.0 rolling 5-night balance (nightsCounted 5, debt 300) instead of the old 14-night cumulative sum.
This commit is contained in:
1 parent
c8673786eb
commit
a6d9a39c73
3 files changed
+8
-83
No files matched your search
@@ -132,10 +132,12 @@ describe("GET /api/sleep/rhythm", () => {
|
||||
const res = await callGet(req());
|
||||
const body = await res.json();
|
||||
expect(body.data.sleepDebt.state).toBe("ready");
|
||||
expect(body.data.sleepDebt.nightsCounted).toBe(10);
|
||||
// v1.19.0 — the rolling balance counts the most recent 5-night window.
|
||||
expect(body.data.sleepDebt.nightsCounted).toBe(5);
|
||||
expect(body.data.sleepDebt.nightsUntilReady).toBe(0);
|
||||
// 10 nights × 60-min deficit = 600 min cumulative debt.
|
||||
expect(body.data.sleepDebt.debtMinutes).toBe(600);
|
||||
// 5-night window × 60-min nightly deficit (all short, no catch-up) = 300 min
|
||||
// running balance.
|
||||
expect(body.data.sleepDebt.debtMinutes).toBe(300);
|
||||
expect(body.data.sleepDebt.needMinutes).toBe(420);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* v1.19.0 — guard the key-rotation script against an uncovered encrypted column.
|
||||
*
|
||||
* Twice now an encrypted `Bytes` column shipped without being added to
|
||||
* `scripts/rotate-encryption-key.ts` (the v1.18.1 clinical-spine notes, then the
|
||||
* v1.19.0 ECG waveform). When a column is missing from the rotation, an operator
|
||||
* rotation leaves those rows on the legacy key, and dropping the legacy key once
|
||||
* the script reports "zero remaining" makes that data permanently undecryptable
|
||||
* (crypto is fail-closed). CLAUDE.md's invariant: the script covers EVERY
|
||||
* encrypted `Bytes` column.
|
||||
*
|
||||
* This test enumerates the encrypted `Bytes` columns straight from
|
||||
* `schema.prisma` (model + field) and asserts the rotation script references
|
||||
* both names, so a newly added encrypted blob fails CI until it is wired in.
|
||||
*
|
||||
* Non-rotation `Bytes` columns (raw avatar image, passkey public key) are not
|
||||
* encrypted at rest and are excluded by the `Encrypted` / `encryptedContent`
|
||||
* naming convention.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function isEncryptedBytesField(line: string): string | null {
|
||||
// matches e.g. ` waveformEncrypted Bytes @map("...")` or
|
||||
// ` encryptedContent Bytes @map("...")`
|
||||
const m = line.match(/^\s*(\w+)\s+Bytes\??\s/);
|
||||
if (!m) return null;
|
||||
const field = m[1];
|
||||
if (field.endsWith("Encrypted") || field === "encryptedContent") return field;
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("encryption key-rotation coverage", () => {
|
||||
it("rotates every encrypted Bytes column declared in the schema", () => {
|
||||
const schema = readFileSync(
|
||||
join(process.cwd(), "prisma/schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
const script = readFileSync(
|
||||
join(process.cwd(), "scripts/rotate-encryption-key.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const pairs: Array<{ model: string; field: string }> = [];
|
||||
let currentModel: string | null = null;
|
||||
for (const line of schema.split("\n")) {
|
||||
const model = line.match(/^model\s+(\w+)\s*\{/);
|
||||
if (model) {
|
||||
currentModel = model[1];
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === "}") {
|
||||
currentModel = null;
|
||||
continue;
|
||||
}
|
||||
const field = isEncryptedBytesField(line);
|
||||
if (field && currentModel) pairs.push({ model: currentModel, field });
|
||||
}
|
||||
|
||||
// sanity: we actually found encrypted columns to check
|
||||
expect(pairs.length).toBeGreaterThan(5);
|
||||
|
||||
const missing = pairs.filter(
|
||||
({ model, field }) =>
|
||||
!(script.includes(`"${model}"`) && script.includes(`"${field}"`)),
|
||||
);
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`Encrypted Bytes column(s) not wired into scripts/rotate-encryption-key.ts: ${missing
|
||||
.map((p) => `${p.model}.${p.field}`)
|
||||
.join(
|
||||
", ",
|
||||
)}. Add a rotateBytesColumn(...) call or every rotation will ` +
|
||||
"strand this data on the legacy key.",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "Biomarker", field: "contextEncrypted", kind: "bytes" },
|
||||
{ model: "IllnessEpisode", field: "noteEncrypted", kind: "bytes" },
|
||||
{ model: "IllnessDayLog", field: "noteEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.19.0 ECG waveform (Bytes column) ─────
|
||||
{ model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" },
|
||||
] as const;
|
||||
|
||||
/** Stable `Model.field` key for a registry entry. */
|
||||
|
||||
Reference in new issue
Block a user