R-351: the restore compares where the backup says the data lived; second press cannot start a second run
gates / gates (push) Successful in 10s
gates / gates (push) Successful in 10s
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.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-351 — THE SCENARIO TABLE from the task, as a truth table.
|
||||
//
|
||||
// Each row states the WRONG outcome it exists to prevent, because a row whose expectation is only
|
||||
// "want X" tells the next reader nothing about why the value matters.
|
||||
//
|
||||
// The live values in these fixtures are the ones measured on demo-hp 2026-08-21, not invented ones:
|
||||
//
|
||||
// calibre-web (declares a data path) drive=/mnt/felhom-drives/hdd_1
|
||||
// opengist (declares NONE, 40-of-53) drive=/mnt/sys_drive
|
||||
func TestCheckPlacement_ScenarioTable(t *testing.T) {
|
||||
const dataDrive = "/mnt/felhom-drives/hdd_1"
|
||||
const sysDrive = "/mnt/sys_drive"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
man *RecoveryManifest
|
||||
liveDrive string
|
||||
wantKnown bool
|
||||
wantMismatch bool
|
||||
wrongOutcome string
|
||||
}{
|
||||
{
|
||||
name: "A/D - recorded drive is where we are restoring",
|
||||
man: &RecoveryManifest{Drive: dataDrive, NamespaceRoot: dataDrive},
|
||||
liveDrive: dataDrive,
|
||||
wantKnown: true,
|
||||
wantMismatch: false,
|
||||
wrongOutcome: "a new question or obstacle on the ordinary path",
|
||||
},
|
||||
{
|
||||
name: "C - the destination differs from what the backup recorded",
|
||||
man: &RecoveryManifest{Drive: dataDrive, NamespaceRoot: dataDrive},
|
||||
liveDrive: "/mnt/felhom-drives/hdd_2",
|
||||
wantKnown: true,
|
||||
wantMismatch: true,
|
||||
wrongOutcome: "silently accepted - a restore into the wrong place under a green message",
|
||||
},
|
||||
{
|
||||
name: "E - the backup records no drive (older unit, missing field)",
|
||||
man: &RecoveryManifest{Drive: "", NamespaceRoot: ""},
|
||||
liveDrive: dataDrive,
|
||||
wantKnown: false,
|
||||
wantMismatch: false,
|
||||
wrongOutcome: "an empty prefill presented as if it were the recorded value",
|
||||
},
|
||||
{
|
||||
name: "E - the manifest could not be read at all",
|
||||
man: nil,
|
||||
liveDrive: dataDrive,
|
||||
wantKnown: false,
|
||||
wantMismatch: false,
|
||||
wrongOutcome: "a nil manifest treated as a match, or as a refusal that strands every old backup",
|
||||
},
|
||||
{
|
||||
name: "no declared data path - the app has no field, but the record is still the truth",
|
||||
man: &RecoveryManifest{Drive: sysDrive, NamespaceRoot: sysDrive + "/felhom-data"},
|
||||
liveDrive: sysDrive,
|
||||
wantKnown: true,
|
||||
wantMismatch: false,
|
||||
wrongOutcome: "falling into the unknown case just because the app has no storage field",
|
||||
},
|
||||
{
|
||||
name: "no declared data path - and it moved to a real drive",
|
||||
man: &RecoveryManifest{Drive: sysDrive, NamespaceRoot: sysDrive + "/felhom-data"},
|
||||
liveDrive: dataDrive,
|
||||
wantKnown: true,
|
||||
wantMismatch: true,
|
||||
wrongOutcome: "the 40-of-53 class silently exempted from the mismatch check",
|
||||
},
|
||||
{
|
||||
name: "a trailing slash is the same destination",
|
||||
man: &RecoveryManifest{Drive: dataDrive + "/", NamespaceRoot: dataDrive},
|
||||
liveDrive: dataDrive,
|
||||
wantKnown: true,
|
||||
wantMismatch: false,
|
||||
wrongOutcome: "a manufactured mismatch the customer has to dismiss for no reason",
|
||||
},
|
||||
{
|
||||
name: "live destination unresolvable",
|
||||
man: &RecoveryManifest{Drive: dataDrive, NamespaceRoot: dataDrive},
|
||||
liveDrive: "",
|
||||
wantKnown: true,
|
||||
wantMismatch: false,
|
||||
wrongOutcome: "comparing against nothing and calling it a difference; the not-installed refusal owns this case",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := CheckPlacement(tc.man, tc.liveDrive, tc.liveDrive)
|
||||
if c.Known != tc.wantKnown {
|
||||
t.Errorf("Known = %v, want %v — wrong outcome guarded: %s", c.Known, tc.wantKnown, tc.wrongOutcome)
|
||||
}
|
||||
if c.Mismatch != tc.wantMismatch {
|
||||
t.Errorf("Mismatch = %v, want %v — wrong outcome guarded: %s", c.Mismatch, tc.wantMismatch, tc.wrongOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal must NAME BOTH VALUES. "The destination differs" without saying from what leaves the
|
||||
// customer holding a decision they have no way to make — which is the same defect in a politer form.
|
||||
func TestPlacementMismatchMessage_NamesBothPlaces(t *testing.T) {
|
||||
c := CheckPlacement(
|
||||
&RecoveryManifest{Drive: "/mnt/felhom-drives/hdd_1"},
|
||||
"/mnt/sys_drive", "/mnt/sys_drive/felhom-data")
|
||||
if !c.Mismatch {
|
||||
t.Fatal("fixture: these differ, or the message under test is never reached")
|
||||
}
|
||||
msg := PlacementMismatchMessage("opengist", c)
|
||||
for _, must := range []string{"opengist", "/mnt/felhom-drives/hdd_1", "/mnt/sys_drive"} {
|
||||
if !strings.Contains(msg, must) {
|
||||
t.Errorf("the refusal must name %q; got: %s", must, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN recording must never be rendered as a value. Scenario E's wrong outcome is precisely an
|
||||
// empty string shown where a recorded path belongs, which reads as "the backup says it lived
|
||||
// nowhere" — a fabricated fact.
|
||||
func TestRecordedPlacement_UnknownIsNotAValue(t *testing.T) {
|
||||
for _, drive := range []string{"", " ", "\t"} {
|
||||
if (RecordedPlacement{Drive: drive}).Known() {
|
||||
t.Errorf("a blank drive (%q) must not count as a recorded value", drive)
|
||||
}
|
||||
}
|
||||
if !(RecordedPlacement{Drive: "/mnt/sys_drive"}).Known() {
|
||||
t.Error("a real recorded drive must count as known, or the whole check is inert")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user