diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5f3c4a51f..82eecc332 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36645,6 +36645,54 @@ components: anyOf: - type: string - type: "null" + note: + anyOf: + - type: string + - type: "null" + sensitive: + anyOf: + - anyOf: + - type: object + properties: + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + - type: "null" + additionalProperties: false + - type: string + - type: "null" source: type: string enum: @@ -36748,6 +36796,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - key - labelKey @@ -36784,6 +36836,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - id - key @@ -36841,6 +36897,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" scaleMin: default: 1 type: integer @@ -38428,6 +38488,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -38495,6 +38559,18 @@ components: - type: string minLength: 1 - type: "null" + reason: + anyOf: + - type: string + - type: "null" + outcome: + anyOf: + - type: string + - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" laterality: anyOf: - type: string @@ -38651,6 +38727,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 diff --git a/messages/de.json b/messages/de.json index 5186bb8b6..a2fc2f7b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7566,7 +7566,11 @@ "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.", "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", - "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)" + "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", + "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", + "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)", + "restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)", + "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index fc41483a9..a39eec3e0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7566,7 +7566,11 @@ "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.", "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", - "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)" + "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", + "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", + "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)", + "restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)", + "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 90919f624..75868f64a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7566,7 +7566,11 @@ "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.", "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", - "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)" + "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", + "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", + "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)", + "restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)", + "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 5d0c5570d..8eb78b68a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7566,7 +7566,11 @@ "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.", "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", - "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)" + "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", + "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", + "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)", + "restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)", + "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 8654d8fea..8461fd0ab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7566,7 +7566,11 @@ "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.", "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", - "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)" + "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", + "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", + "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)", + "restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)", + "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index ac44f12e5..1857e9ad4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7545,7 +7545,11 @@ "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.", "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", - "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)" + "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", + "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", + "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", + "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", + "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 17a2e68fe..fb7f39017 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7566,7 +7566,11 @@ "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.", "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", - "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)" + "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", + "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", + "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)", + "restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)", + "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 5545404eb..67d9fe8f5 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // The PUT writes inside one interactive transaction; run its callback + // against the same mocked client. + vi.mocked(prisma.$transaction).mockImplementation((async ( + fn: (tx: typeof prisma) => unknown, + ) => fn(prisma)) as never); // v1.40 — the category and course helpers' defaults, which // `resetAllMocks` clears. vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); @@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { vi.mocked(auditLog).mockResolvedValue(undefined); const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX); expect(res.status).toBe(200); - // v1.40 (#1024) — the window goes through the course writer, which - // writes the row and the latest course in one transaction; the plain - // row update no longer carries it. + // v1.40 (#1024) — the window goes through the course writer, inside the + // same transaction as the row update; the plain row update no longer + // carries it. const call = lastUpdateCall(); expect(call.data).not.toHaveProperty("endsOn"); expect(setCurrentWindow).toHaveBeenCalledWith( @@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { medicationId: "m1", endsOn: new Date("2026-12-31"), }), + prisma, ); }); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c30e57891..3587a4dda 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -667,7 +667,12 @@ export const PUT = apiHandler( // Tombstone today's and future open pending rows of this medication. // Runs on a schedule replace (the old anchors are stale) and when intake // tracking is switched off (nothing is due any more). - const tombstoneOpenSlots = async (): Promise => { + // The rollup days the tombstoned slots sat on, recomputed once the + // transaction below has committed the tombstones. + const staleRollupDays = new Set(); + const tombstoneOpenSlots = async ( + db: Prisma.TransactionClient, + ): Promise => { // A schedule replace invalidates the open slot anchors the projector / // reminder worker minted for the OLD times: a pending 08:00 row for a // medication that now doses at 20:00 would linger as a phantom slot, @@ -689,12 +694,12 @@ export const PUT = apiHandler( scheduledFor: { gte: todayStart }, } as const; // Capture the affected slots BEFORE the tombstone so the compliance - // rollups for those days can be recomputed immediately below — + // rollups for those days can be recomputed after the commit — // `updateMany` does not return the touched rows. Without the // recompute a tombstoned pending kept counting as `scheduled` in // the rollup row until the next event-driven write for that day, // depressing the rate the cards read in the meantime. - const tombstoned = await prisma.medicationIntakeEvent.findMany({ + const tombstoned = await db.medicationIntakeEvent.findMany({ where: tombstoneWhere, select: { scheduledFor: true }, }); @@ -708,331 +713,337 @@ export const PUT = apiHandler( tombstonedTo = row.scheduledFor; } } - await prisma.medicationIntakeEvent.updateMany({ + await db.medicationIntakeEvent.updateMany({ where: tombstoneWhere, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // Recompute each affected rollup day (deduped via the user-tz day - // key). Best-effort like every other rollup write-hook: a failure - // is annotated, never blocks the schedule replace. - const staleDayKeys = new Set( - tombstoned.map((row) => - dayKeyForScheduledFor(row.scheduledFor, userTz), - ), - ); - for (const dayKey of staleDayKeys) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - userTz, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + // Deduped via the user-tz day key. + for (const row of tombstoned) { + staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); } }; - // If schedules provided, replace all - if (schedules && normalisedSchedules) { - // v1.16.3 — effective dating. Before the wholesale replace below wipes - // the old rows, archive them as ONE revision covering - // `[previous validUntil | medication.createdAt, now)` — but only when a - // cadence-relevant field actually changes. A no-op edit (the Zeitplan - // tab echoing the same rows back) must not mint a phantom era. - const previousRows = await prisma.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - const previousEntries = previousRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ); - replacedScheduleCount = previousRows.length; - replacedCadence = - previousRows.length > 0 - ? previousRows - .map((row) => - [ - row.timesOfDay, - row.rrule ?? "", - row.rollingIntervalDays !== null - ? `every ${row.rollingIntervalDays}d` - : "", - row.daysOfWeek ?? "", - ] - .filter((part) => part !== "") - .join(" "), - ) - .join(" | ") - : null; - - // v1.39.1 (#1033) — a stretch with intake tracking off expected - // nothing, so it archives with an empty payload whatever the rows - // said; and a tracking transition is itself an era boundary even when - // the rows stay the same. - const archivedEntries = wasTracked ? previousEntries : []; - if ( - previousRows.length > 0 && - (trackingChanged || - !wasTracked || - schedulesMateriallyDiffer( - previousEntries, - normalisedSchedules.map((n) => n.snapshot), - )) - ) { - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - // Chain from the ACTIVE boundary: a superseded row is an - // audit record whose `validUntil` may sit past its correction. - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: archivedEntries.length }, - }); - } else if ( - previousRows.length === 0 && - existing.asNeeded && - asNeeded === false && - normalisedSchedules.length > 0 - ) { - // v1.16.11 — flipping as-needed OFF. The medication carried ZERO - // schedule rows while as-needed, so the wholesale-replace archive - // above never fires — and without a revision the live era would - // start at the PREVIOUS revision's `validUntil` (or `createdAt`), - // retro-painting the schedule-less as-needed stretch with the NEW - // schedule's expected slots: every PRN day would read as missed. - // Archive an EMPTY era covering the as-needed stretch instead; an - // empty payload expands to zero schedules, so era-aware compliance - // expects nothing there — exactly the as-needed contract. - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: [] as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: 0 }, - }); - } - await tombstoneOpenSlots(); - await prisma.medicationSchedule.deleteMany({ - where: { medicationId: id }, - }); - } else if (trackingChanged) { - // v1.39.1 (#1033) — intake tracking switched without a schedule - // replace. Close the current era at this instant: switching OFF - // archives the schedule that was in force (the tracked stretch keeps - // its expected doses); switching back ON archives the untracked - // stretch with an empty payload, so compliance never counts it as - // missed and the live schedule, the projector and the reminder worker - // all resume from now. - const liveRows = await prisma.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - if (liveRows.length > 0) { - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - const archivedEntries = wasTracked - ? liveRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ) - : []; - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { - schedule_revision_rows: archivedEntries.length, - track_intake: willTrack, - }, - }); - } - } - - // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: - // the open placeholders of today and later go the way a schedule - // replace sends them (a replace in the same request already did it). - if (trackingChanged && !willTrack && !schedules) { - await tombstoneOpenSlots(); - } - - const baseUpdateData = { - ...(name !== undefined && { name }), - ...(dose !== undefined && { dose }), - ...(treatmentClass !== undefined && { treatmentClass }), - ...(dosesPerUnit !== undefined && { dosesPerUnit }), - // v1.16.10 — units consumed per dose. Field-by-field; the stamp on - // already-taken intake events freezes their recorded consumption. - ...(unitsPerDose !== undefined && { unitsPerDose }), - // v1.17.0 — reorder lead override. Field-by-field; `null` is a - // valid explicit value (clears the override → user-level default). - ...(reorderLeadDays !== undefined && { reorderLeadDays }), - ...(deliveryForm !== undefined && { deliveryForm }), - // v1.8.5 — injection-site tracking opt-in + per-medication allowed - // sites. Field-by-field; `false` / `[]` are valid explicit values - // (deactivate tracking / clear the per-med restriction). - ...(trackInjectionSites !== undefined && { trackInjectionSites }), - ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), - ...(active !== undefined && { active }), - ...(notificationsEnabled !== undefined && { notificationsEnabled }), - // v1.7.0 — iOS reminder flags, field-by-field. - ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), - ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), - // v1.9.0 — optional drug-classification codes (ATC / RxNorm). - // Field-by-field; `null` is a valid explicit value (clears the code). - ...(atcCode !== undefined && { atcCode }), - ...(rxNormCode !== undefined && { rxNormCode }), - // v1.5 scheduling primitives — pass-through when supplied. - // `startsOn` / `endsOn` are `Date | null | undefined` (the - // schema lets the user clear them explicitly with null). - // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the - // course writer below writes them together with the course they - // project, in one transaction, so the row and its courses cannot - // disagree if one of the two writes fails. - ...(oneShot !== undefined && { oneShot }), - // v1.16.11 — as-needed flag, field-by-field (the invariants above - // already guaranteed the medication ends schedule-consistent). - ...(asNeeded !== undefined && { asNeeded }), - // v1.39.1 (#1033) — intake tracking, field-by-field. - ...(trackIntake !== undefined && { trackIntake }), - ...(normalisedSchedules && { - schedules: { - create: normalisedSchedules.map((n) => n.createData), - }, - }), - }; - - const withoutNotifications = { ...baseUpdateData } as Record< - string, - unknown - >; - delete withoutNotifications.notificationsEnabled; - const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; - const hasNotificationsPatch = notificationsEnabled !== undefined; - - const updateCandidates: Array> = [ - { ...baseUpdateData, ...pausedAtPatch }, - ]; - if (hasPausedAtPatch) { - updateCandidates.push(baseUpdateData); - } - if (hasNotificationsPatch) { - updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); - if (hasPausedAtPatch) { - updateCandidates.push(withoutNotifications); - } - } - + // v1.40 — everything the PUT writes to the medication, its schedule and + // its course commits as one transaction. The course write can still be + // refused after the check above (another request wrote a course in + // between); with the row update committed first, the client got the + // refusal while the name, the schedule and `oneShot` were already + // changed. Now a refusal rolls all of it back. The rollup recompute and + // the grace bridge stay outside: both read or write what this commit + // settles, and neither may undo it. let medication; - let lastUpdateErr: unknown; - for (const candidate of updateCandidates) { - try { - medication = await prisma.medication.update({ + try { + medication = await prisma.$transaction(async (tx) => { + // If schedules provided, replace all + if (schedules && normalisedSchedules) { + // v1.16.3 — effective dating. Before the wholesale replace below wipes + // the old rows, archive them as ONE revision covering + // `[previous validUntil | medication.createdAt, now)` — but only when a + // cadence-relevant field actually changes. A no-op edit (the Zeitplan + // tab echoing the same rows back) must not mint a phantom era. + const previousRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + const previousEntries = previousRows.map((row) => + toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }), + ); + replacedScheduleCount = previousRows.length; + replacedCadence = + previousRows.length > 0 + ? previousRows + .map((row) => + [ + row.timesOfDay, + row.rrule ?? "", + row.rollingIntervalDays !== null + ? `every ${row.rollingIntervalDays}d` + : "", + row.daysOfWeek ?? "", + ] + .filter((part) => part !== "") + .join(" "), + ) + .join(" | ") + : null; + + // v1.39.1 (#1033) — a stretch with intake tracking off expected + // nothing, so it archives with an empty payload whatever the rows + // said; and a tracking transition is itself an era boundary even when + // the rows stay the same. + const archivedEntries = wasTracked ? previousEntries : []; + if ( + previousRows.length > 0 && + (trackingChanged || + !wasTracked || + schedulesMateriallyDiffer( + previousEntries, + normalisedSchedules.map((n) => n.snapshot), + )) + ) { + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + // Chain from the ACTIVE boundary: a superseded row is an + // audit record whose `validUntil` may sit past its correction. + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: archivedEntries as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: archivedEntries.length }, + }); + } else if ( + previousRows.length === 0 && + existing.asNeeded && + asNeeded === false && + normalisedSchedules.length > 0 + ) { + // v1.16.11 — flipping as-needed OFF. The medication carried ZERO + // schedule rows while as-needed, so the wholesale-replace archive + // above never fires — and without a revision the live era would + // start at the PREVIOUS revision's `validUntil` (or `createdAt`), + // retro-painting the schedule-less as-needed stretch with the NEW + // schedule's expected slots: every PRN day would read as missed. + // Archive an EMPTY era covering the as-needed stretch instead; an + // empty payload expands to zero schedules, so era-aware compliance + // expects nothing there — exactly the as-needed contract. + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: [] as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: 0 }, + }); + } + await tombstoneOpenSlots(tx); + await tx.medicationSchedule.deleteMany({ + where: { medicationId: id }, + }); + } else if (trackingChanged) { + // v1.39.1 (#1033) — intake tracking switched without a schedule + // replace. Close the current era at this instant: switching OFF + // archives the schedule that was in force (the tracked stretch keeps + // its expected doses); switching back ON archives the untracked + // stretch with an empty payload, so compliance never counts it as + // missed and the live schedule, the projector and the reminder worker + // all resume from now. + const liveRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + if (liveRows.length > 0) { + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + const archivedEntries = wasTracked + ? liveRows.map((row) => + toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }), + ) + : []; + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: archivedEntries as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { + schedule_revision_rows: archivedEntries.length, + track_intake: willTrack, + }, + }); + } + } + + // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: + // the open placeholders of today and later go the way a schedule + // replace sends them (a replace in the same request already did it). + if (trackingChanged && !willTrack && !schedules) { + await tombstoneOpenSlots(tx); + } + + const baseUpdateData = { + ...(name !== undefined && { name }), + ...(dose !== undefined && { dose }), + ...(treatmentClass !== undefined && { treatmentClass }), + ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose. Field-by-field; the stamp on + // already-taken intake events freezes their recorded consumption. + ...(unitsPerDose !== undefined && { unitsPerDose }), + // v1.17.0 — reorder lead override. Field-by-field; `null` is a + // valid explicit value (clears the override → user-level default). + ...(reorderLeadDays !== undefined && { reorderLeadDays }), + ...(deliveryForm !== undefined && { deliveryForm }), + // v1.8.5 — injection-site tracking opt-in + per-medication allowed + // sites. Field-by-field; `false` / `[]` are valid explicit values + // (deactivate tracking / clear the per-med restriction). + ...(trackInjectionSites !== undefined && { trackInjectionSites }), + ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), + ...(active !== undefined && { active }), + ...(notificationsEnabled !== undefined && { notificationsEnabled }), + // v1.7.0 — iOS reminder flags, field-by-field. + ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), + ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Field-by-field; `null` is a valid explicit value (clears the code). + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), + // v1.5 scheduling primitives — pass-through when supplied. + // `startsOn` / `endsOn` are `Date | null | undefined` (the + // schema lets the user clear them explicitly with null). + // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the + // course writer below writes them together with the course they + // project, in one transaction, so the row and its courses cannot + // disagree if one of the two writes fails. + ...(oneShot !== undefined && { oneShot }), + // v1.16.11 — as-needed flag, field-by-field (the invariants above + // already guaranteed the medication ends schedule-consistent). + ...(asNeeded !== undefined && { asNeeded }), + // v1.39.1 (#1033) — intake tracking, field-by-field. + ...(trackIntake !== undefined && { trackIntake }), + ...(normalisedSchedules && { + schedules: { + create: normalisedSchedules.map((n) => n.createData), + }, + }), + }; + + const updated = await tx.medication.update({ where: { id }, - data: candidate, + data: { ...baseUpdateData, ...pausedAtPatch }, include: { schedules: true }, }); - break; - } catch (updateErr) { - lastUpdateErr = updateErr; - } - } - if (!medication) throw lastUpdateErr; - // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a - // single live marker that resume clears, so the paused window is - // irrecoverable once resumed and every paused day's slot collapses to - // "missed" in the compliance denominator. Mirror the live transition - // onto an additive era record the rate paths read: a pause opens an era - // (resumedAt null), a resume closes the latest open era. `userId` comes - // from the authenticated session; the data object is built field-by-field. - if (active === false && existing.active) { - await prisma.medicationPauseEra.create({ - data: { - medicationId: id, - userId: user.id, - pausedAt: new Date(), - resumedAt: null, - }, - }); - } else if (active === true && !existing.active) { - await prisma.medicationPauseEra.updateMany({ - where: { medicationId: id, userId: user.id, resumedAt: null }, - data: { resumedAt: new Date() }, + // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a + // single live marker that resume clears, so the paused window is + // irrecoverable once resumed and every paused day's slot collapses to + // "missed" in the compliance denominator. Mirror the live transition + // onto an additive era record the rate paths read: a pause opens an era + // (resumedAt null), a resume closes the latest open era. `userId` comes + // from the authenticated session; the data object is built field-by-field. + if (active === false && existing.active) { + await tx.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, + }, + }); + } else if (active === true && !existing.active) { + await tx.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); + } + + // v1.40 (#1024) — write the window onto the latest course and + // project it back, then serve the projected row. + if (touchesWindow) { + const projected = await setCurrentWindow( + { + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + }, + tx, + ); + return { ...updated, ...projected }; + } + return updated; }); + } catch (err) { + // A concurrent course write landed between the check above and the + // course write. The transaction rolled back: nothing was written. + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; + } + + // Recompute each rollup day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every other rollup + // write-hook: a failure is annotated, never fails the edit. + for (const dayKey of staleRollupDays) { + try { + await recomputeMedicationComplianceForDay( + user.id, + id, + dayKey, + user.timezone || DEFAULT_TIMEZONE, + ); + } catch (err) { + annotate({ + meta: { + medication_compliance_rollup_failed: true, + medication_compliance_rollup_error: + err instanceof Error ? err.message : String(err), + }, + }); + } } // Apply the v1.5.5 primary-schedule grace bridge after the @@ -1055,29 +1066,6 @@ export const PUT = apiHandler( } } - // v1.40 (#1024) — write the window onto the latest course and project it - // back, then serve the projected row. - if (touchesWindow) { - let projected; - try { - projected = await setCurrentWindow({ - userId: user.id, - medicationId: id, - timeZone: courseTz, - ...nextWindow, - }); - } catch (err) { - // A concurrent course write landed between the check above and this - // transaction. Nothing of the window was written. - if (err instanceof CourseWriteError) { - const r = courseRefusalResponse(err.refusal); - return apiError(r.message, r.status, { errorCode: r.errorCode }); - } - throw err; - } - medication = { ...medication, ...projected }; - } - // v1.40 (#1041) — an iPhone app build that predates custom categories // decodes an unknown category as OTHER and sends it back on every edit, // which would overwrite the person's own category with OTHER. A native diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 3a0550cc5..22b557d71 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,18 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "visitCiphertext") { + return t("admin.section.backups.restoreSkippedVisitCiphertext"); + } + if (catalogue === "vaccinationCiphertext") { + return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); + } + if (catalogue === "moodLabelCiphertext") { + return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); + } + if (catalogue === "cycleCiphertext") { + return t("admin.section.backups.restoreSkippedCycleCiphertext"); + } if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index 2bcc923ec..cd7318a86 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { buildCycleBackupSection } from "../backup"; @@ -236,3 +239,87 @@ describe("a symptom the account created survives the round trip", () => { expect(section.customSymptoms).toEqual([]); }); }); + +describe("a portable file carries the cycle free text readable", () => { + // Ciphertext in a portable file is noise on any host with another key, so + // the note, the sensitive envelope and a custom label travel opened, and + // the restore seals them under the receiving host's key. + const savedKey = process.env.ENCRYPTION_KEY; + beforeEach(() => { + process.env.ENCRYPTION_KEY = "ab".repeat(32); + _resetCryptoCacheForTests(); + }); + afterEach(() => { + process.env.ENCRYPTION_KEY = savedKey; + _resetCryptoCacheForTests(); + }); + + function client(dayLog: Record, label: string | null) { + return { + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { + findMany: vi + .fn() + .mockResolvedValue([ + { date: "2026-07-02", symptomLinks: [], ...dayLog }, + ]), + }, + cycleSymptom: { + findMany: vi.fn().mockResolvedValue([ + { + key: "custom_ache", + labelKey: "cycle.symptom.custom_ache", + categoryId: "cat-1", + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: label, + }, + ]), + }, + } as never; + } + + it("opens each sealed value and carries no ciphertext", async () => { + const section = await buildCycleBackupSection( + client( + { + notesEncrypted: encrypt("Cramps at four"), + sensitiveEncrypted: encrypt( + JSON.stringify({ sexualActivity: true, contraceptive: "ORAL" }), + ), + }, + encrypt("Back ache"), + ), + "user-1", + {}, + ); + const day = section.cycleDayLogs[0]; + expect(day.note).toBe("Cramps at four"); + expect(day.sensitive).toEqual({ + sexualActivity: true, + contraceptive: "ORAL", + }); + expect(day).not.toHaveProperty("notesEncrypted"); + expect(day).not.toHaveProperty("sensitiveEncrypted"); + expect(section.customSymptoms[0].label).toBe("Back ache"); + expect(section.customSymptoms[0]).not.toHaveProperty("labelEncrypted"); + }); + + it("writes the unreadable marker where this host cannot open a value", async () => { + const section = await buildCycleBackupSection( + client( + { + notesEncrypted: "v9.bm90LWEtcmVhbC1jaXBoZXJ0ZXh0LWF0LWFsbC1yZWFsbHk=", + }, + null, + ), + "user-1", + {}, + ); + expect(section.cycleDayLogs[0].note).toBe(UNREADABLE_EXPORT_MARKER); + expect(section.cycleDayLogs[0].sensitive).toBeNull(); + expect(section.customSymptoms[0].label).toBeNull(); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 1a216b812..c04859d87 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -5,10 +5,25 @@ * never drift. Mirrors the `backupPayloadSchema` cycle shapes. * * Reads are scoped to `userId` + `deletedAt: null` and exclude predicted - * (forecast) cycle rows — only observed history round-trips. `notesEncrypted` - * is carried as the ciphertext envelope VERBATIM: the backup never decrypts - * the day-log note, so a wrong-surface plaintext leak is impossible and the - * owner's note round-trips encrypted at rest. + * (forecast) cycle rows — only observed history round-trips. + * + * ## Free text follows the note contract + * + * Three columns are sealed: the day-log note, the sensitive-category envelope + * and a custom symptom's label. A disaster-recovery file carries them as the + * stored ciphertext, which the same instance's key reads back. A portable file + * carries them readable (`note`, `sensitive`, `label`), and the restore seals + * them under the receiving host's key, because a portable file exists to be + * moved, and ciphertext from one host is noise on another. + * + * Until v1.40 the portable file carried the ciphertext too, so a note restored + * onto a host with a different key was unreadable for good. Such a file is + * still accepted: every ciphertext value in it is opened with this host's keys + * before it is written, and one that does not open is kept out of the row and + * named in the skip report (`cycleCiphertext`), never written back as + * ciphertext nobody can read. The same check covers a disaster-recovery file, + * which is why `RESTORE_SELF_VERIFIED_SECTIONS` lets these sections past the + * restore's key preflight. */ import type { CervicalMucus, @@ -25,6 +40,12 @@ import type { PrismaClient, SecondarySymptom, } from "@/generated/prisma/client"; +import { + openSealedForExport, + sealForRestore, + UNOPENED, +} from "@/lib/export/sealed-text"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import type { BackupPayload } from "@/lib/validations/backup"; import { recordUnknownKeys, @@ -91,8 +112,17 @@ export interface CycleBackupSection { pregnancyTest: string | null; progesteroneTest: string | null; contraceptive: string | null; - sensitiveEncrypted: string | null; - notesEncrypted: string | null; + /** Ciphertext; present on a disaster-recovery payload. */ + sensitiveEncrypted?: string | null; + /** Ciphertext; present on a disaster-recovery payload. */ + notesEncrypted?: string | null; + /** Plaintext; present on a portable payload. */ + note?: string | null; + /** + * The sensitive-category envelope opened; present on a portable payload. + * The unreadable marker when this instance could not open it. + */ + sensitive?: CycleSensitiveFields | string | null; source: string; externalId: string | null; tz: string | null; @@ -142,18 +172,40 @@ export interface CycleBackupSection { sortOrder: number; isActive: boolean; /** - * The user's own words for the symptom, carried as ciphertext verbatim. + * The user's own words for the symptom: ciphertext on a disaster-recovery + * payload, plaintext (`label`) on a portable one. * - * The builder has always emitted it and this interface did not declare it, - * which held while the payload was assembled key by key and stopped holding - * the moment the section is spread onto the wire: an undeclared field is a - * field the wire type says is not there. Declared, so the type describes - * the file. + * Declared rather than left to the spread, because an undeclared field is + * a field the wire type says is not there. */ - labelEncrypted: string | null; + labelEncrypted?: string | null; + label?: string | null; }>; } +/** The five intent fields the sensitive-category envelope holds. */ +export interface CycleSensitiveFields { + sexualActivity?: boolean; + protectedSex?: boolean | null; + pregnancyTest?: string | null; + progesteroneTest?: string | null; + contraceptive?: string | null; +} + +/** The sensitive envelope opened for a portable file, or the marker. */ +function openSensitiveForExport( + sealed: string | null, +): CycleSensitiveFields | string | null { + if (!sealed) return null; + const opened = openSealedForExport(sealed, "cycle sensitive envelope"); + if (opened === UNREADABLE_EXPORT_MARKER) return opened; + try { + return JSON.parse(opened) as CycleSensitiveFields; + } catch { + return UNREADABLE_EXPORT_MARKER; + } +} + /** * Build the cycle section of a user's full backup. Accepts any client with * the cycle delegates (the route's global client OR the worker's local @@ -259,8 +311,17 @@ export async function buildCycleBackupSection( pregnancyTest: d.pregnancyTest, progesteroneTest: d.progesteroneTest, contraceptive: d.contraceptive, - sensitiveEncrypted: d.sensitiveEncrypted, - notesEncrypted: d.notesEncrypted, + ...(disasterRecovery + ? { + sensitiveEncrypted: d.sensitiveEncrypted, + notesEncrypted: d.notesEncrypted, + } + : { + sensitive: openSensitiveForExport(d.sensitiveEncrypted), + note: d.notesEncrypted + ? openSealedForExport(d.notesEncrypted, "cycle day-log note") + : null, + }), source: d.source, externalId: d.externalId, tz: d.tz, @@ -277,7 +338,13 @@ export async function buildCycleBackupSection( icon: sym.icon, sortOrder: sym.sortOrder, isActive: sym.isActive, - labelEncrypted: sym.labelEncrypted, + ...(disasterRecovery + ? { labelEncrypted: sym.labelEncrypted } + : { + label: sym.labelEncrypted + ? openSealedForExport(sym.labelEncrypted, "cycle symptom label") + : null, + }), })), }; } @@ -383,8 +450,9 @@ function enumOrNull( * inside an existing transaction. Delete-then-recreate, matching the * measurement/mood restore contract. Symptom links are re-resolved against * the seeded catalogue by key, and each one gets back the 1-4 intensity the - * file recorded for it, or none if the file recorded none. `notesEncrypted` is - * written back as the ciphertext envelope verbatim — never re-encrypted. + * file recorded for it, or none if the file recorded none. Sealed free text + * comes back as the file-header comment describes: readable values sealed + * under this host's key, ciphertext written back only when this host opens it. * * A key that resolves against nothing is dropped from the day's links and * recorded on `skips`. It is a required argument rather than an optional one: @@ -400,6 +468,9 @@ export async function restoreCycleData( payload: BackupPayload, skips: RestoreSkipLog, ): Promise { + // Sealed values from the file this host's keys do not open, by file path. + const unopened: string[] = []; + // Wipe (child links cascade off the day-log delete). const dayLogs = await tx.cycleDayLog.deleteMany({ where: { userId: ownerId }, @@ -488,7 +559,12 @@ export async function restoreCycleData( icon: sym.icon ?? null, sortOrder: sym.sortOrder, isActive: sym.isActive, - labelEncrypted: sym.labelEncrypted ?? null, + labelEncrypted: sealForRestore( + sym.labelEncrypted, + sym.label, + `customSymptoms.${sym.key}.labelEncrypted`, + unopened, + ), }, // `key` is globally unique, so a seeded key would collide. Leave the // catalogue row alone: the account's links resolve against it either way. @@ -591,9 +667,22 @@ export async function restoreCycleData( pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS), progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS), contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES), - // Both ciphertext envelopes written back verbatim — never re-encrypted. - sensitiveEncrypted: d.sensitiveEncrypted ?? null, - notesEncrypted: d.notesEncrypted ?? null, + sensitiveEncrypted: sealForRestore( + d.sensitiveEncrypted, + d.sensitive === undefined || d.sensitive === null + ? d.sensitive + : typeof d.sensitive === "string" + ? UNOPENED + : JSON.stringify(d.sensitive), + `cycleDayLogs.${d.date}.sensitiveEncrypted`, + unopened, + ), + notesEncrypted: sealForRestore( + d.notesEncrypted, + d.note, + `cycleDayLogs.${d.date}.notesEncrypted`, + unopened, + ), source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL", externalId: d.externalId ?? null, tz: d.tz ?? null, @@ -608,6 +697,8 @@ export async function restoreCycleData( }); } + recordUnknownKeys(skips, "cycleCiphertext", unopened, unopened); + return { cycles: cycles.count, cycleDayLogs: dayLogs.count, diff --git a/src/lib/export/__tests__/backup-key-ids.test.ts b/src/lib/export/__tests__/backup-key-ids.test.ts index 03377c809..386f12878 100644 --- a/src/lib/export/__tests__/backup-key-ids.test.ts +++ b/src/lib/export/__tests__/backup-key-ids.test.ts @@ -138,6 +138,30 @@ describe("assessBackupKeys", () => { ).toEqual([]); }); + it("leaves the cycle sections to the restore, which opens each value itself", () => { + // A same-id impostor in a cycle note must neither refuse the file nor be + // picked as the sample that proves the key for the sections that need it. + const fine = encrypt("a measurement note under the real key"); + useKeys({ old: K3, cur: K2 }, "old"); + const impostor = encrypt("x"); + useKeys({ old: K1, cur: K2 }, "cur"); + const file = { + measurements: [{ notesEncrypted: fine }], + cycleDayLogs: [ + { notesEncrypted: impostor }, + { notesEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` }, + ], + customSymptoms: [ + { labelEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` }, + ], + }; + const collector = new BackupKeyIdCollector(); + collector.visit(file); + const verdict = assessBackupKeys(collector); + expect(verdict.missing).toEqual([]); + expect(verdict.unreadable).toEqual([]); + }); + it("catches an id that is configured with different key material", () => { const file = fileWrittenUnderOld(); useKeys({ old: K3, cur: K2 }, "cur"); diff --git a/src/lib/export/backup-key-ids.ts b/src/lib/export/backup-key-ids.ts index acbe75e07..88bb09ad6 100644 --- a/src/lib/export/backup-key-ids.ts +++ b/src/lib/export/backup-key-ids.ts @@ -97,6 +97,32 @@ export function isEncryptedMember(name: string): boolean { return name.endsWith("Encrypted") && name.length > "Encrypted".length; } +/** + * Sections whose restore opens every inner value with this host's keys itself, + * and keeps out (and names in the skip report) any value it cannot open. + * + * The cycle day-logs, custom cycle symptoms, custom mood tags and mood + * categories, practitioners, visits and vaccinations are here because a + * portable file written before v1.40 carried their free text as the stored + * ciphertext, + * not as readable text. Such a file is still restorable onto another host: + * what that host can open comes back, what it cannot stays out of the row and + * is reported. Refusing the whole file over a note would cost every other + * part of the account. The trade, stated: a disaster-recovery copy whose ONLY + * missing key sits in these sections restores with those values reported as + * skipped instead of being refused; any other section needing the key still + * refuses it, which in practice is every real copy. + */ +export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet = new Set([ + "cycleDayLogs", + "customSymptoms", + "customMoodTags", + "customMoodTagCategories", + "practitioners", + "encounters", + "vaccinations", +]); + interface KeyUse { /** How many values need this key. */ count: number; @@ -127,7 +153,10 @@ export class BackupKeyIdCollector { } use.count += 1; use.sections.add(section); + // A sample from a self-verified section proves nothing the restore needs: + // a value there that does not open is skipped, not fatal. if ( + !RESTORE_SELF_VERIFIED_SECTIONS.has(section) && value.length <= MAX_SAMPLE_CHARS && (!use.sample || value.length < use.sample.value.length) ) { @@ -257,7 +286,9 @@ function openSample(sample: { value: string; form: InnerCiphertextForm }) { * key to prove the key under that id is the right one. * * `ignoreSections` leaves out sections the caller will not write: the - * instance settings, when the operator has not asked for them back. + * instance settings, when the operator has not asked for them back. The + * sections in {@link RESTORE_SELF_VERIFIED_SECTIONS} are always left out, + * because their restore checks each value itself. */ export function assessBackupKeys( collector: BackupKeyIdCollector, @@ -274,7 +305,14 @@ export function assessBackupKeys( for (const [keyId, use] of [...collector.entries()].sort(([a], [b]) => a.localeCompare(b), )) { - if ([...use.sections].every((section) => ignore.has(section))) continue; + if ( + [...use.sections].every( + (section) => + ignore.has(section) || RESTORE_SELF_VERIFIED_SECTIONS.has(section), + ) + ) { + continue; + } keyIds.push(keyId); configured ??= new Set(getConfiguredKeyIds()); if (!configured.has(keyId)) { diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9bd1b84d7..f96f79935 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -25,6 +25,7 @@ import { dayKeyOfDate } from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; +import { openSealedForExport } from "@/lib/export/sealed-text"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; import { @@ -1284,8 +1285,18 @@ export async function buildFullBackupPayload( icon: category.icon, sortOrder: category.sortOrder, isActive: category.isActive, - // Ciphertext verbatim, like the tag label beside it. - labelEncrypted: category.labelEncrypted, + // The person's own words: ciphertext in a disaster-recovery payload, + // readable in a portable one (see `sealed-text.ts`). + ...(disasterRecovery + ? { labelEncrypted: category.labelEncrypted } + : { + label: category.labelEncrypted + ? openSealedForExport( + category.labelEncrypted, + "mood category label", + ) + : null, + }), })), hiddenMoodTags: hiddenMoodTags.map((hidden) => ({ key: hidden.moodTag.key, @@ -1300,9 +1311,15 @@ export async function buildFullBackupPayload( isActive: tag.isActive, icon: tag.icon, sortOrder: tag.sortOrder, - // Ciphertext verbatim, like every other *Encrypted column in a DR - // payload: the same instance's key reads it back unchanged. - labelEncrypted: tag.labelEncrypted, + // Ciphertext in a disaster-recovery payload (the same instance's key + // reads it back), readable in a portable one (see `sealed-text.ts`). + ...(disasterRecovery + ? { labelEncrypted: tag.labelEncrypted } + : { + label: tag.labelEncrypted + ? openSealedForExport(tag.labelEncrypted, "mood tag label") + : null, + }), scaleMin: tag.scaleMin, scaleMax: tag.scaleMax, inverse: tag.inverse, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 0ac7e1a68..e0bd34eef 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -66,6 +66,7 @@ import { listTenantEdges, } from "@/lib/export/tenant-boundary"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { sealForRestore } from "@/lib/export/sealed-text"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { encryptNoteToBytes } from "@/lib/labs/store"; @@ -1514,6 +1515,130 @@ export async function restoreBackup( } reportSection("mood"); + // The account's own categories, tags and hidden set are written + // whether or not the file carries a mood entry: an account that set + // up its mood vocabulary before logging a single entry has them too, + // and they used to be written only inside the entries branch. + // The account's own CATEGORIES go in before its tags. A custom + // tag's `categoryId` is a real foreign key, so writing the tag + // first threw `mood_tags_category_id_fkey` and rolled the entire + // restore back — an account that had grouped its own mood factors + // could not be restored at all, and the failure named a constraint + // rather than the thing that was missing. + // + // Upserted by key for the same reason the tags below are: `key` is + // globally unique, so a seeded category that later claims the key + // must be left alone rather than overwritten by a restore. + // Labels this host's keys do not open, by file path. + const unopenedMoodLabels: string[] = []; + for (const category of payload.customMoodTagCategories) { + await tx.moodTagCategory.upsert({ + where: { key: category.key }, + create: { + id: category.id, + userId: ownerId, + key: category.key, + labelKey: category.labelKey, + icon: category.icon ?? null, + sortOrder: category.sortOrder ?? 0, + isActive: category.isActive ?? true, + labelEncrypted: sealForRestore( + category.labelEncrypted, + category.label, + `customMoodTagCategories.${category.key}.labelEncrypted`, + unopenedMoodLabels, + ), + }, + update: {}, + }); + } + + // Re-create the account's own tag definitions first. The lookup + // below used to ask only for the seeded catalogue (`userId: null`), + // so a single custom rated tag made the whole restore throw + // "Unknown mood factor keys" — the file was complete and unusable. + for (const tag of payload.customMoodTags) { + await tx.moodTag.upsert({ + where: { key: tag.key }, + create: { + ...(tag.id ? { id: tag.id } : {}), + userId: ownerId, + key: tag.key, + labelKey: tag.labelKey, + categoryId: tag.categoryId, + kind: tag.kind, + isActive: tag.isActive, + icon: tag.icon ?? null, + sortOrder: tag.sortOrder, + labelEncrypted: sealForRestore( + tag.labelEncrypted, + tag.label, + `customMoodTags.${tag.key}.labelEncrypted`, + unopenedMoodLabels, + ), + scaleMin: tag.scaleMin, + scaleMax: tag.scaleMax, + inverse: tag.inverse, + }, + // `key` is globally unique, so a seeded key would collide. + // Leave the catalogue row alone; the links resolve either way. + update: {}, + }); + } + recordUnknownKeys( + skips, + "moodLabelCiphertext", + unopenedMoodLabels, + unopenedMoodLabels, + ); + + // Which tags the account hid. Delete-then-recreate like every + // other section, and resolved by KEY: what people hide is almost + // always a seeded tag, whose id differs on every instance. + // + // A key this instance no longer knows costs one hidden row and is + // REPORTED. Dropping it silently would un-hide a factor the person + // had deliberately taken out of their mood editor, and they would + // have no way to know why it came back. + await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); + if (payload.hiddenMoodTags.length > 0) { + const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); + const hiddenRows = await tx.moodTag.findMany({ + where: { + key: { in: [...new Set(hiddenKeys)] }, + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const hiddenByKey = new Map( + hiddenRows.map((row) => [row.key, row.id]), + ); + const unresolvedHidden: string[] = []; + const writableHidden = payload.hiddenMoodTags.filter((entry) => { + if (hiddenByKey.has(entry.key)) return true; + unresolvedHidden.push(entry.key); + return false; + }); + if (writableHidden.length > 0) { + await tx.moodTagHidden.createMany({ + data: writableHidden.map((entry) => ({ + userId: ownerId, + moodTagId: hiddenByKey.get(entry.key)!, + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + })), + skipDuplicates: true, + }); + } + recordUnknownKeys( + skips, + "moodTag", + [...new Set(unresolvedHidden)], + unresolvedHidden, + ); + } + if (payload.moodEntries.length > 0) { const referencedFactorKeys = payload.moodEntries.flatMap((entry) => entry.factors.map((factor) => factor.key), @@ -1523,108 +1648,6 @@ export async function restoreBackup( (entry) => entry.structuredTags, ); const tagKeys = [...new Set(referencedTagKeys)]; - // The account's own CATEGORIES go in before its tags. A custom - // tag's `categoryId` is a real foreign key, so writing the tag - // first threw `mood_tags_category_id_fkey` and rolled the entire - // restore back — an account that had grouped its own mood factors - // could not be restored at all, and the failure named a constraint - // rather than the thing that was missing. - // - // Upserted by key for the same reason the tags below are: `key` is - // globally unique, so a seeded category that later claims the key - // must be left alone rather than overwritten by a restore. - for (const category of payload.customMoodTagCategories) { - await tx.moodTagCategory.upsert({ - where: { key: category.key }, - create: { - id: category.id, - userId: ownerId, - key: category.key, - labelKey: category.labelKey, - icon: category.icon ?? null, - sortOrder: category.sortOrder ?? 0, - isActive: category.isActive ?? true, - labelEncrypted: category.labelEncrypted ?? null, - }, - update: {}, - }); - } - - // Re-create the account's own tag definitions first. The lookup - // below used to ask only for the seeded catalogue (`userId: null`), - // so a single custom rated tag made the whole restore throw - // "Unknown mood factor keys" — the file was complete and unusable. - for (const tag of payload.customMoodTags) { - await tx.moodTag.upsert({ - where: { key: tag.key }, - create: { - ...(tag.id ? { id: tag.id } : {}), - userId: ownerId, - key: tag.key, - labelKey: tag.labelKey, - categoryId: tag.categoryId, - kind: tag.kind, - isActive: tag.isActive, - icon: tag.icon ?? null, - sortOrder: tag.sortOrder, - labelEncrypted: tag.labelEncrypted ?? null, - scaleMin: tag.scaleMin, - scaleMax: tag.scaleMax, - inverse: tag.inverse, - }, - // `key` is globally unique, so a seeded key would collide. - // Leave the catalogue row alone; the links resolve either way. - update: {}, - }); - } - - // Which tags the account hid. Delete-then-recreate like every - // other section, and resolved by KEY: what people hide is almost - // always a seeded tag, whose id differs on every instance. - // - // A key this instance no longer knows costs one hidden row and is - // REPORTED. Dropping it silently would un-hide a factor the person - // had deliberately taken out of their mood editor, and they would - // have no way to know why it came back. - await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } }); - if (payload.hiddenMoodTags.length > 0) { - const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key); - const hiddenRows = await tx.moodTag.findMany({ - where: { - key: { in: [...new Set(hiddenKeys)] }, - OR: [{ userId: null }, { userId: ownerId }], - }, - select: { id: true, key: true }, - }); - const hiddenByKey = new Map( - hiddenRows.map((row) => [row.key, row.id]), - ); - const unresolvedHidden: string[] = []; - const writableHidden = payload.hiddenMoodTags.filter((entry) => { - if (hiddenByKey.has(entry.key)) return true; - unresolvedHidden.push(entry.key); - return false; - }); - if (writableHidden.length > 0) { - await tx.moodTagHidden.createMany({ - data: writableHidden.map((entry) => ({ - userId: ownerId, - moodTagId: hiddenByKey.get(entry.key)!, - ...(entry.createdAt - ? { createdAt: new Date(entry.createdAt) } - : {}), - })), - skipDuplicates: true, - }); - } - recordUnknownKeys( - skips, - "moodTag", - [...new Set(unresolvedHidden)], - unresolvedHidden, - ); - } - const factorRows = factorKeys.length === 0 ? [] diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index d79d09bbd..c8bb1fd92 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -152,6 +152,25 @@ * real foreign key, so the POINTER is dropped and the occurrence restores * unlinked. A portable export omits soft-deleted episodes, which is the * ordinary way an occurrence ends up naming one the file does not carry. + * + * The sixteenth, `cycleCiphertext`, is not a reference at all. It names a + * sealed cycle value (a day-log note, the sensitive-category envelope, a + * custom symptom's label) the file carried as ciphertext this host's keys do + * not open — a portable file written before v1.40 on a host with another key. + * Writing it back would store a value no reader can open, so the FIELD is kept + * out and the day or the symptom restores without it. Its key is the path in + * the file (`cycleDayLogs..notesEncrypted`), never the value. + * + * The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood + * tag or mood category the account created + * (`customMoodTags..labelEncrypted`): the tag or category restores, under + * its key, without the person's own name for it. + * + * The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`, + * are the same again for the free text of the visits record (a practitioner's + * note, address and phone; a visit's reason, outcome and body site) and of a + * vaccination's note (`vaccinations..noteEncrypted`). The row restores + * without the field. */ export type SkippedCatalogue = | "cycleSymptom" @@ -172,7 +191,11 @@ export type SkippedCatalogue = | "scheduleRevisionLink" | "checkupClosure" | "accountSetting" - | "symptomEpisodeReference"; + | "symptomEpisodeReference" + | "cycleCiphertext" + | "moodLabelCiphertext" + | "visitCiphertext" + | "vaccinationCiphertext"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/export/sealed-text.ts b/src/lib/export/sealed-text.ts new file mode 100644 index 000000000..4773d6186 --- /dev/null +++ b/src/lib/export/sealed-text.ts @@ -0,0 +1,134 @@ +/** + * Both ends of a sealed free-text column in a backup section: the string + * codec (`.` in a text column — the cycle day-log note and + * sensitive envelope, custom cycle symptom and mood labels) and the shared + * bytes codec (the practitioner note, the visit's reason, outcome and body + * site, the vaccination note). + * + * The split is the one every other section uses. A disaster-recovery file + * carries the stored ciphertext, which the same instance's key reads back. A + * portable file carries the readable value (or the unreadable marker when + * the writing host could not open it), and the restore seals it under the + * receiving host's active key, because ciphertext from one host is noise on + * another. + * + * Portable files written before v1.40 carried these columns as ciphertext. + * They still restore: a ciphertext value is written back only when this + * host's keys open it. One that does not open is kept out of the row and its + * file path is recorded, so the caller can name it in the skip report rather + * than store a value nobody can read. That per-value check is why the key + * preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`). + */ +import { Buffer } from "node:buffer"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; +import { getEvent } from "@/lib/logging/context"; + +/** Open a sealed value for a portable file, or the unreadable marker. */ +export function openSealedForExport(sealed: string, field: string): string { + try { + return decrypt(sealed); + } catch (err) { + getEvent()?.addWarning( + `${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** A readable value the file could not supply: the writer could not open it. */ +export const UNOPENED: unique symbol = Symbol("unopened"); + +/** + * The sealed column value for one free-text field from the file. + * + * Ciphertext is written back only when this host opens it — a value sealed + * under a key this host does not hold (or the same key id over other key + * material) would be a row nobody can read. A readable value is sealed under + * this host's active key. Anything kept out is recorded under `path`. + */ +export function sealForRestore( + sealed: string | null | undefined, + readable: string | null | undefined | typeof UNOPENED, + path: string, + unopened: string[], +): string | null { + if (sealed) { + try { + decrypt(sealed); + return sealed; + } catch { + unopened.push(path); + return null; + } + } + if (readable === UNOPENED) { + unopened.push(path); + return null; + } + return readable ? encrypt(readable) : null; +} + +/** + * The `Bytes` twin of {@link openSealedForExport}, for columns written with + * the shared bytes codec (`encryptToBytes`): the practitioner note, the + * visit's reason, outcome and body site, the vaccination note. + */ +export function openSealedBytesForExport( + sealed: Uint8Array | null, + field: string, +): string | null { + if (!sealed || sealed.byteLength === 0) return null; + try { + return decryptFromBytes(sealed); + } catch (err) { + getEvent()?.addWarning( + `${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return UNREADABLE_EXPORT_MARKER; + } +} + +/** The stored `Bytes` ciphertext as a disaster-recovery file carries it. */ +export function encodeSealedBytes(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + return Buffer.from(value).toString("base64"); +} + +function decodeBase64(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * The `Bytes` twin of {@link sealForRestore}. `sealed` is the base64 a + * disaster-recovery (or pre-v1.40 portable) file carries; it is written back + * only when this host opens it. A readable value is sealed under this host's + * active key. + */ +export function sealBytesForRestore( + sealed: string | null | undefined, + readable: string | null | undefined, + path: string, + unopened: string[], +): Uint8Array | null { + if (sealed) { + const bytes = decodeBase64(sealed); + try { + decryptFromBytes(bytes); + return bytes; + } catch { + unopened.push(path); + return null; + } + } + return readable ? encryptToBytes(readable) : null; +} diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index f88bcc151..2e15242b0 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -35,7 +35,11 @@ * guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts` * holds. */ -import { Buffer } from "node:buffer"; +import { + encodeSealedBytes, + openSealedBytesForExport, + sealBytesForRestore, +} from "@/lib/export/sealed-text"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { VaccinationSite } from "@/generated/prisma/client"; @@ -72,8 +76,10 @@ export interface VaccinationBackupEntry { * lacks the reminder. */ reminderId: string | null; - /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ - noteEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + noteEncrypted?: string | null; + /** The note readable; portable payloads only. */ + note?: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -96,12 +102,6 @@ export interface VaccinationsBackupCounts { vaccinationLinks: number; } -/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ -function encodeCiphertext(value: Uint8Array | null): string | null { - if (!value || value.byteLength === 0) return null; - return Buffer.from(value).toString("base64"); -} - /** * Named select constants, one per model — a structural matcher binds a model * to the literal beside its delegate call, and an inline object inside a @@ -172,7 +172,14 @@ export async function buildVaccinationsBackupSection( practitionerId: row.practitionerId, encounterId: row.encounterId, reminderId: row.reminderId, - noteEncrypted: encodeCiphertext(row.noteEncrypted), + ...(disasterRecovery + ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } + : { + note: openSealedBytesForExport( + row.noteEncrypted, + "vaccination note", + ), + }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), ...(disasterRecovery @@ -240,16 +247,10 @@ export type RestoredVaccination = Pick< | "encounterId" | "reminderId" | "noteEncrypted" + | "note" > > & { deletedAt?: string | null }; -function decodeCiphertext(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - /** * Re-create the account's immunization log and its document links. * @@ -307,6 +308,8 @@ export async function restoreVaccinationsData( const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; const droppedReminders: string[] = []; + // Sealed notes this host's keys do not open, by file path. + const unopened: string[] = []; if (payload.vaccinations.length > 0) { await tx.vaccinationRecord.createMany({ @@ -349,10 +352,12 @@ export async function restoreVaccinationsData( practitionerId, encounterId, reminderId, - noteEncrypted: - entry.noteEncrypted == null - ? null - : decodeCiphertext(entry.noteEncrypted), + noteEncrypted: sealBytesForRestore( + entry.noteEncrypted, + entry.note, + `vaccinations.${entry.id}.noteEncrypted`, + unopened, + ), createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, @@ -379,6 +384,7 @@ export async function restoreVaccinationsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened); const restoredVaccinations = new Set( payload.vaccinations.map((entry) => entry.id), diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts index 32ebca5f4..2957bb947 100644 --- a/src/lib/export/visits-backup.ts +++ b/src/lib/export/visits-backup.ts @@ -33,7 +33,6 @@ * result ids, so a lab link in one cannot resolve; a disaster-recovery * payload carries every id and they all do. */ -import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { EncounterKind, @@ -42,6 +41,11 @@ import type { } from "@/generated/prisma/client"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { + encodeSealedBytes, + openSealedBytesForExport, + sealBytesForRestore, +} from "@/lib/export/sealed-text"; import { readPractitionerContact } from "@/lib/practitioners/dto"; import { recordUnknownKeys, @@ -71,8 +75,10 @@ export interface PractitionerBackupEntry { locationEncrypted?: string | null; /** Base64 ciphertext of the phone number, disaster-recovery payloads only. */ phoneEncrypted?: string | null; - /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ - noteEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + noteEncrypted?: string | null; + /** The note readable; portable payloads only. */ + note?: string | null; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -87,10 +93,15 @@ export interface EncounterBackupEntry { status: EncounterStatus; kind: EncounterKind; practitionerId: string | null; - reasonEncrypted: string | null; - outcomeEncrypted: string | null; + /** Base64 ciphertext; disaster-recovery payloads only. */ + reasonEncrypted?: string | null; + outcomeEncrypted?: string | null; /** The procedure's body site, ciphertext like the two above (v1.39.1). */ - bodySiteEncrypted: string | null; + bodySiteEncrypted?: string | null; + /** The three readable; portable payloads only. */ + reason?: string | null; + outcome?: string | null; + bodySite?: string | null; laterality: Laterality | null; /** * The appointment's reminder row, which restores in the same run since @@ -124,12 +135,6 @@ export interface VisitsBackupCounts { encounterLinks: number; } -/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ -function encodeCiphertext(value: Uint8Array | null): string | null { - if (!value || value.byteLength === 0) return null; - return Buffer.from(value).toString("base64"); -} - /** * Named select constants, one per model. * @@ -256,10 +261,10 @@ export async function buildVisitsBackupSection( ? { location: null, phone: null, - locationEncrypted: encodeCiphertext( + locationEncrypted: encodeSealedBytes( row.locationEncrypted ?? encryptNote(row.location), ), - phoneEncrypted: encodeCiphertext( + phoneEncrypted: encodeSealedBytes( row.phoneEncrypted ?? encryptNote(row.phone), ), } @@ -275,7 +280,14 @@ export async function buildVisitsBackupSection( "phone", ), }), - noteEncrypted: encodeCiphertext(row.noteEncrypted), + ...(disasterRecovery + ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } + : { + note: openSealedBytesForExport( + row.noteEncrypted, + "practitioner note", + ), + }), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), ...(disasterRecovery @@ -288,9 +300,26 @@ export async function buildVisitsBackupSection( status: row.status, kind: row.kind, practitionerId: row.practitionerId, - reasonEncrypted: encodeCiphertext(row.reasonEncrypted), - outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted), - bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted), + ...(disasterRecovery + ? { + reasonEncrypted: encodeSealedBytes(row.reasonEncrypted), + outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted), + bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted), + } + : { + reason: openSealedBytesForExport( + row.reasonEncrypted, + "visit reason", + ), + outcome: openSealedBytesForExport( + row.outcomeEncrypted, + "visit outcome", + ), + bodySite: openSealedBytesForExport( + row.bodySiteEncrypted, + "visit body site", + ), + }), laterality: row.laterality, reminderId: row.reminderId, createdAt: row.createdAt.toISOString(), @@ -380,6 +409,7 @@ export type RestoredPractitioner = Pick< | "locationEncrypted" | "phoneEncrypted" | "noteEncrypted" + | "note" > > & { deletedAt?: string | null }; @@ -394,18 +424,14 @@ export type RestoredEncounter = Pick< | "reasonEncrypted" | "outcomeEncrypted" | "bodySiteEncrypted" + | "reason" + | "outcome" + | "bodySite" | "laterality" | "reminderId" > > & { deletedAt?: string | null }; -function decodeCiphertext(encoded: string): Uint8Array { - const decoded = Buffer.from(encoded, "base64"); - const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); - bytes.set(decoded); - return bytes; -} - /** * Re-create the account's visits, address book and link rows. * @@ -440,6 +466,9 @@ export async function restoreVisitsData( where: { userId: ownerId }, }); + // Sealed values this host's keys do not open, by file path. + const unopened: string[] = []; + if (payload.practitioners.length > 0) { await tx.practitioner.createMany({ data: payload.practitioners.map((entry) => ({ @@ -451,18 +480,24 @@ export async function restoreVisitsData( // v1.39.4 — ciphertext wins when a disaster-recovery file carried it; // a portable file's (or an older file's) readable value is sealed // under this instance's key. The readable columns are never written. - locationEncrypted: - entry.locationEncrypted != null - ? decodeCiphertext(entry.locationEncrypted) - : encryptNote(entry.location), - phoneEncrypted: - entry.phoneEncrypted != null - ? decodeCiphertext(entry.phoneEncrypted) - : encryptNote(entry.phone), - noteEncrypted: - entry.noteEncrypted == null - ? null - : decodeCiphertext(entry.noteEncrypted), + locationEncrypted: sealBytesForRestore( + entry.locationEncrypted, + entry.location, + `practitioners.${entry.id}.locationEncrypted`, + unopened, + ), + phoneEncrypted: sealBytesForRestore( + entry.phoneEncrypted, + entry.phone, + `practitioners.${entry.id}.phoneEncrypted`, + unopened, + ), + noteEncrypted: sealBytesForRestore( + entry.noteEncrypted, + entry.note, + `practitioners.${entry.id}.noteEncrypted`, + unopened, + ), createdAt: new Date(entry.createdAt), updatedAt: new Date(entry.updatedAt), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, @@ -517,20 +552,26 @@ export async function restoreVisitsData( status: entry.status, kind: entry.kind, practitionerId, - reasonEncrypted: - entry.reasonEncrypted == null - ? null - : decodeCiphertext(entry.reasonEncrypted), - outcomeEncrypted: - entry.outcomeEncrypted == null - ? null - : decodeCiphertext(entry.outcomeEncrypted), + reasonEncrypted: sealBytesForRestore( + entry.reasonEncrypted, + entry.reason, + `encounters.${entry.id}.reasonEncrypted`, + unopened, + ), + outcomeEncrypted: sealBytesForRestore( + entry.outcomeEncrypted, + entry.outcome, + `encounters.${entry.id}.outcomeEncrypted`, + unopened, + ), // Absent in a file written before v1.39.1: the visit comes back // with no site, which is what it had. - bodySiteEncrypted: - entry.bodySiteEncrypted == null - ? null - : decodeCiphertext(entry.bodySiteEncrypted), + bodySiteEncrypted: sealBytesForRestore( + entry.bodySiteEncrypted, + entry.bodySite, + `encounters.${entry.id}.bodySiteEncrypted`, + unopened, + ), laterality: entry.laterality ?? null, reminderId, createdAt: new Date(entry.createdAt), @@ -553,6 +594,7 @@ export async function restoreVisitsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys(skips, "visitCiphertext", unopened, unopened); const restoredEncounters = new Set(payload.encounters.map((e) => e.id)); diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 760c5baee..4abd7b7d0 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -323,57 +323,69 @@ function windowAsCourse( * Clearing both on a medication with one course deletes it (chronic again, * the pre-course meaning of no dates); on one with several it is refused, * because it would silently drop the history. + * + * `tx` runs the write inside the caller's transaction, so the PUT's row + * update and this course write commit or roll back together: a refusal here + * then leaves nothing of the PUT behind. */ export async function setCurrentWindow( args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, + tx?: Tx, +): Promise { + return tx + ? writeCurrentWindow(tx, args) + : prisma.$transaction((own) => writeCurrentWindow(own, args)); +} + +async function writeCurrentWindow( + tx: Tx, + args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, ): Promise { const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); - return prisma.$transaction(async (tx) => { - await lockMedication(tx, args.medicationId); - const { medication, courses } = await loadOwned( - tx, - args.userId, - args.medicationId, - ); - const latest = sortCourses(courses).at(-1); + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const latest = sortCourses(courses).at(-1); - if (args.startsOn === null && args.endsOn === null) { - if (!latest) return project(tx, args.medicationId); - if (courses.length > 1) throw new CourseWriteError("windowRequired"); - await tx.medicationCourse.delete({ where: { id: latest.id } }); - return project(tx, args.medicationId); - } - - const next = windowAsCourse( - args.startsOn, - args.endsOn, - latest, - medication.createdAt, - args.timeZone, - ); - - if (latest) { - refuseIfInvalid( - courses.map((c) => (c.id === latest.id ? next : c)), - todayKey, - medication.oneShot, - ); - await tx.medicationCourse.update({ - where: { id: latest.id }, - data: next, - }); - } else { - refuseIfInvalid([next], todayKey, medication.oneShot); - await tx.medicationCourse.create({ - data: { - medicationId: args.medicationId, - userId: args.userId, - ...next, - }, - }); - } + if (args.startsOn === null && args.endsOn === null) { + if (!latest) return project(tx, args.medicationId); + if (courses.length > 1) throw new CourseWriteError("windowRequired"); + await tx.medicationCourse.delete({ where: { id: latest.id } }); return project(tx, args.medicationId); - }); + } + + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + medication.createdAt, + args.timeZone, + ); + + if (latest) { + refuseIfInvalid( + courses.map((c) => (c.id === latest.id ? next : c)), + todayKey, + medication.oneShot, + ); + await tx.medicationCourse.update({ + where: { id: latest.id }, + data: next, + }); + } else { + refuseIfInvalid([next], todayKey, medication.oneShot); + await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + ...next, + }, + }); + } + return project(tx, args.medicationId); } /** diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 8a9af3f62..f0a31c31f 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -625,6 +625,15 @@ const cycleSymptomSeveritySchema = z }) .passthrough(); +/** The sensitive-category envelope's five fields, as a portable file carries them. */ +const cycleSensitiveFieldsSchema = z.object({ + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: z.enum(HomeTestResult).nullable().optional(), + progesteroneTest: z.enum(HomeTestResult).nullable().optional(), + contraceptive: z.enum(ContraceptiveKind).nullable().optional(), +}); + const cycleDayLogSchema = z .object({ id: z.string().min(1).optional(), @@ -644,8 +653,20 @@ const cycleDayLogSchema = z pregnancyTest: z.enum(HomeTestResult).nullable().optional(), progesteroneTest: z.enum(HomeTestResult).nullable().optional(), contraceptive: z.enum(ContraceptiveKind).nullable().optional(), + // Free text travels like every other note: a disaster-recovery file + // carries the ciphertext (`*Encrypted`), a portable file the readable + // value (`note`, `sensitive`), which the restore seals under the + // receiving host's key. A portable file written before v1.40 carries the + // ciphertext too; the restore keeps only what this host can open. sensitiveEncrypted: z.string().nullable().optional(), notesEncrypted: z.string().nullable().optional(), + note: z.string().nullable().optional(), + // The five intent fields the sensitive-category envelope holds, or the + // unreadable marker when the writing host could not open the envelope. + sensitive: z + .union([cycleSensitiveFieldsSchema, z.string()]) + .nullable() + .optional(), source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), tz: z.string().nullable().optional(), @@ -696,10 +717,12 @@ const customMoodTagSchema = z isActive: z.boolean().default(true), icon: z.string().nullable().optional(), sortOrder: z.number().int().default(0), - // The user's own words for the tag, encrypted at rest and carried - // verbatim. Without it the tag comes back as a bare key and the person - // who named it "Migräne" gets `custom:cm3x9…` instead. + // The user's own words for the tag. Without it the tag comes back as a + // bare key and the person who named it "Migräne" gets `custom:cm3x9…` + // instead. Ciphertext on a disaster-recovery file, readable `label` on a + // portable one; the restore seals the readable value under its own key. labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), // The scale a RATED factor was recorded on. `inverse` marks a factor // where a HIGH value is bad (stress, conflict). Dropping it does not lose // a label, it silently reverses the meaning of every rating already @@ -752,7 +775,10 @@ const customCycleSymptomSchema = z sortOrder: z.number().int().default(0), isActive: z.boolean().default(true), // Same reasoning as the mood tag's: the user's own words live here. + // Ciphertext on a disaster-recovery file, readable `label` on a portable + // one (the day-log note's split). labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), }) .passthrough(); @@ -1181,8 +1207,9 @@ const symptomDefinitionBackupSchema = z * * `id` is required on both records, unlike the lab result above: a link * addresses an encounter and its far side by id and nothing else on either row - * is unique enough to rebuild the reference from. The ciphertext columns ride - * verbatim as base64 — a visit note is never decrypted into the file. + * is unique enough to rebuild the reference from. Free text follows the note + * contract: ciphertext as base64 on a disaster-recovery file, the readable + * value on a portable one (sealed under the receiving host's key on restore). */ const practitionerBackupSchema = z .object({ @@ -1196,6 +1223,7 @@ const practitionerBackupSchema = z locationEncrypted: base64BytesSchema.nullable().optional(), phoneEncrypted: base64BytesSchema.nullable().optional(), noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), @@ -1214,6 +1242,10 @@ const encounterBackupSchema = z // v1.39.1 — the procedure's body site and side. Optional so a file written // before they existed still parses; the visit restores with neither. bodySiteEncrypted: base64BytesSchema.nullable().optional(), + // The readable reason, outcome and body site of a portable file. + reason: z.string().nullable().optional(), + outcome: z.string().nullable().optional(), + bodySite: z.string().nullable().optional(), laterality: z.enum(Laterality).nullable().optional(), // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. @@ -1255,7 +1287,9 @@ const vaccinationBackupSchema = z // Remapped against the restored reminders (they travel since v1.37.20); // dropped to NULL, with the drop named, only when the file lacks the row. reminderId: z.string().nullable().optional(), + // Ciphertext on a disaster-recovery file, readable on a portable one. noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), createdAt: isoDateTime, updatedAt: isoDateTime, deletedAt: isoDateTime.nullable().optional(), @@ -1618,7 +1652,9 @@ const customMoodTagCategorySchema = z icon: z.string().nullable().optional(), sortOrder: z.number().int().optional(), isActive: z.boolean().optional(), + // Same split as the custom tag's label. labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), }) .passthrough(); diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index ee3015901..bb20699a6 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -391,8 +391,10 @@ describe("canonical disaster-recovery backup round-trip", () => { cervixFirmness: "FIRM", cervixOpening: "CLOSED", sexualActivity: false, - sensitiveEncrypted: "sensitive-cycle-dr", - notesEncrypted: "notes-cycle-dr", + // Real ciphertext: the restore writes a sealed value back only when + // this host opens it. + sensitiveEncrypted: encrypt(JSON.stringify({ sexualActivity: false })), + notesEncrypted: encrypt("notes-cycle-dr"), source: "APPLE_HEALTH", externalId: "cycle-day-external-dr", tz: "Europe/London", diff --git a/tests/integration/backup-cycle-cross-key-restore.test.ts b/tests/integration/backup-cycle-cross-key-restore.test.ts new file mode 100644 index 000000000..4d3f50aee --- /dev/null +++ b/tests/integration/backup-cycle-cross-key-restore.test.ts @@ -0,0 +1,313 @@ +/** + * A portable file's cycle free text survives a move to a host with other keys. + * + * The day-log note, the sensitive-category envelope and a custom symptom's + * label are sealed under the writing host's key. A portable file used to carry + * those three as the stored ciphertext, so a restore onto a host with a + * different key either refused the whole file at the key check or, with a + * matching key id over different key material, wrote rows nobody could open. + * + * Both ends and the pipe: the file is built by the real `buildFullBackupPayload` + * under key A, stored and restored through the real route under key B, and + * the restored rows are opened under key B. + * + * The second case is the file written before the fix, which still carries + * ciphertext. A value this host's keys open comes back; a value they cannot + * open is kept out of the row and named in the skip report, rather than + * written back as ciphertext no reader can open. + * + * Mutation: restore the verbatim copy of `notesEncrypted` in + * `restoreCycleData` and the legacy case writes the foreign ciphertext back. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +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), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const NOTE = "Cramps woke me at four"; +const SENSITIVE = { + sexualActivity: true, + protectedSex: false, + pregnancyTest: "NEGATIVE", + progesteroneTest: null, + contraceptive: "ORAL", +}; +const LABEL = "Lower back ache"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "cycle-cross-key-owner", + email: "cycle-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "CYCLE_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("cycle free text in a portable file restored under another key", () => { + it("comes back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + /* ── host A writes the account and exports it ─────────────────────── */ + useKeys(HOST_A, "a"); + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: "custom:cross-key-ache", + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt(LABEL), + }, + }); + await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + date: "2026-06-02", + flow: "MEDIUM", + notesEncrypted: encrypt(NOTE), + sensitiveEncrypted: encrypt(JSON.stringify(SENSITIVE)), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + /* ── host B, which has never held key A, restores it ──────────────── */ + useKeys(HOST_B, "b"); + // Host B is another database: the account's own symptom row from host A + // is not there (the restore upserts by key and keeps an existing row). + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } }); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const day = await prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: owner.id }, + }); + expect(day.flow).toBe("MEDIUM"); + expect(day.notesEncrypted).not.toBeNull(); + expect(decrypt(day.notesEncrypted!)).toBe(NOTE); + expect(day.sensitiveEncrypted).not.toBeNull(); + expect(JSON.parse(decrypt(day.sensitiveEncrypted!))).toEqual(SENSITIVE); + + const symptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: "custom:cross-key-ache" }, + }); + expect(decrypt(symptom.labelEncrypted!)).toBe(LABEL); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + // Written under host B's own key: opens here. + useKeys(HOST_B, "b"); + const ownNote = encrypt(NOTE); + // Written elsewhere: an id host B does not hold, and host B's id over + // different key material. + useKeys(HOST_A, "a"); + const foreignNote = encrypt("written under key a"); + const foreignLabel = encrypt("label under key a"); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorSensitive = encrypt(JSON.stringify(SENSITIVE)); + + // The shape a portable file had before the fix: every free-text field as + // the stored ciphertext, no plaintext beside it. + useKeys(HOST_B, "b"); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + file.customSymptoms = [ + { + key: "custom:legacy-foreign", + labelKey: "cycle.symptom.custom", + categoryId: category.id, + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: foreignLabel, + }, + ]; + const day = ( + date: string, + notes: string | null, + sensitive: string | null, + ) => ({ + date, + flow: "LIGHT", + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: sensitive, + notesEncrypted: notes, + source: "MANUAL", + externalId: null, + tz: null, + symptomKeys: [], + symptomSeverities: [], + }); + file.cycleDayLogs = [ + day("2026-06-01", ownNote, null), + day("2026-06-02", foreignNote, impostorSensitive), + ]; + // Still a file this release accepts. + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const days = await prisma.cycleDayLog.findMany({ + where: { userId: owner.id }, + orderBy: { date: "asc" }, + }); + expect(days.map((d) => d.date)).toEqual(["2026-06-01", "2026-06-02"]); + expect(decrypt(days[0].notesEncrypted!)).toBe(NOTE); + // The day itself comes back; only what cannot be opened stays out. + expect(days[1].flow).toBe("LIGHT"); + expect(days[1].notesEncrypted).toBeNull(); + expect(days[1].sensitiveEncrypted).toBeNull(); + + const symptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: "custom:legacy-foreign" }, + }); + expect(symptom.labelEncrypted).toBeNull(); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "cycleCiphertext", + key: "customSymptoms.custom:legacy-foreign.labelEncrypted", + links: 1, + }, + { + catalogue: "cycleCiphertext", + key: "cycleDayLogs.2026-06-02.notesEncrypted", + links: 1, + }, + { + catalogue: "cycleCiphertext", + key: "cycleDayLogs.2026-06-02.sensitiveEncrypted", + links: 1, + }, + ]); + expect(body.data.skipped.links).toBe(3); + }); +}); diff --git a/tests/integration/backup-mood-label-cross-key-restore.test.ts b/tests/integration/backup-mood-label-cross-key-restore.test.ts new file mode 100644 index 000000000..f1a0b6719 --- /dev/null +++ b/tests/integration/backup-mood-label-cross-key-restore.test.ts @@ -0,0 +1,251 @@ +/** + * A portable file's custom mood labels survive a move to a host with other + * keys. + * + * The labels of the mood tags and mood tag categories a person created are + * sealed under the writing host's key. A portable file used to carry them as + * the stored ciphertext, so a restore onto a host with a different key + * refused the file at the key check, or with a matching key id over other key + * material wrote labels nobody could open. + * + * Both ends and the pipe: built by the real `buildFullBackupPayload` under key + * A, restored through the real route under key B, opened under key B. The + * second case is a file written before the fix: a label this host opens comes + * back, one it cannot open stays out and is named in the skip report. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests, decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +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), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const CATEGORY_LABEL = "Evenings"; +const TAG_LABEL = "Migraine"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "mood-cross-key-owner", + email: "mood-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "MOOD_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("custom mood labels in a portable file restored under another key", () => { + // Neither case logs a mood entry: an account's own tags and categories + // travel whether or not it has entries (they used to be written only inside + // the restore's entries branch, and a vocabulary set up before the first + // entry did not come back). + it("come back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_A, "a"); + const category = await prisma.moodTagCategory.create({ + data: { + userId: owner.id, + key: "customcat:cross-key", + labelKey: "mood.category.custom", + labelEncrypted: encrypt(CATEGORY_LABEL), + }, + }); + await prisma.moodTag.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: "custom:cross-key", + labelKey: "mood.tag.custom", + kind: "BINARY", + labelEncrypted: encrypt(TAG_LABEL), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + // Host B is another database: the rows host A wrote are not there. + useKeys(HOST_B, "b"); + await prisma.moodTag.deleteMany({ where: { userId: owner.id } }); + await prisma.moodTagCategory.deleteMany({ where: { userId: owner.id } }); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const restoredCategory = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "customcat:cross-key" }, + }); + expect(decrypt(restoredCategory.labelEncrypted!)).toBe(CATEGORY_LABEL); + const restoredTag = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "custom:cross-key" }, + }); + expect(decrypt(restoredTag.labelEncrypted!)).toBe(TAG_LABEL); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's label ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_B, "b"); + const ownLabel = encrypt(TAG_LABEL); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorLabel = encrypt(CATEGORY_LABEL); + + // The shape a portable file had before the fix: labels as ciphertext. + useKeys(HOST_B, "b"); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + file.customMoodTagCategories = [ + { + id: "legacy-category-id", + key: "customcat:legacy", + labelKey: "mood.category.custom", + icon: null, + sortOrder: 0, + isActive: true, + labelEncrypted: impostorLabel, + }, + ]; + file.customMoodTags = [ + { + key: "custom:legacy", + labelKey: "mood.tag.custom", + categoryId: "legacy-category-id", + kind: "BINARY", + isActive: true, + icon: null, + sortOrder: 0, + labelEncrypted: ownLabel, + scaleMin: 1, + scaleMax: 5, + inverse: false, + }, + ]; + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const category = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "customcat:legacy" }, + }); + expect(category.labelEncrypted).toBeNull(); + const tag = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "custom:legacy" }, + }); + expect(decrypt(tag.labelEncrypted!)).toBe(TAG_LABEL); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "moodLabelCiphertext", + key: "customMoodTagCategories.customcat:legacy.labelEncrypted", + links: 1, + }, + ]); + }); +}); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2911cadfc..cd3018e99 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -77,6 +77,7 @@ import { } from "@/lib/labs/biomarker-store"; import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; +import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids"; import { TWO_ENDED_MODELS, USER_COLUMN_BACKUP_CLASS, @@ -3694,6 +3695,12 @@ function lostColumns( return { lost: lost.sort(), compared }; } +/** + * Sections a portable file may carry stored ciphertext in, each with the + * reason. Empty: every sealed column travels readable in a portable file. + */ +const PORTABLE_CIPHERTEXT_ALLOWED: ReadonlyMap = new Map(); + describe("every column of every two-ended model survives a real restore", () => { it("brings each column back with the value it left with", async () => { const prisma = getPrismaClient(); @@ -3758,6 +3765,58 @@ describe("every column of every two-ended model survives a real restore", () => }, { purpose: "disaster-recovery", exportedAt }, ); + + // ── No ciphertext in a portable file ────────────────────────────────── + // + // Every sealed column is filled at this point, so the portable file of + // this account is the one place a column still exported as stored + // ciphertext shows up. A portable file is read and restored on hosts + // with other keys; ciphertext in it is noise there, and a restore either + // refuses the file or writes values nobody can open. That class was + // fixed one section at a time (cycle, mood labels, visits, vaccinations) + // and kept coming back, because each builder decides its own encoding. + // This reads what the builders actually wrote, rather than how their + // source is spelt. Limit: it sees members named `…Encrypted` only, the + // naming the key preflight relies on too. + const sealedIn = (text: string) => { + const collector = new BackupKeyIdCollector(); + collector.visit(JSON.parse(text)); + const sections = new Map(); + for (const [, use] of collector.entries()) { + for (const section of use.sections) { + sections.set(section, (sections.get(section) ?? 0) + use.count); + } + } + return sections; + }; + // The floor: the same matcher over the disaster-recovery file of the same + // account, which carries every sealed column as ciphertext. A matcher + // that finds nothing there would make the portable check pass vacuously. + const drSealed = sealedIn(json); + expect( + [...drSealed.values()].reduce((a, b) => a + b, 0), + ).toBeGreaterThanOrEqual(40); + expect(drSealed.size).toBeGreaterThanOrEqual(15); + let portableJson = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + portableJson += chunk; + }, + { purpose: "portable-export", exportedAt }, + ); + const portableSealed = [...sealedIn(portableJson).keys()].filter( + (section) => !PORTABLE_CIPHERTEXT_ALLOWED.has(section), + ); + expect( + portableSealed, + "a portable file carries these sections' sealed columns as stored " + + "ciphertext. Carry them readable (`src/lib/export/sealed-text.ts`) " + + "and seal them again on restore, or name the section in " + + "PORTABLE_CIPHERTEXT_ALLOWED with the reason", + ).toEqual([]); + await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); const backup = await prisma.dataBackup.create({ diff --git a/tests/integration/backup-visits-cross-key-restore.test.ts b/tests/integration/backup-visits-cross-key-restore.test.ts new file mode 100644 index 000000000..234f6c136 --- /dev/null +++ b/tests/integration/backup-visits-cross-key-restore.test.ts @@ -0,0 +1,296 @@ +/** + * A portable file's visit and vaccination free text survives a move to a host + * with other keys. + * + * A practitioner's note, a visit's reason, outcome and body site and a + * vaccination's note are sealed under the writing host's key. A portable file + * used to carry them as the stored ciphertext, so a restore onto a host with + * a different key refused the file at the key check, or with a matching key id + * over other key material wrote values nobody could open. + * + * Both ends and the pipe: built by the real `buildFullBackupPayload` under key + * A, restored through the real route under key B, opened under key B. The + * second case is a file written before the fix: a value this host opens comes + * back, one it cannot open stays out and is named in the skip report. + */ +import { Buffer } from "node:buffer"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +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), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +const saved = { + key: process.env.ENCRYPTION_KEY, + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; + +/** Make this process the host holding exactly `keys`, writing under `active`. */ +function useKeys(keys: Record, active: string) { + process.env.ENCRYPTION_KEY = ""; + process.env.ENCRYPTION_KEYS = JSON.stringify(keys); + process.env.ENCRYPTION_ACTIVE_KEY_ID = active; + _resetCryptoCacheForTests(); +} + +const HOST_A = { a: "11".repeat(32) }; +const HOST_B = { b: "22".repeat(32) }; +/** Same key id as host B, different key material. */ +const HOST_B_IMPOSTOR = { b: "33".repeat(32) }; + +const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + process.env.ENCRYPTION_KEY = saved.key ?? ""; + process.env.ENCRYPTION_KEYS = saved.keys ?? ""; + process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? ""; + _resetCryptoCacheForTests(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +async function seedOwner() { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "visits-cross-key-owner", + email: "visits-cross-key-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner; +} + +/** Store `payload` as a backup on this host and run the real restore. */ +async function restore(ownerId: string, payload: unknown) { + const prisma = getPrismaClient(); + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "VISITS_CROSS_KEY_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + return POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); +} + +describe("visit and vaccination free text restored under another key", () => { + it("comes back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_A, "a"); + const practitioner = await prisma.practitioner.create({ + data: { + userId: owner.id, + name: "Practice", + noteEncrypted: encryptToBytes("ring the upper bell"), + locationEncrypted: encryptToBytes("Main street 1"), + }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-06-30T08:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + practitionerId: practitioner.id, + reasonEncrypted: encryptToBytes("annual check"), + outcomeEncrypted: encryptToBytes("all fine"), + bodySiteEncrypted: encryptToBytes("left knee"), + }, + }); + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: owner.id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + vaccineName: "Tetanus", + noteEncrypted: encryptToBytes("sore arm for a day"), + }, + }); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as unknown; + + useKeys(HOST_B, "b"); + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const p = await prisma.practitioner.findUniqueOrThrow({ + where: { id: practitioner.id }, + }); + expect(decryptFromBytes(p.noteEncrypted!)).toBe("ring the upper bell"); + expect(decryptFromBytes(p.locationEncrypted!)).toBe("Main street 1"); + const e = await prisma.encounter.findUniqueOrThrow({ + where: { id: encounter.id }, + }); + expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check"); + expect(decryptFromBytes(e.outcomeEncrypted!)).toBe("all fine"); + expect(decryptFromBytes(e.bodySiteEncrypted!)).toBe("left knee"); + const v = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: vaccination.id }, + }); + expect(decryptFromBytes(v.noteEncrypted!)).toBe("sore arm for a day"); + + const body = (await response.json()) as { + data: { skipped: { links: number } }; + }; + expect(body.data.skipped.links).toBe(0); + }); + + it("restores a legacy file's ciphertext only where this host can open it", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_B, "b"); + const ownReason = b64(encryptToBytes("annual check")); + useKeys(HOST_A, "a"); + const foreignNote = b64(encryptToBytes("written under key a")); + useKeys(HOST_B_IMPOSTOR, "b"); + const impostorOutcome = b64(encryptToBytes("same id, other key")); + const impostorVaccination = b64(encryptToBytes("same id, other key")); + + // The shape a portable file had before the fix: ciphertext, no text. + useKeys(HOST_B, "b"); + const { payload: built } = await buildFullBackupPayload(prisma, owner.id); + const file = JSON.parse(JSON.stringify(built)) as Record; + const at = "2026-06-30T08:00:00.000Z"; + file.practitioners = [ + { + id: "legacy-practitioner", + name: "Practice", + specialty: null, + practice: null, + location: null, + phone: null, + noteEncrypted: foreignNote, + createdAt: at, + updatedAt: at, + }, + ]; + file.encounters = [ + { + id: "legacy-encounter", + occurredAt: at, + status: "DONE", + kind: "ROUTINE", + practitionerId: "legacy-practitioner", + reasonEncrypted: ownReason, + outcomeEncrypted: impostorOutcome, + bodySiteEncrypted: null, + laterality: null, + reminderId: null, + createdAt: at, + updatedAt: at, + }, + ]; + file.vaccinations = [ + { + id: "legacy-vaccination", + occurredAt: at, + vaccineName: "Tetanus", + noteEncrypted: impostorVaccination, + createdAt: at, + updatedAt: at, + }, + ]; + parseBackupPayload(JSON.stringify(file)); + + const response = await restore(owner.id, file); + expect(response.status).toBe(200); + + const p = await prisma.practitioner.findUniqueOrThrow({ + where: { id: "legacy-practitioner" }, + }); + expect(p.noteEncrypted).toBeNull(); + const e = await prisma.encounter.findUniqueOrThrow({ + where: { id: "legacy-encounter" }, + }); + expect(decryptFromBytes(e.reasonEncrypted!)).toBe("annual check"); + expect(e.outcomeEncrypted).toBeNull(); + const v = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id: "legacy-vaccination" }, + }); + expect(v.vaccineName).toBe("Tetanus"); + expect(v.noteEncrypted).toBeNull(); + + const body = (await response.json()) as { + data: { + skipped: { + links: number; + catalogueKeys: Array<{ + catalogue: string; + key: string; + links: number; + }>; + }; + }; + }; + expect(body.data.skipped.catalogueKeys).toEqual([ + { + catalogue: "vaccinationCiphertext", + key: "vaccinations.legacy-vaccination.noteEncrypted", + links: 1, + }, + { + catalogue: "visitCiphertext", + key: "encounters.legacy-encounter.outcomeEncrypted", + links: 1, + }, + { + catalogue: "visitCiphertext", + key: "practitioners.legacy-practitioner.noteEncrypted", + links: 1, + }, + ]); + }); +}); diff --git a/tests/integration/cycle-import-export.test.ts b/tests/integration/cycle-import-export.test.ts index 73eed32e2..a6f3014b3 100644 --- a/tests/integration/cycle-import-export.test.ts +++ b/tests/integration/cycle-import-export.test.ts @@ -6,8 +6,8 @@ * into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled, * merging same-day samples into one row with first-write-wins re-import * - the full-backup cycle section round-trips through - * `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted` - * preserved verbatim and symptom links re-resolved by key + * `buildCycleBackupSection` → `restoreCycleData` with the note carried + * readable, re-sealed on restore, and symptom links re-resolved by key */ import { writeFileSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export"; import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup"; +import { decrypt, encrypt } from "@/lib/crypto"; import type { RestoreSkipLog } from "@/lib/export/restore-skips"; import { parseBackupPayload } from "@/lib/validations/backup"; @@ -163,7 +164,7 @@ describe("cycle import — reproductive HK samples route to CycleDayLog", () => }); describe("cycle backup — round-trips through build + restore", () => { - it("preserves notesEncrypted ciphertext and symptom links by key", async () => { + it("carries the note readable and re-seals it, with symptom links by key", async () => { const prisma = getPrismaClient(); const cycle = await prisma.menstrualCycle.create({ @@ -184,7 +185,7 @@ describe("cycle backup — round-trips through build + restore", () => { cervixPosition: "HIGH", cervixFirmness: "SOFT", cervixOpening: "OPEN", - notesEncrypted: "v1:abc123:cipher-envelope", + notesEncrypted: encrypt("Cramps in the evening"), source: "MANUAL", }, }); @@ -207,9 +208,9 @@ describe("cycle backup — round-trips through build + restore", () => { const section = await buildCycleBackupSection(prisma, USER_ID); expect(section.cycles).toHaveLength(1); expect(section.cycleDayLogs).toHaveLength(1); - expect(section.cycleDayLogs[0].notesEncrypted).toBe( - "v1:abc123:cipher-envelope", - ); + // A portable section carries the note readable, never the ciphertext. + expect(section.cycleDayLogs[0].note).toBe("Cramps in the evening"); + expect(section.cycleDayLogs[0]).not.toHaveProperty("notesEncrypted"); expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]); const payload = parseBackupPayload({ @@ -234,7 +235,7 @@ describe("cycle backup — round-trips through build + restore", () => { include: { symptomLinks: { include: { symptom: true } } }, }); expect(restored).toHaveLength(1); - expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope"); + expect(decrypt(restored[0].notesEncrypted!)).toBe("Cramps in the evening"); expect(restored[0].cervixPosition).toBe("HIGH"); expect(restored[0].cervixFirmness).toBe("SOFT"); expect(restored[0].cervixOpening).toBe("OPEN"); diff --git a/tests/integration/encounter-procedures.test.ts b/tests/integration/encounter-procedures.test.ts index 31198c652..036aa2416 100644 --- a/tests/integration/encounter-procedures.test.ts +++ b/tests/integration/encounter-procedures.test.ts @@ -343,7 +343,10 @@ describe("the backup", () => { const entry = section.encounters.find((row) => row.id === created.id); expect(entry?.kind).toBe("PROCEDURE"); expect(entry?.laterality).toBe("BOTH"); - expect(entry?.bodySiteEncrypted).toEqual(expect.any(String)); + // A portable section carries the site readable, never the ciphertext; the + // restore seals it under this host's key. + expect(entry?.bodySite).toBe("Hüfte"); + expect(entry).not.toHaveProperty("bodySiteEncrypted"); // Through the file schema, as a restore reads it: a field the schema does // not know would be dropped here and come back as null. diff --git a/tests/integration/medication-put-course-race.test.ts b/tests/integration/medication-put-course-race.test.ts new file mode 100644 index 000000000..5b6410394 --- /dev/null +++ b/tests/integration/medication-put-course-race.test.ts @@ -0,0 +1,152 @@ +/** + * A medication PUT the course writer refuses changes nothing. + * + * The PUT checks the window against the other courses first, then writes the + * row, then writes the course. A course written by another request between + * the check and the course write makes the course write refuse. If the row + * write had already committed, the client got an error while the name, the + * schedule and `oneShot` were changed anyway: a one-time medication with a + * window that spans more than its one day. + * + * The race is reproduced by letting the up-front check pass (it is the check + * the other request outran), so the real course writer meets the conflicting + * courses and refuses, through the shipped route and a real Postgres. + * + * Mutation: move the row update back out of the transaction the course write + * runs in, and the name and `oneShot` assertions turn red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-put-race"; + +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), +})); + +// The check the concurrent request outran: it saw no conflict. +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + checkCurrentWindow: vi.fn(async () => null), +})); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: OWNER, + username: OWNER, + email: `${OWNER}@example.test`, + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +describe("a medication PUT refused by the course writer", () => { + it("leaves the medication row, its schedule and its courses as they were", async () => { + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/medications/route"); + const created = await POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + startsOn: "2026-03-01", + endsOn: "2026-03-07", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(created.status).toBe(201); + const { id } = (await created.json()).data as { id: string }; + + // The course the other request wrote. + const { POST: postCourse } = + await import("@/app/api/medications/[id]/courses/route"); + const course = await postCourse( + req("POST", `/api/medications/${id}/courses`, { + startsOn: "2026-06-10", + endsOn: "2026-06-16", + }), + { params: Promise.resolve({ id }) }, + ); + expect(course.status).toBe(201); + + const before = await prisma.medication.findUniqueOrThrow({ + where: { id }, + include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } }, + }); + const revisionsBefore = await prisma.medicationScheduleRevision.count({ + where: { medicationId: id }, + }); + + // A one-time medication cannot carry two courses: the writer refuses. + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${id}`, { + name: "Oseltamivir", + oneShot: true, + startsOn: "2026-03-05", + schedules: [{ windowStart: "20:00", windowEnd: "21:00" }], + }), + { params: Promise.resolve({ id }) }, + ); + expect(res.status).toBe(422); + expect((await res.json()).meta.errorCode).toMatch(/^medications\.course\./); + + const after = await prisma.medication.findUniqueOrThrow({ + where: { id }, + include: { schedules: true, courses: { orderBy: { startsOn: "asc" } } }, + }); + expect(after.name).toBe("Tamiflu"); + expect(after.oneShot).toBe(false); + expect(after.startsOn).toEqual(before.startsOn); + expect(after.endsOn).toEqual(before.endsOn); + expect(after.updatedAt).toEqual(before.updatedAt); + expect(after.schedules.map((s) => s.id)).toEqual( + before.schedules.map((s) => s.id), + ); + expect(after.schedules.map((s) => s.windowStart)).toEqual(["08:00"]); + expect(after.courses.map((c) => [c.startsOn, c.endsOn])).toEqual( + before.courses.map((c) => [c.startsOn, c.endsOn]), + ); + expect( + await prisma.medicationScheduleRevision.count({ + where: { medicationId: id }, + }), + ).toBe(revisionsBefore); + }); +});