Files
felhom-controller/controller/internal/web/setup_gate_test.go
T

218 lines
9.9 KiB
Go

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"))
}
}