mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
test(backup): guard that a backed-up model travels both ways
The classification guard proved every model has an opinion and would have passed unchanged while a model classified as carried was read into the payload and never written back — which is what happened to the nutrient day totals, the custom mood tags and the custom cycle symptoms, three releases running. Split BACKED_UP into the models with both ends and the models without, each of the latter carrying what an account loses meanwhile, so the debt is a register rather than a silence. The check reads declared writer and restore files rather than grepping for a route, because the route delegates: a review that grepped only the route concluded the cycle data was never restored and had to be retracted. Declaring the file list caught its own first omission — measurements are read through the pager, not directly. Adds a round trip that builds a real payload, parses it through the wire schema and drives the real restore, asserting on the rows written rather than on the payload. Dropping the cycle section spread makes it fail the way production did: the restore throws on a symptom key nothing wrote back.
This commit is contained in:
1 parent
c6593b292f
commit
faa89ef54b
4 files changed
+693
-15
No files matched your search
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Export → wire schema → restore, driven end to end.
|
||||
*
|
||||
* Every other test around the backup proves ONE end. `full-backup-payload.test.ts`
|
||||
* proves the builder emits a key; the restore tests prove the route refuses a
|
||||
* bad file. Between them sits the gap that lost three domains in a row: a key
|
||||
* the builder emits, the schema drops or nobody reads, and the restore reports
|
||||
* success over.
|
||||
*
|
||||
* So this test does not assert on the payload. It builds a real payload from a
|
||||
* populated account, serialises it, parses it back through the REAL
|
||||
* `parseBackupPayload`, hands it to the REAL route, and asserts on the rows the
|
||||
* restore tried to write. A field that survives that survives a restore.
|
||||
*
|
||||
* The transaction stand-in is deliberately not inert: `cycleSymptom.findMany`
|
||||
* answers from what the restore actually upserted. If the account's own symptom
|
||||
* definitions do not ride the file, the link resolution finds nothing and the
|
||||
* restore throws — which is what a real instance does, and is exactly how the
|
||||
* inert v1.33.1 fix behaved in production.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
decrypt: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
HttpError: class extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dataBackup: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: mocks.transaction,
|
||||
},
|
||||
}));
|
||||
|
||||
// Only `decrypt` is stubbed — it stands in for reading the stored backup blob.
|
||||
// The rest of the module stays real, so the ciphertext this test builds and the
|
||||
// ciphertext the restore writes are produced by the same code an instance runs.
|
||||
vi.mock("@/lib/crypto", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/crypto")>()),
|
||||
decrypt: mocks.decrypt,
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency: (fn: unknown) => fn,
|
||||
defaultUserIdResolver: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() }));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeUserMoodRollups: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeUserMedicationCompliance: vi.fn(),
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30,
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeUserRollups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
const OWNER = "user-A";
|
||||
|
||||
/* ── the account being backed up ────────────────────────────────────── */
|
||||
|
||||
function sourceClient() {
|
||||
const empty = { findMany: vi.fn().mockResolvedValue([]) };
|
||||
return {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
measurement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: empty,
|
||||
moodEntry: empty,
|
||||
moodTag: empty,
|
||||
nutrientIntakeDay: empty,
|
||||
// Cycle: one day-log whose symptom is one the ACCOUNT defined, which is
|
||||
// the case the payload assembly used to drop.
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "day-1",
|
||||
date: "2026-07-19",
|
||||
cycleId: null,
|
||||
flow: null,
|
||||
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: null,
|
||||
notesEncrypted: null,
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
tz: null,
|
||||
syncVersion: 0,
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
cycleSymptom: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "sym-1",
|
||||
key: "custom:jaw_tension",
|
||||
labelKey: "custom",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: "envelope",
|
||||
},
|
||||
]),
|
||||
},
|
||||
// Records section: empty, exercised by its own suite.
|
||||
labResult: empty,
|
||||
biomarker: empty,
|
||||
illnessEpisode: empty,
|
||||
allergy: empty,
|
||||
familyHistoryEntry: empty,
|
||||
workout: empty,
|
||||
inboundDocument: empty,
|
||||
// The two domains this stream adds.
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "profile-1",
|
||||
userId: OWNER,
|
||||
aboutMeEncrypted: encryptToBytes("desk job, two kids"),
|
||||
conditionsEncrypted: null,
|
||||
allergiesEncrypted: encryptToBytes("penicillin"),
|
||||
coachFocusEncrypted: null,
|
||||
pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'),
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "metric-1",
|
||||
userId: OWNER,
|
||||
name: "Morning grip strength",
|
||||
unit: "kg",
|
||||
targetLow: 40,
|
||||
targetHigh: null,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
entries: [
|
||||
{
|
||||
id: "entry-1",
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-07-19T06:30:00.000Z"),
|
||||
note: "felt strong",
|
||||
createdAt: new Date("2026-07-19T06:31:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── the instance being restored into ───────────────────────────────── */
|
||||
|
||||
interface Written {
|
||||
model: string;
|
||||
op: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function recordingTx(written: Written[]) {
|
||||
// Symptom definitions the restore has written back, so link resolution can
|
||||
// only succeed if the definitions actually rode the file.
|
||||
const restoredSymptoms = new Map<string, string>();
|
||||
|
||||
const delegate = (model: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, op: string) {
|
||||
return async (args: Record<string, unknown> = {}) => {
|
||||
if (op === "deleteMany") return { count: 0 };
|
||||
if (op === "findMany") {
|
||||
if (model === "cycleSymptom") {
|
||||
return [...restoredSymptoms].map(([key, id]) => ({ id, key }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (op === "findFirst" || op === "findUnique") return null;
|
||||
const data = (args.data ?? args.create ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (op === "create" || op === "upsert" || op === "createMany") {
|
||||
if (model === "cycleSymptom" && typeof data.key === "string") {
|
||||
restoredSymptoms.set(data.key, (data.id as string) ?? "new-id");
|
||||
}
|
||||
written.push({ model, op, data });
|
||||
}
|
||||
return { id: (data.id as string) ?? `${model}-created` };
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return new Proxy({} as Record<string, unknown>, {
|
||||
get: (_t, model: string) => delegate(model),
|
||||
});
|
||||
}
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/backups/b-1/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a real backup for the account above, then restore it. */
|
||||
async function roundTrip(): Promise<{ res: Response; written: Written[] }> {
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
sourceClient() as never,
|
||||
OWNER,
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
|
||||
// The file as it lands on disk: JSON, nothing else.
|
||||
mocks.decrypt.mockReturnValue(JSON.stringify(payload));
|
||||
|
||||
const written: Written[] = [];
|
||||
mocks.transaction.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) => fn(recordingTx(written)),
|
||||
);
|
||||
|
||||
const res = await (
|
||||
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
|
||||
)(request(), { params: Promise.resolve({ id: "b-1" }) });
|
||||
|
||||
return { res, written };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({
|
||||
id: "b-1",
|
||||
userId: OWNER,
|
||||
data: "cipher",
|
||||
user: { id: OWNER, username: "owner" },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: OWNER,
|
||||
username: "owner",
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("backup round trip — export, wire schema, restore", () => {
|
||||
it("completes without the restore reporting a failure", async () => {
|
||||
const { res } = await roundTrip();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("writes the durable self-context back with its ciphertext intact", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const profile = written.find((w) => w.model === "userHealthProfile");
|
||||
expect(
|
||||
profile,
|
||||
"the profile reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
expect(profile!.data.userId).toBe(OWNER);
|
||||
// The envelope round-trips as bytes, not as a re-encryption of plaintext
|
||||
// the DR file never carried.
|
||||
expect(profile!.data.aboutMeEncrypted).toBeInstanceOf(Uint8Array);
|
||||
expect(profile!.data.conditionsEncrypted).toBeNull();
|
||||
expect(profile!.data.pendingQuestionsEncrypted).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("writes each custom metric back with its readings", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const metric = written.find((w) => w.model === "customMetric");
|
||||
expect(
|
||||
metric,
|
||||
"the metric reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
expect(metric!.data).toMatchObject({
|
||||
userId: OWNER,
|
||||
name: "Morning grip strength",
|
||||
unit: "kg",
|
||||
targetLow: 40,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
});
|
||||
|
||||
// The readings are the half that would go missing quietly: a metric with
|
||||
// no entries renders as an empty chart rather than as an error.
|
||||
const entries = (
|
||||
metric!.data.entries as { create: Array<Record<string, unknown>> }
|
||||
).create;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
note: "felt strong",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the account's own cycle symptom back before resolving its links", async () => {
|
||||
const { written, res } = await roundTrip();
|
||||
|
||||
// The definition has to land, or the link lookup below finds nothing.
|
||||
expect(
|
||||
written.find(
|
||||
(w) =>
|
||||
w.model === "cycleSymptom" && w.data.key === "custom:jaw_tension",
|
||||
),
|
||||
"the custom symptom definition never reached the restore",
|
||||
).toBeDefined();
|
||||
|
||||
// And the day-log that references it restores rather than throwing on an
|
||||
// unresolvable key — the failure a real account hit while the fix was inert.
|
||||
expect(res.status).toBe(200);
|
||||
expect(written.find((w) => w.model === "cycleDayLog")).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user