985388c6e9
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.
117 lines
4.8 KiB
Go
117 lines
4.8 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// R-351 — THE SECOND PRESS. Part 3 asked whether a second press really starts a second run or is
|
|
// refused somewhere deeper. It is NOT refused: it starts a second run, and the customer is told so.
|
|
//
|
|
// WHY THE EXISTING GUARD DOES NOT CATCH IT. Every restore handler gates on `backupMgr.IsRunning()`
|
|
// (offbox_handlers.go:355/436/524/557/583, handlers.go:1404/1463). That reads `m.running`, the
|
|
// CONCURRENCY single-flight, which is acquired INSIDE the restore function on the background
|
|
// goroutine (offbox_reconstitute.go:180, offbox_restore.go:393) — not by the handler. So between the
|
|
// handler's check and the goroutine's acquire there is a window in which `IsRunning()` is false while
|
|
// a restore is unmistakably in flight. `restoreOpInFlight` and the whole wizard already read the
|
|
// other flag (`RestoreStatus().Running`, set synchronously by BeginRestoreOp) for exactly this
|
|
// reason — see the long note on restoreOpInFlight. The handlers were never moved over.
|
|
//
|
|
// THE FIXTURE IS THE LIVE STATE, NOT AN INVENTED ONE: display flag set, concurrency flag NOT held.
|
|
// That is precisely what the box looks like for the entire duration of an off-box restore.
|
|
//
|
|
// This test pins the CONSEQUENCE (does a second run start?), not the mechanism (which flag is read),
|
|
// per CLAUDE.md's preference. Mutating the new guard back to `IsRunning()` must make it fail.
|
|
func TestRestoreHandlers_SecondPressDoesNotStartASecondRun(t *testing.T) {
|
|
const firstApp = "alpha"
|
|
const secondApp = "beta"
|
|
|
|
s, sett, m := newOffboxWebServer(t)
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
|
Schedule: "daily", EscrowState: "escrowed",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.OffboxConfigured() {
|
|
t.Fatal("fixture: the target must be configured, or the handler exits earlier and proves nothing")
|
|
}
|
|
|
|
// A restore is in flight, exactly as the live box has it.
|
|
m.BeginRestoreOp("offbox-restore", firstApp)
|
|
|
|
// The fixture must reproduce the GAP, or this test is vacuous: the display flag says running,
|
|
// the concurrency flag — the one every handler reads — says it is not.
|
|
if !m.RestoreStatus().Running {
|
|
t.Fatal("fixture: the display flag must report a running op")
|
|
}
|
|
if m.IsRunning() {
|
|
t.Fatal("fixture: the concurrency flag must NOT be held — that gap IS the defect under test")
|
|
}
|
|
|
|
post := func(path string, form url.Values) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
switch path {
|
|
case "/backup/offbox/reconstitute":
|
|
s.offboxReconstituteHandler(w, r)
|
|
case "/backup/offbox/place":
|
|
s.offboxPlaceHandler(w, r)
|
|
default:
|
|
t.Fatalf("unrouted path %q", path)
|
|
}
|
|
return w
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
path string
|
|
form url.Values
|
|
}{
|
|
{"reconstitute", "/backup/offbox/reconstitute", url.Values{"app": {secondApp}, "confirm": {"1"}}},
|
|
{"place", "/backup/offbox/place", url.Values{"app": {secondApp}}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := post(tc.path, tc.form)
|
|
if w.Code != 302 {
|
|
t.Fatalf("the handler redirects; got %d", w.Code)
|
|
}
|
|
loc := w.Header().Get("Location")
|
|
|
|
// CONSEQUENCE 1 — the customer must not be told a second restore started.
|
|
// "elind" is the ASCII stem shared by every „elindult" flash; matching it avoids putting
|
|
// accented bytes through a comparison (strict rule 7).
|
|
if strings.Contains(loc, "elind") {
|
|
t.Errorf("a second press while a restore runs must NOT report a started restore. Location: %q", loc)
|
|
}
|
|
|
|
// CONSEQUENCE 2 — the in-flight op must still be the FIRST one. If the handler ran,
|
|
// BeginRestoreOp overwrote the op name and stack, so the first restore's identity is
|
|
// gone from the status the banner reads.
|
|
st := m.RestoreStatus()
|
|
if st.Op != "offbox-restore" || st.Stack != firstApp {
|
|
t.Errorf("the first restore's identity was overwritten by the second press: op=%q stack=%q (want offbox-restore/%s)",
|
|
st.Op, st.Stack, firstApp)
|
|
}
|
|
|
|
// CONSEQUENCE 3 — a refusal must name a route the person can act on, not just a reason.
|
|
// The wizard path is the route; it is where the live status is shown.
|
|
if !strings.Contains(loc, "/backups/restore") {
|
|
t.Errorf("the refusal must route somewhere actionable; got %q", loc)
|
|
}
|
|
|
|
// Restore the fixture for the next subtest — a handler that (today) ran will have
|
|
// clobbered it.
|
|
m.BeginRestoreOp("offbox-restore", firstApp)
|
|
})
|
|
}
|
|
}
|