package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "os" "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" ) // v0.280.0 (`09` §3 decision 46) — the setup gate's answerer: a stranger is refused, the household passes with // its dashboard session, a pass survives a controller restart, and an opened gate stops asking. Driven through // the same handlers traefik and the browser reach (ServeGateAuth, ServeGateStart). Docker is a stub on PATH. func gateHarness(t *testing.T) *Server { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "bin") for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "gapp")} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin) app := filepath.Join(dir, "stacks", "gapp") write := func(name, body string) { if err := os.WriteFile(filepath.Join(app, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } write("docker-compose.yml", "services:\n gapp:\n image: busybox\n") write(".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n") write("app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.example.hu, gapp-db.example.hu]\n") lg := log.New(io.Discard, "", 0) cfg := config.Default() cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } if err := mgr.ScanStacks(); err != nil { t.Fatal(err) } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{}} s.loadTemplates() return s } // traefik's forwardAuth request for host+uri. func gateAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+gateAuthPath, nil) r.Header.Set("X-Forwarded-Host", host) r.Header.Set("X-Forwarded-Method", method) r.Header.Set("X-Forwarded-Uri", uri) r.Header.Set("Accept", accept) for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() s.ServeGateAuth(w, r) return w } func gateStart(s *Server, rd string, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+gateStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() s.ServeGateStart(w, r) return w } // A stranger never reaches the app: a browser is sent to the dashboard's gate page, a script gets 401, a host no // app owns gets 403, a forged pass is refused, and the gate page is no open redirect. // COMPANION RED-PROOF: make ServeGateAuth answer 200 when there is no gate cookie (the pre-gate behaviour: the // app answers everyone) → the first two assertions fail. func TestSetupGate_AStrangerIsRefused(t *testing.T) { s := gateHarness(t) w := gateAsk(s, "gapp.example.hu", "GET", "/setup", "text/html,application/xhtml+xml") if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu/__gate/start?rd=https%3A%2F%2Fgapp.example.hu%2Fsetup") { t.Fatalf("a stranger's browser: %d %q — want 302 to the dashboard's gate page", w.Code, w.Header().Get("Location")) } for _, c := range []struct{ method, accept string }{{"POST", "text/html"}, {"GET", "application/json"}, {"PUT", "*/*"}} { if w := gateAsk(s, "gapp.example.hu", c.method, "/api/auth/admin-sign-up", c.accept); w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), "waiting for its first setup") { t.Fatalf("a stranger's %s %s: %d %q — want 401", c.method, c.accept, w.Code, w.Body.String()) } } if w := gateAsk(s, "nobody.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden { t.Fatalf("a host no gated app owns: %d, want 403 (fail closed)", w.Code) } if w := gateAsk(s, "gapp.example.hu", "GET", gateCallbackURI+"?t=eyJoIjoiZ2FwcC5leGFtcGxlLmh1In0", "text/html"); w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 { t.Fatalf("a forged pass: %d cookies=%d, want 403 and no cookie", w.Code, len(w.Result().Cookies())) } // The gate page, without a dashboard session: the sentence and a sign-in link, no pass. w = gateStart(s, "https://gapp.example.hu/setup") body := w.Body.String() if w.Code != http.StatusOK || !strings.Contains(body, "Jelentkezz be a Felhom") || !strings.Contains(body, "/login?next=%2F__gate%2Fstart") { t.Fatalf("gate page: %d, sentence/link missing:\n%s", w.Code, body) } if strings.Contains(body, gateCallbackURI) || len(w.Result().Cookies()) != 0 { t.Fatal("the gate page handed a stranger a pass") } for _, rd := range []string{"https://evil.example/", "https://other.example.hu/", "http://gapp.example.hu/", "https://gapp.example.hu:8443/"} { if w := gateStart(s, rd); w.Code != http.StatusFound || w.Header().Get("Location") != "/" { t.Fatalf("rd %q: %d %q — want a plain 302 to / (no open redirect)", rd, w.Code, w.Header().Get("Location")) } } } // The household passes with its dashboard session: a one-use token, swapped for a host-only gate cookie that // opens that app's gate and no other. The dashboard cookie never reaches the app host. // COMPANION RED-PROOF: drop the nonce check in takeGateToken → "a token worked twice" fails; drop the host from // the cookie's MAC → "the pass for gapp opened gapp-db" fails. func TestSetupGate_TheHouseholdPassesWithItsSession(t *testing.T) { s := gateHarness(t) sess := &http.Cookie{Name: sessionCookieName, Value: s.createSession()} w := gateStart(s, "https://gapp.example.hu/setup", sess) loc := w.Header().Get("Location") if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://gapp.example.hu"+gateCallbackURI+"?t=") { t.Fatalf("with a session: %d %q — want 302 to the app's callback", w.Code, loc) } cb, _ := url.Parse(loc) w = gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html") if w.Code != http.StatusFound || w.Header().Get("Location") != "https://gapp.example.hu/setup" { t.Fatalf("callback: %d %q", w.Code, w.Header().Get("Location")) } var pass *http.Cookie for _, c := range w.Result().Cookies() { if c.Name == sessionCookieName { t.Fatal("the dashboard session cookie was set on the app host") } if c.Name == gateCookieName { pass = c } } if pass == nil || !pass.HttpOnly || !pass.Secure || pass.Domain != "" { t.Fatalf("gate cookie %+v — want HttpOnly, Secure, host-only", pass) } if w := gateAsk(s, "gapp.example.hu", "POST", "/api/auth/admin-sign-up", "application/json", pass); w.Code != http.StatusOK { t.Fatalf("the household's pass: %d, want 200", w.Code) } if w := gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html"); w.Code != http.StatusForbidden { t.Fatalf("a token worked twice: %d", w.Code) } if w := gateAsk(s, "gapp-db.example.hu", "GET", "/", "application/json", pass); w.Code != http.StatusUnauthorized { t.Fatalf("the pass for gapp opened gapp-db: %d", w.Code) } // A token for one host is refused on another, and an expired one is refused. w = gateStart(s, "https://gapp.example.hu/", sess) cb2, _ := url.Parse(w.Header().Get("Location")) if w := gateAsk(s, "gapp-db.example.hu", "GET", cb2.RequestURI(), "text/html"); w.Code != http.StatusForbidden { t.Fatalf("a token for gapp worked on gapp-db: %d", w.Code) } s.gateClock = func() time.Time { return time.Now().Add(gateTokenLife + time.Minute) } if w := gateAsk(s, "gapp.example.hu", "GET", cb2.RequestURI(), "text/html"); w.Code != http.StatusForbidden { t.Fatalf("an expired token: %d", w.Code) } } // A controller restart does not re-gate a browser that already passed: the key is persisted. // COMPANION RED-PROOF: skip the os.WriteFile of the key in gateKey → the second server rejects the pass. func TestSetupGate_ARestartKeepsTheHouseholdsPass(t *testing.T) { s := gateHarness(t) sess := &http.Cookie{Name: sessionCookieName, Value: s.createSession()} cb, _ := url.Parse(gateStart(s, "https://gapp.example.hu/", sess).Header().Get("Location")) var pass *http.Cookie for _, c := range gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html").Result().Cookies() { if c.Name == gateCookieName { pass = c } } if pass == nil { t.Fatal("no pass") } s2 := &Server{cfg: s.cfg, settings: s.settings, stackMgr: s.stackMgr, logger: s.logger, sessions: map[string]*session{}} if w := gateAsk(s2, "gapp.example.hu", "GET", "/", "application/json", pass); w.Code != http.StatusOK { t.Fatalf("after a restart the household's pass was refused: %d", w.Code) } if fi, err := os.Stat(filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key")); err != nil || fi.Mode().Perm() != 0o600 { t.Fatalf("key file: %v %v", fi, err) } } // Once the gate is open, the answerer lets everything through (traefik may still hold the file for a moment). // COMPANION RED-PROOF: drop the `if !closed` branch in ServeGateAuth → the opened app still refuses. func TestSetupGate_AnOpenedGateLetsEverythingThrough(t *testing.T) { s := gateHarness(t) if err := s.stackMgr.OpenSetupGate("gapp", stacks.SetupGateByHousehold); err != nil { t.Fatal(err) } if w := gateAsk(s, "gapp.example.hu", "POST", "/api/x", "application/json"); w.Code != http.StatusOK { t.Fatalf("an opened gate: %d, want 200", w.Code) } if w := gateStart(s, "https://gapp.example.hu/"); w.Header().Get("Location") != "/" { t.Fatalf("the gate page served for an opened app: %d %q", w.Code, w.Header().Get("Location")) } }