From aa56246ba2e9781e71bf741e6911bb2a8b4710fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 02:52:41 +0200 Subject: [PATCH] fix(backup): the restore states a level-A value only when the file has one Matches what syncVersion, syncedAt and the row stamps already do a few lines above: a key the file does not carry is a key the file predates, not a key the file says is empty. Reachable when one file addresses the same row twice, where the second entry now adds without erasing what the first one set; an explicit null still clears. Worth stating plainly, because the audit that asked for this expected it to protect live rows from an older file: it does not, and it cannot. The restore deletes the owner's whole mood partition before it rebuilds, so by the time the update arm runs there is nothing live left to protect. A test now pins that contract too, so nobody reads the omission as a guard it is not. --- .../api/admin/backups/[id]/restore/route.ts | 22 +-- .../mood-backup-round-trip.test.ts | 140 ++++++++++++++++++ 2 files changed, 153 insertions(+), 9 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index a6d393133..7a3956b06 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -774,15 +774,19 @@ const handler = apiHandler( date: entry.date, mood: entry.mood, score: entry.score, - // The five level-A values. `?? null` rather than a derivation: - // a file that carries none is a file from before they existed, - // and filling them in here would put a number the person never - // gave into a restored row and call it their answer. - moodA1: entry.a1 ?? null, - stressA2: entry.a2 ?? null, - energyA3: entry.a3 ?? null, - connectionA4: entry.a4 ?? null, - stabilityA5: entry.a5 ?? null, + // The five level-A values, carried the same way `syncVersion` + // is: absent in the file means the file predates the columns, + // and an upsert whose update arm stated them would wipe live + // answers off every matched row when an older backup is + // restored over a going concern. A file that has them writes + // them, including an explicit null; a file that does not says + // nothing. Never derived — filling one in here would put a + // number the person never gave into a restored row. + ...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }), + ...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }), + ...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }), + ...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }), + ...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }), tags: entry.tags ?? null, // The note comes back the way every other dual-column note // does: ciphertext verbatim when the file carries it, and a diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 677b40f6d..71cbe1236 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -318,6 +318,146 @@ describe("mood backup round trip", () => { expect(byKey.get("rt_sleep_quality")!.rating).toBe(4); }); + it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + + // A row as it stands today, with answered sliders. + await prisma.moodEntry.create({ + data: { + id: "mood-rt-live", + userId: OWNER_ID, + date: "2026-07-19", + mood: "OKAY", + score: 3, + moodA1: 4, + stressA2: 9, + source: "MANUAL", + moodLoggedAt: LOGGED_AT, + }, + }); + + // Restoring a file written before these columns existed. The restore + // deletes the owner's mood partition and rebuilds it from the file, so + // the row that comes back is the file's row and carries nothing the file + // did not hold. This is the disaster-recovery contract and it is asserted + // here so nobody reads the omit-on-update below as protecting a live row: + // by the time that arm runs there are no live rows left. + const legacyPayload = { + schemaVersion: "2", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-live", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + factors: [], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(legacyPayload), + "MOOD_LEVEL_A_WIPE", + ); + expect(response.status).toBe(200); + + const after = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-live" }, + }); + // Not 4 and not 9: the file said nothing about them and the file is what + // was restored. Nothing was derived to fill the gap either. + expect(after.moodA1).toBeNull(); + expect(after.stressA2).toBeNull(); + }); + + it("lets a second entry for the same row add without erasing", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await seedOwner(prisma); + + // Two entries in ONE file addressing the same row. The first creates it, + // the second lands on the upsert's update arm — the only way that arm is + // reachable, since the partition was wiped a moment earlier. A second + // entry that mentions nothing about a dimension must not blank what the + // first one set, and one that mentions it wins, including a null. + const payload = { + schemaVersion: "2", + exportedAt: "2026-08-08T00:00:00.000Z", + userId: OWNER_ID, + appSettings: null, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: "mood-rt-collide", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + a1: 4, + a2: 9, + a3: 6, + factors: [], + }, + { + id: "mood-rt-collide", + date: "2026-07-19", + mood: "GUT", + score: 4, + tags: null, + source: "MANUAL", + loggedAt: LOGGED_AT.toISOString(), + externalId: null, + deletedAt: null, + a1: 7, + a3: null, + factors: [], + }, + ], + customMoodTags: [], + nutrientDays: [], + }; + + const response = await restoreFromPayload( + prisma, + parseBackupPayload(payload), + "MOOD_LEVEL_A_COLLIDE", + ); + expect(response.status).toBe(200); + + const after = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: "mood-rt-collide" }, + }); + expect(after.mood).toBe("GUT"); + // Stated by the second entry. + expect(after.moodA1).toBe(7); + // Not mentioned by the second entry: the first entry's answer stands. + expect(after.stressA2).toBe(9); + // Stated as null by the second entry: cleared. + expect(after.energyA3).toBeNull(); + }); + it("restores a file written in the shape that predates these fields", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma);