package web import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── R-543 — the household is ASKED for the recovery code ──────────────────────────────────────── // // Off-site backup is ON by default and does NOT run until the escrow ceremony is done. A fresh box // measured on 2026-09-16 sat at „Kulcsletétre vár" with nothing anywhere asking for the code, while // the backup page told the household their files were protected. // // These drive the REAL pages through ServeHTTP, so they bite on the WIRING — the bar hangs off // executeTemplate, and a bar that renders only where someone remembered to call a helper is the // seam-built-but-never-wired failure this repo has shipped four times. const ( escrowBarSentence = "A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot." escrowBarLink = `href="/backup/escrow"` ) // escrowServer builds a box whose off-site tier is genuinely CONFIGURED — enabled, valid, with the // key and password files on disk — and whose escrow sits in the given state. The fixture asserts // OffboxConfigured() itself: a test that silently loses its precondition measures nothing. func escrowServer(t *testing.T, escrowState string) *Server { t.Helper() s := newDashboardServer(t, time.Time{}) if err := s.settings.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo", EscrowState: escrowState, }); err != nil { t.Fatalf("set offbox target: %v", err) } dir := filepath.Join(s.cfg.Paths.DataDir, "offbox") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "ssh_key"), []byte("not-a-real-key"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "repo_password"), []byte(strings.Repeat("a", 64)), 0o600); err != nil { t.Fatal(err) } if !s.backupMgr.OffboxConfigured() { t.Fatal("fixture invalid: the off-site tier is not configured, so nothing here measures the paused state") } return s } // ── SCENARIO A — paused, and EVERY dashboard page asks ────────────────────────────────────────── // // RED-PROOF: remove `s.addEscrowBanner(data, r)` from executeTemplate (server.go) and this fails on // every page — the fresh box goes back to saying nothing while the copy never runs. func TestR543_A_PausedBoxAsksOnEveryPage(t *testing.T) { s := escrowServer(t, "pending") for _, path := range []string{"/dashboard", "/launcher"} { rec := getPage(t, s, path) if rec.Code != 200 { t.Fatalf("GET %s = %d: %s", path, rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, escrowBarSentence) { t.Errorf("R-543: %s does not tell the household the off-site copy is PAUSED. "+ "The tier is on, nothing is running, and the page is silent about it", path) } if !strings.Contains(body, escrowBarLink) { t.Errorf("R-543: %s states the pause but names no route to end it — a reminder without "+ "its door is the shape that left a fresh box waiting indefinitely", path) } } } // ── SCENARIO B — the finished state is silent (negative control) ──────────────────────────────── func TestR543_B_EscrowedBoxIsNotNagged(t *testing.T) { s := escrowServer(t, "escrowed") rec := getPage(t, s, "/dashboard") if rec.Code != 200 { t.Fatalf("GET /dashboard = %d", rec.Code) } if strings.Contains(rec.Body.String(), escrowBarSentence) { t.Error("R-543: a box whose recovery code EXISTS is still told the copy is paused — " + "the bar must disappear for good when the state is escrowed, not merely be dismissable") } } // ── SCENARIO C — an UNCONFIGURED off-site tier is not nagged either ───────────────────────────── // // A box with no off-site target at all has a different, honest sentence on the backups page. Asking // it for a recovery code would be asking about a copy it has not chosen. func TestR543_C_UnconfiguredBoxIsNotNagged(t *testing.T) { s := newDashboardServer(t, time.Time{}) // no offbox target at all rec := getPage(t, s, "/dashboard") if rec.Code != 200 { t.Fatalf("GET /dashboard = %d", rec.Code) } if strings.Contains(rec.Body.String(), escrowBarSentence) { t.Error("R-543: a box with no off-site tier is asked for a recovery code it does not need") } } // ── SCENARIO D — „Most nem" silences it for the VISIT, and the next visit asks again ──────────── // // RED-PROOF: give the cookie a MaxAge in escrowBannerDismissHandler and the last assertion fails — // the reminder becomes permanent while the copy stays paused, which is the R-241 lesson exactly. func TestR543_D_DismissIsForThisVisitOnly(t *testing.T) { s := escrowServer(t, "pending") // The dismiss POST issues a SESSION cookie: no Max-Age and no Expires. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/backup/escrow/banner/dismiss", nil) s.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("dismiss POST = %d, want 302: %s", rec.Code, rec.Body.String()) } setCookie := rec.Header().Get("Set-Cookie") if !strings.Contains(setCookie, escrowBannerCookie+"=1") { t.Fatalf("dismiss set no banner cookie: %q", setCookie) } if strings.Contains(strings.ToLower(setCookie), "max-age") || strings.Contains(strings.ToLower(setCookie), "expires") { t.Errorf("R-543: the dismissal outlives the browser session (%q). The off-site copy is still "+ "paused tomorrow, so the question must still be asked tomorrow", setCookie) } // A visit carrying the cookie: quiet. rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/dashboard", nil) req2.AddCookie(&http.Cookie{Name: escrowBannerCookie, Value: "1"}) s.ServeHTTP(rec2, req2) if strings.Contains(rec2.Body.String(), escrowBarSentence) { t.Error("R-543: the dismissal does not quieten the bar for this visit") } // A NEW visit (no cookie): asked again. if !strings.Contains(getPage(t, s, "/dashboard").Body.String(), escrowBarSentence) { t.Error("R-543: the next visit is not asked again, although nothing about the box changed") } }