diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ffdc1ead6..9f672d83b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36796,6 +36796,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - id - key @@ -36853,6 +36857,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" scaleMin: default: 1 type: integer diff --git a/messages/de.json b/messages/de.json index 5390c5fb4..d50600800 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7555,7 +7555,8 @@ "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)", - "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie 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)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index dd65185b3..b2a3d92c6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7555,7 +7555,8 @@ "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)", - "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)" + "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)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 8ec71bc7a..daf60d074 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7555,7 +7555,8 @@ "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)", - "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)" + "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)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index e9d1163a5..29378a5a1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7555,7 +7555,8 @@ "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)", - "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)" + "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)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index d1538b30b..00a8957cf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7555,7 +7555,8 @@ "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)", - "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)" + "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)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index a61e278d9..7a1991f25 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7534,7 +7534,8 @@ "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", - "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)" + "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", + "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 6a51e0eeb..d8cbfe8b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7555,7 +7555,8 @@ "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)", - "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)" + "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)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 9a295f8a3..96f34bdd9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,9 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "moodLabelCiphertext") { + return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); + } if (catalogue === "cycleCiphertext") { return t("admin.section.backups.restoreSkippedCycleCiphertext"); } diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 4862a41a3..c04859d87 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -40,9 +40,12 @@ import type { PrismaClient, SecondarySymptom, } from "@/generated/prisma/client"; -import { decrypt, encrypt } from "@/lib/crypto"; +import { + openSealedForExport, + sealForRestore, + UNOPENED, +} from "@/lib/export/sealed-text"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; -import { getEvent } from "@/lib/logging/context"; import type { BackupPayload } from "@/lib/validations/backup"; import { recordUnknownKeys, @@ -189,26 +192,12 @@ export interface CycleSensitiveFields { contraceptive?: string | null; } -/** Open a sealed value for a portable file, or the unreadable marker. */ -function openForExport(sealed: string, field: string): string { - try { - return decrypt(sealed); - } catch (err) { - getEvent()?.addWarning( - `cycle ${field} decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return UNREADABLE_EXPORT_MARKER; - } -} - /** 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 = openForExport(sealed, "sensitive envelope"); + const opened = openSealedForExport(sealed, "cycle sensitive envelope"); if (opened === UNREADABLE_EXPORT_MARKER) return opened; try { return JSON.parse(opened) as CycleSensitiveFields; @@ -330,7 +319,7 @@ export async function buildCycleBackupSection( : { sensitive: openSensitiveForExport(d.sensitiveEncrypted), note: d.notesEncrypted - ? openForExport(d.notesEncrypted, "day-log note") + ? openSealedForExport(d.notesEncrypted, "cycle day-log note") : null, }), source: d.source, @@ -353,7 +342,7 @@ export async function buildCycleBackupSection( ? { labelEncrypted: sym.labelEncrypted } : { label: sym.labelEncrypted - ? openForExport(sym.labelEncrypted, "symptom label") + ? openSealedForExport(sym.labelEncrypted, "cycle symptom label") : null, }), })), @@ -447,39 +436,6 @@ function severityOrNull(value: number | null | undefined): number | null { : null; } -/** A readable value the file could not supply: the writer could not open it. */ -const UNOPENED = 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`. - */ -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; -} - function enumOrNull( value: string | null | undefined, allow: ReadonlySet, diff --git a/src/lib/export/backup-key-ids.ts b/src/lib/export/backup-key-ids.ts index aead1cb94..c4113125e 100644 --- a/src/lib/export/backup-key-ids.ts +++ b/src/lib/export/backup-key-ids.ts @@ -101,8 +101,9 @@ export function isEncryptedMember(name: string): boolean { * 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 and custom cycle symptoms are here because a portable - * file written before v1.40 carried their free text as the stored ciphertext, + * The cycle day-logs, custom cycle symptoms and custom mood tags and mood + * categories 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 @@ -114,6 +115,8 @@ export function isEncryptedMember(name: string): boolean { export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet = new Set([ "cycleDayLogs", "customSymptoms", + "customMoodTags", + "customMoodTagCategories", ]); interface KeyUse { 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..edf38d815 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"; @@ -1533,6 +1534,8 @@ export async function restoreBackup( // 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 }, @@ -1544,7 +1547,12 @@ export async function restoreBackup( icon: category.icon ?? null, sortOrder: category.sortOrder ?? 0, isActive: category.isActive ?? true, - labelEncrypted: category.labelEncrypted ?? null, + labelEncrypted: sealForRestore( + category.labelEncrypted, + category.label, + `customMoodTagCategories.${category.key}.labelEncrypted`, + unopenedMoodLabels, + ), }, update: {}, }); @@ -1567,7 +1575,12 @@ export async function restoreBackup( isActive: tag.isActive, icon: tag.icon ?? null, sortOrder: tag.sortOrder, - labelEncrypted: tag.labelEncrypted ?? null, + labelEncrypted: sealForRestore( + tag.labelEncrypted, + tag.label, + `customMoodTags.${tag.key}.labelEncrypted`, + unopenedMoodLabels, + ), scaleMin: tag.scaleMin, scaleMax: tag.scaleMax, inverse: tag.inverse, @@ -1577,6 +1590,12 @@ export async function restoreBackup( 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 diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 2b2ca30fc..e292b78e4 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -160,6 +160,11 @@ * 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. */ export type SkippedCatalogue = | "cycleSymptom" @@ -181,7 +186,8 @@ export type SkippedCatalogue = | "checkupClosure" | "accountSetting" | "symptomEpisodeReference" - | "cycleCiphertext"; + | "cycleCiphertext" + | "moodLabelCiphertext"; /** 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..c1e28a3c7 --- /dev/null +++ b/src/lib/export/sealed-text.ts @@ -0,0 +1,70 @@ +/** + * Both ends of a sealed text column stored with the string codec + * (`.`), for the backup sections that carry one: the cycle + * day-log note and sensitive envelope, custom cycle symptom labels, and the + * custom mood tag and mood category labels. + * + * 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 { 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; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ad0334bcc..a96fa893a 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -717,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 @@ -1642,7 +1644,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/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..e2bb51883 --- /dev/null +++ b/tests/integration/backup-mood-label-cross-key-restore.test.ts @@ -0,0 +1,266 @@ +/** + * 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 }), + }); +} + +/** + * One mood entry. The restore writes the account's own tags and categories + * inside its mood-entry branch, so a file without entries never reaches them. + */ +async function seedMoodEntry(userId: string) { + await getPrismaClient().moodEntry.create({ + data: { + userId, + date: "2026-07-01", + mood: "GUT", + score: 4, + source: "MOODLOG", + moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), + }, + }); +} + +describe("custom mood labels in a portable file restored under another key", () => { + it("come back readable under the receiving host's key", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner(); + + useKeys(HOST_A, "a"); + await seedMoodEntry(owner.id); + 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"); + await seedMoodEntry(owner.id); + 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, + }, + ]); + }); +});