From 811fcf55092eb5d0b6a6e51a4c86900bd418f9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 19:57:47 +0200 Subject: [PATCH] fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext A disaster-recovery restore wrote these rows stamped with the restore's own time: the medication's updatedAt (moved again by the course window), the category assignment, custom category labels, courses and composition, the mood day context and tag links, illness and cycle symptom links, nutrient days, and a flare's updatedAt after the parent pointer pass. The recovery file now carries each stamp (additive fields: linkCreatedAt on a mood entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness symptom, categoryUpdatedAt on a medication, updatedAt where missing) and the restore writes it back. A file without them restores as before. A custom category label, a course note and a marker's context rode the recovery file as readable text and were sealed again under the active key. They now ride as ciphertext (labelEncrypted, noteEncrypted, contextEncrypted) and come back as the same bytes; a file that carries the readable value instead is sealed on the way in, as before. The portable file keeps the readable values. The rotation script and the encrypted-column writer guard already cover all three columns. The parity golden copies change for exactly this: the new fields in the recovery file, the restored stamps in place of , and the three values coming back under the key they were sealed with. The legacy readable coach title and custom metric note are still sealed on restore, since the row holds no ciphertext to carry. The round-trip column audit no longer excludes the four link stamps. --- docs/api/openapi.yaml | 74 +++- .../baselines/source-function-lines.json | 4 +- .../mood-backup-completeness.test.ts | 6 - src/lib/cycle/__tests__/backup.test.ts | 17 +- src/lib/cycle/backup-schema.ts | 3 + src/lib/cycle/backup.ts | 79 +++-- .../__tests__/full-backup-payload.test.ts | 78 +++-- .../export/__tests__/records-backup.test.ts | 24 +- .../restore-backup-round-trip.test.ts | 7 +- src/lib/export/illness-backup-schema.ts | 3 + src/lib/export/illness-backup.ts | 20 +- src/lib/export/labs-backup-schema.ts | 3 + src/lib/export/labs-backup.ts | 36 +- src/lib/export/medications-backup-schema.ts | 23 +- src/lib/export/medications-backup.ts | 34 +- src/lib/export/medications-restore.ts | 43 ++- src/lib/export/mood-backup-schema.ts | 9 + src/lib/export/mood-backup.ts | 15 +- src/lib/export/mood-restore.ts | 14 +- src/lib/export/nutrients-backup-schema.ts | 3 + src/lib/export/nutrients-backup.ts | 7 + src/lib/medication-category.ts | 17 +- .../export-disaster-recovery.json | 90 ++++- .../restore-disaster-recovery.json | 77 +++-- .../backup-parity/restore-portable.json | 4 +- .../backup-parity/restore-version-1.json | 12 +- .../backup-dr-stamps-and-ciphertext.test.ts | 316 ++++++++++++++++++ tests/integration/backup-fixture.ts | 11 - tests/integration/backup-round-trip.test.ts | 26 +- 29 files changed, 901 insertions(+), 154 deletions(-) create mode 100644 tests/integration/backup-dr-stamps-and-ciphertext.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c271ca188..bba317fee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -40862,6 +40862,11 @@ components: category: type: string maxLength: 64 + categoryUpdatedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" deliveryForm: type: string enum: @@ -41138,6 +41143,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - nutrient - amountPerUnit @@ -41492,9 +41500,17 @@ components: - type: string maxLength: 280 - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - startsOn - endsOn @@ -41522,9 +41538,14 @@ components: type: string pattern: ^custom:[0-9a-f-]{36}$ label: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + labelEncrypted: type: string minLength: 1 - maxLength: 40 sortOrder: default: 0 type: integer @@ -41536,9 +41557,11 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - key - - label - sortOrder - isActive additionalProperties: {} @@ -41749,6 +41772,21 @@ components: items: type: string minLength: 1 + linkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false context: anyOf: - type: object @@ -41851,6 +41889,12 @@ components: - type: string minLength: 1 - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 additionalProperties: {} - type: "null" required: @@ -42214,6 +42258,21 @@ components: required: - key additionalProperties: {} + symptomLinkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false required: - date - symptomKeys @@ -42515,6 +42574,11 @@ components: type: - string - "null" + contextEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" aliases: type: array items: @@ -42705,6 +42769,9 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + createdAt: + type: string + minLength: 1 required: - key additionalProperties: {} @@ -43381,6 +43448,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - day - nutrient diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d977290b1..d984004ea 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 411, - "total": 148227, + "total": 148208, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -305,7 +305,7 @@ "lib/api-handler.ts :: apiHandler > wrapped": 201, "lib/auth/profile-update.ts :: applyProfileUpdate": 353, "lib/cache/server-cache.ts :: createServerCaches": 230, - "lib/cycle/backup.ts :: restoreCycleData": 243, + "lib/cycle/backup.ts :: restoreCycleData": 224, "lib/cycle/day-log-write.ts :: writeDayLog": 311, "lib/daily/digest.ts :: buildDailyDigest": 227, "lib/daily/load-digest.ts :: loadDailyDigest": 452, diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index 050953e4f..041112000 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id; the context rides inside its entry", userId: "user-scoped backup; owner is re-derived from the restoring account", - createdAt: - "the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed", - updatedAt: - "the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value", }; const EXCLUDED_TAG_LINK_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id, never carried", moodTagId: "the link travels by the tag's key; a source-instance id need not exist in the target catalogue", - createdAt: - "the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one", }; function modelBlock(schema: string, model: string): string { diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index cd7318a86..88d644c62 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -92,8 +92,16 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { updatedAt, // One symptom the person put a number on, one they did not. symptomLinks: [ - { severity: 4, symptom: { key: "cramps" } }, - { severity: null, symptom: { key: "fatigue" } }, + { + severity: 4, + symptom: { key: "cramps" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + { + severity: null, + symptom: { key: "fatigue" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, ], }, ]), @@ -171,6 +179,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { // Only the rated link is listed. An unrated one is left out rather than // written as a zero, so the file says "never rated" and not "rated none". symptomSeverities: [{ key: "cramps", severity: 4 }], + // When each link was written, every link, disaster recovery only. + symptomLinkCreatedAt: [ + { key: "cramps", createdAt: "2026-04-10T07:00:00.000Z" }, + { key: "fatigue", createdAt: "2026-04-10T07:00:00.000Z" }, + ], }); }); }); diff --git a/src/lib/cycle/backup-schema.ts b/src/lib/cycle/backup-schema.ts index 4dae4e9ca..66dd41e35 100644 --- a/src/lib/cycle/backup-schema.ts +++ b/src/lib/cycle/backup-schema.ts @@ -122,6 +122,9 @@ export const cycleDayLogSchema = z updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), + symptomLinkCreatedAt: z + .array(z.object({ key: z.string().min(1), createdAt: isoDateTime })) + .optional(), }) .passthrough(); /** Cycle-tracking preferences (one row per user). */ diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index c04859d87..6bf55b3d4 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -154,6 +154,12 @@ export interface CycleBackupSection { * never rated it — which is not the same as rating it zero. */ symptomSeverities: Array<{ key: string; severity: number }>; + /** + * When each symptom link was written, by key. Disaster-recovery files + * only, and sparse like `symptomSeverities`; a file without it restores + * the links stamped with the restore. + */ + symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>; }>; /** * The account's OWN symptom definitions. @@ -329,6 +335,14 @@ export async function buildCycleBackupSection( symptomSeverities: d.symptomLinks .filter((l): l is typeof l & { severity: number } => l.severity != null) .map((l) => ({ key: l.symptom.key, severity: l.severity })), + ...(disasterRecovery + ? { + symptomLinkCreatedAt: d.symptomLinks.map((l) => ({ + key: l.symptom.key, + createdAt: l.createdAt.toISOString(), + })), + } + : {}), })), customSymptoms: customSymptoms.map((sym) => ({ ...(disasterRecovery ? { id: sym.id } : {}), @@ -445,6 +459,50 @@ function enumOrNull( : null; } +/** + * The symptom links of one restored day log. + * + * Severities: the day's rated intensities, keyed by symptom. A file written + * before severities were carried has no list at all and every link comes back + * unrated, which is what it was. An entry naming a symptom the day does not + * have is simply never read. Each link's own write time comes back when the + * file carries it (disaster recovery), and is the restore's otherwise. + * + * Every unresolvable key was counted into `skips` by the caller, so dropping + * it here is a reported drop rather than a silent one. The rest of the day is + * unaffected by a symptom chip that will not resolve. + */ +function dayLogSymptomLinks( + d: { + symptomKeys?: string[]; + symptomSeverities?: Array<{ key: string; severity?: number | null }>; + symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>; + }, + symptomIdByKey: ReadonlyMap, +) { + const severityByKey = new Map(); + for (const s of d.symptomSeverities ?? []) { + const severity = severityOrNull(s.severity); + if (severity !== null) severityByKey.set(s.key, severity); + } + const linkCreatedAt = new Map( + (d.symptomLinkCreatedAt ?? []).map((l) => [l.key, l.createdAt]), + ); + return (d.symptomKeys ?? []).flatMap((k) => { + const id = symptomIdByKey.get(k); + const createdAt = linkCreatedAt.get(k); + return id + ? [ + { + symptomId: id, + severity: severityByKey.get(k) ?? null, + ...(createdAt ? { createdAt: new Date(createdAt) } : {}), + }, + ] + : []; + }); +} + /** * Restore the cycle tables for `ownerId` from a parsed backup payload, * inside an existing transaction. Delete-then-recreate, matching the @@ -617,26 +675,7 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { - // The day's rated intensities, keyed by symptom. A file written before - // severities were carried has no list at all and every link comes back - // unrated, which is what it was. An entry naming a symptom the day does not - // have is simply never read. - const severityByKey = new Map(); - for (const s of d.symptomSeverities ?? []) { - const severity = severityOrNull(s.severity); - if (severity !== null) severityByKey.set(s.key, severity); - } - - // Every unresolvable key was counted into `skips` above, so dropping it - // here is a reported drop rather than a silent one. The rest of the day — - // flow, temperature, the encrypted note, every observation the person - // actually wrote — is unaffected by a symptom chip that will not resolve. - const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => { - const id = symptomIdByKey.get(k); - return id - ? [{ symptomId: id, severity: severityByKey.get(k) ?? null }] - : []; - }); + const symptomLinks = dayLogSymptomLinks(d, symptomIdByKey); const cycleId = d.cycleId !== undefined ? d.cycleId === null diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0f5f486d5..d8270f380 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { Buffer } from "node:buffer"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { parseBackupPayload } from "@/lib/validations/backup"; process.env.ENCRYPTION_KEY = @@ -201,6 +201,7 @@ function makePrisma() { enteredUnit: "IU", source: "MANUAL", createdAt: new Date("2026-07-01T08:00:00.000Z"), + updatedAt: new Date("2026-07-01T08:05:00.000Z"), }, ], // v1.40 (#1024) — one ended course with a note, one running. @@ -211,6 +212,7 @@ function makePrisma() { endsOn: new Date("2026-03-07T00:00:00.000Z"), noteEncrypted: encryptToBytes("the March flu"), createdAt: new Date("2026-03-01T08:00:00.000Z"), + updatedAt: new Date("2026-03-01T08:05:00.000Z"), }, { id: "course-june", @@ -218,6 +220,7 @@ function makePrisma() { endsOn: null, noteEncrypted: null, createdAt: new Date("2026-06-10T08:00:00.000Z"), + updatedAt: new Date("2026-06-10T08:05:00.000Z"), }, ], // A two-step ramp, one row carrying an encrypted note and one @@ -374,10 +377,12 @@ function makePrisma() { { rating: 4, moodTag: { key: "sleep_quality", kind: "RATED" }, + createdAt: new Date("2026-07-19T20:00:00.000Z"), }, { rating: null, moodTag: { key: "headache", kind: "BINARY" }, + createdAt: new Date("2026-07-19T20:00:01.000Z"), }, ], }, @@ -393,6 +398,7 @@ function makePrisma() { source: "MANUAL", externalSourceVersion: null, createdAt: new Date("2026-07-19T20:15:00.000Z"), + updatedAt: new Date("2026-07-19T20:16:00.000Z"), }, ]), }, @@ -785,32 +791,47 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { * decision. */ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => { - it.each(["portable-export", "disaster-recovery"] as const)( - "carries every course with its note readable in a %s file", - async (purpose) => { - installSectionMocks(); - const { payload } = await buildFullBackupPayload( - makePrisma() as never, - "user-1", - { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, - ); - const parsed = parseBackupPayload(payload); - expect(parsed.medications[0].courses).toEqual([ - { - startsOn: "2026-03-01", - endsOn: "2026-03-07", - note: "the March flu", - createdAt: "2026-03-01T08:00:00.000Z", - }, - { - startsOn: "2026-06-10", - endsOn: null, - note: null, - createdAt: "2026-06-10T08:00:00.000Z", - }, - ]); - }, - ); + async function courses(purpose: "portable-export" | "disaster-recovery") { + installSectionMocks(); + const { payload } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, + ); + return parseBackupPayload(payload).medications[0].courses; + } + + it("carries every course with its note readable in a portable file", async () => { + expect(await courses("portable-export")).toEqual([ + { + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: "the March flu", + createdAt: "2026-03-01T08:00:00.000Z", + }, + { + startsOn: "2026-06-10", + endsOn: null, + note: null, + createdAt: "2026-06-10T08:00:00.000Z", + }, + ]); + }); + + it("carries every course with its note as ciphertext in a disaster-recovery file", async () => { + const [march, june] = (await courses("disaster-recovery"))!; + expect(march).toMatchObject({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: null, + createdAt: "2026-03-01T08:00:00.000Z", + updatedAt: "2026-03-01T08:05:00.000Z", + }); + expect(decryptFromBytes(Buffer.from(march!.noteEncrypted!, "base64"))).toBe( + "the March flu", + ); + expect(june).toMatchObject({ note: null, noteEncrypted: null }); + }); }); describe("buildFullBackupPayload — supplement composition (v1.43)", () => { @@ -836,6 +857,9 @@ describe("buildFullBackupPayload — supplement composition (v1.43)", () => { enteredUnit: "IU", source: "MANUAL", createdAt: "2026-07-01T08:00:00.000Z", + ...(purpose === "disaster-recovery" + ? { updatedAt: "2026-07-01T08:05:00.000Z" } + : {}), }, ]); }, diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index c0d68ee1b..406b60bc1 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -112,7 +112,13 @@ function buildMockPrisma() { feverC: null, noteEncrypted: encryptToBytes("Bad day."), updatedAt: new Date("2026-04-10T00:00:00.000Z"), - symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + symptomLinks: [ + { + severity: 3, + symptom: { key: "headache" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + ], }, ], }, @@ -298,7 +304,13 @@ describe("buildRecordsBackupSection", () => { createdAt, updatedAt, deletedAt, - symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + symptomLinks: [ + { + severity: 3, + symptom: { key: "headache" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + ], }, ], }, @@ -384,7 +396,13 @@ describe("buildRecordsBackupSection", () => { createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), deletedAt: deletedAt.toISOString(), - symptoms: [{ key: "headache", severity: 3 }], + symptoms: [ + { + key: "headache", + severity: 3, + createdAt: "2026-04-10T07:00:00.000Z", + }, + ], }, ], }); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index b647ccbf4..45266aa47 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -139,7 +139,12 @@ function sourceClient() { 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" } }], + symptomLinks: [ + { + symptom: { key: "custom:jaw_tension" }, + createdAt: new Date("2026-07-19T08:00:00.000Z"), + }, + ], }, ]), }, diff --git a/src/lib/export/illness-backup-schema.ts b/src/lib/export/illness-backup-schema.ts index 09f6de025..2022d5ced 100644 --- a/src/lib/export/illness-backup-schema.ts +++ b/src/lib/export/illness-backup-schema.ts @@ -18,6 +18,9 @@ export const illnessSymptomBackupSchema = z .object({ key: z.string().min(1), severity: z.number().int().nullable().optional(), + // When the link was written. Disaster-recovery files only; without it the + // link is stamped with the restore, as before. + createdAt: isoDateTime.optional(), }) .passthrough(); export const illnessDayLogBackupSchema = z diff --git a/src/lib/export/illness-backup.ts b/src/lib/export/illness-backup.ts index 64486bb95..19eb4423c 100644 --- a/src/lib/export/illness-backup.ts +++ b/src/lib/export/illness-backup.ts @@ -94,6 +94,7 @@ export async function buildIllnessBackupSlice( symptoms: dayLog.symptomLinks.map((link) => ({ key: link.symptom.key, severity: link.severity, + createdAt: link.createdAt.toISOString(), })), })), })) @@ -168,7 +169,14 @@ async function restoreEpisodes(ctx: RestoreContext): Promise { if (episode.parentConditionId) { await tx.illnessEpisode.update({ where: { id: episode.id, userId: ownerId }, - data: { parentConditionId: episode.parentConditionId }, + // The pointer is a second write to the row; the row's own last write + // stays the one the file carries. + data: { + parentConditionId: episode.parentConditionId, + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, }); } } @@ -212,7 +220,15 @@ async function restoreDayLogs(ctx: RestoreContext): Promise { const symptomLinks = dayLog.symptoms.flatMap((symptom) => { const symptomId = symptomByKey.get(symptom.key); return symptomId - ? [{ symptomId, severity: symptom.severity ?? null }] + ? [ + { + symptomId, + severity: symptom.severity ?? null, + ...(symptom.createdAt + ? { createdAt: new Date(symptom.createdAt) } + : {}), + }, + ] : []; }); await tx.illnessDayLog.create({ diff --git a/src/lib/export/labs-backup-schema.ts b/src/lib/export/labs-backup-schema.ts index b28577007..df15b82e6 100644 --- a/src/lib/export/labs-backup-schema.ts +++ b/src/lib/export/labs-backup-schema.ts @@ -81,6 +81,9 @@ export const biomarkerBackupSchema = z // The catalogue analyte the marker resolves to. analyteKey: z.string().nullable().optional(), context: z.string().nullable().optional(), + // The context's ciphertext, disaster-recovery files only. A file without + // it carries the readable context, which the restore seals. + contextEncrypted: base64BytesSchema.nullable().optional(), // The other spellings the marker answers to. Absent on // files written before them. aliases: z diff --git a/src/lib/export/labs-backup.ts b/src/lib/export/labs-backup.ts index f2e5291d5..2205385d9 100644 --- a/src/lib/export/labs-backup.ts +++ b/src/lib/export/labs-backup.ts @@ -103,6 +103,11 @@ export interface BiomarkerBackupEntry { panel: string | null; hidden: boolean; context: string | null; + /** + * The context's ciphertext as base64, disaster-recovery files only, where + * `context` is null. A file without it carries the readable context. + */ + contextEncrypted?: string | null; /** The catalogue key the marker's name and unit resolve to. */ analyteKey?: string | null; /** @@ -259,11 +264,22 @@ export async function buildLabsBackupSlice( upperBound: b.upperBound, panel: b.panel, hidden: b.hidden, - context: readForExport( - b.contextEncrypted, - "biomarker context", - decryptContextFromBytes, - ), + // Ciphertext verbatim in a disaster-recovery file, like every other + // sealed column; the readable context in a portable one. + ...(disasterRecovery + ? { + context: null, + contextEncrypted: b.contextEncrypted + ? Buffer.from(b.contextEncrypted).toString("base64") + : null, + } + : { + context: readForExport( + b.contextEncrypted, + "biomarker context", + decryptContextFromBytes, + ), + }), analyteKey: b.analyteKey, aliases: aliasesByMarker.get(b.id) ?? [], })); @@ -347,9 +363,13 @@ export async function restoreLabMarkers(ctx: RestoreContext): Promise { panel: biomarker.panel ?? null, hidden: biomarker.hidden ?? false, contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), + biomarker.contextEncrypted !== undefined + ? biomarker.contextEncrypted === null + ? null + : decodeEncryptedBytes(biomarker.contextEncrypted) + : biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), analyteKey: biomarker.analyteKey ?? null, ...(biomarker.createdAt ? { createdAt: new Date(biomarker.createdAt) } diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts index eade10b55..4b43396fd 100644 --- a/src/lib/export/medications-backup-schema.ts +++ b/src/lib/export/medications-backup-schema.ts @@ -109,6 +109,9 @@ export const medicationIngredientSchema = z enteredUnit: z.string().max(8).nullable().optional(), source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"), createdAt: isoDateTime.optional(), + // The row's last write. Disaster-recovery files only; a file without it + // restores the row stamped with the restore, as before. + updatedAt: isoDateTime.optional(), }) .passthrough(); /** @@ -121,7 +124,11 @@ export const medicationCourseSchema = z startsOn: z.iso.date(), endsOn: z.iso.date().nullable(), note: z.string().max(280).nullable().optional(), + // The note's ciphertext, disaster-recovery files only. A file without it + // (any written before) carries the readable note, which the restore seals. + noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); /** @@ -248,12 +255,21 @@ export const medicationEfficacyTargetSchema = z export const customMedicationCategorySchema = z .object({ key: z.string().regex(/^custom:[0-9a-f-]{36}$/), - label: z.string().trim().min(1).max(40), + // The readable label: a portable file, and every disaster-recovery file + // written before the label travelled as ciphertext. + label: z.string().trim().min(1).max(40).nullable().optional(), + // The label's ciphertext, as a disaster-recovery file carries it now. + labelEncrypted: base64BytesSchema.optional(), sortOrder: z.number().int().min(0).default(0), isActive: z.boolean().default(true), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) - .passthrough(); + .passthrough() + .refine((c) => c.labelEncrypted !== undefined || (c.label ?? "") !== "", { + message: "a custom category needs its label or its ciphertext", + path: ["label"], + }); export const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -293,6 +309,9 @@ export const medicationSchema = z // whole file. Since v1.40 it may be `custom:`, the key of an entry // in `customMedicationCategories`. category: z.string().max(64).optional(), + // When the category was last set. Disaster-recovery files only; without + // it the restored assignment is stamped with the restore, as before. + categoryUpdatedAt: isoDateTime.nullable().optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts index 2ffbfd80c..990d579e5 100644 --- a/src/lib/export/medications-backup.ts +++ b/src/lib/export/medications-backup.ts @@ -54,6 +54,9 @@ const MEDICATION_BACKUP_INCLUDE = { { id: "asc" }, ] as const, }, + // When the category was last set. Read for the disaster-recovery file, + // which gives the assignment back with its own timestamp. + categoryAssignment: { select: { updatedAt: true } }, // What the drug was supposed to move. The biomarker is read by NAME // rather than by id, exactly as the lab results beside it are: a // portable restore mints a fresh biomarker id, and `(userId, name)` is @@ -101,6 +104,8 @@ function serialiseMedicationRow( externalSource: m.externalSource, externalId: m.externalId, updatedAt: m.updatedAt.toISOString(), + categoryUpdatedAt: + m.categoryAssignment?.updatedAt.toISOString() ?? null, } : // No id: a portable restore mints a fresh one. The relation links // still name this drug, so the file says which row they mean. @@ -206,7 +211,9 @@ function serialiseMedicationHistory( // counting toward the day's micronutrients. Canonical amount plus the // amount as entered, so the form shows what was typed. ingredients: m.ingredients.map((i) => ({ - ...(disasterRecovery ? { id: i.id } : {}), + ...(disasterRecovery + ? { id: i.id, updatedAt: i.updatedAt.toISOString() } + : {}), nutrient: i.nutrient, amountPerUnit: i.amountPerUnit, unit: i.unit, @@ -215,10 +222,21 @@ function serialiseMedicationHistory( source: i.source, createdAt: i.createdAt.toISOString(), })), + // The note follows the contract of every other sealed column: the + // disaster-recovery file carries the ciphertext verbatim (and the row's + // own last write), a portable file the decrypted text. courses: m.courses.map((c) => ({ startsOn: dayKeyOfDate(c.startsOn), endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, - note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + ...(disasterRecovery + ? { + note: null, + noteEncrypted: c.noteEncrypted + ? Buffer.from(c.noteEncrypted).toString("base64") + : null, + updatedAt: c.updatedAt.toISOString(), + } + : { note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null }), createdAt: c.createdAt.toISOString(), })), // Titration: when the dose moved, to what, and why. The drug comes @@ -473,11 +491,15 @@ export async function buildMedicationsBackupPart( prisma, ); // The account's own categories, restored before the medications that name - // them by key. Labels travel decrypted so a file restores on an instance - // with a different key; every one is carried, hidden ones included, - // because a medication can still be filed under it. + // them by key. Every one is carried, hidden ones included, because a + // medication can still be filed under it. A portable file carries the + // label decrypted, so it restores on an instance with another key; a + // disaster-recovery file carries the ciphertext verbatim, like every other + // sealed column, and the row's own last write. const customMedicationCategories = - await readMedicationCategoryLabelsForBackup(userId, prisma); + await readMedicationCategoryLabelsForBackup(userId, prisma, { + disasterRecovery, + }); // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the // supersede pointer never crosses a drug — every route that writes it diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts index 90c7d5cb2..d71a2505f 100644 --- a/src/lib/export/medications-restore.ts +++ b/src/lib/export/medications-restore.ts @@ -77,10 +77,18 @@ async function restoreCustomCategories( data: { userId: ownerId, key, - labelEncrypted: encryptCategoryLabel(c.label), + // The ciphertext verbatim when the file carries it (a + // disaster-recovery file), the readable label sealed here otherwise + // (a portable file, or a recovery file written before the label + // travelled sealed). + labelEncrypted: + c.labelEncrypted !== undefined + ? decodeEncryptedBytes(c.labelEncrypted) + : encryptCategoryLabel(c.label ?? ""), sortOrder: c.sortOrder, isActive: c.isActive, ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, }); } @@ -254,6 +262,7 @@ function medicationChildrenData(m: BackupMedication, ownerId: string) { enteredUnit: i.enteredUnit ?? null, source: i.source, ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), + ...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}), })), }, // `resumedAt` passes through as null when the era is open. It is not @@ -341,14 +350,20 @@ async function restoreCourses( startsOn: Date; endsOn: Date | null; note: string | null; + noteEncrypted?: string | null; createdAt?: Date; + updatedAt?: Date; }> = m.courses !== undefined ? m.courses.map((c) => ({ startsOn: dateOfDayKey(c.startsOn), endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, note: c.note ?? null, + ...(c.noteEncrypted !== undefined + ? { noteEncrypted: c.noteEncrypted } + : {}), ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), })) : (() => { const derived = courseFromWindow( @@ -365,8 +380,18 @@ async function restoreCourses( userId: ownerId, startsOn: c.startsOn, endsOn: c.endsOn, - noteEncrypted: c.note ? encryptToBytes(c.note) : null, + // Ciphertext verbatim from a disaster-recovery file; the readable note + // of a portable or older file sealed here. + noteEncrypted: + c.noteEncrypted !== undefined + ? c.noteEncrypted === null + ? null + : decodeEncryptedBytes(c.noteEncrypted) + : c.note + ? encryptToBytes(c.note) + : null, ...(c.createdAt ? { createdAt: c.createdAt } : {}), + ...(c.updatedAt ? { updatedAt: c.updatedAt } : {}), })), }); const window = projectCourseWindow(courseRows); @@ -589,6 +614,12 @@ export async function restoreMedications(ctx: RestoreContext): Promise { categoryKeyRemap.get(m.category) ?? m.category, tx, ); + if (m.categoryUpdatedAt) { + await tx.medicationCategoryAssignment.update({ + where: { medicationId: created.id }, + data: { updatedAt: new Date(m.categoryUpdatedAt) }, + }); + } } await restoreCourses(tx, ownerId, m, created, restoreTz); await restoreScheduleRevisions( @@ -598,6 +629,14 @@ export async function restoreMedications(ctx: RestoreContext): Promise { medicationIndex, unresolvedRevisionLinks, ); + // The writes above (the course window) moved the row's `updatedAt` to + // now; a file that carries the row's own last write puts it back. + if (m.updatedAt) { + await tx.medication.update({ + where: { id: created.id }, + data: { updatedAt: new Date(m.updatedAt) }, + }); + } for (const target of m.efficacyTargets) { links.pendingEfficacyTargets.push({ medicationId: created.id, target }); } diff --git a/src/lib/export/mood-backup-schema.ts b/src/lib/export/mood-backup-schema.ts index 4f571f411..a61ea130c 100644 --- a/src/lib/export/mood-backup-schema.ts +++ b/src/lib/export/mood-backup-schema.ts @@ -81,6 +81,12 @@ export const moodEntrySchema = z // RATED-only meaning, so every file written before this one parses // unchanged and restores identically: absent reads as none. structuredTags: z.array(z.string().min(1)).default([]), + // When each link (rated or not) was written, by tag key. Disaster-recovery + // files only; a file without it restores the links stamped with the + // restore, as before. + linkCreatedAt: z + .array(z.object({ key: z.string().min(1), createdAt: isoDateTime })) + .optional(), // The day context, when the entry had one. Optional and nullable at the // top: a file written before this existed carries no `context` key at all // and must parse and restore exactly as it did. Every field inside is @@ -116,6 +122,9 @@ export const moodEntrySchema = z // plain text, a disaster-recovery file the ciphertext. note: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), + // The context row's own stamps, disaster-recovery files only. + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough() .nullable() diff --git a/src/lib/export/mood-backup.ts b/src/lib/export/mood-backup.ts index 439a3f4d9..69f4cc380 100644 --- a/src/lib/export/mood-backup.ts +++ b/src/lib/export/mood-backup.ts @@ -60,6 +60,7 @@ const MOOD_ENTRY_BACKUP_SELECT = { */ const MOOD_ENTRY_TAG_LINK_SELECT = { rating: true, + createdAt: true, moodTag: { select: { key: true, kind: true } }, } as const satisfies Prisma.MoodEntryTagLinkSelect; @@ -69,9 +70,8 @@ const MOOD_ENTRY_TAG_LINK_SELECT = { * `moodEntryId` and `userId` are absent for the reasons the exclusion list in * `mood-backup-completeness.test.ts` states: the context rides inside its * entry and is re-bound to whatever id that entry got, and the owner is - * re-derived from the restoring account. The two row stamps are absent for the - * third reason there — they describe when the sub-row was written, not when - * the day happened, and the entry already carries the moment that matters. + * re-derived from the restoring account. The two row stamps travel in the + * disaster-recovery file only, which gives the account back as it was. * * Named rather than inlined so the guard can compare it field-by-field against * the model, exactly as it does for the two selects above. This is the whole @@ -97,6 +97,8 @@ const MOOD_CONTEXT_BACKUP_SELECT = { eventValence: true, eventAt: true, notesEncrypted: true, + createdAt: true, + updatedAt: true, } as const satisfies Prisma.MoodContextSelect; type BackupMoodContext = Prisma.MoodContextGetPayload<{ @@ -149,6 +151,8 @@ function serialiseMoodContext( notesEncrypted: moodContext.notesEncrypted ? Buffer.from(moodContext.notesEncrypted).toString("base64") : null, + createdAt: moodContext.createdAt.toISOString(), + updatedAt: moodContext.updatedAt.toISOString(), } : { note: readNote(moodContext.notesEncrypted, null) }), workStatus: moodContext.workStatus, @@ -220,6 +224,11 @@ function serialiseMoodEntry( syncVersion: moodEntry.syncVersion, createdAt: moodEntry.createdAt.toISOString(), updatedAt: moodEntry.updatedAt.toISOString(), + // When each link was written, by tag key, for both link arms. + linkCreatedAt: moodEntry.tagLinks.map((tagLink) => ({ + key: tagLink.moodTag.key, + createdAt: tagLink.createdAt.toISOString(), + })), } : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }), id: moodEntry.id, diff --git a/src/lib/export/mood-restore.ts b/src/lib/export/mood-restore.ts index 938195124..26596eb76 100644 --- a/src/lib/export/mood-restore.ts +++ b/src/lib/export/mood-restore.ts @@ -256,6 +256,9 @@ async function restoreMoodContext( context.notesEncrypted == null ? encryptNote(context.note ?? null) : decodeEncryptedBytes(context.notesEncrypted), + // The row's own stamps when the file carries them (disaster recovery). + ...(context.createdAt ? { createdAt: new Date(context.createdAt) } : {}), + ...(context.updatedAt ? { updatedAt: new Date(context.updatedAt) } : {}), }, }); } @@ -320,7 +323,16 @@ async function restoreMoodEntry( ? [{ moodEntryId: restored.id, moodTagId, rating: null }] : []; }); - const links = [...factorLinks, ...tagLinks]; + // Each link's own write time, when the file carries it. + const linkCreatedAt = new Map( + (entry.linkCreatedAt ?? []).map((link) => [link.key, link.createdAt]), + ); + const keyOfTag = new Map(); + for (const [key, id] of [...factorByKey, ...tagByKey]) keyOfTag.set(id, key); + const links = [...factorLinks, ...tagLinks].map((link) => { + const createdAt = linkCreatedAt.get(keyOfTag.get(link.moodTagId) ?? ""); + return createdAt ? { ...link, createdAt: new Date(createdAt) } : link; + }); if (links.length > 0) { await tx.moodEntryTagLink.createMany({ data: links }); } diff --git a/src/lib/export/nutrients-backup-schema.ts b/src/lib/export/nutrients-backup-schema.ts index edd766e67..b7b701e93 100644 --- a/src/lib/export/nutrients-backup-schema.ts +++ b/src/lib/export/nutrients-backup-schema.ts @@ -29,5 +29,8 @@ export const nutrientDaySchema = z // Absent from files written before v1.39.7. externalSourceVersion: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + // Disaster-recovery files only; without it the day is stamped with the + // restore, as before. + updatedAt: isoDateTime.optional(), }) .passthrough(); diff --git a/src/lib/export/nutrients-backup.ts b/src/lib/export/nutrients-backup.ts index 09693fc4d..ecfef114a 100644 --- a/src/lib/export/nutrients-backup.ts +++ b/src/lib/export/nutrients-backup.ts @@ -33,6 +33,7 @@ export async function buildNutrientsBackupPart( source: true, externalSourceVersion: true, createdAt: true, + updatedAt: true, }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }); @@ -46,6 +47,11 @@ export async function buildNutrientsBackupPart( source: n.source, externalSourceVersion: n.externalSourceVersion, createdAt: n.createdAt.toISOString(), + // The day's last write, for the disaster-recovery file, which gives + // the account back as it was. + ...(ctx.disasterRecovery + ? { updatedAt: n.updatedAt.toISOString() } + : {}), })), }, counts: { nutrientDays: nutrientDays.length }, @@ -79,6 +85,7 @@ export async function restoreNutrientDays(ctx: RestoreContext): Promise { source: n.source, externalSourceVersion: n.externalSourceVersion ?? null, ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), + ...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}), })), }); } diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index f6240db5a..00ef279ee 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import type { Prisma } from "@/generated/prisma/client"; @@ -254,13 +255,16 @@ export async function listMedicationCategoryLabels( export async function readMedicationCategoryLabelsForBackup( userId: string, client: CategoryClient = prisma, + options: { disasterRecovery?: boolean } = {}, ): Promise< Array<{ key: string; - label: string; + label: string | null; + labelEncrypted?: string; sortOrder: number; isActive: boolean; createdAt: string; + updatedAt?: string; }> > { const rows = await client.medicationCategoryLabel.findMany({ @@ -272,14 +276,23 @@ export async function readMedicationCategoryLabelsForBackup( sortOrder: true, isActive: true, createdAt: true, + updatedAt: true, }, }); return rows.map((r) => ({ key: r.key, - label: decryptCategoryLabel(r.labelEncrypted), + ...(options.disasterRecovery + ? { + label: null, + labelEncrypted: Buffer.from(r.labelEncrypted).toString("base64"), + } + : { label: decryptCategoryLabel(r.labelEncrypted) }), sortOrder: r.sortOrder, isActive: r.isActive, createdAt: r.createdAt.toISOString(), + ...(options.disasterRecovery + ? { updatedAt: r.updatedAt.toISOString() } + : {}), })); } diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index 81e8ccae0..48a526e97 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -295,10 +295,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -329,6 +331,7 @@ "externalSource": "WEB", "externalId": "rt-Medication-externalId", "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -406,6 +409,7 @@ "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", @@ -419,13 +423,17 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", "createdAt": "2026-05-02T00:46:01.000Z" } ], @@ -623,6 +631,7 @@ "syncVersion": 0, "createdAt": "2026-05-02T04:18:01.000Z", "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "SCHLECHT", @@ -649,6 +658,12 @@ "syncVersion": 3, "createdAt": "2026-05-02T04:18:00.000Z", "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "id": "", "date": "2026-07-01", "mood": "GUT", @@ -673,6 +688,8 @@ "structuredTags": [], "context": { "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -827,6 +844,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -857,7 +880,8 @@ "externalId": null, "tz": null, "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -933,7 +957,8 @@ "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { @@ -1007,7 +1032,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] }, @@ -2275,7 +2301,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] }, @@ -2718,6 +2745,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "deliveryForm": "INJECTION", "trackInjectionSites": true, "allowedInjectionSites": [ @@ -2809,7 +2837,8 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "2026-05-02T02:29:00.000Z" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "doseChanges": [ @@ -2954,14 +2983,18 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", - "createdAt": "2026-05-02T00:46:00.000Z" + "note": null, + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "2026-05-02T00:46:01.000Z" + "noteEncrypted": null, + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ] } @@ -2969,10 +3002,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "intakeEvents": [ @@ -3046,6 +3081,7 @@ "updatedAt": "2026-05-02T04:19:01.000Z", "factors": [], "structuredTags": [], + "linkCreatedAt": [], "context": null }, { @@ -3077,6 +3113,12 @@ } ], "structuredTags": [], + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "context": { "workStatus": "regular", "workMinutes": 3, @@ -3095,7 +3137,9 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } } ], @@ -3200,6 +3244,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -3230,7 +3280,8 @@ "createdAt": "2026-05-03T10:25:01.000Z", "updatedAt": "2026-05-03T10:26:01.000Z", "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -3338,7 +3389,8 @@ "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "aliases": [ { "id": "", @@ -3407,7 +3459,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ], "note": null, @@ -3702,7 +3755,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "healthProfile": { diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index 852833970..439fe95bc 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -629,7 +629,7 @@ "externalSource": "WEB", "externalId": "rt-Medication-externalId", "createdAt": "2026-05-01T11:50:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-01T11:51:00.000Z" } ], "MedicationSchedule": [ @@ -778,7 +778,7 @@ { "medicationId": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "updatedAt": "" + "updatedAt": "2026-05-01T21:22:00.000Z" } ], "MedicationCategoryLabel": [ @@ -786,11 +786,11 @@ "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, "createdAt": "2026-05-01T23:06:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "MedicationCourse": [ @@ -800,9 +800,9 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "2026-05-02T00:46:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "id": "", @@ -812,7 +812,7 @@ "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, "createdAt": "2026-05-02T00:46:01.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T00:47:01.000Z" } ], "MedicationIngredient": [ @@ -827,7 +827,7 @@ "enteredUnit": "IU", "source": "LABEL_AI", "createdAt": "2026-05-02T02:29:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "MoodEntry": [ @@ -902,15 +902,15 @@ "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } ], "MoodEntryTagLink": [ { "moodEntryId": "", "moodTagId": "", - "createdAt": "", + "createdAt": "2026-05-02T07:22:00.000Z", "rating": 4 } ], @@ -987,7 +987,7 @@ "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", @@ -1081,7 +1081,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null }, { @@ -1150,7 +1150,7 @@ "dayLogId": "", "symptomId": "", "severity": 2, - "createdAt": "" + "createdAt": "2026-05-02T20:43:00.000Z" } ], "SymptomDefinition": [ @@ -1449,7 +1449,7 @@ "dayLogId": "", "symptomId": "", "severity": 3, - "createdAt": "" + "createdAt": "2026-05-03T13:23:00.000Z" } ], "CustomMetric": [ @@ -1604,7 +1604,7 @@ "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", "createdAt": "2026-05-03T23:27:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "InboundDocument": [ @@ -3190,10 +3190,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -3223,7 +3225,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -3301,6 +3304,7 @@ "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", @@ -3314,13 +3318,17 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", "createdAt": "2026-05-02T00:46:01.000Z" } ], @@ -3518,6 +3526,7 @@ "syncVersion": 0, "createdAt": "2026-05-02T04:18:01.000Z", "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "SCHLECHT", @@ -3544,6 +3553,12 @@ "syncVersion": 3, "createdAt": "2026-05-02T04:18:00.000Z", "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "id": "", "date": "2026-07-01", "mood": "GUT", @@ -3568,6 +3583,8 @@ "structuredTags": [], "context": { "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -3722,6 +3739,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -3752,7 +3775,8 @@ "externalId": null, "tz": null, "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -3828,7 +3852,8 @@ "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { @@ -3866,7 +3891,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null, "dayLogs": [] }, @@ -3902,7 +3927,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] }, @@ -5170,7 +5196,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 479f19de8..5b81b82e2 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -1047,7 +1047,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null }, { @@ -2959,7 +2959,7 @@ "bodySite": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "dayLogs": [] }, { diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index f079e68ac..7128d4c26 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -1391,6 +1391,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Ramipril", "dose": "5 mg", "active": true, @@ -1454,6 +1455,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Vitamin D", "dose": "1000 IU", "active": true, @@ -1471,6 +1473,8 @@ "startsOn": "2026-07-01", "endsOn": "2026-08-01", "note": null, + "noteEncrypted": null, + "updatedAt": "", "createdAt": "" } ], @@ -1534,6 +1538,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "OKAY", @@ -1560,6 +1565,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-02", "mood": "GOOD", @@ -1624,7 +1630,8 @@ "upperBound": 400, "panel": "Iron", "hidden": false, - "context": "fasted", + "context": null, + "contextEncrypted": "", "analyteKey": null, "aliases": [] } @@ -1802,7 +1809,8 @@ "unit": "mL", "source": "MANUAL", "externalSourceVersion": null, - "createdAt": "" + "createdAt": "", + "updatedAt": "" } ] } diff --git a/tests/integration/backup-dr-stamps-and-ciphertext.test.ts b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts new file mode 100644 index 000000000..512f70636 --- /dev/null +++ b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts @@ -0,0 +1,316 @@ +/** + * A disaster-recovery restore gives back the timestamps and the ciphertext the + * account had, and still takes a recovery file written before it did. + * + * - The row stamps of the medication rows (the drug, its courses, + * composition, category and category label), of the mood day context and + * tag links, of the illness and cycle symptom links and of the nutrient + * days used to come back as the restore's own time. The file now carries + * them and the restore writes them back. + * - A custom category's label, a course note and a marker's context rode a + * recovery file as readable text and were sealed again under whatever key + * was active; the file now carries the ciphertext and the restore writes + * the bytes back unchanged. + * - A recovery file without the new fields (any written before) restores + * the way it always did: the readable values sealed here, the stamps the + * restore's own. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; + +import { + createOwner, + OWNER_ID, + seedAdminSession, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { POST } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const CREATED = new Date("2026-03-03T03:03:03.000Z"); +const UPDATED = new Date("2026-04-04T04:04:04.000Z"); +const CONTEXT = "fasted, morning draw"; + +beforeEach(async () => { + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); +}); + +/** Give every tracked row a historical stamp, and the marker a context. */ +async function seed(): Promise { + await seedAdminSession(prisma); + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + const owner = { userId: OWNER_ID }; + const both = { createdAt: CREATED, updatedAt: UPDATED }; + await prisma.biomarker.updateMany({ + where: owner, + data: { contextEncrypted: encryptContextToBytes(CONTEXT), ...both }, + }); + await prisma.medicationCategoryLabel.updateMany({ where: owner, data: both }); + await prisma.medicationCourse.updateMany({ where: owner, data: both }); + await prisma.medicationIngredient.updateMany({ where: owner, data: both }); + await prisma.medicationCategoryAssignment.updateMany({ + where: { medication: owner }, + data: { updatedAt: UPDATED }, + }); + await prisma.moodContext.updateMany({ where: owner, data: both }); + await prisma.moodEntryTagLink.updateMany({ + where: { moodEntry: owner }, + data: { createdAt: CREATED }, + }); + await prisma.illnessSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.cycleSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.nutrientIntakeDay.updateMany({ where: owner, data: both }); + // Last: the writes above do not touch the drug, this pins its own stamp. + await prisma.medication.updateMany({ + where: owner, + data: { updatedAt: UPDATED }, + }); +} + +const b64 = (bytes: Uint8Array | null) => + bytes ? Buffer.from(bytes).toString("base64") : null; + +/** The tracked stamps and ciphertext of the account, in a stable order. */ +async function tracked(client: PrismaClient) { + const owner = { userId: OWNER_ID }; + return { + medication: await client.medication.findMany({ + where: owner, + orderBy: { name: "asc" }, + select: { updatedAt: true }, + }), + assignment: await client.medicationCategoryAssignment.findMany({ + where: { medication: owner }, + select: { updatedAt: true }, + }), + labels: ( + await client.medicationCategoryLabel.findMany({ + where: owner, + select: { labelEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, labelEncrypted: b64(r.labelEncrypted) })), + courses: ( + await client.medicationCourse.findMany({ + where: owner, + orderBy: { startsOn: "asc" }, + select: { noteEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, noteEncrypted: b64(r.noteEncrypted) })), + ingredients: await client.medicationIngredient.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodContexts: await client.moodContext.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodLinks: await client.moodEntryTagLink.findMany({ + where: { moodEntry: owner }, + select: { createdAt: true }, + }), + illnessLinks: await client.illnessSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + cycleLinks: await client.cycleSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + nutrients: await client.nutrientIntakeDay.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + markers: ( + await client.biomarker.findMany({ + where: owner, + select: { contextEncrypted: true }, + }) + ).map((r) => ({ contextEncrypted: b64(r.contextEncrypted) })), + }; +} + +async function restore(payload: unknown): Promise { + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "DR_STAMPS", + data: encrypt(JSON.stringify(payload)), + }, + }); + const startedAt = new Date(); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + return startedAt; +} + +type Payload = Record & { + customMedicationCategories: Array>; + medications: Array>; + moodEntries: Array>; + illnessEpisodes: Array>; + cycleDayLogs: Array>; + nutrientDays: Array>; + biomarkers: Array>; +}; + +/** The same file as a release before this one wrote it. */ +function asOlderRecoveryFile(payload: Payload): Payload { + const older = structuredClone(payload); + for (const c of older.customMedicationCategories) { + c.label = decryptFromBytes( + Buffer.from(c.labelEncrypted as string, "base64"), + ); + delete c.labelEncrypted; + delete c.updatedAt; + } + for (const m of older.medications) { + delete m.categoryUpdatedAt; + for (const i of m.ingredients as Array>) { + delete i.updatedAt; + } + for (const c of m.courses as Array>) { + c.note = c.noteEncrypted + ? decryptFromBytes(Buffer.from(c.noteEncrypted as string, "base64")) + : null; + delete c.noteEncrypted; + delete c.updatedAt; + } + } + for (const e of older.moodEntries) { + delete e.linkCreatedAt; + const context = e.context as Record | null; + if (context) { + delete context.createdAt; + delete context.updatedAt; + } + } + for (const episode of older.illnessEpisodes) { + for (const day of episode.dayLogs as Array>) { + for (const s of day.symptoms as Array>) { + delete s.createdAt; + } + } + } + for (const day of older.cycleDayLogs) delete day.symptomLinkCreatedAt; + for (const n of older.nutrientDays) delete n.updatedAt; + for (const marker of older.biomarkers) { + marker.context = marker.contextEncrypted + ? decryptContextFromBytes( + Buffer.from(marker.contextEncrypted as string, "base64"), + ) + : null; + delete marker.contextEncrypted; + } + return older; +} + +describe("a disaster-recovery restore", () => { + it("gives back the row stamps and the ciphertext as they were", async () => { + await seed(); + const before = await tracked(prisma); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const file = JSON.stringify(payload); + // None of the three sealed values rides the file readable. + expect(file).not.toContain("Heart and circulation"); + expect(file).not.toContain("the March flu"); + expect(file).not.toContain(CONTEXT); + + await restore(payload); + const after = await tracked(prisma); + expect(after).toEqual(before); + // The comparison saw rows, not two empty lists. + for (const rows of Object.values(after)) + expect(rows.length).toBeGreaterThan(0); + }); + + it("takes an older recovery file the way it always did", async () => { + await seed(); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const startedAt = await restore(asOlderRecoveryFile(payload as Payload)); + + const label = await prisma.medicationCategoryLabel.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptFromBytes(label.labelEncrypted)).toBe( + "Heart and circulation", + ); + const course = await prisma.medicationCourse.findFirstOrThrow({ + where: { userId: OWNER_ID, noteEncrypted: { not: null } }, + }); + expect(decryptFromBytes(course.noteEncrypted!)).toBe("the March flu"); + const marker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptContextFromBytes(marker.contextEncrypted!)).toBe(CONTEXT); + + const after = await tracked(prisma); + const restamped = [ + ...after.labels.map((r) => r.updatedAt), + ...after.courses.map((r) => r.updatedAt), + ...after.ingredients.map((r) => r.updatedAt), + ...after.assignment.map((r) => r.updatedAt), + ...after.moodContexts.map((r) => r.createdAt), + ...after.moodLinks.map((r) => r.createdAt), + ...after.illnessLinks.map((r) => r.createdAt), + ...after.cycleLinks.map((r) => r.createdAt), + ...after.nutrients.map((r) => r.updatedAt), + ]; + expect(restamped.length).toBeGreaterThan(8); + for (const at of restamped) { + expect(at.getTime()).toBeGreaterThanOrEqual(startedAt.getTime() - 1000); + } + }); +}); diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts index 1ce127aee..bfe84a6c5 100644 --- a/tests/integration/backup-fixture.ts +++ b/tests/integration/backup-fixture.ts @@ -1374,17 +1374,6 @@ export async function seedAdminSession(prisma: PrismaClient) { export const COLUMN_EXCLUSIONS: Readonly> = { "MeasurementReminder.lastNotifiedAt": "operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour", - // When a join row was written. Nothing reads these: the links are read for - // which tag, symptom or context applies, never for when the row appeared, - // and the parent entry carries its own timestamps, which do travel. - "MoodContext.createdAt": - "insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel", - "MoodEntryTagLink.createdAt": - "insert stamp of a tag link; no reader, the mood entry's own timestamps travel", - "CycleSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the day log's own timestamps travel", - "IllnessSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", }; /** diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index fcd577d42..5b7c100da 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1924,14 +1924,26 @@ describe("every model the plan claims two-ended survives a real restore", () => Unfiled: "OTHER", Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + // A portable file carries the label readable; a disaster-recovery file + // carries its ciphertext, like every other sealed column. expect(payload.customMedicationCategories).toEqual([ - { - key: ROUND_TRIP_CATEGORY_KEY, - label: "Travel kit", - sortOrder: 0, - isActive: false, - createdAt: expect.any(String), - }, + purpose === "disaster-recovery" + ? { + key: ROUND_TRIP_CATEGORY_KEY, + label: null, + labelEncrypted: expect.any(String), + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + updatedAt: expect.any(String), + } + : { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, ]); await prisma.user.delete({ where: { id: OWNER_ID } });