Files
Felis/docs/changes/2026-07-08-backup-restore-mutation-audit.md
T
flyemoji b7b4a3be45 docs(changes): record the round-2 backup/restore mutation audit and index the owner-gate test
Round-2 mutation audit of the backup/restore data-safety surface (7 fail-open gates pinned, 1 coverage gap found and closed by the owner-gate test in 85b8a92). Reverts the Pending section and indexes c67a4d3 + 85b8a92 into the committed ledger.
2026-07-08 05:59:13 +09:00

94 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Backup/restore data-safety mutation audit (round 2) — 7 gates pinned, 1 gap closed
- **Type:** test-quality audit + one test added (code change: `internal/api/handlers_backups_test.go`)
- **Date:** 2026-07-08
- **Area:** `internal/api` (`handlers_backups.go`), `internal/backupjob`
- **Task:** continues the #82 test-quality integrity audit onto the on-demand
backup/restore surface, which postdates the 18-gate round-1 audit
([mutation audit `4626ab5`](2026-07-07-test-quality-mutation-audit.md)). The
backup/restore endpoints (`7a7c0d5` / `f2fc57c` / `fc748d3`) were not in that pass.
## Method
Same as round 1: apply a one-line mutation to a fail-open gate in the source, run the
package tests, confirm the **specifically-named** test reddens with an *assertion*
failure (`--- FAIL: <subtest>`), then revert. A build break (`declared and not used`,
`undefined`) is not a valid verdict, so mutations are operator-flips that keep every
operand referenced (`!=`→`==`, drop a `!`, a literal→`true`, or `if false && <orig>` to
disable a gate without orphaning its variables). Airtightness: each mutation is re-run
with `-run` scoped to the intended subtest and `grep -- "--- FAIL: <subtest>"`, so a
reddening sibling can't be mistaken for the gate under test. Oracle: WSL Fedora-44,
go1.26.4.
## Fail-open gates mutation-verified (all CAUGHT at the named subtest)
| # | Gate (file:line) | What it guards | Mutation | Subtest that reddened |
|---|---|---|---|---|
| A | `handlers_backups.go:282` enqueueBackup stopped-gate | RWO double-mount / torn archive while the world is up | `!=`→`==` | `TestBackupNow/starting_server_->_409_not_stopped` |
| B | `:151` restore stopped-gate | restore Job can't mount a live world's RWO PVC | `!=`→`==` | `TestRestoreBackup/starting_server_->_409_not_stopped` |
| C | `:136` restore former-owner match | a fresh claimant resurrecting the previous owner's world | `!=`→`==` | `TestRestoreBackup/current_owner_who_is_not_former_owner_->_403` |
| D | `:116` restore cross-server guard | restoring server A's backup onto server B | `!=`→`==` | `TestRestoreBackup/restore_by_backup_id_cross-server_->_403` |
| E | `:214` backup owner-or-admin authz | a stranger backing up someone else's world | drop `!` | `TestBackupNow/non-owner_->_403,_no_backup` |
| F | `:26` list cross-user scope | a user seeing other tenants' backups | `p.IsAdmin()`→`true` | `TestListBackups/user_sees_only_own_former-owned_present_backups` |
## The gap this audit found — and closed
**Restore's owner-or-admin gate (`handlers_backups.go:85`) was not pinned by any test.**
It is the twin of gate E, but the two are *not* symmetric. Disabling it
(`if false && !a.isOwnerOrAdmin(p, rec)`, which keeps `rec` referenced so the package
still builds) reddened **nothing** — `go test ./internal/api/` stayed `ok`. The same
disable applied to backup's L214 (gate E) reddened `non-owner` immediately, proving the
technique valid and the asymmetry real.
Root cause: the former-owner gate at L136 backstops every non-owner case the suite
exercised (a stranger and a wrong-backup current owner both fail L136 *and* L85, so
L136's 403 masks a broken L85). The one case only L85 catches went untested: a
**superseded former owner** — a user who owned a server, took this backup
(`FormerOwner=them`), then released it to a *new* owner. They still pass L136 (they *are*
the former owner) but must be stopped by L85, or they could roll the new owner's live
server back onto their old world (cross-tenant clobber). The handler comment names this
the "must re-claim first" rule (`handlers_backups.go:77-79`).
**Fix (code):** added `TestRestoreBackup/former owner after release -> 403, no restore`,
the mirror of the existing L136 test. Verified both directions: green on the clean tree,
and it is the sole subtest that reddens when L85 is disabled — so it now pins the owner
gate specifically, not L136. No production code changed; `handlers_backups.go` is a pure
test addition away from where it was.
## Enumeration — covered vs. scoped (so "the gates" means all of them)
- **Fail-open data-safety gates — all pinned:** A–F above, plus L85 (now closed). 7/7.
- **Accountability, not fail-open (verified non-vacuous):** the `os_user` attribution at
`handlers_backups.go:254` — a supplied operator name overrides the default `break-glass`
audit actor. Mutating `u != ""`→`u == ""` reddens
`TestInternalBackup/os_user_body_attributes_the_audit_to_the_operator`, so the
attribution test isn't vacuous. A failure here degrades the audit actor; it grants no
bypass, so it is out of the fail-open bucket.
- **Contract/behavioral (tested, out of mutation scope):** nil `Backuper`/`Restorer` → 503;
a failed backup/restore → 500 **not** audited; `backup_ref` never serialized to the wire;
the internal-face actor defaults to `break-glass`. Each has a direct test; none is a
fail-open safety gate.
- **`internal/backupjob` (glanced, not mutated):** orchestration only — each backup gets a
unique Job name (`BackupJobName` + random suffix) so a repeat "立即备份" tap can't collide
with a just-finished Job still inside its TTL; `ErrAlreadyExists` is a defensive no-op;
`Backup` returns once the Job is created (the async 202 is honest). Unit-tested against a
fake `Jobs`; the controller-runtime `k8sjobs.go` and the `jobspec.go` Pod shape (weak SA
with its token un-mounted, config Secret mounted for the self-recorded row, read-only
world mount) are integration-verified per the package doc — not fail-open API gates.
## Coverage nuance (documented, not a gate failure)
The stopped-gate is `if info.Ready || info.DesiredState != DesiredStopped`. The `!=`→`==`
mutation pins the `DesiredState` operand (both A and B reddened), but `info.Ready` is not
*independently* pinned: no test sets `Ready=true` together with `DesiredState=Stopped` —
the stopping-but-still-up race. Low risk because in practice `Ready` drops as
`DesiredState` leaves `Stopped`, but the belt-and-suspenders `Ready` operand rides on
coverage of the operand beside it rather than its own case.
## Verdict
The backup/restore data-safety surface is a coherent unit, and this closes it: **7/7
fail-open gates pinned** (6 pre-existing, 1 added this round), one accountability gate
shown non-vacuous, one coverage edge documented. Not extended to every handler — that
would be an unbounded "continue the audit."