package web import ( "bytes" "context" "errors" "log" "net/http/httptest" "net/url" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-238 — the full-restore size gate must never refuse in silence. // // WHAT WAS MEASURED (2026-08-06, part4 venue). `POST /backup/offbox/restore` with `mode=full` and no // `confirm=1` is step 1 of a deliberate two-step: it computes size + headroom, starts NO job, and // redirects carrying `&full_prep=` so the wizard reveals the commit. Driving it without // carrying that parameter forward lands back on the intent step — which is correct behaviour, and is // why the endpoint-level run looked like "the button does nothing". // // The REAL defect underneath, and the one this pins: neither branch of that step wrote anything to // the controller's log. `restore-status` is empty by design (no job), the redirect is invisible, and // `offboxRedirectTo` only flashes to the page — so a customer refused a disaster restore, INCLUDING // a refusal by the headroom gate, left no trace on the box at all. "No error, no log line" is a // diagnosis problem whoever triggers it. // // Handler-level on purpose: the silence was in the handler, and a helper-level assertion cannot // observe it. That mistake has been made three times in this arc. func TestOffboxRestore_FullPrepareRefusal_IsNotSilent(t *testing.T) { 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 before the size gate") } // Deterministic refusal: every restic call fails, so the size gate errors for a real reason // instead of being skipped, and no network is involved. m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { return nil, errors.New("repository unreachable") }) var logbuf bytes.Buffer s.logger = log.New(&logbuf, "", 0) form := url.Values{"app": {"calibre-web"}, "mode": {"full"}} req := httptest.NewRequest("POST", "/backup/offbox/restore", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.offboxRestoreHandler(w, req) if w.Code != 302 { t.Fatalf("the size gate redirects; got %d", w.Code) } got := logbuf.String() // The CONSEQUENCE, not the mechanism: whatever the outcome, this step must be findable in the log. if !strings.Contains(got, "full-restore preparation REFUSED") && !strings.Contains(got, "full-restore prepared") { t.Errorf("the full-restore size gate wrote NOTHING to the log \u2014 that is R-238's residue.\nlog was: %q", got) } if !strings.Contains(got, "calibre-web") { t.Errorf("the line must name the app it refused, got %q", got) } }