Files
felhom-controller/controller/internal/web/claim_test.go
T
admin 3cf49c7fd5 controller: customer-claim password gate v0.122.0 (closes DRILL-day0-vm F-4/F-5)
The customer sets + owns the dashboard password via a hub-emailed one-time
claim code. An unclaimed box (code hash present, no password) serves ONLY the
claim page — every other route → claim page (302) or 401, so a Day-0 box is
never open on the internet. A set password disables the gate (auth wins).
Reset rides the same code engine (login "Elfelejtett jelszó"). Legacy-open
(no password, no hash) shows a red transition banner until the hub delivers a
hash. Report ACK caches the code state idempotently by generation; report
carries claimed (set-only). --print-reset-code root escape hatch. Requires
hub v0.50.0. Gate-coverage signature test + 4 red-proofs proven.
2026-07-12 18:42:39 +02:00

246 lines
9.9 KiB
Go

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
}