mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The title encryption backfill moved each Coach conversation's title into ciphertext with a plain Prisma update, which stamps updatedAt. The Coach panel orders and groups conversations by that column, so threads untouched for months showed up under Today. The memory summary refresh did the same in the background. Storage-only rewrites now carry the row's own updatedAt and match on it, so a turn that lands meanwhile wins and the row is retried on the next pass: conversation titles, practitioner contacts, measurement and mood notes (where updatedAt is the sync cursor), inventory notes, and the mood-note script. The summary refresh writes through raw SQL. A guard freezes both rules: every backfill write on a model with @updatedAt names the column or is listed with the reason the stamp is wanted, and every conversation write outside the person's own actions keeps it.
132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
/**
|
|
* v1.23 — free-text health-note encryption backfill (real Postgres).
|
|
*
|
|
* Seeds legacy plaintext rows (note in the plaintext column, ciphertext null),
|
|
* runs the per-user backfill, and asserts:
|
|
* - the plaintext is encrypted into `*Encrypted` and the plaintext column is
|
|
* nulled, in one transaction (no row left both-null when it had content);
|
|
* - the ciphertext decrypts back to the original (no data loss);
|
|
* - a note-less row is untouched;
|
|
* - a second run is a no-op (idempotent).
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { readNote } from "@/lib/crypto/note-cipher";
|
|
import { runNoteEncryptionBackfillForUser } from "@/lib/jobs/note-encryption-backfill";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const TEST_USER_ID = "user-note-backfill";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: TEST_USER_ID,
|
|
username: "note-backfill",
|
|
email: "note-backfill@example.test",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("note-encryption backfill (real Postgres)", () => {
|
|
it("migrates legacy plaintext notes to ciphertext and nulls the plaintext", async () => {
|
|
const prisma = getPrismaClient();
|
|
// Legacy rows: plaintext present, ciphertext null (the pre-v1.23 shape).
|
|
const withNote = await prisma.measurement.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
|
notes: "felt dizzy after",
|
|
updatedAt: new Date("2026-05-16T08:05:00.000Z"),
|
|
},
|
|
});
|
|
const noNote = await prisma.measurement.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEIGHT",
|
|
value: 81,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-05-17T08:00:00.000Z"),
|
|
notes: null,
|
|
},
|
|
});
|
|
const mood = await prisma.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: "rough night",
|
|
updatedAt: new Date("2026-05-16T08:06:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const summary = await runNoteEncryptionBackfillForUser(TEST_USER_ID);
|
|
expect(summary.measurementsMigrated).toBe(1);
|
|
expect(summary.moodEntriesMigrated).toBe(1);
|
|
|
|
const m = await prisma.measurement.findUnique({
|
|
where: { id: withNote.id },
|
|
});
|
|
expect(m?.notes).toBeNull();
|
|
expect(m?.notesEncrypted).not.toBeNull();
|
|
expect(readNote(m?.notesEncrypted ?? null, null)).toBe("felt dizzy after");
|
|
|
|
const untouched = await prisma.measurement.findUnique({
|
|
where: { id: noNote.id },
|
|
});
|
|
expect(untouched?.notes).toBeNull();
|
|
expect(untouched?.notesEncrypted).toBeNull();
|
|
|
|
const x = await prisma.moodEntry.findUnique({ where: { id: mood.id } });
|
|
expect(x?.note).toBeNull();
|
|
expect(readNote(x?.noteEncrypted ?? null, null)).toBe("rough night");
|
|
|
|
// updatedAt is the sync cursor: moving a note into ciphertext changes
|
|
// nothing a client reads, so neither row takes a new stamp.
|
|
expect(m?.updatedAt).toEqual(new Date("2026-05-16T08:05:00.000Z"));
|
|
expect(x?.updatedAt).toEqual(new Date("2026-05-16T08:06:00.000Z"));
|
|
});
|
|
|
|
it("is idempotent: a second run migrates zero rows and preserves the data", async () => {
|
|
const prisma = getPrismaClient();
|
|
const row = await prisma.measurement.create({
|
|
data: {
|
|
userId: TEST_USER_ID,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
|
notes: "side effect note",
|
|
},
|
|
});
|
|
|
|
await runNoteEncryptionBackfillForUser(TEST_USER_ID);
|
|
const first = await prisma.measurement.findUnique({
|
|
where: { id: row.id },
|
|
});
|
|
const firstCipher = Buffer.from(first?.notesEncrypted ?? new Uint8Array());
|
|
|
|
const second = await runNoteEncryptionBackfillForUser(TEST_USER_ID);
|
|
expect(second.measurementsMigrated).toBe(0);
|
|
|
|
const after = await prisma.measurement.findUnique({
|
|
where: { id: row.id },
|
|
});
|
|
// Same ciphertext, plaintext still null, note still recoverable.
|
|
expect(Buffer.from(after?.notesEncrypted ?? new Uint8Array())).toEqual(
|
|
firstCipher,
|
|
);
|
|
expect(after?.notes).toBeNull();
|
|
expect(readNote(after?.notesEncrypted ?? null, null)).toBe(
|
|
"side effect note",
|
|
);
|
|
});
|
|
});
|