Merge remote-tracking branch 'origin/main' into ready/pr-1131

This commit is contained in:
Marc-André Bombeck committed 2026-10-04 14:08:27 +02:00
commit fbd639d9f1
87 files changed
+5627 -772

No files matched your search

@@ -391,8 +391,10 @@ describe("canonical disaster-recovery backup round-trip", () => {
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
sensitiveEncrypted: "sensitive-cycle-dr",
notesEncrypted: "notes-cycle-dr",
// Real ciphertext: the restore writes a sealed value back only when
// this host opens it.
sensitiveEncrypted: encrypt(JSON.stringify({ sexualActivity: false })),
notesEncrypted: encrypt("notes-cycle-dr"),
source: "APPLE_HEALTH",
externalId: "cycle-day-external-dr",
tz: "Europe/London",
@@ -0,0 +1,313 @@
/**
* A portable file's cycle free text survives a move to a host with other keys.
*
* The day-log note, the sensitive-category envelope and a custom symptom's
* label are sealed under the writing host's key. A portable file used to carry
* those three as the stored ciphertext, so a restore onto a host with a
* different key either refused the whole file at the key check or, with a
* matching key id over different key material, wrote rows nobody could open.
*
* Both ends and the pipe: the file is built by the real `buildFullBackupPayload`
* under key A, stored and restored through the real route under key B, and
* the restored rows are opened under key B.
*
* The second case is the file written before the fix, which still carries
* ciphertext. A value this host's keys open comes back; a value they cannot
* open is kept out of the row and named in the skip report, rather than
* written back as ciphertext no reader can open.
*
* Mutation: restore the verbatim copy of `notesEncrypted` in
* `restoreCycleData` and the legacy case writes the foreign ciphertext back.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
import { POST } from "./restore-job-driver";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
}));
const saved = {
key: process.env.ENCRYPTION_KEY,
keys: process.env.ENCRYPTION_KEYS,
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
};
/** Make this process the host holding exactly `keys`, writing under `active`. */
function useKeys(keys: Record<string, string>, active: string) {
process.env.ENCRYPTION_KEY = "";
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
_resetCryptoCacheForTests();
}
const HOST_A = { a: "11".repeat(32) };
const HOST_B = { b: "22".repeat(32) };
/** Same key id as host B, different key material. */
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
const NOTE = "Cramps woke me at four";
const SENSITIVE = {
sexualActivity: true,
protectedSex: false,
pregnancyTest: "NEGATIVE",
progesteroneTest: null,
contraceptive: "ORAL",
};
const LABEL = "Lower back ache";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
process.env.ENCRYPTION_KEY = saved.key ?? "";
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
_resetCryptoCacheForTests();
});
function makeRequest(id: string) {
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
});
}
async function seedOwner() {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "cycle-cross-key-owner",
email: "cycle-cross-key-owner@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return owner;
}
/** Store `payload` as a backup on this host and run the real restore. */
async function restore(ownerId: string, payload: unknown) {
const prisma = getPrismaClient();
const backup = await prisma.dataBackup.create({
data: {
userId: ownerId,
type: "CYCLE_CROSS_KEY_RESTORE",
data: encrypt(JSON.stringify(payload)),
},
});
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
}
describe("cycle free text in a portable file restored under another key", () => {
it("comes back readable under the receiving host's key", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner();
/* ── host A writes the account and exports it ─────────────────────── */
useKeys(HOST_A, "a");
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
await prisma.cycleSymptom.create({
data: {
userId: owner.id,
categoryId: category.id,
key: "custom:cross-key-ache",
labelKey: "cycle.symptom.custom",
labelEncrypted: encrypt(LABEL),
},
});
await prisma.cycleDayLog.create({
data: {
userId: owner.id,
date: "2026-06-02",
flow: "MEDIUM",
notesEncrypted: encrypt(NOTE),
sensitiveEncrypted: encrypt(JSON.stringify(SENSITIVE)),
},
});
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as unknown;
/* ── host B, which has never held key A, restores it ──────────────── */
useKeys(HOST_B, "b");
// Host B is another database: the account's own symptom row from host A
// is not there (the restore upserts by key and keeps an existing row).
await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } });
await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } });
const response = await restore(owner.id, file);
expect(response.status).toBe(200);
const day = await prisma.cycleDayLog.findFirstOrThrow({
where: { userId: owner.id },
});
expect(day.flow).toBe("MEDIUM");
expect(day.notesEncrypted).not.toBeNull();
expect(decrypt(day.notesEncrypted!)).toBe(NOTE);
expect(day.sensitiveEncrypted).not.toBeNull();
expect(JSON.parse(decrypt(day.sensitiveEncrypted!))).toEqual(SENSITIVE);
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
where: { key: "custom:cross-key-ache" },
});
expect(decrypt(symptom.labelEncrypted!)).toBe(LABEL);
const body = (await response.json()) as {
data: { skipped: { links: number } };
};
expect(body.data.skipped.links).toBe(0);
});
it("restores a legacy file's ciphertext only where this host can open it", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner();
// Written under host B's own key: opens here.
useKeys(HOST_B, "b");
const ownNote = encrypt(NOTE);
// Written elsewhere: an id host B does not hold, and host B's id over
// different key material.
useKeys(HOST_A, "a");
const foreignNote = encrypt("written under key a");
const foreignLabel = encrypt("label under key a");
useKeys(HOST_B_IMPOSTOR, "b");
const impostorSensitive = encrypt(JSON.stringify(SENSITIVE));
// The shape a portable file had before the fix: every free-text field as
// the stored ciphertext, no plaintext beside it.
useKeys(HOST_B, "b");
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
file.customSymptoms = [
{
key: "custom:legacy-foreign",
labelKey: "cycle.symptom.custom",
categoryId: category.id,
icon: null,
sortOrder: 0,
isActive: true,
labelEncrypted: foreignLabel,
},
];
const day = (
date: string,
notes: string | null,
sensitive: string | null,
) => ({
date,
flow: "LIGHT",
intermenstrualBleeding: false,
basalBodyTempC: null,
temperatureExcluded: false,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: sensitive,
notesEncrypted: notes,
source: "MANUAL",
externalId: null,
tz: null,
symptomKeys: [],
symptomSeverities: [],
});
file.cycleDayLogs = [
day("2026-06-01", ownNote, null),
day("2026-06-02", foreignNote, impostorSensitive),
];
// Still a file this release accepts.
parseBackupPayload(JSON.stringify(file));
const response = await restore(owner.id, file);
expect(response.status).toBe(200);
const days = await prisma.cycleDayLog.findMany({
where: { userId: owner.id },
orderBy: { date: "asc" },
});
expect(days.map((d) => d.date)).toEqual(["2026-06-01", "2026-06-02"]);
expect(decrypt(days[0].notesEncrypted!)).toBe(NOTE);
// The day itself comes back; only what cannot be opened stays out.
expect(days[1].flow).toBe("LIGHT");
expect(days[1].notesEncrypted).toBeNull();
expect(days[1].sensitiveEncrypted).toBeNull();
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
where: { key: "custom:legacy-foreign" },
});
expect(symptom.labelEncrypted).toBeNull();
const body = (await response.json()) as {
data: {
skipped: {
links: number;
catalogueKeys: Array<{
catalogue: string;
key: string;
links: number;
}>;
};
};
};
expect(body.data.skipped.catalogueKeys).toEqual([
{
catalogue: "cycleCiphertext",
key: "customSymptoms.custom:legacy-foreign.labelEncrypted",
links: 1,
},
{
catalogue: "cycleCiphertext",
key: "cycleDayLogs.2026-06-02.notesEncrypted",
links: 1,
},
{
catalogue: "cycleCiphertext",
key: "cycleDayLogs.2026-06-02.sensitiveEncrypted",
links: 1,
},
]);
expect(body.data.skipped.links).toBe(3);
});
});
@@ -0,0 +1,251 @@
/**
* A portable file's custom mood labels survive a move to a host with other
* keys.
*
* The labels of the mood tags and mood tag categories a person created are
* sealed under the writing host's key. A portable file used to carry them as
* the stored ciphertext, so a restore onto a host with a different key
* refused the file at the key check, or with a matching key id over other key
* material wrote labels nobody could open.
*
* Both ends and the pipe: built by the real `buildFullBackupPayload` under key
* A, restored through the real route under key B, opened under key B. The
* second case is a file written before the fix: a label this host opens comes
* back, one it cannot open stays out and is named in the skip report.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
import { POST } from "./restore-job-driver";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
}));
const saved = {
key: process.env.ENCRYPTION_KEY,
keys: process.env.ENCRYPTION_KEYS,
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
};
/** Make this process the host holding exactly `keys`, writing under `active`. */
function useKeys(keys: Record<string, string>, active: string) {
process.env.ENCRYPTION_KEY = "";
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
_resetCryptoCacheForTests();
}
const HOST_A = { a: "11".repeat(32) };
const HOST_B = { b: "22".repeat(32) };
/** Same key id as host B, different key material. */
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
const CATEGORY_LABEL = "Evenings";
const TAG_LABEL = "Migraine";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
process.env.ENCRYPTION_KEY = saved.key ?? "";
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
_resetCryptoCacheForTests();
});
function makeRequest(id: string) {
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
});
}
async function seedOwner() {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "mood-cross-key-owner",
email: "mood-cross-key-owner@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return owner;
}
/** Store `payload` as a backup on this host and run the real restore. */
async function restore(ownerId: string, payload: unknown) {
const prisma = getPrismaClient();
const backup = await prisma.dataBackup.create({
data: {
userId: ownerId,
type: "MOOD_CROSS_KEY_RESTORE",
data: encrypt(JSON.stringify(payload)),
},
});
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
}
describe("custom mood labels in a portable file restored under another key", () => {
// Neither case logs a mood entry: an account's own tags and categories
// travel whether or not it has entries (they used to be written only inside
// the restore's entries branch, and a vocabulary set up before the first
// entry did not come back).
it("come back readable under the receiving host's key", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner();
useKeys(HOST_A, "a");
const category = await prisma.moodTagCategory.create({
data: {
userId: owner.id,
key: "customcat:cross-key",
labelKey: "mood.category.custom",
labelEncrypted: encrypt(CATEGORY_LABEL),
},
});
await prisma.moodTag.create({
data: {
userId: owner.id,
categoryId: category.id,
key: "custom:cross-key",
labelKey: "mood.tag.custom",
kind: "BINARY",
labelEncrypted: encrypt(TAG_LABEL),
},
});
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as unknown;
// Host B is another database: the rows host A wrote are not there.
useKeys(HOST_B, "b");
await prisma.moodTag.deleteMany({ where: { userId: owner.id } });
await prisma.moodTagCategory.deleteMany({ where: { userId: owner.id } });
const response = await restore(owner.id, file);
expect(response.status).toBe(200);
const restoredCategory = await prisma.moodTagCategory.findUniqueOrThrow({
where: { key: "customcat:cross-key" },
});
expect(decrypt(restoredCategory.labelEncrypted!)).toBe(CATEGORY_LABEL);
const restoredTag = await prisma.moodTag.findUniqueOrThrow({
where: { key: "custom:cross-key" },
});
expect(decrypt(restoredTag.labelEncrypted!)).toBe(TAG_LABEL);
const body = (await response.json()) as {
data: { skipped: { links: number } };
};
expect(body.data.skipped.links).toBe(0);
});
it("restores a legacy file's label ciphertext only where this host can open it", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner();
useKeys(HOST_B, "b");
const ownLabel = encrypt(TAG_LABEL);
useKeys(HOST_B_IMPOSTOR, "b");
const impostorLabel = encrypt(CATEGORY_LABEL);
// The shape a portable file had before the fix: labels as ciphertext.
useKeys(HOST_B, "b");
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
file.customMoodTagCategories = [
{
id: "legacy-category-id",
key: "customcat:legacy",
labelKey: "mood.category.custom",
icon: null,
sortOrder: 0,
isActive: true,
labelEncrypted: impostorLabel,
},
];
file.customMoodTags = [
{
key: "custom:legacy",
labelKey: "mood.tag.custom",
categoryId: "legacy-category-id",
kind: "BINARY",
isActive: true,
icon: null,
sortOrder: 0,
labelEncrypted: ownLabel,
scaleMin: 1,
scaleMax: 5,
inverse: false,
},
];
parseBackupPayload(JSON.stringify(file));
const response = await restore(owner.id, file);
expect(response.status).toBe(200);
const category = await prisma.moodTagCategory.findUniqueOrThrow({
where: { key: "customcat:legacy" },
});
expect(category.labelEncrypted).toBeNull();
const tag = await prisma.moodTag.findUniqueOrThrow({
where: { key: "custom:legacy" },
});
expect(decrypt(tag.labelEncrypted!)).toBe(TAG_LABEL);
const body = (await response.json()) as {
data: {
skipped: {
links: number;
catalogueKeys: Array<{
catalogue: string;
key: string;
links: number;
}>;
};
};
};
expect(body.data.skipped.catalogueKeys).toEqual([
{
catalogue: "moodLabelCiphertext",
key: "customMoodTagCategories.customcat:legacy.labelEncrypted",
links: 1,
},
]);
});
});
@@ -77,6 +77,7 @@ import {
} from "@/lib/labs/biomarker-store";
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
import {
TWO_ENDED_MODELS,
USER_COLUMN_BACKUP_CLASS,
@@ -3694,6 +3695,12 @@ function lostColumns(
return { lost: lost.sort(), compared };
}
/**
* Sections a portable file may carry stored ciphertext in, each with the
* reason. Empty: every sealed column travels readable in a portable file.
*/
const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap<string, string> = new Map();
describe("every column of every two-ended model survives a real restore", () => {
it("brings each column back with the value it left with", async () => {
const prisma = getPrismaClient();
@@ -3758,6 +3765,58 @@ describe("every column of every two-ended model survives a real restore", () =>
},
{ purpose: "disaster-recovery", exportedAt },
);
// ── No ciphertext in a portable file ──────────────────────────────────
//
// Every sealed column is filled at this point, so the portable file of
// this account is the one place a column still exported as stored
// ciphertext shows up. A portable file is read and restored on hosts
// with other keys; ciphertext in it is noise there, and a restore either
// refuses the file or writes values nobody can open. That class was
// fixed one section at a time (cycle, mood labels, visits, vaccinations)
// and kept coming back, because each builder decides its own encoding.
// This reads what the builders actually wrote, rather than how their
// source is spelt. Limit: it sees members named `…Encrypted` only, the
// naming the key preflight relies on too.
const sealedIn = (text: string) => {
const collector = new BackupKeyIdCollector();
collector.visit(JSON.parse(text));
const sections = new Map<string, number>();
for (const [, use] of collector.entries()) {
for (const section of use.sections) {
sections.set(section, (sections.get(section) ?? 0) + use.count);
}
}
return sections;
};
// The floor: the same matcher over the disaster-recovery file of the same
// account, which carries every sealed column as ciphertext. A matcher
// that finds nothing there would make the portable check pass vacuously.
const drSealed = sealedIn(json);
expect(
[...drSealed.values()].reduce((a, b) => a + b, 0),
).toBeGreaterThanOrEqual(40);
expect(drSealed.size).toBeGreaterThanOrEqual(15);
let portableJson = "";
await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
portableJson += chunk;
},
{ purpose: "portable-export", exportedAt },
);
const portableSealed = [...sealedIn(portableJson).keys()].filter(
(section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section),
);
expect(
portableSealed,
"a portable file carries these sections' sealed columns as stored " +
"ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " +
"and seal them again on restore, or name the section in " +
"PORTABLE_CIPHERTEXT_ALLOWED with the reason",
).toEqual([]);
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
@@ -0,0 +1,296 @@
/**
* A portable file's visit and vaccination free text survives a move to a host
* with other keys.
*
* A practitioner's note, a visit's reason, outcome and body site and a
* vaccination's note are sealed under the writing host's key. A portable file
* used to carry them as the stored ciphertext, so a restore onto a host with
* a different key refused the file at the key check, or with a matching key id
* over other key material wrote values nobody could open.
*
* Both ends and the pipe: built by the real `buildFullBackupPayload` under key
* A, restored through the real route under key B, opened under key B. The
* second case is a file written before the fix: a value this host opens comes
* back, one it cannot open stays out and is named in the skip report.
*/
import { Buffer } from "node:buffer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { parseBackupPayload } from "@/lib/validations/backup";
import { POST } from "./restore-job-driver";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
}));
const saved = {
key: process.env.ENCRYPTION_KEY,
keys: process.env.ENCRYPTION_KEYS,
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
};
/** Make this process the host holding exactly `keys`, writing under `active`. */
function useKeys(keys: Record<string, string>, active: string) {
process.env.ENCRYPTION_KEY = "";
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
_resetCryptoCacheForTests();
}
const HOST_A = { a: "11".repeat(32) };
const HOST_B = { b: "22".repeat(32) };
/** Same key id as host B, different key material. */
const HOST_B_IMPOSTOR = { b: "33".repeat(32) };
const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
process.env.ENCRYPTION_KEY = saved.key ?? "";
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
_resetCryptoCacheForTests();
});
function makeRequest(id: string) {
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
});
}
async function seedOwner() {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "visits-cross-key-owner",
email: "visits-cross-key-owner@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return owner;
}
/** Store `payload` as a backup on this host and run the real restore. */
async function restore(ownerId: string, payload: unknown) {
const prisma = getPrismaClient();
const backup = await prisma.dataBackup.create({
data: {
userId: ownerId,
type: "VISITS_CROSS_KEY_RESTORE",
data: encrypt(JSON.stringify(payload)),
},
});
return POST(makeRequest(backup.id) as unknown as Parameters<typeof POST>[0], {
params: Promise.resolve({ id: backup.id }),
});
}
describe("visit and vaccination free text restored under another key", () => {
it("comes back readable under the receiving host's key", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner();
useKeys(HOST_A, "a");
const practitioner = await prisma.practitioner.create({
data: {
userId: owner.id,
name: "Practice",
noteEncrypted: encryptToBytes("ring the upper bell"),
locationEncrypted: encryptToBytes("Main street 1"),
},
});
const encounter = await prisma.encounter.create({
data: {
userId: owner.id,
occurredAt: new Date("2026-06-30T08:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
practitionerId: practitioner.id,
reasonEncrypted: encryptToBytes("annual check"),
outcomeEncrypted: encryptToBytes("all fine"),
bodySiteEncrypted: encryptToBytes("left knee"),
},
});
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId: owner.id,
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
vaccineName: "Tetanus",
noteEncrypted: encryptToBytes("sore arm for a day"),
},
});
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as unknown;
useKeys(HOST_B, "b");
const response = await restore(owner.id, file);
expect(response.status).toBe(200);
const p = await prisma.practitioner.findUniqueOrThrow({
where: { id: practitioner.id },
});
expect(decryptFromBytes(p.noteEncrypted!)).toBe("ring the upper bell");
expect(decryptFromBytes(p.locationEncrypted!)).toBe("Main street 1");
const e = await prisma.encounter.findUniqueOrThrow({
where: { id: encounter.id },
});
expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check");
expect(decryptFromBytes(e.outcomeEncrypted!)).toBe("all fine");
expect(decryptFromBytes(e.bodySiteEncrypted!)).toBe("left knee");
const v = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id: vaccination.id },
});
expect(decryptFromBytes(v.noteEncrypted!)).toBe("sore arm for a day");
const body = (await response.json()) as {
data: { skipped: { links: number } };
};
expect(body.data.skipped.links).toBe(0);
});
it("restores a legacy file's ciphertext only where this host can open it", async () => {
const prisma = getPrismaClient();
const owner = await seedOwner();
useKeys(HOST_B, "b");
const ownReason = b64(encryptToBytes("annual check"));
useKeys(HOST_A, "a");
const foreignNote = b64(encryptToBytes("written under key a"));
useKeys(HOST_B_IMPOSTOR, "b");
const impostorOutcome = b64(encryptToBytes("same id, other key"));
const impostorVaccination = b64(encryptToBytes("same id, other key"));
// The shape a portable file had before the fix: ciphertext, no text.
useKeys(HOST_B, "b");
const { payload: built } = await buildFullBackupPayload(prisma, owner.id);
const file = JSON.parse(JSON.stringify(built)) as Record<string, unknown>;
const at = "2026-06-30T08:00:00.000Z";
file.practitioners = [
{
id: "legacy-practitioner",
name: "Practice",
specialty: null,
practice: null,
location: null,
phone: null,
noteEncrypted: foreignNote,
createdAt: at,
updatedAt: at,
},
];
file.encounters = [
{
id: "legacy-encounter",
occurredAt: at,
status: "DONE",
kind: "ROUTINE",
practitionerId: "legacy-practitioner",
reasonEncrypted: ownReason,
outcomeEncrypted: impostorOutcome,
bodySiteEncrypted: null,
laterality: null,
reminderId: null,
createdAt: at,
updatedAt: at,
},
];
file.vaccinations = [
{
id: "legacy-vaccination",
occurredAt: at,
vaccineName: "Tetanus",
noteEncrypted: impostorVaccination,
createdAt: at,
updatedAt: at,
},
];
parseBackupPayload(JSON.stringify(file));
const response = await restore(owner.id, file);
expect(response.status).toBe(200);
const p = await prisma.practitioner.findUniqueOrThrow({
where: { id: "legacy-practitioner" },
});
expect(p.noteEncrypted).toBeNull();
const e = await prisma.encounter.findUniqueOrThrow({
where: { id: "legacy-encounter" },
});
expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check");
expect(e.outcomeEncrypted).toBeNull();
const v = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id: "legacy-vaccination" },
});
expect(v.vaccineName).toBe("Tetanus");
expect(v.noteEncrypted).toBeNull();
const body = (await response.json()) as {
data: {
skipped: {
links: number;
catalogueKeys: Array<{
catalogue: string;
key: string;
links: number;
}>;
};
};
};
expect(body.data.skipped.catalogueKeys).toEqual([
{
catalogue: "vaccinationCiphertext",
key: "vaccinations.legacy-vaccination.noteEncrypted",
links: 1,
},
{
catalogue: "visitCiphertext",
key: "encounters.legacy-encounter.outcomeEncrypted",
links: 1,
},
{
catalogue: "visitCiphertext",
key: "practitioners.legacy-practitioner.noteEncrypted",
links: 1,
},
]);
});
});
@@ -6,8 +6,8 @@
* into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled,
* merging same-day samples into one row with first-write-wins re-import
* - the full-backup cycle section round-trips through
* `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted`
* preserved verbatim and symptom links re-resolved by key
* `buildCycleBackupSection` → `restoreCycleData` with the note carried
* readable, re-sealed on restore, and symptom links re-resolved by key
*/
import { writeFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup";
import { decrypt, encrypt } from "@/lib/crypto";
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
import { parseBackupPayload } from "@/lib/validations/backup";
@@ -163,7 +164,7 @@ describe("cycle import — reproductive HK samples route to CycleDayLog", () =>
});
describe("cycle backup — round-trips through build + restore", () => {
it("preserves notesEncrypted ciphertext and symptom links by key", async () => {
it("carries the note readable and re-seals it, with symptom links by key", async () => {
const prisma = getPrismaClient();
const cycle = await prisma.menstrualCycle.create({
@@ -184,7 +185,7 @@ describe("cycle backup — round-trips through build + restore", () => {
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
notesEncrypted: "v1:abc123:cipher-envelope",
notesEncrypted: encrypt("Cramps in the evening"),
source: "MANUAL",
},
});
@@ -207,9 +208,9 @@ describe("cycle backup — round-trips through build + restore", () => {
const section = await buildCycleBackupSection(prisma, USER_ID);
expect(section.cycles).toHaveLength(1);
expect(section.cycleDayLogs).toHaveLength(1);
expect(section.cycleDayLogs[0].notesEncrypted).toBe(
"v1:abc123:cipher-envelope",
);
// A portable section carries the note readable, never the ciphertext.
expect(section.cycleDayLogs[0].note).toBe("Cramps in the evening");
expect(section.cycleDayLogs[0]).not.toHaveProperty("notesEncrypted");
expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]);
const payload = parseBackupPayload({
@@ -234,7 +235,7 @@ describe("cycle backup — round-trips through build + restore", () => {
include: { symptomLinks: { include: { symptom: true } } },
});
expect(restored).toHaveLength(1);
expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope");
expect(decrypt(restored[0].notesEncrypted!)).toBe("Cramps in the evening");
expect(restored[0].cervixPosition).toBe("HIGH");
expect(restored[0].cervixFirmness).toBe("SOFT");
expect(restored[0].cervixOpening).toBe("OPEN");
@@ -343,7 +343,10 @@ describe("the backup", () => {
const entry = section.encounters.find((row) => row.id === created.id);
expect(entry?.kind).toBe("PROCEDURE");
expect(entry?.laterality).toBe("BOTH");
expect(entry?.bodySiteEncrypted).toEqual(expect.any(String));
// A portable section carries the site readable, never the ciphertext; the
// restore seals it under this host's key.
expect(entry?.bodySite).toBe("Hüfte");
expect(entry).not.toHaveProperty("bodySiteEncrypted");
// Through the file schema, as a restore reads it: a field the schema does
// not know would be dropped here and come back as null.
@@ -0,0 +1,249 @@
/**
* One `Idempotency-Key`, two different requests.
*
* Native clients up to 1.1.0 post a manual blood pressure as two
* `POST /api/measurements` calls — the systolic half, then the diastolic half —
* under the same key. While the replay cell was keyed on
* `(user, key, method, path)` alone, the second call was answered with the
* first call's cached 201 and the diastolic value was never written. The cell
* now carries a fingerprint of the request body: only the same body replays.
*
* Everything here is real — the shipped route, the shipped wrapper, the
* session resolver and the `idempotency_keys` unique index.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { POST as postMeasurement } from "@/app/api/measurements/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
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),
}));
const USER_A = "idem-fingerprint-a";
const USER_B = "idem-fingerprint-b";
const KEY = "bp-entry-5c1e2a7f";
const MEASURED_AT = "2026-09-30T07:15:00.000Z";
const SYS = { type: "BLOOD_PRESSURE_SYS", value: 128, measuredAt: MEASURED_AT };
const DIA = { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: MEASURED_AT };
const sessions = new Map<string, string>();
function post(body: unknown, key = KEY): Promise<Response> {
return postMeasurement(
new NextRequest("http://localhost/api/measurements", {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": key,
},
body: JSON.stringify(body),
}),
);
}
function signInAs(userId: string) {
cookieJar.set("healthlog_session", sessions.get(userId)!);
}
async function rowsOf(userId: string) {
const rows = await getPrismaClient().measurement.findMany({
where: { userId, deletedAt: null },
select: { type: true, value: true },
});
// By name, not by the enum's declaration order.
return rows.sort((a, b) => a.type.localeCompare(b.type));
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
sessions.clear();
for (const id of [USER_A, USER_B]) {
await prisma.user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
timezone: "UTC",
},
});
const session = await prisma.session.create({
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
sessions.set(id, session.id);
}
signInAs(USER_A);
});
describe("Idempotency-Key reused for a different body (real Postgres)", () => {
it("writes both halves of a blood pressure posted under one key", async () => {
const first = await post(SYS);
expect(first.status).toBe(201);
const second = await post(DIA);
expect(second.status).toBe(201);
expect(second.headers.get("X-Idempotent-Replay")).toBeNull();
expect(await rowsOf(USER_A)).toEqual([
{ type: "BLOOD_PRESSURE_DIA", value: 84 },
{ type: "BLOOD_PRESSURE_SYS", value: 128 },
]);
});
it("still replays a retry of the same body and writes one row", async () => {
const first = await post(SYS);
expect(first.status).toBe(201);
const firstBody = await first.json();
// Same object, different key order: the same request.
const retry = await post({
measuredAt: MEASURED_AT,
value: 128,
type: "BLOOD_PRESSURE_SYS",
});
expect(retry.status).toBe(201);
expect(retry.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await retry.json()).toEqual(firstBody);
expect(await rowsOf(USER_A)).toEqual([
{ type: "BLOOD_PRESSURE_SYS", value: 128 },
]);
});
it("keeps the first cell answering its own retries after a different body ran", async () => {
await post(SYS);
await post(DIA);
const retry = await post(SYS);
expect(retry.headers.get("X-Idempotent-Replay")).toBe("true");
expect(await rowsOf(USER_A)).toHaveLength(2);
const cells = await getPrismaClient().idempotencyKey.findMany({
where: { userId: USER_A },
});
expect(cells).toHaveLength(1);
expect(cells[0]?.requestFingerprint).toMatch(/^[0-9a-f]{64}$/);
});
it("writes both halves when they are in flight at the same time", async () => {
const [a, b] = await Promise.all([post(SYS), post(DIA)]);
expect([a.status, b.status]).toEqual([201, 201]);
expect(await rowsOf(USER_A)).toEqual([
{ type: "BLOOD_PRESSURE_DIA", value: 84 },
{ type: "BLOOD_PRESSURE_SYS", value: 128 },
]);
});
it("still lets only one of two identical bodies in flight run", async () => {
const [a, b] = await Promise.all([post(SYS), post(SYS)]);
expect([a.status, b.status].sort()).toEqual([201, 409]);
expect(await rowsOf(USER_A)).toHaveLength(1);
});
it("does not replay a row written before fingerprints existed", async () => {
await getPrismaClient().idempotencyKey.create({
data: {
userId: USER_A,
key: KEY,
method: "POST",
path: "/api/measurements",
responseStatus: 201,
responseBody: JSON.stringify({ data: { id: "legacy" }, error: null }),
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const res = await post(DIA);
expect(res.status).toBe(201);
expect(res.headers.get("X-Idempotent-Replay")).toBeNull();
expect(await rowsOf(USER_A)).toEqual([
{ type: "BLOOD_PRESSURE_DIA", value: 84 },
]);
// The legacy row is left alone, not claimed over.
const cell = await getPrismaClient().idempotencyKey.findFirstOrThrow({
where: { userId: USER_A, key: KEY },
});
expect(cell.requestFingerprint).toBeNull();
});
it("never hands one user's cached response to another using the same key", async () => {
const first = await post(SYS);
const firstBody = await first.json();
signInAs(USER_B);
const other = await post(SYS);
expect(other.status).toBe(201);
expect(other.headers.get("X-Idempotent-Replay")).toBeNull();
expect(await other.json()).not.toEqual(firstBody);
expect(await rowsOf(USER_A)).toHaveLength(1);
expect(await rowsOf(USER_B)).toHaveLength(1);
});
it("the documented operator query finds a systolic reading left without its diastolic half", async () => {
const doc = readFileSync(
join(process.cwd(), "docs/ops/blood-pressure-missing-diastolic.md"),
"utf8",
);
const block =
/<!-- bp-missing-diastolic:start -->([\s\S]*?)<!-- bp-missing-diastolic:end -->/.exec(
doc,
);
const sql = block?.[1]
.replace(/```sql|```/g, "")
.trim()
.replace(/;$/, "");
expect(sql).toBeTruthy();
// A complete reading through the fixed path, and the damage the old
// cache left behind: a systolic half on its own.
await post(SYS);
await post(DIA);
const lone = await getPrismaClient().measurement.create({
data: {
userId: USER_A,
type: "BLOOD_PRESSURE_SYS",
value: 141,
unit: "mmHg",
measuredAt: new Date("2026-09-29T19:40:00.000Z"),
},
});
const found = await getPrismaClient().$queryRawUnsafe<
Array<{ id: string; systolic: number }>
>(sql!);
expect(found.map((r) => [r.id, r.systolic])).toEqual([[lone.id, 141]]);
});
});
@@ -0,0 +1,152 @@
/**
* A medication PUT the course writer refuses changes nothing.
*
* The PUT checks the window against the other courses first, then writes the
* row, then writes the course. A course written by another request between
* the check and the course write makes the course write refuse. If the row
* write had already committed, the client got an error while the name, the
* schedule and `oneShot` were changed anyway: a one-time medication with a
* window that spans more than its one day.
*
* The race is reproduced by letting the up-front check pass (it is the check
* the other request outran), so the real course writer meets the conflicting
* courses and refuses, through the shipped route and a real Postgres.
*
* Mutation: move the row update back out of the transaction the course write
* runs in, and the name and `oneShot` assertions turn red.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const OWNER = "user-med-put-race";
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),
}));
// The check the concurrent request outran: it saw no conflict.
vi.mock("@/lib/medications/courses", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/medications/courses")>()),
checkCurrentWindow: vi.fn(async () => null),
}));
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
await prisma.user.create({
data: {
id: OWNER,
username: OWNER,
email: `${OWNER}@example.test`,
timezone: "UTC",
},
});
const session = await prisma.session.create({
data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
});
function req(method: string, path: string, body?: unknown) {
return new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
describe("a medication PUT refused by the course writer", () => {
it("leaves the medication row, its schedule and its courses as they were", async () => {
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/medications/route");
const created = await POST(
req("POST", "/api/medications", {
name: "Tamiflu",
dose: "75 mg",
startsOn: "2026-03-01",
endsOn: "2026-03-07",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(created.status).toBe(201);
const { id } = (await created.json()).data as { id: string };
// The course the other request wrote.
const { POST: postCourse } =
await import("@/app/api/medications/[id]/courses/route");
const course = await postCourse(
req("POST", `/api/medications/${id}/courses`, {
startsOn: "2026-06-10",
endsOn: "2026-06-16",
}),
{ params: Promise.resolve({ id }) },
);
expect(course.status).toBe(201);
const before = await prisma.medication.findUniqueOrThrow({
where: { id },
include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } },
});
const revisionsBefore = await prisma.medicationScheduleRevision.count({
where: { medicationId: id },
});
// A one-time medication cannot carry two courses: the writer refuses.
const { PUT } = await import("@/app/api/medications/[id]/route");
const res = await PUT(
req("PUT", `/api/medications/${id}`, {
name: "Oseltamivir",
oneShot: true,
startsOn: "2026-03-05",
schedules: [{ windowStart: "20:00", windowEnd: "21:00" }],
}),
{ params: Promise.resolve({ id }) },
);
expect(res.status).toBe(422);
expect((await res.json()).meta.errorCode).toMatch(/^medications\.course\./);
const after = await prisma.medication.findUniqueOrThrow({
where: { id },
include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } },
});
expect(after.name).toBe("Tamiflu");
expect(after.oneShot).toBe(false);
expect(after.startsOn).toEqual(before.startsOn);
expect(after.endsOn).toEqual(before.endsOn);
expect(after.updatedAt).toEqual(before.updatedAt);
expect(after.schedules.map((s) => s.id)).toEqual(
before.schedules.map((s) => s.id),
);
expect(after.schedules.map((s) => s.windowStart)).toEqual(["08:00"]);
expect(after.courses.map((c) => [c.startsOn, c.endsOn])).toEqual(
before.courses.map((c) => [c.startsOn, c.endsOn]),
);
expect(
await prisma.medicationScheduleRevision.count({
where: { medicationId: id },
}),
).toBe(revisionsBefore);
});
});