mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(backups): preserve canonical recovery integrity
This commit is contained in:
1 parent
5f4017d039
commit
f8b0a64d69
11 files changed
+853
-615
No files matched your search
@@ -27,6 +27,7 @@ import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
parseBackupPayload,
|
||||
isCompatibleSchemaVersion,
|
||||
summarizeBackup,
|
||||
type BackupSummary,
|
||||
} from "@/lib/validations/backup";
|
||||
@@ -72,43 +73,6 @@ function decodeEncryptedBytes(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a measurement-type string from the backup back into the
|
||||
* Prisma enum literal. Backups are written from `enum MeasurementType`
|
||||
* (Postgres-mapped to lowercase column type), so the round-trip is
|
||||
* normally identity. But the schema is a `passthrough` schema so a
|
||||
* malformed type slips through Zod — guard it explicitly here so the
|
||||
* restore fails fast instead of erroring deep in `prisma.create()`.
|
||||
*/
|
||||
const MEASUREMENT_TYPES = new Set([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESTING_HEART_RATE",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"VO2_MAX",
|
||||
"BODY_TEMPERATURE",
|
||||
]);
|
||||
|
||||
const MEASUREMENT_SOURCES = new Set([
|
||||
"MANUAL",
|
||||
"WITHINGS",
|
||||
"IMPORT",
|
||||
"APPLE_HEALTH",
|
||||
]);
|
||||
const INTAKE_SOURCES = new Set(["WEB", "API", "REMINDER", "IMPORT"]);
|
||||
|
||||
const handler = apiHandler(
|
||||
async (
|
||||
@@ -185,7 +149,24 @@ const handler = apiHandler(
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
return apiError("Backup payload failed schema validation", 500);
|
||||
return apiError("Backup payload failed schema validation", 422);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
await auditLog("admin.backups.restore.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId: backup.userId,
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// The payload declares its own owner, and the backup ROW records who the
|
||||
@@ -250,55 +231,6 @@ const handler = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-validate enum values OUTSIDE the transaction so a malformed
|
||||
// payload doesn't half-wipe the user before failing the create
|
||||
// step. Cheap up-front guard.
|
||||
for (const m of payload.measurements) {
|
||||
if (!MEASUREMENT_TYPES.has(m.type)) {
|
||||
await auditLog("admin.backups.restore.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId,
|
||||
reason: "unknown_measurement_type",
|
||||
type: m.type,
|
||||
},
|
||||
});
|
||||
return apiError(`Unknown measurement type in backup: '${m.type}'`, 422);
|
||||
}
|
||||
if (m.source && !MEASUREMENT_SOURCES.has(m.source)) {
|
||||
await auditLog("admin.backups.restore.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId,
|
||||
reason: "unknown_measurement_source",
|
||||
source: m.source,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Unknown measurement source in backup: '${m.source}'`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const e of payload.intakeEvents) {
|
||||
if (e.source && !INTAKE_SOURCES.has(e.source)) {
|
||||
await auditLog("admin.backups.restore.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId,
|
||||
reason: "unknown_intake_source",
|
||||
source: e.source,
|
||||
},
|
||||
});
|
||||
return apiError(`Unknown intake source in backup: '${e.source}'`, 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Portable exports intentionally omit document ciphertext. They remain
|
||||
// valid upload/download artifacts, but cannot be used to manufacture an
|
||||
@@ -398,27 +330,56 @@ const handler = apiHandler(
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
// ── re-create from payload ──
|
||||
if (payload.measurements.length > 0) {
|
||||
await tx.measurement.createMany({
|
||||
data: payload.measurements.map((m) => ({
|
||||
// Stable ids are authoritative for canonical snapshots. Legacy payloads
|
||||
// without ids fall back to the historic natural measurement key.
|
||||
for (const measurement of payload.measurements) {
|
||||
const measuredAt = new Date(measurement.measuredAt);
|
||||
const source = measurement.source ?? "MANUAL";
|
||||
const restoredData = {
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
unit: measurement.unit,
|
||||
source,
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(measurement.notes ?? null),
|
||||
deletedAt: measurement.deletedAt
|
||||
? new Date(measurement.deletedAt)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (measurement.id) {
|
||||
await tx.measurement.upsert({
|
||||
where: { id: measurement.id, userId: ownerId },
|
||||
create: {
|
||||
id: measurement.id,
|
||||
userId: ownerId,
|
||||
...restoredData,
|
||||
},
|
||||
update: restoredData,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await tx.measurement.findFirst({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
type: m.type as never, // already enum-validated above
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: (m.source ?? "MANUAL") as never,
|
||||
measuredAt: new Date(m.measuredAt),
|
||||
// v1.23 — re-encrypt the note from the (decrypted) backup payload
|
||||
// into the at-rest column; legacy plaintext column nulled.
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
})),
|
||||
// Backups can replay the same data — the unique
|
||||
// (userId, type, measuredAt, source) constraint catches
|
||||
// accidental dupes. `skipDuplicates` keeps the restore
|
||||
// idempotent across retries instead of throwing.
|
||||
skipDuplicates: true,
|
||||
type: measurement.type,
|
||||
source,
|
||||
measuredAt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await tx.measurement.update({
|
||||
where: { id: existing.id, userId: ownerId },
|
||||
data: restoredData,
|
||||
});
|
||||
} else {
|
||||
await tx.measurement.create({
|
||||
data: { userId: ownerId, ...restoredData },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Medications + schedules — restore one at a time so we can
|
||||
@@ -501,23 +462,55 @@ const handler = apiHandler(
|
||||
}
|
||||
|
||||
for (const entry of payload.moodEntries) {
|
||||
const created = await tx.moodEntry.create({
|
||||
data: {
|
||||
...(entry.id ? { id: entry.id } : {}),
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
tags: entry.tags ?? null,
|
||||
source: entry.source ?? "MOODLOG",
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt: new Date(entry.loggedAt),
|
||||
},
|
||||
const moodLoggedAt = new Date(entry.loggedAt);
|
||||
const restoredData = {
|
||||
date: entry.date,
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
tags: entry.tags ?? null,
|
||||
source: entry.source ?? "MOODLOG",
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt,
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
};
|
||||
const createData = { userId: ownerId, ...restoredData };
|
||||
const restored = entry.id
|
||||
? await tx.moodEntry.upsert({
|
||||
where: { id: entry.id, userId: ownerId },
|
||||
create: { id: entry.id, ...createData },
|
||||
update: restoredData,
|
||||
})
|
||||
: entry.externalId
|
||||
? await tx.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: ownerId,
|
||||
source: restoredData.source,
|
||||
externalId: entry.externalId,
|
||||
},
|
||||
},
|
||||
create: createData,
|
||||
update: restoredData,
|
||||
})
|
||||
: await tx.moodEntry.upsert({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
moodLoggedAt,
|
||||
},
|
||||
},
|
||||
create: createData,
|
||||
update: restoredData,
|
||||
});
|
||||
|
||||
await tx.moodEntryTagLink.deleteMany({
|
||||
where: { moodEntryId: restored.id },
|
||||
});
|
||||
if (entry.factors.length > 0) {
|
||||
await tx.moodEntryTagLink.createMany({
|
||||
data: entry.factors.map((factor) => ({
|
||||
moodEntryId: created.id,
|
||||
moodEntryId: restored.id,
|
||||
moodTagId: factorByKey.get(factor.key)!,
|
||||
rating: factor.rating,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMeasurementsPaged: vi.fn(),
|
||||
buildCycleBackupSection: vi.fn(),
|
||||
buildRecordsBackupSection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/export/paged-measurements", () => ({
|
||||
findMeasurementsPaged: mocks.findMeasurementsPaged,
|
||||
}));
|
||||
vi.mock("@/lib/cycle/backup", () => ({
|
||||
buildCycleBackupSection: mocks.buildCycleBackupSection,
|
||||
}));
|
||||
vi.mock("@/lib/export/records-backup", () => ({
|
||||
buildRecordsBackupSection: mocks.buildRecordsBackupSection,
|
||||
countRecordsBackupSection: vi.fn(() => ({
|
||||
labResults: 0,
|
||||
biomarkers: 0,
|
||||
illnessEpisodes: 0,
|
||||
illnessDayLogs: 0,
|
||||
allergies: 0,
|
||||
familyHistory: 0,
|
||||
workouts: 0,
|
||||
documents: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { buildFullBackupPayload } from "../full-backup-payload";
|
||||
|
||||
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
|
||||
|
||||
function makePrisma() {
|
||||
return {
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "mood-tombstone",
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: null,
|
||||
source: "MOODLOG",
|
||||
externalId: "moodlog-42",
|
||||
moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"),
|
||||
deletedAt,
|
||||
tagLinks: [
|
||||
{ rating: 4, moodTag: { key: "sleep_quality" } },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installSectionMocks() {
|
||||
mocks.findMeasurementsPaged.mockResolvedValue([
|
||||
{
|
||||
id: "measurement-tombstone",
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-07-19T07:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
deletedAt,
|
||||
},
|
||||
]);
|
||||
mocks.buildCycleBackupSection.mockResolvedValue({
|
||||
cycleProfile: null,
|
||||
cycles: [],
|
||||
cycleDayLogs: [],
|
||||
});
|
||||
mocks.buildRecordsBackupSection.mockResolvedValue({
|
||||
labResults: [],
|
||||
biomarkers: [],
|
||||
illnessEpisodes: [],
|
||||
allergies: [],
|
||||
familyHistory: [],
|
||||
workouts: [],
|
||||
documents: [],
|
||||
manifest: {
|
||||
documents: { included: "encrypted-content", note: "included" },
|
||||
workouts: { included: "summary-only", note: "included" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildFullBackupPayload disaster-recovery mode", () => {
|
||||
it("preserves measurement and mood tombstones with stable ids", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma as never, "user-1", {
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(mocks.findMeasurementsPaged).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
{ userId: "user-1" },
|
||||
expect.objectContaining({ id: true, deletedAt: true }),
|
||||
);
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1" },
|
||||
include: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
"user-1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
measurements: [
|
||||
{
|
||||
id: "measurement-tombstone",
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
},
|
||||
],
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-tombstone",
|
||||
externalId: "moodlog-42",
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
factors: [{ key: "sleep_quality", rating: 4 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,14 +35,21 @@ export interface FullBackupResult {
|
||||
counts: FullBackupCounts;
|
||||
}
|
||||
|
||||
export interface FullBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
exportedAt?: Date;
|
||||
}
|
||||
/**
|
||||
* Build the canonical full-backup payload for `userId`. Soft-deleted rows are
|
||||
* excluded so a round-trip via admin restore never resurrects deleted records.
|
||||
* Build the canonical full-backup payload for `userId`. Portable exports omit
|
||||
* tombstones and document ciphertext; disaster-recovery payloads preserve
|
||||
* both so weekly and off-host snapshots share one restorable wire format.
|
||||
*/
|
||||
export async function buildFullBackupPayload(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
options: FullBackupOptions = {},
|
||||
): Promise<FullBackupResult> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
const [measurements, medications, intakeEvents, moodEntries, cycle, records] =
|
||||
await Promise.all([
|
||||
// v1.28.25 — keyset-paginated read with a narrow select. The backup
|
||||
@@ -55,7 +62,7 @@ export async function buildFullBackupPayload(
|
||||
// note columns `readNote` decrypts + the `id` cursor key.
|
||||
findMeasurementsPaged(
|
||||
prisma,
|
||||
{ userId, deletedAt: null },
|
||||
disasterRecovery ? { userId } : { userId, deletedAt: null },
|
||||
{
|
||||
id: true,
|
||||
type: true,
|
||||
@@ -65,6 +72,7 @@ export async function buildFullBackupPayload(
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
),
|
||||
prisma.medication.findMany({
|
||||
@@ -89,24 +97,37 @@ export async function buildFullBackupPayload(
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
include: {
|
||||
tagLinks: {
|
||||
where: { rating: { not: null } },
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
buildCycleBackupSection(prisma, userId),
|
||||
buildRecordsBackupSection(prisma, userId),
|
||||
buildRecordsBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
|
||||
userId,
|
||||
measurements: measurements.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
notes: readNote(m.notesEncrypted, m.notes),
|
||||
deletedAt: m.deletedAt?.toISOString() ?? null,
|
||||
})),
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
@@ -127,12 +148,19 @@ export async function buildFullBackupPayload(
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
id: e.id,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
externalId: e.externalId,
|
||||
deletedAt: e.deletedAt?.toISOString() ?? null,
|
||||
factors: e.tagLinks.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating!,
|
||||
})),
|
||||
})),
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
BACKUP_SCHEMA_VERSION,
|
||||
backupPayloadSchema,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildFullBackupPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/export/full-backup-payload", () => ({
|
||||
buildFullBackupPayload: mocks.buildFullBackupPayload,
|
||||
}));
|
||||
import {
|
||||
encryptBackup,
|
||||
decryptBackup,
|
||||
@@ -6,7 +18,6 @@ import {
|
||||
runOffhostBackup,
|
||||
runOffhostRoundtripTest,
|
||||
} from "../offhost-backup";
|
||||
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
|
||||
|
||||
const ENC_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -89,12 +100,36 @@ function makeS3Mock() {
|
||||
describe("runOffhostBackup", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example");
|
||||
vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups");
|
||||
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA");
|
||||
vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret");
|
||||
vi.stubEnv("BACKUP_S3_REGION", "auto");
|
||||
vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY);
|
||||
mocks.buildFullBackupPayload.mockImplementation(
|
||||
async (_prisma: unknown, userId: string) => ({
|
||||
payload: {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: "2026-05-08T03:00:00.000Z",
|
||||
userId,
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: `mood-${userId}`,
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
loggedAt: "2026-05-08T20:00:00.000Z",
|
||||
factors: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
counts: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uploads one object per user and never deletes existing ones", async () => {
|
||||
@@ -137,56 +172,66 @@ describe("runOffhostBackup", () => {
|
||||
const decoded = decryptBackup(ct, Buffer.from(ENC_KEY, "hex"));
|
||||
const parsed = JSON.parse(decoded);
|
||||
expect(parsed.userId).toBe("u1");
|
||||
expect(() => backupPayloadSchema.parse(parsed)).not.toThrow();
|
||||
expect(parsed.schemaVersion).toBe(BACKUP_SCHEMA_VERSION);
|
||||
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(prisma, "u1", {
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
|
||||
it("uploads the canonical builder output without reshaping it", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
// A full first page (=== page size) followed by a short page proves the
|
||||
// reader walks the id cursor instead of one unbounded findMany.
|
||||
const fullPage = Array.from(
|
||||
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
|
||||
(_, i) => ({
|
||||
id: `e${String(i).padStart(5, "0")}`,
|
||||
userId: "u1",
|
||||
takenAt: new Date("2026-05-01T00:00:00Z"),
|
||||
}),
|
||||
);
|
||||
const tailPage = [
|
||||
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
|
||||
];
|
||||
const intakeFindMany = vi.fn(
|
||||
async (args: { where?: { id?: { gt?: string } } }) =>
|
||||
args?.where?.id?.gt ? tailPage : fullPage,
|
||||
);
|
||||
|
||||
const prisma = {
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: intakeFindMany },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
const canonicalPayload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: "2026-05-08T00:00:00.000Z",
|
||||
userId: "u1",
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
documents: [
|
||||
{
|
||||
id: "document-1",
|
||||
kind: "LAB_RESULT",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 4,
|
||||
status: "STORED",
|
||||
contentEncrypted: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
contentSha256: null,
|
||||
contentCodec: "binary2",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
summaryEncrypted: null,
|
||||
summaryGeneratedAt: null,
|
||||
summaryState: "NONE",
|
||||
createdAt: "2026-05-08T00:00:00.000Z",
|
||||
updatedAt: "2026-05-08T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.buildFullBackupPayload.mockResolvedValueOnce({
|
||||
payload: canonicalPayload,
|
||||
counts: {},
|
||||
});
|
||||
|
||||
const report = await runOffhostBackup(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma as any,
|
||||
prisma as never,
|
||||
s3,
|
||||
new Date("2026-05-08T00:00:00Z"),
|
||||
);
|
||||
|
||||
expect(report.uploaded).toBe(1);
|
||||
// Two round-trips: the full page, then the short tail that ends the walk.
|
||||
expect(intakeFindMany).toHaveBeenCalledTimes(2);
|
||||
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
|
||||
orderBy: { id: "asc" },
|
||||
take: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
});
|
||||
// Every paged row lands in the decrypted payload — no rows dropped.
|
||||
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
|
||||
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
|
||||
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
|
||||
const ciphertext = s3.store.get("2026-05-08/user-u1.json.enc")!;
|
||||
expect(
|
||||
JSON.parse(decryptBackup(ciphertext, Buffer.from(ENC_KEY, "hex"))),
|
||||
).toEqual(canonicalPayload);
|
||||
expect(() => backupPayloadSchema.parse(canonicalPayload)).not.toThrow();
|
||||
});
|
||||
|
||||
it("counts per-user failures without aborting the whole run", async () => {
|
||||
@@ -195,25 +240,24 @@ describe("runOffhostBackup", () => {
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]),
|
||||
},
|
||||
measurement: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockRejectedValueOnce(new Error("db gone")),
|
||||
},
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
mocks.buildFullBackupPayload
|
||||
.mockResolvedValueOnce({
|
||||
payload: {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
exportedAt: "2026-05-08T00:00:00.000Z",
|
||||
userId: "u1",
|
||||
},
|
||||
counts: {},
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("db gone"));
|
||||
|
||||
const report = await runOffhostBackup(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma as any,
|
||||
prisma as never,
|
||||
s3,
|
||||
new Date("2026-05-08T00:00:00Z"),
|
||||
);
|
||||
|
||||
expect(report.uploaded).toBe(1);
|
||||
expect(report.failed).toBe(1);
|
||||
});
|
||||
|
||||
@@ -19,11 +19,8 @@
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
collectPagedMeasurements,
|
||||
MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
} from "@/lib/jobs/backup/measurement-page";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
@@ -218,147 +215,12 @@ export async function runOffhostBackup(
|
||||
const evt = getEvent();
|
||||
for (const user of users) {
|
||||
try {
|
||||
const [
|
||||
measurements,
|
||||
medications,
|
||||
intakeEvents,
|
||||
moodEntries,
|
||||
cycleProfile,
|
||||
cycles,
|
||||
cycleDayLogs,
|
||||
] = await Promise.all([
|
||||
// includes soft-deleted rows because this is the DR snapshot,
|
||||
// not a user-facing export — see
|
||||
// `/api/export/full-backup/route.ts` for the symmetric exclusion.
|
||||
//
|
||||
// Keyset-paged narrow read instead of one full-set findMany: a
|
||||
// heavy multi-year tenant otherwise loads every measurement ORM
|
||||
// row into a single array alongside the giant JSON.stringify
|
||||
// below, the worker's dominant heap spike. The DR dump keeps the
|
||||
// FULL row shape, so the select lists every scalar column (no
|
||||
// relations) — identical bytes to the prior default findMany —
|
||||
// while the page reader bounds peak heap to the page size. No
|
||||
// `deletedAt` filter: the tombstoned rows stay in the snapshot.
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
type: true,
|
||||
value: true,
|
||||
valueMin: true,
|
||||
valueMax: true,
|
||||
unit: true,
|
||||
source: true,
|
||||
measuredAt: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
externalId: true,
|
||||
externalSourceVersion: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
rhythmClassification: true,
|
||||
deviceType: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
// The intake / mood / cycle tables scale with history depth the same
|
||||
// way measurements do (multiple intakes + a mood + a cycle-day-log per
|
||||
// day over years), so they get the same keyset-paged read: at most one
|
||||
// page is resident per round-trip instead of the entire table landing
|
||||
// in one array alongside the giant JSON.stringify below. The projection
|
||||
// keeps the full row (no select), so payload bytes are identical to the
|
||||
// prior default findMany — only peak heap during the read is bounded.
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
|
||||
// disaster-recovery dump, not the canonical user-facing backup);
|
||||
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
|
||||
// stays a direct read; the cycle + day-log tables are paged.
|
||||
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
include: { symptomLinks: true },
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
exportedAt: now.toISOString(),
|
||||
userId: user.id,
|
||||
measurements,
|
||||
medications,
|
||||
intakeEvents,
|
||||
moodEntries,
|
||||
cycleProfile,
|
||||
cycles,
|
||||
cycleDayLogs,
|
||||
const { payload } = await buildFullBackupPayload(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: now,
|
||||
});
|
||||
|
||||
const ciphertext = encryptBackup(payload, cfg.encryptionKey);
|
||||
const plaintext = JSON.stringify(payload);
|
||||
const ciphertext = encryptBackup(plaintext, cfg.encryptionKey);
|
||||
const key = `${dateKey}/user-${user.id}.json.enc`;
|
||||
await s3.putObject(key, ciphertext);
|
||||
uploaded++;
|
||||
|
||||
@@ -3,22 +3,14 @@ import { Buffer } from "node:buffer";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildRecordsBackupSection: vi.fn(),
|
||||
buildFullBackupPayload: vi.fn(),
|
||||
encrypt: vi.fn((value: string) => value),
|
||||
getWorkerPrisma: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/export/records-backup", () => ({
|
||||
buildRecordsBackupSection: mocks.buildRecordsBackupSection,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cycle/backup", () => ({
|
||||
buildCycleBackupSection: vi.fn().mockResolvedValue({
|
||||
cycleProfile: null,
|
||||
cycles: [],
|
||||
cycleDayLogs: [],
|
||||
}),
|
||||
vi.mock("@/lib/export/full-backup-payload", () => ({
|
||||
buildFullBackupPayload: mocks.buildFullBackupPayload,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt }));
|
||||
@@ -50,26 +42,6 @@ function buildPrismaMock() {
|
||||
{ id: "user-dr", username: "backup-owner" },
|
||||
]),
|
||||
},
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "mood-dr",
|
||||
date: "2026-07-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: null,
|
||||
source: "MOODLOG",
|
||||
externalId: "mood-external-dr",
|
||||
moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"),
|
||||
tagLinks: [
|
||||
{ rating: 5, moodTag: { key: "sleep_quality" } },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
dataBackup: { upsert: mocks.upsert },
|
||||
};
|
||||
}
|
||||
@@ -79,51 +51,36 @@ describe("handleDataBackup canonical DR payload", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock());
|
||||
mocks.upsert.mockResolvedValue({});
|
||||
mocks.buildRecordsBackupSection.mockResolvedValue({
|
||||
labResults: [],
|
||||
biomarkers: [],
|
||||
illnessEpisodes: [],
|
||||
allergies: [],
|
||||
familyHistory: [],
|
||||
workouts: [],
|
||||
documents: [
|
||||
{
|
||||
id: "document-dr",
|
||||
kind: "OTHER",
|
||||
title: null,
|
||||
filename: "record.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 4,
|
||||
status: "STORED",
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
contentEncrypted: documentCiphertext,
|
||||
contentSha256: null,
|
||||
contentCodec: "binary2",
|
||||
providerType: null,
|
||||
errorReason: null,
|
||||
summaryEncrypted: null,
|
||||
summaryGeneratedAt: null,
|
||||
summaryState: "NONE",
|
||||
},
|
||||
],
|
||||
manifest: {
|
||||
documents: {
|
||||
included: "encrypted-content",
|
||||
note: "Encrypted content included",
|
||||
},
|
||||
workouts: { included: "summary-only", note: "Summary only" },
|
||||
mocks.buildFullBackupPayload.mockResolvedValue({
|
||||
payload: {
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: "user-dr",
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-dr",
|
||||
externalId: "mood-external-dr",
|
||||
factors: [{ key: "sleep_quality", rating: 5 }],
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
id: "document-dr",
|
||||
contentEncrypted: documentCiphertext,
|
||||
contentCodec: "binary2",
|
||||
},
|
||||
],
|
||||
},
|
||||
counts: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("requests canonical records and serializes rated mood factors", async () => {
|
||||
it("serializes the shared canonical disaster-recovery payload", async () => {
|
||||
await handleDataBackup([]);
|
||||
|
||||
expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
const prisma = mocks.getWorkerPrisma.mock.results[0]!.value;
|
||||
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
"user-dr",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
|
||||
@@ -6,17 +6,10 @@
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { buildCycleBackupSection } from "@/lib/cycle/backup";
|
||||
import { buildRecordsBackupSection } from "@/lib/export/records-backup";
|
||||
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
collectPagedMeasurements,
|
||||
MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
sortWeeklyMeasurementsDesc,
|
||||
toWeeklyBackupMeasurement,
|
||||
} from "@/lib/jobs/backup/measurement-page";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
export interface DataBackupPayload {
|
||||
@@ -65,139 +58,12 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
|
||||
let backed = 0;
|
||||
for (const user of users) {
|
||||
try {
|
||||
const [
|
||||
measurements,
|
||||
medications,
|
||||
intakeEvents,
|
||||
moodEntries,
|
||||
cycle,
|
||||
records,
|
||||
] = await Promise.all([
|
||||
// Keyset-paged narrow read instead of one full-set
|
||||
// findMany + map: a heavy multi-year tenant otherwise
|
||||
// loads every measurement ORM row into a single array
|
||||
// alongside the giant JSON.stringify below, the worker's
|
||||
// dominant heap spike. The page reader projects each page
|
||||
// to the compact backup shape before the next page loads,
|
||||
// bounding peak heap to the page size. Row scope is
|
||||
// unchanged — no `deletedAt` filter, so tombstoned rows
|
||||
// still round-trip exactly as before. The order is
|
||||
// restored to measuredAt-desc to match the prior output.
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
// Exactly the columns the admin restore recreates a
|
||||
// row from, plus `id` for the keyset cursor.
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
source: true,
|
||||
measuredAt: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: toWeeklyBackupMeasurement,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}).then(sortWeeklyMeasurementsDesc),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
include: {
|
||||
tagLinks: {
|
||||
where: { rating: { not: null } },
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
// v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim).
|
||||
buildCycleBackupSection(prisma, user.id),
|
||||
// Canonical DR mode includes InboundDocument ciphertext and the
|
||||
// exact codec/hash metadata needed to re-create the row.
|
||||
buildRecordsBackupSection(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
}),
|
||||
]);
|
||||
|
||||
const backupJson = JSON.stringify({
|
||||
// Bumped only when the on-disk shape changes incompatibly.
|
||||
// Mirrors `BACKUP_SCHEMA_VERSION` in
|
||||
// `src/lib/validations/backup.ts` — keep them in sync.
|
||||
schemaVersion: "1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: user.id,
|
||||
// Already projected + ordered by the paged reader above; the
|
||||
// decrypted note rode into each row so an admin restore
|
||||
// re-encrypts on re-insert (v1.23 contract).
|
||||
measurements,
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
medication: e.medication.name,
|
||||
scheduledFor: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
skipped: e.skipped,
|
||||
source: e.source,
|
||||
})),
|
||||
moodEntries: moodEntries.map((e) => ({
|
||||
id: e.id,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
externalId: e.externalId,
|
||||
factors: e.tagLinks.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating,
|
||||
})),
|
||||
})),
|
||||
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
|
||||
cycleProfile: cycle.cycleProfile,
|
||||
cycles: cycle.cycles,
|
||||
cycleDayLogs: cycle.cycleDayLogs,
|
||||
// Canonical disaster-recovery records. Documents carry encrypted
|
||||
// bytes plus codec/hash metadata; workout route/sample sidecars
|
||||
// remain outside the serialized workout-summary contract.
|
||||
labResults: records.labResults,
|
||||
biomarkers: records.biomarkers,
|
||||
illnessEpisodes: records.illnessEpisodes,
|
||||
allergies: records.allergies,
|
||||
familyHistory: records.familyHistory,
|
||||
workouts: records.workouts,
|
||||
documents: records.documents,
|
||||
manifest: records.manifest,
|
||||
});
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
prisma,
|
||||
user.id,
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const backupJson = JSON.stringify(payload);
|
||||
|
||||
// Encrypt the backup data (contains sensitive health information)
|
||||
const encryptedBackup = encrypt(backupJson);
|
||||
|
||||
@@ -383,3 +383,135 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
|
||||
expect(summary.documents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backupPayloadSchema — database enum boundaries", () => {
|
||||
const enumPayload = {
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: "u1",
|
||||
measurements: [
|
||||
{
|
||||
id: "measurement-1",
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-07-19T07:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
},
|
||||
],
|
||||
intakeEvents: [
|
||||
{
|
||||
medication: "Example",
|
||||
scheduledFor: "2026-07-19T08:00:00.000Z",
|
||||
source: "WEB",
|
||||
},
|
||||
],
|
||||
cycleProfile: {
|
||||
goal: "GENERAL_HEALTH",
|
||||
secondarySymptom: "MUCUS",
|
||||
},
|
||||
cycleDayLogs: [
|
||||
{
|
||||
date: "2026-07-19",
|
||||
flow: "MEDIUM",
|
||||
ovulationTest: "NEGATIVE",
|
||||
cervicalMucus: "CREAMY",
|
||||
cervixPosition: "LOW",
|
||||
cervixFirmness: "FIRM",
|
||||
cervixOpening: "CLOSED",
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: "INDETERMINATE",
|
||||
contraceptive: "NONE",
|
||||
source: "MANUAL",
|
||||
},
|
||||
],
|
||||
illnessEpisodes: [
|
||||
{
|
||||
id: "illness-1",
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: "2026-07-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
allergies: [
|
||||
{
|
||||
id: "allergy-1",
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
severity: "SEVERE",
|
||||
status: "ACTIVE",
|
||||
},
|
||||
],
|
||||
familyHistory: [
|
||||
{
|
||||
id: "family-1",
|
||||
relationship: "MOTHER",
|
||||
condition: "Diabetes",
|
||||
},
|
||||
],
|
||||
workouts: [
|
||||
{
|
||||
id: "workout-1",
|
||||
sportType: "RUNNING",
|
||||
startedAt: "2026-07-19T06:00:00.000Z",
|
||||
endedAt: "2026-07-19T06:30:00.000Z",
|
||||
durationSec: 1800,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
id: "document-1",
|
||||
kind: "LAB_RESULT",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 42,
|
||||
status: "STORED",
|
||||
summaryState: "NONE",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const enumPaths: Array<[string, Array<string | number>]> = [
|
||||
["measurement type", ["measurements", 0, "type"]],
|
||||
["measurement source", ["measurements", 0, "source"]],
|
||||
["intake source", ["intakeEvents", 0, "source"]],
|
||||
["cycle goal", ["cycleProfile", "goal"]],
|
||||
["cycle secondary symptom", ["cycleProfile", "secondarySymptom"]],
|
||||
["cycle flow", ["cycleDayLogs", 0, "flow"]],
|
||||
["cycle ovulation test", ["cycleDayLogs", 0, "ovulationTest"]],
|
||||
["cycle cervical mucus", ["cycleDayLogs", 0, "cervicalMucus"]],
|
||||
["cycle cervix position", ["cycleDayLogs", 0, "cervixPosition"]],
|
||||
["cycle cervix firmness", ["cycleDayLogs", 0, "cervixFirmness"]],
|
||||
["cycle cervix opening", ["cycleDayLogs", 0, "cervixOpening"]],
|
||||
["cycle pregnancy test", ["cycleDayLogs", 0, "pregnancyTest"]],
|
||||
["cycle progesterone test", ["cycleDayLogs", 0, "progesteroneTest"]],
|
||||
["cycle contraceptive", ["cycleDayLogs", 0, "contraceptive"]],
|
||||
["cycle source", ["cycleDayLogs", 0, "source"]],
|
||||
["illness type", ["illnessEpisodes", 0, "type"]],
|
||||
["illness lifecycle", ["illnessEpisodes", 0, "lifecycle"]],
|
||||
["allergy category", ["allergies", 0, "category"]],
|
||||
["allergy type", ["allergies", 0, "type"]],
|
||||
["allergy severity", ["allergies", 0, "severity"]],
|
||||
["allergy status", ["allergies", 0, "status"]],
|
||||
["family relationship", ["familyHistory", 0, "relationship"]],
|
||||
["workout source", ["workouts", 0, "source"]],
|
||||
["document kind", ["documents", 0, "kind"]],
|
||||
["document status", ["documents", 0, "status"]],
|
||||
["document summary state", ["documents", 0, "summaryState"]],
|
||||
];
|
||||
|
||||
it.each(enumPaths)("rejects an unsupported %s", (_label, path) => {
|
||||
const candidate = structuredClone(enumPayload);
|
||||
let cursor: Record<PropertyKey, unknown> = candidate;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
cursor = cursor[segment] as Record<PropertyKey, unknown>;
|
||||
}
|
||||
cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM";
|
||||
|
||||
const parsed = backupPayloadSchema.safeParse(candidate);
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,32 @@
|
||||
* a useful summary for them.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
AllergyCategory,
|
||||
AllergySeverity,
|
||||
AllergyStatus,
|
||||
AllergyType,
|
||||
CervicalMucus,
|
||||
CervixFirmness,
|
||||
CervixOpening,
|
||||
CervixPosition,
|
||||
ContraceptiveKind,
|
||||
CycleTrackingGoal,
|
||||
DocumentSummaryState,
|
||||
FamilyRelationship,
|
||||
FlowLevel,
|
||||
HomeTestResult,
|
||||
IllnessLifecycle,
|
||||
IllnessType,
|
||||
InboundDocumentKind,
|
||||
InboundDocumentStatus,
|
||||
IntakeSource,
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
OvulationTest,
|
||||
SecondarySymptom,
|
||||
} from "@/generated/prisma/enums";
|
||||
|
||||
|
||||
export const BACKUP_SCHEMA_VERSION = "1" as const;
|
||||
|
||||
@@ -32,12 +58,14 @@ const isoDateTime = z
|
||||
|
||||
const measurementSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1).optional(),
|
||||
type: z.enum(MeasurementType),
|
||||
value: z.number(),
|
||||
unit: z.string().min(1),
|
||||
measuredAt: isoDateTime,
|
||||
source: z.string().min(1).optional(),
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -65,7 +93,7 @@ const intakeEventSchema = z
|
||||
scheduledFor: isoDateTime,
|
||||
takenAt: isoDateTime.nullable().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
source: z.string().min(1).optional(),
|
||||
source: z.enum(IntakeSource).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -114,6 +142,7 @@ const moodEntrySchema = z
|
||||
source: z.string().min(1).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
loggedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
factors: z.array(moodFactorSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -148,23 +177,23 @@ const cycleSpanSchema = z
|
||||
const cycleDayLogSchema = z
|
||||
.object({
|
||||
date: z.string().min(1),
|
||||
flow: z.string().nullable().optional(),
|
||||
flow: z.enum(FlowLevel).nullable().optional(),
|
||||
intermenstrualBleeding: z.boolean().optional(),
|
||||
basalBodyTempC: z.number().nullable().optional(),
|
||||
temperatureExcluded: z.boolean().optional(),
|
||||
ovulationTest: z.string().nullable().optional(),
|
||||
cervicalMucus: z.string().nullable().optional(),
|
||||
cervixPosition: z.string().nullable().optional(),
|
||||
cervixFirmness: z.string().nullable().optional(),
|
||||
cervixOpening: z.string().nullable().optional(),
|
||||
ovulationTest: z.enum(OvulationTest).nullable().optional(),
|
||||
cervicalMucus: z.enum(CervicalMucus).nullable().optional(),
|
||||
cervixPosition: z.enum(CervixPosition).nullable().optional(),
|
||||
cervixFirmness: z.enum(CervixFirmness).nullable().optional(),
|
||||
cervixOpening: z.enum(CervixOpening).nullable().optional(),
|
||||
sexualActivity: z.boolean().optional(),
|
||||
protectedSex: z.boolean().nullable().optional(),
|
||||
pregnancyTest: z.string().nullable().optional(),
|
||||
progesteroneTest: z.string().nullable().optional(),
|
||||
contraceptive: z.string().nullable().optional(),
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
sensitiveEncrypted: z.string().nullable().optional(),
|
||||
notesEncrypted: z.string().nullable().optional(),
|
||||
source: z.string().min(1).optional(),
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
tz: z.string().nullable().optional(),
|
||||
symptomKeys: z.array(z.string()).default([]),
|
||||
@@ -174,12 +203,12 @@ const cycleDayLogSchema = z
|
||||
/** Cycle-tracking preferences (one row per user). */
|
||||
const cycleProfileSchema = z
|
||||
.object({
|
||||
goal: z.string().min(1).optional(),
|
||||
goal: z.enum(CycleTrackingGoal).optional(),
|
||||
cycleTrackingEnabled: z.boolean().nullable().optional(),
|
||||
typicalCycleLength: z.number().int().nullable().optional(),
|
||||
typicalPeriodLength: z.number().int().nullable().optional(),
|
||||
lutealPhaseLength: z.number().int().nullable().optional(),
|
||||
secondarySymptom: z.string().nullable().optional(),
|
||||
secondarySymptom: z.enum(SecondarySymptom).nullable().optional(),
|
||||
predictionEnabled: z.boolean().optional(),
|
||||
rawChartMode: z.boolean().optional(),
|
||||
discreetNotifications: z.boolean().optional(),
|
||||
@@ -254,8 +283,8 @@ const illnessEpisodeBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
lifecycle: z.string().min(1),
|
||||
type: z.enum(IllnessType),
|
||||
lifecycle: z.enum(IllnessLifecycle),
|
||||
onsetAt: isoDateTime,
|
||||
resolvedAt: isoDateTime.nullable().optional(),
|
||||
// Self-referencing flare/exacerbation link, carried as the exported
|
||||
@@ -272,10 +301,10 @@ const allergyBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
substance: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
severity: z.string().nullable().optional(),
|
||||
status: z.string().min(1),
|
||||
category: z.enum(AllergyCategory),
|
||||
type: z.enum(AllergyType),
|
||||
severity: z.enum(AllergySeverity).nullable().optional(),
|
||||
status: z.enum(AllergyStatus),
|
||||
onsetAt: isoDateTime.nullable().optional(),
|
||||
reaction: z.string().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
@@ -287,7 +316,7 @@ const allergyBackupSchema = z
|
||||
const familyHistoryBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
relationship: z.string().min(1),
|
||||
relationship: z.enum(FamilyRelationship),
|
||||
condition: z.string().min(1),
|
||||
ageAtOnset: z.number().int().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
@@ -311,7 +340,7 @@ const workoutBackupSchema = z
|
||||
stepCount: z.number().int().nullable().optional(),
|
||||
elevationM: z.number().nullable().optional(),
|
||||
pauseDurationSec: z.number().int().nullable().optional(),
|
||||
source: z.string().min(1),
|
||||
source: z.enum(MeasurementSource),
|
||||
externalId: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
@@ -330,12 +359,12 @@ const base64BytesSchema = z
|
||||
const documentBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.string().min(1),
|
||||
kind: z.enum(InboundDocumentKind),
|
||||
title: z.string().nullable().optional(),
|
||||
filename: z.string().nullable().optional(),
|
||||
mimeType: z.string().min(1),
|
||||
byteSize: z.number().int(),
|
||||
status: z.string().min(1),
|
||||
status: z.enum(InboundDocumentStatus),
|
||||
reportDate: z.string().nullable().optional(),
|
||||
documentDate: z.string().nullable().optional(),
|
||||
contentEncrypted: base64BytesSchema.optional(),
|
||||
@@ -349,7 +378,7 @@ const documentBackupSchema = z
|
||||
errorReason: z.string().nullable().optional(),
|
||||
summaryEncrypted: base64BytesSchema.nullable().optional(),
|
||||
summaryGeneratedAt: isoDateTime.nullable().optional(),
|
||||
summaryState: z.string().min(1).optional(),
|
||||
summaryState: z.enum(DocumentSummaryState).optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
|
||||
@@ -4,11 +4,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const OFFHOST_ENCRYPTION_KEY =
|
||||
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
process.env.BACKUP_S3_ENDPOINT = "https://s3.example.test";
|
||||
process.env.BACKUP_S3_BUCKET = "healthlog-test";
|
||||
process.env.BACKUP_S3_ACCESS_KEY = "test-access";
|
||||
process.env.BACKUP_S3_SECRET_KEY = "test-secret";
|
||||
process.env.BACKUP_ENCRYPTION_KEY = OFFHOST_ENCRYPTION_KEY;
|
||||
|
||||
|
||||
import { encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildRecordsBackupSection } from "@/lib/export/records-backup";
|
||||
import { backupPayloadSchema } from "@/lib/validations/backup";
|
||||
import {
|
||||
decryptBackup,
|
||||
runOffhostBackup,
|
||||
type S3Like,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
backupPayloadSchema,
|
||||
parseBackupPayload,
|
||||
} from "@/lib/validations/backup";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
@@ -74,8 +89,32 @@ function makeRequest(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function makeS3Store(): S3Like & { objects: Map<string, Buffer> } {
|
||||
const objects = new Map<string, Buffer>();
|
||||
return {
|
||||
objects,
|
||||
putObject: vi.fn(async (key, body) => {
|
||||
objects.set(key, Buffer.from(body));
|
||||
}),
|
||||
getObject: vi.fn(async (key) => {
|
||||
const body = objects.get(key);
|
||||
if (!body) throw new Error(`Missing S3 object: ${key}`);
|
||||
return body;
|
||||
}),
|
||||
headObject: vi.fn(async (key) => objects.has(key)),
|
||||
listObjects: vi.fn(async (prefix) =>
|
||||
[...objects.keys()]
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => ({ key })),
|
||||
),
|
||||
deleteObject: vi.fn(async (key) => {
|
||||
objects.delete(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("canonical disaster-recovery backup round-trip", () => {
|
||||
it("restores every serialized record class and document ciphertext verbatim", async () => {
|
||||
it("restores an off-host payload with tombstones and encrypted documents", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const ownerId = admin.id;
|
||||
@@ -91,6 +130,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
const tombstoneDeletedAt = new Date("2026-07-01T12:00:00.000Z");
|
||||
const deletedMeasurement = await prisma.measurement.create({
|
||||
data: {
|
||||
id: "measurement-deleted-dr",
|
||||
userId: ownerId,
|
||||
type: "PULSE",
|
||||
value: 61,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date("2026-07-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const factorCategory = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
@@ -129,6 +181,18 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const deletedMood = await prisma.moodEntry.create({
|
||||
data: {
|
||||
id: "mood-deleted-dr",
|
||||
userId: ownerId,
|
||||
date: "2026-06-30",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: new Date("2026-06-30T20:00:00.000Z"),
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: {
|
||||
@@ -265,48 +329,27 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const records = await buildRecordsBackupSection(prisma, ownerId, {
|
||||
purpose: "disaster-recovery",
|
||||
const s3 = makeS3Store();
|
||||
const snapshotAt = new Date("2026-07-02T00:00:00.000Z");
|
||||
const offhostReport = await runOffhostBackup(prisma, s3, snapshotAt);
|
||||
expect(offhostReport).toMatchObject({
|
||||
uploaded: 1,
|
||||
failed: 0,
|
||||
totalUsers: 1,
|
||||
});
|
||||
|
||||
const payload = backupPayloadSchema.parse({
|
||||
const offhostObject = s3.objects.get(
|
||||
`2026-07-02/user-${ownerId}.json.enc`,
|
||||
);
|
||||
expect(offhostObject).toBeDefined();
|
||||
const payload = parseBackupPayload(
|
||||
decryptBackup(
|
||||
offhostObject!,
|
||||
Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex"),
|
||||
),
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-07-02T00:00:00.000Z",
|
||||
userId: ownerId,
|
||||
measurements: [
|
||||
{
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
unit: measurement.unit,
|
||||
measuredAt: measurement.measuredAt.toISOString(),
|
||||
source: measurement.source,
|
||||
},
|
||||
],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: mood.id,
|
||||
date: mood.date,
|
||||
mood: mood.mood,
|
||||
score: mood.score,
|
||||
tags: mood.tags,
|
||||
source: mood.source,
|
||||
loggedAt: mood.moodLoggedAt.toISOString(),
|
||||
factors: mood.tagLinks.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating,
|
||||
})),
|
||||
},
|
||||
],
|
||||
labResults: records.labResults,
|
||||
biomarkers: records.biomarkers,
|
||||
illnessEpisodes: records.illnessEpisodes,
|
||||
allergies: records.allergies,
|
||||
familyHistory: records.familyHistory,
|
||||
workouts: records.workouts,
|
||||
documents: records.documents,
|
||||
manifest: records.manifest,
|
||||
});
|
||||
expect(payload.documents[0]).not.toHaveProperty("summary");
|
||||
expect(JSON.stringify(payload.documents[0])).not.toContain(
|
||||
@@ -345,25 +388,30 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1);
|
||||
expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1);
|
||||
expect(
|
||||
await prisma.measurement.findMany({
|
||||
where: { userId: ownerId },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
},
|
||||
await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: measurement.id },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: ownerId,
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
unit: measurement.unit,
|
||||
measuredAt: measurement.measuredAt,
|
||||
source: measurement.source,
|
||||
},
|
||||
]);
|
||||
deletedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: deletedMeasurement.id },
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: ownerId,
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const restoredLab = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: lab.id },
|
||||
@@ -519,6 +567,16 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
moodTag: { key: "dr_sleep_quality" },
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: deletedMood.id },
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: ownerId,
|
||||
deletedAt: tombstoneDeletedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await restore()).status).toBe(200);
|
||||
expect(
|
||||
@@ -546,6 +604,76 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
}),
|
||||
).toEqual([{ id: illness.id }]);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
label: "invalid enum",
|
||||
mutate: (payload: Record<string, unknown>) => {
|
||||
payload.measurements = [
|
||||
{
|
||||
type: "INVALID_MEASUREMENT_TYPE",
|
||||
value: 72,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-07-03T07:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "unsupported schema version",
|
||||
mutate: (payload: Record<string, unknown>) => {
|
||||
payload.schemaVersion = "999";
|
||||
},
|
||||
},
|
||||
])("rejects $label with 422 before writes or invalidation", async ({ mutate }) => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const existing = await prisma.measurement.create({
|
||||
data: {
|
||||
id: "pre-validation-measurement",
|
||||
userId: admin.id,
|
||||
type: "WEIGHT",
|
||||
value: 91,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-07-03T06:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const payload: Record<string, unknown> = {
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-07-03T08:00:00.000Z",
|
||||
userId: admin.id,
|
||||
measurements: [],
|
||||
moodEntries: [],
|
||||
};
|
||||
mutate(payload);
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`,
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(await response.json()).toMatchObject({
|
||||
data: null,
|
||||
error: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
await prisma.measurement.findMany({
|
||||
where: { userId: admin.id },
|
||||
select: { id: true },
|
||||
}),
|
||||
).toEqual([{ id: existing.id }]);
|
||||
expect(invalidateUserData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it("rejects metadata-only documents before mutation or invalidation", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
@@ -70,7 +70,34 @@ async function seedAdminSession() {
|
||||
return admin;
|
||||
}
|
||||
|
||||
function buildPayload(userId: string) {
|
||||
interface UploadPayloadFixture {
|
||||
schemaVersion: string;
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
measurements: Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
source: string;
|
||||
notes: null;
|
||||
}>;
|
||||
medications: Array<{
|
||||
name: string;
|
||||
dose: string;
|
||||
active: boolean;
|
||||
schedules: Array<{
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string;
|
||||
dose: null;
|
||||
}>;
|
||||
}>;
|
||||
intakeEvents: [];
|
||||
moodEntries: [];
|
||||
}
|
||||
|
||||
function buildPayload(userId: string): UploadPayloadFixture {
|
||||
return {
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-05-09T10:00:00.000Z",
|
||||
@@ -211,6 +238,36 @@ describe("POST /api/admin/backups/upload", () => {
|
||||
expect(denied[0]?.details ?? "").toContain("owner_not_found");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["measurement type", (payload: UploadPayloadFixture) => {
|
||||
payload.measurements[0]!.type = "NOT_A_MEASUREMENT";
|
||||
}],
|
||||
["measurement source", (payload: UploadPayloadFixture) => {
|
||||
payload.measurements[0]!.source = "NOT_A_SOURCE";
|
||||
}],
|
||||
])("rejects an invalid %s with 422 and zero backup writes", async (_label, mutate) => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const payload = buildPayload(admin.id);
|
||||
mutate(payload);
|
||||
const file = new File([JSON.stringify(payload)], "invalid-enum.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/admin/backups/upload/route");
|
||||
const res = await POST(
|
||||
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(await res.json()).toMatchObject({
|
||||
data: null,
|
||||
error: "Backup payload failed schema validation",
|
||||
meta: { issues: expect.any(Array) },
|
||||
});
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an incompatible schemaVersion with 422", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
@@ -224,6 +281,11 @@ describe("POST /api/admin/backups/upload", () => {
|
||||
buildMultipart(file) as unknown as Parameters<typeof POST>[0],
|
||||
);
|
||||
|
||||
expect(await res.json()).toMatchObject({
|
||||
data: null,
|
||||
error: expect.stringContaining("not supported"),
|
||||
});
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
expect(res.status).toBe(422);
|
||||
const denied = await prisma.auditLog.findMany({
|
||||
where: { action: "admin.backups.upload.denied", userId: admin.id },
|
||||
|
||||
Reference in new issue
Block a user