Files
admin 985388c6e9
gates / gates (push) Successful in 10s
R-351: the restore compares where the backup says the data lived; second press cannot start a second run
Part 3 (not droppable) and the engine half of Part 2. No version bump yet - one bump and
one bake at the end of the session.

PART 3a - a second press really did start a second run. Established with a test BEFORE any
change: both offboxReconstituteHandler and offboxPlaceHandler answered "...elindult" and
overwrote the first restore's op/stack. Cause: every restore handler gated on
backupMgr.IsRunning() - the CONCURRENCY flag, which the restore goroutine acquires AFTER the
handler returns (offbox_reconstitute.go:180, offbox_restore.go:393). Seven sites. The wizard
had read the correct flag since v0.154.0 and said so in a comment; the handlers never moved.
New Server.restoreOpBlocked() reads BOTH flags - the display flag covers the whole off-box
restore, the concurrency flag is the only one the nightly backup holds - and the refusal now
names the running app and a route.

PART 3b - the page DOES refresh; the defect was the RESULT. backups_shared.html gated the
terminal result on a page-local sawRunning flag, so a restore that finished before the page
was opened, or inside one 3s poll, was shown to nobody. The 2026-08-21 OpenGist restore took
8.666s and no screen ever said it completed - the answer existed only in docker logs.
RestoreOpStatus.LastRecent now carries the server's verdict. The 10-minute window moved to
internal/backup as RestoreResultWindow and internal/web's constant is an alias: one
expression, two surfaces. Also removed the wizard's self-contradiction, which said the state
refreshes automatically AND that you must refresh the page.

PART 2 (engine) - every recovery unit manifest has carried drive and namespace_root since
schema 1, and NO non-test code read either back. The reconstitution opened the manifest and
took only the coherence stamp, then resolved its destination from the live app. A restore
into a different destination succeeded silently under a green message. New
backup/offbox_placement.go: CheckPlacement (pure, total), PlacementMismatchMessage,
recordedPlacementFromScratch. Compared before the safety dump and before the first byte.
A mismatch is NAMED and refused; ackPlacementChange lets the customer proceed deliberately -
a separate field from confirm=1, because one click must not carry two decisions. An UNKNOWN
recording is never a mismatch: refusing on an absence would strand every pre-field unit.
The not-installed refusal (R-253) now names the drive the backup recorded.

RED-PROOFS, each mutation asserted applied and reverted to 0:
  B  both guards removed (count asserted 2) -> the restore WAS seen starting with no drive
     attached: no error, full 3.00s run, wrote into /tmp/mutant-destination
  C  Mismatch forced false -> the silent divergent restore returned
  E  Known() forced true  -> the fabricated empty prefill appeared
  D  Mismatch forced true -> 8 ordinary reconstitute tests broke, proving reachability both ways
Note on D: the existing fixtures write a schema-1 manifest with NO drive, so they are
scenario-E shaped. The matching case is covered in the scenario table, not by them.

Gates 11/11 OK. Suite 28 packages ok. Hungarian verified as hex, no BOM, no mojibake sentinels.

NOT in this commit, still open: Part 2's scenario-A prefill UI, Part 1's deploy-page
visibility line, Part 1's specification document, Part 4's measurement.
2026-08-21 21:04:16 +02:00

85 lines
2.9 KiB
Go

package backup
import (
"testing"
"time"
)
// R-351 — A FINISHED RESTORE MUST BE VISIBLE TO SOMEONE WHO WAS NOT WATCHING.
//
// THE MEASURED CASE (demo-hp, 2026-08-21). An off-box reconstitution refused at 16:37:14, the person
// reinstalled and ran the local unit restore, and it completed at 16:39:25 — in 8.666s. No screen
// ever said so. The banner's JS gated its terminal result on a page-local `sawRunning` flag, so the
// result was rendered only for a browser that happened to be open and polling across the transition.
// Land on the page a moment later and the banner stayed hidden: "completed" and "never ran" looked
// identical. The answer existed only in `docker logs felhom-controller`, which a customer cannot
// reach.
//
// This pins the CONSEQUENCE — the status carries a recency verdict a late arrival can act on — not
// the mechanism. Mutating LastRecent to stay false must fail this test.
func TestRestoreStatus_LastRecent(t *testing.T) {
for _, tc := range []struct {
name string
finishedAt time.Time
wantRecent bool
why string
}{
{
name: "just finished",
finishedAt: time.Now().Add(-9 * time.Second),
wantRecent: true,
why: "the 8.7s OpenGist restore: finished before a poll could see it running",
},
{
name: "inside the window",
finishedAt: time.Now().Add(-RestoreResultWindow + time.Minute),
wantRecent: true,
why: "still answers \"what just happened\"",
},
{
name: "outside the window",
finishedAt: time.Now().Add(-RestoreResultWindow - time.Minute),
wantRecent: false,
why: "landing here later must not claim a restore just finished",
},
{
name: "unstamped result",
finishedAt: time.Time{},
wantRecent: false,
why: "a zero timestamp is an UNKNOWN and must never be drawn as \"just now\" (presence is not success)",
},
} {
t.Run(tc.name, func(t *testing.T) {
m := &Manager{}
m.opLast = &RestoreOpResult{
Op: "restore", Stack: "opengist", OK: true,
Message: "Restore completed", FinishedAt: tc.finishedAt,
}
st := m.RestoreStatus()
if st.Last == nil {
t.Fatal("fixture: the status must carry the terminal result")
}
if st.LastRecent != tc.wantRecent {
t.Errorf("LastRecent = %v, want %v — %s", st.LastRecent, tc.wantRecent, tc.why)
}
})
}
}
// A RUNNING op must not be reported as a recent RESULT — the banner shows one or the other, and
// conflating them would put a success message over an operation that is still writing.
func TestRestoreStatus_RunningIsNotAResult(t *testing.T) {
m := &Manager{}
m.BeginRestoreOp("offbox-reconstitute", "opengist")
st := m.RestoreStatus()
if !st.Running {
t.Fatal("fixture: the op must be reported running")
}
if st.Last != nil {
t.Errorf("a fresh op has no terminal result yet, got %+v", st.Last)
}
if st.LastRecent {
t.Error("a running op must never carry a recent-result verdict")
}
}