mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge remote-tracking branch 'origin/main' into ready/pr-1131
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user