package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "golang.org/x/crypto/bcrypt" ) // claimTestServer builds a Server with a claim code installed (unclaimed, no password) and the // full mux (RequireAuth+CsrfProtect wired exactly as main.go does), so route-level gating is // exercised end-to-end. Returns the server, the plaintext code, and the settings. func claimTestServer(t *testing.T) (*Server, string, *settings.Settings) { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Customer.ID = "c1" cfg.Customer.Name = "Teszt" cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Stacks.ComposeCommand = "docker compose" cfg.Web.SessionSecret = "test-session-secret-abcdef" sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatalf("settings: %v", err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatalf("stacks: %v", err) } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() code := "alma-korte-szilva" hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10) if err := sett.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil { t.Fatalf("SetClaimCode: %v", err) } return s, code, sett } // fullMux replicates main.go's handler composition so the gate is tested where it actually runs. func (s *Server) fullMux() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) mux.Handle("/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeHTTP)))) return mux } // §10 THE SIGNATURE TEST — every route of an unclaimed box (with a code hash) answers the claim // page (redirect to /claim) or a 401 JSON; NOTHING else is reachable, and a mutating POST reaches // NO handler. Red-proof: remove the claim-gate block in RequireAuth → these assertions fail. func TestClaimGate_EveryRouteGated(t *testing.T) { s, _, _ := claimTestServer(t) mux := s.fullMux() // A representative sweep of the real route surface (pages + APIs + a mutating deploy POST). htmlRoutes := []string{"/", "/dashboard", "/stacks", "/backups", "/monitoring", "/settings", "/settings/security", "/storage", "/apps/vaultwarden", "/import", "/debug"} for _, p := range htmlRoutes { rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" { t.Errorf("GET %s: got %d loc=%q, want 302→/claim", p, rr.Code, rr.Header().Get("Location")) } } apiRoutes := []string{"/api/disks", "/api/storage/x", "/api/host-metrics", "/api/backup/restore-status"} for _, p := range apiRoutes { rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) if rr.Code != http.StatusUnauthorized || !strings.Contains(rr.Body.String(), "not yet claimed") { t.Errorf("GET %s: got %d body=%q, want 401 not-yet-claimed", p, rr.Code, rr.Body.String()) } } // A mutating deploy POST must be REFUSED before any handler runs (401, no side effect). rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/stacks/vaultwarden/deploy", strings.NewReader("{}"))) if rr.Code != http.StatusUnauthorized { t.Errorf("POST deploy on unclaimed box: got %d, want 401 (no mutation reachable)", rr.Code) } // The claim page + its assets + health ARE reachable. for _, p := range []string{"/claim", "/api/health", "/static/style.css"} { rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) if rr.Code != http.StatusOK { t.Errorf("GET %s on unclaimed box: got %d, want 200 (allowed)", p, rr.Code) } } } // The happy-path claim: correct code + password → password set, claimed, code consumed, session // issued; a second use of the SAME code is refused (single-use via consumed generation). func TestClaimSubmit_HappyPathThenReuseRefused(t *testing.T) { s, code, sett := claimTestServer(t) do := func(codeVal, pw string) *httptest.ResponseRecorder { form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {codeVal}, "new_password": {pw}, "confirm_password": {pw}} req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) rr := httptest.NewRecorder() s.handleClaimSubmit(rr, req) return rr } rr := do(code, "a-strong-passphrase-12") if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" { t.Fatalf("claim submit: got %d loc=%q, want 302→/", rr.Code, rr.Header().Get("Location")) } if !sett.GetClaimed() { t.Fatal("box not marked claimed after a successful claim") } if !s.authEnabled() { t.Fatal("password not set after claim (authEnabled false)") } if s.claimGateActive() { t.Fatal("gate still active after claim") } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("a-strong-passphrase-12")) != nil { t.Fatal("stored password does not verify the chosen password") } // A session cookie was issued. if len(rr.Result().Cookies()) == 0 { t.Fatal("no session cookie issued on claim") } // Reuse the SAME code (generation 1, now consumed) → refused even though the hash matches. rr = do(code, "another-strong-pass-12") if rr.Code == http.StatusFound { t.Fatal("consumed code was accepted again — single-use broken") } if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") { t.Errorf("reuse should show the wrong/expired-code error, body=%q", claimFirstLine(rr.Body.String())) } } // Wrong codes lock the endpoint after 5 attempts (fake clock); the window then reopens. func TestClaimSubmit_LockoutAndWindowReopen(t *testing.T) { s, _, _ := claimTestServer(t) now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) s.claimClock = func() time.Time { return now } submitWrong := func() *httptest.ResponseRecorder { form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {"wrong-wrong-wrong"}, "new_password": {"x-really-long-pass"}, "confirm_password": {"x-really-long-pass"}} req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "203.0.113.7:5000" req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) rr := httptest.NewRecorder() s.handleClaimSubmit(rr, req) return rr } for i := 0; i < claimMaxAttempts; i++ { submitWrong() } // The 6th (post-cap) attempt is locked out. rr := submitWrong() if !strings.Contains(rr.Body.String(), "Túl sok próbálkozás") { t.Fatalf("expected lockout after %d failures, body=%q", claimMaxAttempts, claimFirstLine(rr.Body.String())) } // Advance past the window → unlocked (a wrong code shows the normal error again, not lockout). now = now.Add(claimLockoutWindow + time.Minute) rr = submitWrong() if strings.Contains(rr.Body.String(), "Túl sok próbálkozás") { t.Fatal("still locked after the window elapsed") } if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") { t.Errorf("post-window wrong code should show the normal error, body=%q", claimFirstLine(rr.Body.String())) } } // An expired code (issued > 72h ago) is refused. func TestClaimSubmit_ExpiredCodeRefused(t *testing.T) { s, code, sett := claimTestServer(t) // Re-issue the code with an old issued_at. hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10) sett.SetClaimCode(string(hash), 2, time.Now().Add(-73*time.Hour).UTC().Format(time.RFC3339)) form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"}} req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) rr := httptest.NewRecorder() s.handleClaimSubmit(rr, req) if rr.Code == http.StatusFound { t.Fatal("expired code was accepted") } if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") { t.Errorf("expected expired-code error, body=%q", claimFirstLine(rr.Body.String())) } } // Legacy-open (no password, no code hash) passes through with the red banner flag; a box with a // set password is entirely ungated (no claim page ever). func TestClaimGate_LegacyOpenAndPasswordSet(t *testing.T) { // Legacy-open: fresh server, no claim code, no password. lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "s") cfg.Paths.DataDir = filepath.Join(dir, "d") cfg.Stacks.ComposeCommand = "docker compose" sett, _ := settings.Load(filepath.Join(dir, "settings.json"), lg) mgr, _ := stacks.NewManager(cfg, lg) s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() if s.claimGateActive() { t.Fatal("no code hash → gate must NOT be active (legacy-open)") } if !s.claimLegacyOpen() { t.Fatal("no password + no code → expected legacy-open") } // Password set → neither gated nor legacy-open. pw, _ := bcrypt.GenerateFromPassword([]byte("existing-strong-pass"), 10) sett.SetPasswordHash(string(pw)) if s.claimGateActive() || s.claimLegacyOpen() { t.Fatal("a set password must disable both the gate and the legacy banner") } } func claimFirstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s }