diff --git a/src/app/api/sleep/rhythm/__tests__/route.test.ts b/src/app/api/sleep/rhythm/__tests__/route.test.ts index a817425b4..f8e3ee7b5 100644 --- a/src/app/api/sleep/rhythm/__tests__/route.test.ts +++ b/src/app/api/sleep/rhythm/__tests__/route.test.ts @@ -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); }); }); diff --git a/src/lib/__tests__/encryption-rotation-coverage.test.ts b/src/lib/__tests__/encryption-rotation-coverage.test.ts deleted file mode 100644 index 758a11ac3..000000000 --- a/src/lib/__tests__/encryption-rotation-coverage.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index a5e601a55..ff667e340 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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. */