mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(export): make the full backup actually full
The full-backup export (plaintext + passphrase-encrypted, and the weekly auto-backup) only ever covered measurements, medications, mood, and cycle data. Labs, illness episodes, allergies, family history, and workouts were silently missing, and the export UI implied "everything" was included. Adds a shared records-backup section (src/lib/export/records-backup.ts) that both the on-demand export routes and the weekly data-backup worker read from: lab results + the biomarker catalog, illness episodes (including flares via parentConditionId) with their day-logs, allergies, family history, and workout summaries. Every read is owner-scoped and soft-delete-filtered; encrypted free-text columns decrypt fail-soft so one bad row never aborts the whole backup. Document original files and workout GPS/per-sample time series stay deliberately out of the JSON payload (size). The payload now carries an explicit manifest disclosing exactly that, and the export UI states it in plain text instead of implying completeness. These new domains are export-only for now — admin restore still only recreates the original measurements/medications/mood/cycle domains; the payload validates and reports counts for the rest, but restoring a snapshot does not yet recreate them. Also fixes the admin backup-upload total to count every domain, not just the original four.
This commit is contained in:
1 parent
837c44e4c2
commit
3dd5dbeb42
22 files changed
+1491
-68
No files matched your search
@@ -57,6 +57,12 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
interface RestoreResponse {
|
||||
restored: true;
|
||||
// v1.28 — `summary` reports every domain PRESENT in the uploaded file
|
||||
// (informational — it now includes labs/illness/allergies/family-history/
|
||||
// workouts/documents counts too). `cleared` below is the narrower,
|
||||
// authoritative list of what this restore actually wiped + recreated —
|
||||
// those newer domains are export-only for now and are NOT touched by a
|
||||
// restore, so they are deliberately absent from `cleared`.
|
||||
summary: BackupSummary;
|
||||
cleared: {
|
||||
measurements: number;
|
||||
|
||||
@@ -218,6 +218,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
moodEntries: summary.moodEntries,
|
||||
cycles: summary.cycles,
|
||||
cycleDayLogs: summary.cycleDayLogs,
|
||||
labResults: summary.labResults,
|
||||
biomarkers: summary.biomarkers,
|
||||
illnessEpisodes: summary.illnessEpisodes,
|
||||
illnessDayLogs: summary.illnessDayLogs,
|
||||
allergies: summary.allergies,
|
||||
familyHistory: summary.familyHistory,
|
||||
workouts: summary.workouts,
|
||||
documents: summary.documents,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* action name, and rate-limit wiring. The full DB round-trip lives in
|
||||
* the integration suite (`tests/integration/export-per-type.test.ts`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -20,6 +20,15 @@ vi.mock("@/lib/db", () => ({
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
// v1.28 backup-completeness — the records section the full-backup
|
||||
// helper now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn() },
|
||||
biomarker: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
workout: { findMany: vi.fn() },
|
||||
inboundDocument: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
@@ -63,6 +72,13 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The v1.28 records-domain end-to-end test stubs ENCRYPTION_KEY to
|
||||
// exercise the real crypto path; unstub unconditionally (harmless no-op
|
||||
// for every other test) so a stub never leaks past its own test.
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("GET /api/export/measurements", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
@@ -385,6 +401,15 @@ describe("GET /api/export/full-backup", () => {
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
@@ -402,6 +427,20 @@ describe("GET /api/export/full-backup", () => {
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
// v1.28 backup-completeness — the newer domains round-trip to empty
|
||||
// arrays (not undefined/missing) when the account has no records,
|
||||
// and the manifest still discloses the two deliberate exclusions.
|
||||
labResults: [],
|
||||
biomarkers: [],
|
||||
illnessEpisodes: [],
|
||||
allergies: [],
|
||||
familyHistory: [],
|
||||
workouts: [],
|
||||
documents: [],
|
||||
manifest: {
|
||||
documents: { included: "metadata-only" },
|
||||
workouts: { included: "summary-only" },
|
||||
},
|
||||
});
|
||||
expect(typeof json.exportedAt).toBe("string");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
@@ -409,4 +448,81 @@ describe("GET /api/export/full-backup", () => {
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes decrypted v1.28 records-domain data end-to-end", async () => {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
|
||||
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
|
||||
{
|
||||
panel: null,
|
||||
analyte: "HbA1c",
|
||||
value: 5.4,
|
||||
valueText: null,
|
||||
unit: "%",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
takenAt: new Date("2026-04-01T09:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
biomarker: null,
|
||||
noteEncrypted: null,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "ep-1",
|
||||
label: "Migraine flare",
|
||||
type: "CHRONIC",
|
||||
lifecycle: "FLARE",
|
||||
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
resolvedAt: null,
|
||||
parentConditionId: "ep-parent",
|
||||
noteEncrypted: encryptToBytes("Triggered by travel."),
|
||||
createdAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
|
||||
dayLogs: [],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { GET } = await import("../full-backup/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
|
||||
expect(json.labResults).toEqual([
|
||||
expect.objectContaining({ analyte: "HbA1c", value: 5.4 }),
|
||||
]);
|
||||
// The flare's parent link + decrypted note round-trip in the JSON —
|
||||
// never the ciphertext.
|
||||
expect(json.illnessEpisodes[0].parentConditionId).toBe("ep-parent");
|
||||
expect(json.illnessEpisodes[0].note).toBe("Triggered by travel.");
|
||||
// The raw ciphertext column never leaves the server — only the
|
||||
// decrypted `note` field is present on the DTO.
|
||||
expect(json.illnessEpisodes[0]).not.toHaveProperty("noteEncrypted");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,18 @@ vi.mock("@/lib/db", () => ({
|
||||
auditLog: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
// v1.28 backup-completeness — the cycle + records sections the
|
||||
// full-backup builder now also reads (`buildCycleBackupSection` /
|
||||
// `buildRecordsBackupSection`).
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
biomarker: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
workout: { findMany: vi.fn() },
|
||||
inboundDocument: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -125,6 +137,18 @@ beforeEach(() => {
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
// v1.28 backup-completeness — the full-backup route now also reads the
|
||||
// cycle + records sections; let them resolve to empty so the route
|
||||
// completes instead of rejecting on an unmocked delegate.
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
@@ -148,6 +172,48 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => {
|
||||
const { GET } = await import("../full-backup/route");
|
||||
await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
|
||||
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", deletedAt: null },
|
||||
}),
|
||||
);
|
||||
expect(prisma.illnessEpisode.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", deletedAt: null },
|
||||
}),
|
||||
);
|
||||
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", deletedAt: null },
|
||||
}),
|
||||
);
|
||||
expect(prisma.familyHistoryEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", deletedAt: null },
|
||||
}),
|
||||
);
|
||||
expect(prisma.inboundDocument.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", deletedAt: null },
|
||||
}),
|
||||
);
|
||||
// Biomarker + Workout carry no soft-delete tombstone — userId-only scope.
|
||||
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: "user-1" } }),
|
||||
);
|
||||
expect(prisma.workout.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: "user-1" } }),
|
||||
);
|
||||
// The document read must never select the encrypted content blob.
|
||||
const documentCall = vi.mocked(prisma.inboundDocument.findMany).mock
|
||||
.calls[0][0] as { select?: Record<string, unknown> };
|
||||
expect(documentCall.select).not.toHaveProperty("contentEncrypted");
|
||||
});
|
||||
|
||||
it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => {
|
||||
const { GET } = await import("../route");
|
||||
await GET(mkReq("http://localhost/api/export?type=measurements"));
|
||||
|
||||
@@ -17,6 +17,15 @@ vi.mock("@/lib/db", () => ({
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.28 backup-completeness — the records section the shared payload
|
||||
// builder now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
allergy: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
workout: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
session: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
},
|
||||
@@ -77,6 +86,18 @@ describe("POST /api/export/encrypted", () => {
|
||||
const payload = JSON.parse(plaintext);
|
||||
expect(payload).toHaveProperty("schemaVersion");
|
||||
expect(payload).toHaveProperty("measurements");
|
||||
// v1.28 backup-completeness — the encrypted archive carries the same
|
||||
// records domains as the plaintext export.
|
||||
expect(payload).toHaveProperty("labResults");
|
||||
expect(payload).toHaveProperty("illnessEpisodes");
|
||||
expect(payload).toHaveProperty("allergies");
|
||||
expect(payload).toHaveProperty("familyHistory");
|
||||
expect(payload).toHaveProperty("workouts");
|
||||
expect(payload).toHaveProperty("documents");
|
||||
expect(payload.manifest).toMatchObject({
|
||||
documents: { included: "metadata-only" },
|
||||
workouts: { included: "summary-only" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* POST /api/export/encrypted
|
||||
*
|
||||
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
|
||||
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
|
||||
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
|
||||
* the caller supplies in the request body. The binary archive is returned as
|
||||
* same payload as `GET /api/export/full-backup` (see that route's doc comment
|
||||
* for which domains restore recreates vs. export-only), sealed into an
|
||||
* `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase the
|
||||
* caller supplies in the request body. The binary archive is returned as
|
||||
* `application/octet-stream`.
|
||||
*
|
||||
* SECURITY:
|
||||
@@ -107,6 +108,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_lab_result_count: counts.labResults,
|
||||
export_biomarker_count: counts.biomarkers,
|
||||
export_illness_episode_count: counts.illnessEpisodes,
|
||||
export_illness_day_log_count: counts.illnessDayLogs,
|
||||
export_allergy_count: counts.allergies,
|
||||
export_family_history_count: counts.familyHistory,
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
* `.json` so the browser writes a `.json` file even though the route
|
||||
* segment doesn't carry the extension.
|
||||
*
|
||||
* Backup-completeness — the payload also carries lab results, the
|
||||
* biomarker catalog, illness episodes (incl. flares/exacerbations) with
|
||||
* their day-logs, allergies, family history, and workout summaries. These
|
||||
* domains are EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore`
|
||||
* only re-creates the original measurements/medications/mood/cycle
|
||||
* domains, so restoring a snapshot does not (yet) recreate these sections
|
||||
* — see `src/lib/export/records-backup.ts`. Document original files and
|
||||
* workout GPS/sample time series are never included; the payload's
|
||||
* `manifest` field discloses both, and the export UI states this too.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `user.export.full-backup` with the row counts.
|
||||
@@ -34,7 +44,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// v1.23 — the payload builder is shared with the passphrase-encrypted
|
||||
// export route so both emit the byte-for-byte same restore-compatible shape.
|
||||
// export route so both emit the byte-for-byte same shape (the measurement/
|
||||
// medication/mood/cycle domains are restore-compatible; the newer records
|
||||
// domains are export-only for now — see the module doc comment above).
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
@@ -51,6 +63,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_lab_result_count: counts.labResults,
|
||||
export_biomarker_count: counts.biomarkers,
|
||||
export_illness_episode_count: counts.illnessEpisodes,
|
||||
export_illness_day_log_count: counts.illnessDayLogs,
|
||||
export_allergy_count: counts.allergies,
|
||||
export_family_history_count: counts.familyHistory,
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user