mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(backup): carry a not-yet-migrated title or note as stored
Sealing a readable Coach title or custom-metric note while building a disaster-recovery file gave each export a fresh IV, so the streaming writer and the materialising builder no longer produced the same file. A row the free-text backfill has not reached yet now rides as stored, readable, inside the encrypted backup; the restore seals it, and a migrated row carries its ciphertext verbatim as before.
This commit is contained in:
1 parent
1a674cf4b0
commit
46eb8c8b94
4 files changed
+51
-43
No files matched your search
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* v1.39.3 — the Coach conversation title in both backup formats.
|
||||
*
|
||||
* A disaster-recovery file carries the title as ciphertext only, including for
|
||||
* a row the free-text backfill has not reached yet (it is sealed on the way
|
||||
* out). A portable file carries it readable, for the person who owns it. A
|
||||
* restore of either writes the ciphertext column and never the readable one,
|
||||
* and a file written before this release (readable title only) still restores.
|
||||
* A disaster-recovery file carries the title as stored: the ciphertext, or
|
||||
* for a row the free-text backfill has not reached yet the old readable value
|
||||
* (sealing it on the way out would make the export non-deterministic). A
|
||||
* portable file carries it readable, for the person who owns it. A restore of
|
||||
* any of these writes the ciphertext column and never the readable one, and a
|
||||
* file written before this release (readable title only) still restores.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -50,19 +51,24 @@ const rows = [
|
||||
];
|
||||
|
||||
describe("coach backup title", () => {
|
||||
it("a disaster-recovery file carries only ciphertext, sealing legacy rows on the way out", async () => {
|
||||
it("a disaster-recovery file carries the title as stored", async () => {
|
||||
const section = await buildCoachBackupSection(prismaWith(rows), "u1", {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const json = JSON.stringify(section);
|
||||
expect(json).not.toContain("Sealed title");
|
||||
expect(json).not.toContain("Legacy readable title");
|
||||
const [sealed, legacy] = section.coachConversations;
|
||||
expect(JSON.stringify(sealed)).not.toContain("Sealed title");
|
||||
expect(sealed).not.toHaveProperty("title");
|
||||
expect(
|
||||
section.coachConversations.map((c) => {
|
||||
expect(c).not.toHaveProperty("title");
|
||||
return decryptFromBytes(Buffer.from(c.titleEncrypted!, "base64"));
|
||||
}),
|
||||
).toEqual(["Sealed title", "Legacy readable title"]);
|
||||
decryptFromBytes(Buffer.from(sealed.titleEncrypted!, "base64")),
|
||||
).toBe("Sealed title");
|
||||
expect(legacy).toMatchObject({ title: "Legacy readable title" });
|
||||
expect(legacy).not.toHaveProperty("titleEncrypted");
|
||||
// The same input exports byte-identically twice: nothing is sealed with
|
||||
// a fresh IV on the way out.
|
||||
const again = await buildCoachBackupSection(prismaWith(rows), "u1", {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
expect(JSON.stringify(again)).toBe(JSON.stringify(section));
|
||||
});
|
||||
|
||||
it("a portable file carries the readable title and no ciphertext", async () => {
|
||||
|
||||
@@ -1286,22 +1286,14 @@ describe("buildFullBackupPayload — profile and custom metrics", () => {
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-07-19T06:30:00.000Z",
|
||||
// v1.39.3 — a DR file carries the note sealed, never readable,
|
||||
// including for a row the backfill has not reached yet.
|
||||
note: null,
|
||||
noteEncrypted: expect.any(String),
|
||||
// v1.39.3 — the fixture row predates the encryption backfill:
|
||||
// a DR file carries it as stored, and the restore seals it.
|
||||
note: "felt strong",
|
||||
noteEncrypted: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const [sealedMetric] = payload.customMetrics as Array<{
|
||||
entries: Array<{ noteEncrypted: string }>;
|
||||
}>;
|
||||
expect(
|
||||
decryptFromBytes(
|
||||
Buffer.from(sealedMetric.entries[0].noteEncrypted, "base64"),
|
||||
),
|
||||
).toBe("felt strong");
|
||||
expect(counts.customMetrics).toBe(1);
|
||||
expect(counts.customMetricEntries).toBe(1);
|
||||
expect(payload.correlationPatterns).toEqual([
|
||||
|
||||
@@ -93,8 +93,9 @@ export interface CoachConversationBackupEntry {
|
||||
* and reminders all address a conversation by it. */
|
||||
id: string;
|
||||
/**
|
||||
* The readable title. Present on a portable payload only (and on every file
|
||||
* written before v1.39.3, when the title was stored readable).
|
||||
* The readable title. Present on a portable payload, on every file written
|
||||
* before v1.39.3, and on a disaster-recovery payload for a row the free-text
|
||||
* backfill had not reached yet.
|
||||
*/
|
||||
title?: string;
|
||||
/** v1.39.3 — the title's ciphertext as base64. Disaster-recovery only. */
|
||||
@@ -208,15 +209,18 @@ export async function buildCoachBackupSection(
|
||||
return {
|
||||
coachConversations: rows.map((row) => ({
|
||||
id: row.id,
|
||||
...(disasterRecovery
|
||||
// A disaster-recovery file carries the stored bytes: the ciphertext,
|
||||
// or, for a row the free-text backfill has not reached yet, the old
|
||||
// readable title exactly as stored (the restore seals it). Sealing it
|
||||
// here instead would make the export non-deterministic, and the
|
||||
// streaming writer and this builder must produce the same file.
|
||||
...(disasterRecovery && row.titleEncrypted
|
||||
? {
|
||||
// A row the backfill has not reached yet is sealed here, so a DR
|
||||
// file never carries a title readable.
|
||||
titleEncrypted: Buffer.from(
|
||||
row.titleEncrypted ?? encryptToBytes(row.title ?? ""),
|
||||
).toString("base64"),
|
||||
titleEncrypted: Buffer.from(row.titleEncrypted).toString("base64"),
|
||||
}
|
||||
: { title: readTitleSoft(row) }),
|
||||
: disasterRecovery
|
||||
? { title: row.title ?? "" }
|
||||
: { title: readTitleSoft(row) }),
|
||||
documentScoped: row.documentScoped,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
|
||||
@@ -117,7 +117,10 @@ export interface CustomMetricEntryBackupEntry {
|
||||
/** Unit snapshot taken from the metric at write time, not a live join. */
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
/** Portable exports carry the readable note; DR payloads leave this null. */
|
||||
/**
|
||||
* The readable note: portable exports, and DR payloads for a row the
|
||||
* free-text backfill had not reached yet. Null otherwise.
|
||||
*/
|
||||
note: string | null;
|
||||
/**
|
||||
* v1.39.3 — base64 ciphertext of the note, present only in disaster-recovery
|
||||
@@ -480,18 +483,21 @@ export async function buildProfileBackupSection(
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
deletedAt: entry.deletedAt?.toISOString() ?? null,
|
||||
// v1.39.3 — the ciphertext rides verbatim. A row the backfill has
|
||||
// not reached yet is sealed here, so a DR file never carries the
|
||||
// note readable either way.
|
||||
noteEncrypted: toBase64(
|
||||
entry.noteEncrypted ?? encryptNote(entry.note),
|
||||
),
|
||||
// v1.39.3 — the ciphertext rides verbatim. A row the free-text
|
||||
// backfill has not reached yet carries its old readable note in
|
||||
// `note` instead, as stored; the restore seals it. Sealing it
|
||||
// here would make the export non-deterministic.
|
||||
noteEncrypted: toBase64(entry.noteEncrypted),
|
||||
}
|
||||
: {}),
|
||||
value: entry.value,
|
||||
unit: entry.unit,
|
||||
measuredAt: entry.measuredAt.toISOString(),
|
||||
note: disasterRecovery ? null : readEntryNoteSoft(entry, decryptFailures),
|
||||
note: disasterRecovery
|
||||
? entry.noteEncrypted
|
||||
? null
|
||||
: entry.note
|
||||
: readEntryNoteSoft(entry, decryptFailures),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user