Files
HealthLog/tests/integration/mood-entries-note-column.test.ts
T
Marc-André Bombeck 4d3a02cc1a feat(security): add second-factor schema and encrypt free-text health notes at rest
Lay the account-security and data-sovereignty substrate for v1.23. No
user-facing behaviour yet — schema, crypto registry, redaction, and the
note-encryption data path only.

Schema + migrations:
- 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced /
  passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at;
  Passkey last_used_at. All additive, nullable / defaulted.
- 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is
  stored, so no session or token is ever minted before the second factor),
  mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor
  keys, kept separate from passkeys, which remain the passwordless-primary set).
- 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA),
  matching the existing free-text encrypted-note convention.

Free-text health notes:
- New writes encrypt the mood / measurement note into the *_encrypted column and
  null the legacy plaintext column; reads prefer the ciphertext and fall back to
  plaintext only for not-yet-migrated rows, via a single read/write helper.
- A boot-time job migrates existing plaintext rows in an idempotent,
  transactional, fail-closed pass (encrypt, then null the plaintext, per row in
  one transaction; safe to re-run; a row with content is never left both-null).
- The legacy plaintext columns are kept this release and dropped in a follow-up
  once the backfill reports zero remaining rows — the same boundary the
  key-rotation playbook uses before dropping a legacy key.

Crypto coverage:
- Register the three new encrypted columns and wire them into the key-rotation
  script so a rotation never orphans them.
- Extend the observability and log redaction denylists so TOTP secrets, MFA /
  recovery / backup codes, and export passphrases never reach a wide event.
2026-06-27 19:33:10 +02:00

190 lines
6.1 KiB
TypeScript

/**
* v1.4.30 / v1.23 — mood-note round-trip integration.
*
* v1.23 moved the free-text note to AES-256-GCM at rest: new writes land in
* `noteEncrypted` (Bytes) and the legacy plaintext `note` column is nulled.
*
* Asserts:
* - POST /api/mood-entries encrypts the `note` (plaintext column nulled)
* - PUT /api/mood-entries/[id] re-encrypts `note` (and accepts null to clear)
* - POST /api/mood-entries/bulk encrypts `note` per entry
* - The Zod cap (500 chars) rejects oversize prose
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readNote } from "@/lib/crypto/note-cipher";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-mood-note";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "mood-note",
email: "mood-note@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function postRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function putRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("MoodEntry.note round-trip (real Postgres)", () => {
it("POST /api/mood-entries persists `note`", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
note: "Long run before breakfast.",
}),
);
expect(res.status).toBe(201);
// The response carries the decrypted note back to the client.
const body = (await res.json()) as { data: { note: string | null } };
expect(body.data.note).toBe("Long run before breakfast.");
const stored = await getPrismaClient().moodEntry.findFirst({
where: { userId: TEST_USER_ID },
});
// At rest: plaintext column nulled, ciphertext present + decrypts back.
expect(stored?.note).toBeNull();
expect(stored?.noteEncrypted).not.toBeNull();
expect(readNote(stored?.noteEncrypted ?? null, null)).toBe(
"Long run before breakfast.",
);
});
it("PUT /api/mood-entries/[id] updates `note` and accepts null to clear it", async () => {
const created = await getPrismaClient().moodEntry.create({
data: {
userId: TEST_USER_ID,
date: "2026-05-16",
tz: "Europe/Berlin",
mood: "OKAY",
score: 3,
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
note: "initial note",
},
});
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
const res1 = await PUT(
putRequest(`/api/mood-entries/${created.id}`, { note: "revised note" }),
{ params: Promise.resolve({ id: created.id }) },
);
expect(res1.status).toBe(200);
const after1 = await getPrismaClient().moodEntry.findUnique({
where: { id: created.id },
});
expect(after1?.note).toBeNull();
expect(readNote(after1?.noteEncrypted ?? null, null)).toBe("revised note");
const res2 = await PUT(
putRequest(`/api/mood-entries/${created.id}`, { note: null }),
{ params: Promise.resolve({ id: created.id }) },
);
expect(res2.status).toBe(200);
const after2 = await getPrismaClient().moodEntry.findUnique({
where: { id: created.id },
});
// Cleared: both the plaintext and the ciphertext column are null.
expect(after2?.note).toBeNull();
expect(after2?.noteEncrypted).toBeNull();
});
it("POST /api/mood-entries/bulk persists `note` per entry", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
postRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
note: "Morning",
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T14:00:00.000Z",
note: "Afternoon dip",
},
],
}),
);
expect(res.status).toBe(200);
const stored = await getPrismaClient().moodEntry.findMany({
where: { userId: TEST_USER_ID },
orderBy: { moodLoggedAt: "asc" },
});
// Plaintext column nulled; ciphertext decrypts back to each note.
expect(stored.map((r) => r.note)).toEqual([null, null]);
expect(stored.map((r) => readNote(r.noteEncrypted, null))).toEqual([
"Morning",
"Afternoon dip",
]);
});
it("rejects an oversize note (501 chars) with 422", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const oversize = "x".repeat(501);
const res = await POST(
postRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
note: oversize,
}),
);
expect(res.status).toBe(422);
});
});