7fa8768cfd
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
218 lines
9.9 KiB
Go
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"))
|
|
}
|
|
}
|