Files
HealthLog/tests/integration/free-text-encryption-backfill.test.ts
T
Marc-André Bombeck 41c906e862 test(encryption): drop the insights cache step from the backfill summaries
The free-text backfill no longer has an insights cache step since the
readable cached-text column was dropped, so its summary carries no
insightsCachesMigrated count. The expected summaries follow.
2026-10-22 01:19:01 +02:00

560 lines
18 KiB
TypeScript

/**
* v1.39.3 — the Coach conversation title and the custom-metric reading note
* move to AES-256-GCM at rest. Migration 0357 only adds the ciphertext
* columns; the existing rows are sealed by the boot-time backfill. This runs
* the real discovery and the real per-user pass against Postgres, with the
* migrated schema:
*
* - discovery finds exactly the accounts still holding a readable value;
* - the pass seals every such row in its own transaction, nulls the readable
* column, and reads back the same text;
* - a second pass and a second discovery find nothing (it converges);
* - a row written the current way is left as it was.
*
* v1.39.4 adds the practitioner phone number and address and the workout GPS
* track (migration 0361) to the same pass; the second case covers them.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Prisma } from "@/generated/prisma/client";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import {
decryptRouteGeometry,
encryptRouteGeometry,
} from "@/lib/workouts/route-geometry-cipher";
import {
enqueueBootTimeFreeTextEncryptionBackfill,
runFreeTextEncryptionBackfillForUser,
} from "@/lib/jobs/free-text-encryption-backfill";
import {
INSIGHTS_CACHE_SELECT,
insightsCacheWrite,
openInsightsCachedText,
} from "@/lib/insights/cached-text-cipher";
import { getPrismaClient, truncateAllTables } from "./setup";
function makeCapturingBoss() {
const sent: { queue: string; userId: string; startAfter?: number }[] = [];
const boss = {
send: async (
queue: string,
payload: { userId?: string },
opts: { startAfter?: number },
) => {
if (payload.userId) sent.push({ queue, userId: payload.userId, ...opts });
return `job-${sent.length}`;
},
};
return { boss, sent };
}
async function seedUser(id: string) {
await getPrismaClient().user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
timezone: "Europe/Berlin",
},
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
afterEach(() => {
setGlobalBoss(null as never);
});
describe("free-text encryption backfill (real Postgres)", () => {
it("discovers, seals, and converges", async () => {
const prisma = getPrismaClient();
await Promise.all([seedUser("ft-a"), seedUser("ft-b"), seedUser("ft-c")]);
// ft-a: a legacy readable title and a sealed one.
// Last active months ago: the backfill must not make it look recent.
const lastActive = new Date("2026-03-02T09:15:00.000Z");
await prisma.coachConversation.create({
data: {
id: "conv-legacy",
userId: "ft-a",
title: "Legacy title",
updatedAt: lastActive,
},
});
await prisma.coachConversation.create({
data: {
id: "conv-sealed",
userId: "ft-a",
titleEncrypted: encryptToBytes("Sealed title"),
},
});
// ft-b: a legacy readable note on a custom-metric reading.
const metric = await prisma.customMetric.create({
data: { userId: "ft-b", name: "Grip", unit: "kg" },
});
await prisma.customMetricEntry.create({
data: {
id: "entry-legacy",
userId: "ft-b",
customMetricId: metric.id,
value: 40,
unit: "kg",
measuredAt: new Date("2026-09-01T08:00:00.000Z"),
note: "legacy note",
},
});
await prisma.customMetricEntry.create({
data: {
id: "entry-none",
userId: "ft-b",
customMetricId: metric.id,
value: 41,
unit: "kg",
measuredAt: new Date("2026-09-02T08:00:00.000Z"),
},
});
// ft-c: nothing readable, only a sealed title.
await prisma.coachConversation.create({
data: { userId: "ft-c", titleEncrypted: encryptToBytes("Only sealed") },
});
const first = makeCapturingBoss();
setGlobalBoss(first.boss as never);
const discovered = await enqueueBootTimeFreeTextEncryptionBackfill(180);
expect(discovered.error).toBeNull();
expect(first.sent.map((s) => s.userId).sort()).toEqual(["ft-a", "ft-b"]);
// The boot call is deferred past the startup storm.
expect(first.sent.every((s) => s.startAfter === 180)).toBe(true);
// The summary carries no location counts: migration 0409 dropped the
// readable home, period and day locations, so there is nothing to seal.
expect(await runFreeTextEncryptionBackfillForUser("ft-a")).toEqual({
conversationTitlesMigrated: 1,
metricNotesMigrated: 0,
practitionerContactsMigrated: 0,
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
});
expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({
conversationTitlesMigrated: 0,
metricNotesMigrated: 1,
practitionerContactsMigrated: 0,
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
});
const conversations = await prisma.coachConversation.findMany({
where: { userId: "ft-a" },
orderBy: { id: "asc" },
});
expect(
conversations.map((c) => ({
id: c.id,
readable: c.title,
text: readNote(c.titleEncrypted, c.title),
})),
).toEqual([
{ id: "conv-legacy", readable: null, text: "Legacy title" },
{ id: "conv-sealed", readable: null, text: "Sealed title" },
]);
// The Coach panel orders and groups by updatedAt; sealing a title is not
// activity, so the stamp is the one the conversation already had.
expect(conversations[0].updatedAt).toEqual(lastActive);
const entries = await prisma.customMetricEntry.findMany({
where: { userId: "ft-b" },
orderBy: { id: "asc" },
});
expect(
entries.map((e) => ({
id: e.id,
readable: e.note,
text: readNote(e.noteEncrypted, e.note),
})),
).toEqual([
{ id: "entry-legacy", readable: null, text: "legacy note" },
{ id: "entry-none", readable: null, text: null },
]);
// Converged: nothing left to discover, nothing left to seal.
const second = makeCapturingBoss();
setGlobalBoss(second.boss as never);
expect(
(await enqueueBootTimeFreeTextEncryptionBackfill()).error,
).toBeNull();
expect(second.sent).toEqual([]);
expect(await runFreeTextEncryptionBackfillForUser("ft-a")).toEqual({
conversationTitlesMigrated: 0,
metricNotesMigrated: 0,
practitionerContactsMigrated: 0,
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
});
});
it("seals practitioner contacts and workout GPS tracks, and converges", async () => {
const prisma = getPrismaClient();
await Promise.all([seedUser("pc-a"), seedUser("pc-b"), seedUser("pc-c")]);
const track = {
type: "LineString",
coordinates: [
[13.4012, 52.5201, 34],
[13.4051, 52.5233, 36],
],
};
// pc-a: a legacy practitioner with both fields, one with only an address.
await prisma.practitioner.create({
data: {
id: "pr-both",
userId: "pc-a",
name: "Praxis Nord",
phone: "+49 30 1234567",
location: "Hauptstr. 1",
},
});
await prisma.practitioner.create({
data: {
id: "pr-loc",
userId: "pc-a",
name: "Praxis Sued",
location: "Am Markt 3",
},
});
// pc-b: a legacy readable track, found through its workout.
const startedAt = new Date("2026-09-01T06:00:00.000Z");
await prisma.workout.create({
data: {
id: "wk-legacy",
userId: "pc-b",
sportType: "running",
startedAt,
endedAt: new Date(startedAt.getTime() + 30 * 60_000),
durationSec: 1800,
route: { create: { id: "rt-legacy", geometry: track } },
},
});
// pc-c: only rows written the current way.
await prisma.practitioner.create({
data: {
userId: "pc-c",
name: "Praxis West",
phoneEncrypted: encryptToBytes("+49 40 7654321"),
},
});
await prisma.workout.create({
data: {
id: "wk-sealed",
userId: "pc-c",
sportType: "running",
startedAt,
endedAt: new Date(startedAt.getTime() + 30 * 60_000),
durationSec: 1800,
route: {
create: {
id: "rt-sealed",
geometryEncrypted: encryptRouteGeometry(track),
},
},
},
});
const first = makeCapturingBoss();
setGlobalBoss(first.boss as never);
expect(
(await enqueueBootTimeFreeTextEncryptionBackfill()).error,
).toBeNull();
expect(first.sent.map((s) => s.userId).sort()).toEqual(["pc-a", "pc-b"]);
expect(await runFreeTextEncryptionBackfillForUser("pc-a")).toEqual({
conversationTitlesMigrated: 0,
metricNotesMigrated: 0,
practitionerContactsMigrated: 2,
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
});
expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({
conversationTitlesMigrated: 0,
metricNotesMigrated: 0,
practitionerContactsMigrated: 0,
routeGeometriesMigrated: 1,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
});
const practitioners = await prisma.practitioner.findMany({
where: { userId: "pc-a" },
orderBy: { id: "asc" },
});
expect(
practitioners.map((p) => ({
id: p.id,
phone: p.phone,
location: p.location,
phoneText: readNote(p.phoneEncrypted, null),
locationText: readNote(p.locationEncrypted, null),
})),
).toEqual([
{
id: "pr-both",
phone: null,
location: null,
phoneText: "+49 30 1234567",
locationText: "Hauptstr. 1",
},
{
id: "pr-loc",
phone: null,
location: null,
phoneText: null,
locationText: "Am Markt 3",
},
]);
// The track is SQL NULL in the readable column, not a JSON null, and the
// sealed value does not carry a coordinate readable on disk.
const onDisk = await prisma.$queryRaw<
{ readable_is_null: boolean; leaks: boolean }[]
>`
SELECT geometry IS NULL AS readable_is_null,
position(convert_to('52.5201', 'UTF8') IN geometry_encrypted) > 0
AS leaks
FROM workout_routes WHERE id = 'rt-legacy'`;
expect(onDisk).toEqual([{ readable_is_null: true, leaks: false }]);
const route = await prisma.workoutRoute.findUniqueOrThrow({
where: { id: "rt-legacy" },
});
expect(decryptRouteGeometry(route.geometryEncrypted!)).toEqual(track);
// Converged.
const second = makeCapturingBoss();
setGlobalBoss(second.boss as never);
expect(
(await enqueueBootTimeFreeTextEncryptionBackfill()).error,
).toBeNull();
expect(second.sent).toEqual([]);
expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({
conversationTitlesMigrated: 0,
metricNotesMigrated: 0,
practitionerContactsMigrated: 0,
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
});
});
it("clears appointment address copies, scrubs old contact audit rows, and settles a JSON-null track", async () => {
const prisma = getPrismaClient();
await Promise.all([seedUser("ad-a"), seedUser("ad-b")]);
// ad-a: an appointment reminder holding a readable copy of the address,
// and a checkup whose own location is the person's words and stays.
await prisma.measurementReminder.create({
data: {
id: "rem-appt",
userId: "ad-a",
label: "Praxis Nord",
origin: "ENCOUNTER",
location: "Hauptstr. 1, Berlin",
},
});
await prisma.measurementReminder.create({
data: {
id: "rem-checkup",
userId: "ad-a",
label: "Blood panel",
location: "Labor Mitte",
},
});
// ad-a: an edit's audit row from before the contact fields were sealed,
// carrying the old address and phone number, and a current-shape row.
await prisma.auditLog.create({
data: {
id: "audit-old",
userId: "ad-a",
action: "practitioner.contact.update",
details: JSON.stringify({
practitionerId: "pr-x",
fields: ["location", "name", "phone"],
previous: {
location: "Hauptstr. 1, Berlin",
name: "Praxis Alt",
phone: "+49 30 1234567",
},
}),
},
});
const currentDetails = JSON.stringify({
practitionerId: "pr-x",
fields: ["location"],
previous: {},
});
await prisma.auditLog.create({
data: {
id: "audit-current",
userId: "ad-a",
action: "practitioner.contact.update",
details: currentDetails,
},
});
// An account deleted since: the row lost its owner and no per-user job
// reaches it, so discovery scrubs it itself.
await prisma.auditLog.create({
data: {
id: "audit-orphan",
userId: null,
action: "practitioner.contact.update",
details: JSON.stringify({
practitionerId: "pr-y",
fields: ["phone"],
previous: { phone: "+49 40 7654321" },
}),
},
});
// ad-b: a route whose readable column is a JSON null rather than SQL
// NULL. Nothing to seal, but discovery must not find it every night.
const startedAt = new Date("2026-09-01T06:00:00.000Z");
await prisma.workout.create({
data: {
id: "wk-jsonnull",
userId: "ad-b",
sportType: "running",
startedAt,
endedAt: new Date(startedAt.getTime() + 30 * 60_000),
durationSec: 1800,
route: { create: { id: "rt-jsonnull", geometry: Prisma.JsonNull } },
},
});
const first = makeCapturingBoss();
setGlobalBoss(first.boss as never);
expect(
(await enqueueBootTimeFreeTextEncryptionBackfill()).error,
).toBeNull();
expect(first.sent.map((s) => s.userId).sort()).toEqual(["ad-a", "ad-b"]);
const summaryA = await runFreeTextEncryptionBackfillForUser("ad-a");
expect(summaryA).toMatchObject({
appointmentAddressesCleared: 1,
contactAuditRowsScrubbed: 1,
});
await runFreeTextEncryptionBackfillForUser("ad-b");
const reminders = await prisma.measurementReminder.findMany({
where: { userId: "ad-a" },
orderBy: { id: "asc" },
});
expect(reminders.map((r) => [r.id, r.location])).toEqual([
["rem-appt", null],
["rem-checkup", "Labor Mitte"],
]);
const audits = await prisma.auditLog.findMany({
where: { action: "practitioner.contact.update" },
orderBy: { id: "asc" },
});
const byId = Object.fromEntries(audits.map((a) => [a.id, a.details]));
expect(JSON.parse(byId["audit-old"]!)).toEqual({
practitionerId: "pr-x",
fields: ["location", "name", "phone"],
previous: { name: "Praxis Alt" },
});
expect(byId["audit-current"]).toBe(currentDetails);
expect(JSON.parse(byId["audit-orphan"]!)).toEqual({
practitionerId: "pr-y",
fields: ["phone"],
previous: {},
});
for (const details of Object.values(byId)) {
expect(details).not.toContain("Hauptstr");
expect(details).not.toContain("+49");
}
const onDisk = await prisma.$queryRaw<{ readable_is_null: boolean }[]>`
SELECT geometry IS NULL AS readable_is_null
FROM workout_routes WHERE id = 'rt-jsonnull'`;
expect(onDisk).toEqual([{ readable_is_null: true }]);
// Converged: the next discovery finds nobody.
const second = makeCapturingBoss();
setGlobalBoss(second.boss as never);
expect(
(await enqueueBootTimeFreeTextEncryptionBackfill()).error,
).toBeNull();
expect(second.sent).toEqual([]);
});
it("has no readable insights cache left after migration 0430", async () => {
const prisma = getPrismaClient();
const columns = await prisma.$queryRaw<{ column_name: string }[]>`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name LIKE 'insights_cached_text%'
ORDER BY column_name`;
expect(columns.map((c) => c.column_name)).toEqual([
"insights_cached_text_encrypted",
]);
const indexes = await prisma.$queryRaw<{ indexname: string }[]>`
SELECT indexname FROM pg_indexes
WHERE indexname = 'users_insights_cached_text_backfill_idx'`;
expect(indexes).toEqual([]);
// The sealed column still round-trips through the one module.
await seedUser("ic-a");
await prisma.user.update({
where: { id: "ic-a" },
data: insightsCacheWrite("current"),
});
const row = await prisma.user.findUniqueOrThrow({
where: { id: "ic-a" },
select: INSIGHTS_CACHE_SELECT,
});
expect(openInsightsCachedText(row)).toBe("current");
});
it("has the partial indexes migration 0361 adds", async () => {
const indexes = await getPrismaClient().$queryRaw<
{ indexname: string; indexdef: string }[]
>`
SELECT indexname, indexdef FROM pg_indexes
WHERE indexname IN (
'practitioners_contact_backfill_idx',
'workout_routes_geometry_backfill_idx'
)
ORDER BY indexname`;
expect(indexes.map((i) => i.indexname)).toEqual([
"practitioners_contact_backfill_idx",
"workout_routes_geometry_backfill_idx",
]);
expect(indexes[0].indexdef).toContain(
"WHERE ((phone IS NOT NULL) OR (location IS NOT NULL))",
);
expect(indexes[1].indexdef).toContain("WHERE (geometry IS NOT NULL)");
});
it("uses the partial indexes migration 0357 adds", async () => {
const indexes = await getPrismaClient().$queryRaw<
{ indexname: string; indexdef: string }[]
>`
SELECT indexname, indexdef FROM pg_indexes
WHERE indexname IN (
'coach_conversations_title_backfill_idx',
'custom_metric_entries_note_backfill_idx'
)
ORDER BY indexname`;
expect(indexes.map((i) => i.indexname)).toEqual([
"coach_conversations_title_backfill_idx",
"custom_metric_entries_note_backfill_idx",
]);
expect(indexes[0].indexdef).toContain("WHERE (title IS NOT NULL)");
expect(indexes[1].indexdef).toContain("WHERE (note IS NOT NULL)");
});
});