merge: s5-export into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:33 +02:00
commit fbdadae0ce
20 files changed
+1573 -38

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();
});
});
@@ -38,6 +38,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -64,6 +65,8 @@ interface RestoreResponse {
familyHistory: number;
workouts: number;
documents: number;
healthProfile: number;
customMetrics: number;
};
}
@@ -562,6 +565,32 @@ const handler = apiHandler(
}
if (payload.intakeEvents.length > 0) {
// An event whose medication did not come back used to be mapped to
// null and filtered away: the dose history came back short and the
// restore reported success. That is the same silent-drop shape the
// cycle symptom links had, and it gets the same answer — name the
// unresolved reference and stop, so the operator learns from the
// error rather than from a compliance rate that quietly moved.
const unresolvedMedications = [
...new Set(
payload.intakeEvents
.filter(
(e) =>
!(
e.medicationId &&
restoredMedicationIds.has(e.medicationId)
) && !medByName.has(e.medication),
)
.map((e) => e.medicationId ?? e.medication),
),
];
if (unresolvedMedications.length > 0) {
throw new Error(
`Unknown medication references in intake events: ${unresolvedMedications.join(
", ",
)}. The backup records doses against medications it does not carry.`,
);
}
const rows = payload.intakeEvents
.map((e) => {
const medId =
@@ -718,6 +747,12 @@ const handler = apiHandler(
// above. `notesEncrypted` is restored as ciphertext verbatim.
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
// Durable self-context + user-defined metrics. Both ends of this pair
// live in `src/lib/export/profile-backup.ts` beside its builder — a
// grep of THIS file alone will not find them, exactly as it does not
// find the cycle restore one line above.
const profileCleared = await restoreProfileData(tx, ownerId, payload);
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
for (const biomarker of payload.biomarkers) {
@@ -1073,6 +1108,8 @@ const handler = apiHandler(
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
healthProfile: profileCleared.healthProfile,
customMetrics: profileCleared.customMetrics,
};
},
{
@@ -1197,6 +1234,9 @@ const handler = apiHandler(
familyHistory: summary.familyHistory,
workouts: summary.workouts,
documents: summary.documents,
healthProfile: summary.healthProfile,
customMetrics: summary.customMetrics,
customMetricEntries: summary.customMetricEntries,
},
},
});
@@ -18,6 +18,10 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn() },
moodTag: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// Durable self-context + user-defined metrics, read by
// `buildProfileBackupSection`.
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -73,9 +77,13 @@ function mkReq(url: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the inline default, and the full-backup payload
// builder reads this unconditionally.
// `resetAllMocks` clears the inline defaults, and the full-backup payload
// builder reads these unconditionally.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
moodTag: { findMany: vi.fn().mockResolvedValue([]) },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// Durable self-context + user-defined metrics, read by
// `buildProfileBackupSection`.
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+3
View File
@@ -117,6 +117,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_archive_bytes: archive.byteLength,
},
});
+3
View File
@@ -76,6 +76,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
},
});