Refuse a backup that claims a section it does not carry (#237)

A restore's first act is to delete the class of records it is about to
rebuild, so a payload whose documents section is simply absent did not
"restore everything else". It emptied the vault, reported success, and left
the operator to find the hole later.

The whole file is refused instead, above the transaction that wipes the
account, and the refusal names the section and says nothing was changed —
an operator holding the only copy of an account cannot act on "restore
failed". The sections ride the error envelope as meta.errorCode
backup.section.missing, and the admin console holds them in a panel beside
the skip report rather than a toast that outlives nothing.

The manifest is the discriminator, and only the manifest. A portable export
leaves the mental-health screeners and the consent receipts out on purpose
and declares both as omitted; those files still restore. So does a file
whose section is present and empty, which is what an account with no
documents honestly writes, and so does every backup written before the
manifest existed. The integration test proves the distinction with the same
builder and the same deleted keys, differing only in what the manifest said.

The route is deliberately outside the published contract — requireAdmin is
cookie-only — so the OpenAPI document is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 16:55:59 +02:00
1 parent e3be71f0e5
commit 283699db63
12 files changed
+789 -8

No files matched your search

@@ -0,0 +1,125 @@
/**
* A missing section and a declared omission are not the same thing.
*
* `findMissingBackupSections` is the rule that separates them, and the whole
* value of the refusal it feeds rests on it getting the separation right in
* both directions:
*
* - too lax and a portable file whose documents section is gone still runs,
* which is the defect (#237) — the restore deletes the vault it is about to
* rebuild and rebuilds nothing;
* - too strict and it refuses the two files that legitimately leave a section
* out. The portable export omits the mental-health screeners and the consent
* receipts ON PURPOSE, declares both in the manifest as `included:
* "omitted"`, and has to stay restorable. So does every backup written
* before the manifest existed, which declares nothing at all.
*
* The manifest is the only discriminator, and the cases below say so by proving
* the two things it is NOT: not the emptiness of the array (an account with no
* documents honestly writes `[]`), and not a guess from the export's purpose,
* which the payload never states.
*
* Mutation check: treat `[]` as missing and the empty-section case goes red;
* drop the `included === "omitted"` skip and the declared-omission case goes
* red; return early on a present-but-manifest-less file and the legacy case
* stays green while the first case goes red.
*/
import { describe, expect, it } from "vitest";
import { findMissingBackupSections } from "../restore-skips";
/** A portable file's manifest, as `buildFullBackupPayload` writes it. */
const PORTABLE_MANIFEST = {
documents: { included: "metadata-only", note: "…" },
workouts: { included: "summary-only", note: "…" },
mentalHealth: { included: "omitted", note: "…" },
consent: { included: "omitted", note: "…" },
};
/** The disaster-recovery writer's manifest — every section carried. */
const DR_MANIFEST = {
documents: { included: "encrypted-content", note: "…" },
workouts: { included: "summary-only", note: "…" },
mentalHealth: { included: "full", note: "…" },
consent: { included: "full", note: "…" },
};
function portable(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: "2",
exportedAt: "2026-08-01T00:00:00.000Z",
userId: "user-1",
manifest: PORTABLE_MANIFEST,
documents: [],
workouts: [],
mentalHealthAssessments: [],
consentReceipts: [],
...overrides,
};
}
describe("findMissingBackupSections", () => {
it("names the documents section a portable file claims and does not carry", () => {
const file = portable();
delete (file as Record<string, unknown>).documents;
expect(findMissingBackupSections(file)).toEqual(["documents"]);
});
it("finds nothing in a complete portable file", () => {
expect(findMissingBackupSections(portable())).toEqual([]);
});
it("does not read an empty section as a missing one", () => {
// An account with no documents writes `[]`, and that file is fine. Refusing
// it would make the check worse than the silence it replaces.
expect(findMissingBackupSections(portable({ documents: [] }))).toEqual([]);
});
it("leaves a DECLARED omission alone", () => {
// The portable export omits both of these on purpose and says so. This is
// the boundary: the keys are gone, and the file is still restorable.
const file = portable();
delete (file as Record<string, unknown>).mentalHealthAssessments;
delete (file as Record<string, unknown>).consentReceipts;
expect(findMissingBackupSections(file)).toEqual([]);
});
it("names the same two sections when the manifest says they ARE carried", () => {
// Identical payload to the case above, one word different in the manifest.
// That word is the whole rule.
const file = portable({ manifest: DR_MANIFEST });
delete (file as Record<string, unknown>).mentalHealthAssessments;
delete (file as Record<string, unknown>).consentReceipts;
expect(findMissingBackupSections(file)).toEqual([
"mentalHealth",
"consent",
]);
});
it("claims nothing about a file with no manifest", () => {
// Every backup written before the manifest existed. It declares nothing, so
// nothing is missing from it, and it stays restorable.
const legacy = portable({ manifest: null });
delete (legacy as Record<string, unknown>).documents;
delete (legacy as Record<string, unknown>).workouts;
expect(findMissingBackupSections(legacy)).toEqual([]);
});
it("reports every missing section rather than the first", () => {
const file = portable({ manifest: DR_MANIFEST });
delete (file as Record<string, unknown>).documents;
delete (file as Record<string, unknown>).workouts;
expect(findMissingBackupSections(file)).toEqual(["documents", "workouts"]);
});
it("survives a payload that is not an object at all", () => {
expect(findMissingBackupSections(null)).toEqual([]);
expect(findMissingBackupSections("nope")).toEqual([]);
expect(findMissingBackupSections([])).toEqual([]);
});
});
+88
View File
@@ -216,3 +216,91 @@ export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary {
links: catalogueKeys.reduce((total, entry) => total + entry.links, 0),
};
}
/**
* A section the file's own manifest says it carries, and does not.
*
* This is the other half of the honesty contract above, and it is deliberately
* the opposite answer. A skip drops one edge and names it, because the record
* it hangs off survives and giving the operator the rest is worth more than
* refusing them everything. A missing SECTION is not one edge — it is a whole
* class of the account, and the restore's first act is to delete the class it
* is about to rebuild. Restoring a file whose documents section is not there
* therefore does not lose a link. It empties the vault, tells the operator the
* restore succeeded, and leaves them to find out later.
*
* So this one refuses the file. Nothing is deleted, and the response names the
* section rather than saying "invalid backup", because an operator holding the
* only copy of an account needs to know whether the file is worthless or
* whether they picked the wrong one of two.
*
* ── The manifest is what tells a missing section from an omitted one ────────
*
* A payload that omits a section on purpose is not broken, and refusing it
* would break the two exports that do it. `buildSensitiveBackupSection` leaves
* the screener administrations and the consent receipts out of a PORTABLE file
* — the first because the encrypted per-item answers include the PHQ-9
* self-harm item, the second because a consent belongs to the operator it was
* given to — and says so in the manifest as `included: "omitted"`. That is a
* declared omission, and it stays restorable.
*
* The discriminator is therefore the manifest and nothing else. Not the emptiness
* of the array: an account with no documents honestly writes `[]`, and treating
* that as a loss would refuse a perfectly good file. Not the export's purpose
* either, which the payload does not state. Only "the file said it carries this,
* and the key is not there".
*
* A file with NO manifest at all declares nothing, so nothing is missing from
* it. Every backup written before the manifest existed is in that category and
* has to stay restorable.
*/
export type MissingBackupSection =
"documents" | "workouts" | "mentalHealth" | "consent";
/**
* Which payload key each manifest entry speaks for.
*
* `included: "omitted"` is the one value that means "not carried". Every other
* value the writers produce — `metadata-only`, `encrypted-content`,
* `summary-only`, `full` — describes HOW MUCH of the section travels, not
* whether the section is there, and all of them come with the key present.
*/
const MANIFEST_SECTIONS: ReadonlyArray<{
manifestKey: MissingBackupSection;
payloadKey: string;
}> = [
{ manifestKey: "documents", payloadKey: "documents" },
{ manifestKey: "workouts", payloadKey: "workouts" },
{ manifestKey: "mentalHealth", payloadKey: "mentalHealthAssessments" },
{ manifestKey: "consent", payloadKey: "consentReceipts" },
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Sections the raw file claims and does not carry, in manifest order.
*
* Takes the RAW parsed JSON, not a `BackupPayload`. Every section key in
* `backupPayloadSchema` carries `.default([])`, so by the time the payload has
* been through the schema an absent section and an empty one are the same
* value — which is exactly the distinction this function exists to make. Run it
* on the object that came out of `JSON.parse`.
*/
export function findMissingBackupSections(
raw: unknown,
): MissingBackupSection[] {
if (!isRecord(raw)) return [];
const manifest = raw.manifest;
if (!isRecord(manifest)) return [];
const missing: MissingBackupSection[] = [];
for (const { manifestKey, payloadKey } of MANIFEST_SECTIONS) {
const entry = manifest[manifestKey];
if (!isRecord(entry)) continue;
if (entry.included === "omitted") continue;
if (raw[payloadKey] === undefined) missing.push(manifestKey);
}
return missing;
}