diff --git a/CHANGELOG.md b/CHANGELOG.md index addeeb34e..d0fda05cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ ## [Unreleased] +## [1.33.2] — 2026-07-28 + +### The cycle-symptom fix in v1.33.1 did not work + +v1.33.1 said it had fixed custom cycle symptoms in backups. It had not. The +fix was real in three places and missing from the one that decides what ends +up in the file. The code that reads your cycle data returned the symptoms you +created yourself. The file format declared a field to hold them. The restore +knew how to put them back. But the step that assembles the file copies its +contents across one field at a time, and that list was never extended, so +every backup written since carried an empty list of custom symptoms. + +The same release also replaced a silent drop with an error. Before it, a +backup referencing a symptom the restoring instance did not have lost that +one link and finished. After it, the restore stops and reports a failure. +Together with the empty list, that took an account with a symptom of its own +from losing a single link to being unable to restore at all. That is the +group the fix was written for. + +Nothing in your stored data was damaged. But a backup written by v1.33.1 or +earlier does not hold the symptom definitions you created, and restoring one +of those files on this version will fail at the first day-log referencing +one. Take a fresh backup after updating and keep it alongside the older +files. + +The assembly step no longer lists fields by hand. It copies each section +whole, so a field added to a section reaches the file because of the way the +code is written rather than because somebody remembered. Both ends of this +were covered by tests that passed for the whole time it was broken, and +nothing covered the join between them, so there is now a test that writes a +real backup for an account with a symptom of its own, removes that symptom +from the database, restores the file, and checks the row and its day-log link +came back. + ## [1.33.1] — 2026-07-27 A patch release about the same thing as the one before it: places where the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 58dc9d0be..994139e02 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.33.1 + version: 1.33.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 6cca5848c..5115fa8d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.33.1", + "version": "1.33.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 8d6d19c22..e97634d40 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.33.1"; + /* @sw-version-fallback */ "v1.33.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 691a65875..ce511a02c 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -108,6 +108,16 @@ export interface CycleBackupSection { icon: string | null; sortOrder: number; isActive: boolean; + /** + * The user's own words for the symptom, carried as ciphertext verbatim. + * + * 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. + */ + labelEncrypted: string | null; }>; } diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 20a5af75c..f8bbe25a9 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -15,11 +15,15 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; +import { + buildCycleBackupSection, + type CycleBackupSection, +} from "@/lib/cycle/backup"; import { buildRecordsBackupSection, countRecordsBackupSection, type RecordsBackupCounts, + type RecordsBackupSection, } from "@/lib/export/records-backup"; export interface FullBackupCounts extends RecordsBackupCounts { @@ -210,6 +214,11 @@ export async function buildFullBackupPayload( }), ]); + // Pinned to the section interfaces so the spreads below cannot quietly widen + // (or narrow) what reaches the wire. + const cycleSection: CycleBackupSection = cycle; + const recordsSection: RecordsBackupSection = records; + const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), @@ -335,20 +344,24 @@ export async function buildFullBackupPayload( scaleMax: tag.scaleMax, inverse: tag.inverse, })), - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - // v1.28 backup-completeness — the domains the pre-existing shape never - // covered. `manifest` discloses the two deliberate exclusions (document - // binaries, workout GPS/sample time series) inline in the file itself, - // not just in the export UI copy. - labResults: records.labResults, - biomarkers: records.biomarkers, - illnessEpisodes: records.illnessEpisodes, - allergies: records.allergies, - familyHistory: records.familyHistory, - workouts: records.workouts, - documents: records.documents, + // Both sections are SPREAD, not hand-picked key by key. + // + // Hand-picking is what lost the custom cycle symptoms. `buildCycleBackupSection` + // returned them, the wire schema declared them, `restoreCycleData` read them + // and — since v1.33.1 — THREW on a key it could not resolve. The list of keys + // copied out here was simply never extended, so every backup written carried + // `customSymptoms: []`. An account with one custom symptom did not get the + // silent drop that release removed; it got a restore that failed outright on + // a key nothing had written back, and the fix was inert in production. + // + // Spreading makes the section interface the single declaration of what rides + // the wire: a field added to `CycleBackupSection` / `RecordsBackupSection` + // reaches the payload by construction rather than by someone remembering. + // `manifest` (from the records section) discloses the two deliberate + // exclusions — document binaries, workout GPS/sample series — in the file + // itself, not just in the export UI copy. + ...cycleSection, + ...recordsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -356,7 +369,6 @@ export async function buildFullBackupPayload( unit: n.unit, source: n.source, })), - manifest: records.manifest, }; return { diff --git a/tests/integration/backup-custom-cycle-symptom-restore.test.ts b/tests/integration/backup-custom-cycle-symptom-restore.test.ts new file mode 100644 index 000000000..be4709966 --- /dev/null +++ b/tests/integration/backup-custom-cycle-symptom-restore.test.ts @@ -0,0 +1,214 @@ +/** + * The seam between the backup writer and the backup reader. + * + * Custom cycle symptoms had a green test on every end and shipped broken + * anyway. `buildCycleBackupSection` returned them, `backupPayloadSchema` + * declared them, `restoreCycleData` read them, and each of those three was + * covered. What nothing covered was the assembly in between: the payload was + * built as an object literal that copied section keys out one at a time, and + * `customSymptoms` was never on that list. Every backup written carried an + * empty array, so an account whose day-log referenced a symptom it had created + * itself could not be restored at all — the restore threw on a key nothing had + * written back and the route answered 500. + * + * So this test asserts on ROWS, never on the payload object. Asserting the + * payload carries `customSymptoms` would re-test the writer, which was already + * passing while production was broken. The account is populated, the REAL + * `buildFullBackupPayload` produces the file, the file is serialised and parsed + * back through the REAL `parseBackupPayload`, the account's own symptom row is + * removed so the file is the only place it survives, and the REAL admin restore + * has to put the row and its day-log link back. + * + * Removing the symptom row before restoring is the point, not a contrivance: + * `restoreCycleData` wipes day-logs, cycles, and the profile, but never the + * symptom catalogue. Restore onto the same instance and the row is still + * sitting there to resolve against, which is exactly why the older round-trip + * test stayed green. The file has to carry the symptom for the case the + * `customSymptoms` field exists for: a restore onto an instance that never had + * it. + * + * Mutation check: hand-pick the cycle section's keys in + * `buildFullBackupPayload` instead of spreading it, and this test reproduces + * the production failure — restore answers 500 on + * `Unknown cycle symptom keys: custom:sacroiliac-ache`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +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(), +})); + +/** The user's own words for the symptom, as they are held at rest. */ +const SYMPTOM_LABEL = "Sacroiliac ache"; +const SYMPTOM_KEY = "custom:sacroiliac-ache"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +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" }), + }); +} + +describe("a backup of an account with its own cycle symptom", () => { + it("restores the symptom and its day-log link onto an instance without it", async () => { + const prisma = getPrismaClient(); + + // The restore writes into the backup's owner, and the route refuses a + // payload whose declared owner differs from the backup row. Owner and + // operator are the same account here so the fixture stays about the + // symptom rather than about the ownership guard. + const owner = await prisma.user.create({ + data: { + username: "cycle-symptom-owner", + email: "cycle-symptom-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); + + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const symptom = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: SYMPTOM_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt(SYMPTOM_LABEL), + sortOrder: 3, + }, + }); + await prisma.cycleProfile.create({ + data: { + userId: owner.id, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 29, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-03", + flow: "MEDIUM", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { create: { symptomId: symptom.id } }, + }, + }); + + // The real writer, the real wire format, the real reader. Nothing is + // asserted about `built` — the writer was green through the whole defect. + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "CUSTOM_CYCLE_SYMPTOM_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + // Model the instance the `customSymptoms` field exists for: one that never + // had this account's own symptom. The restore clears day-logs, cycles, and + // the profile itself, but never the symptom catalogue, so the row has to go + // before the restore runs or the file is not what resolves the link. + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleProfile.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } }); + expect( + await prisma.cycleSymptom.count({ where: { key: SYMPTOM_KEY } }), + ).toBe(0); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(200); + + // Rows, not payload keys. + const restoredSymptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: SYMPTOM_KEY }, + }); + expect(restoredSymptom).toEqual( + expect.objectContaining({ + userId: owner.id, + categoryId: category.id, + labelKey: "cycle.symptom.custom", + sortOrder: 3, + isActive: true, + }), + ); + // The label is ciphertext on the wire and stays readable after the trip: + // a restored symptom whose label came back empty is the same lost data in + // a quieter form. + expect(decrypt(restoredSymptom.labelEncrypted!)).toBe(SYMPTOM_LABEL); + + const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: dayLog.id }, + }); + expect(restoredDayLog.date).toBe("2026-07-03"); + expect( + await prisma.cycleSymptomLink.findMany({ + where: { dayLogId: restoredDayLog.id }, + select: { symptomId: true }, + }), + ).toEqual([{ symptomId: restoredSymptom.id }]); + }); +});