53e9bf0224
gates / gates (push) Successful in 26s
R-237: /backups/restore listed apps that are CURRENTLY DEPLOYED and CURRENTLY TOGGLED ON for future off-site backups. A rebuilt box has neither, so a household that had just lost everything was shown nothing to restore while the repository held their snapshots — measured live on the R-201 re-walk. To restore an app you had to select it, to select it you had to have installed it, and to know what to install you had to see the backup you could not see. The store is now the source of the list (offsite_restore_list.go), built on the existing R-193 OffsiteInventoryList. Installed-ness became a property OF a row, never a filter on it. Every case is answered rather than hidden: a snapshot for an app that is not installed is offered and says it will reinstall first; an installed app with no snapshot is shown as having nothing; an unreadable store renders as UNKNOWN (R-225's rule, one screen over) AND keeps the action, because "we could not look" is not "there is nothing"; no-target is its own state. The felhom-offbox and _shares marker tags are excluded from the app list. R-238 classified as a HARNESS ARTIFACT: mode=full without confirm=1 is step 1 of a deliberate two-step — it starts no job by design and redirects carrying &full_prep=<app>, which deriveWizardStep requires to reveal the commit. A driver that did not carry it forward landed back on the intent step. The operator's browser run completed the same restore. The wizard's precedence rules were NOT re-keyed: a stale ?full_prep= must never resurrect a commit button mid-restore. The residue WAS real and is fixed: neither branch of that step wrote anything to the log, so a refusal — including by the headroom gate — left no trace on the box. Both branches now log, and so does the concurrent-op refusal. resolveWizardApp is removed: it was dead once the gate moved, and its test pinned the defect's behaviour (an untoggled app refused), which would have read as policy. 28 packages ok, 9/9 gates OK. Three red-proofs, each asserted to have applied.
73 lines
3.0 KiB
Go
73 lines
3.0 KiB
Go
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=<app>` 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)
|
|
}
|
|
}
|