package web import ( "html" "io" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strings" "testing" "time" "golang.org/x/crypto/bcrypt" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/family" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // v0.287.0 (`09` §3 decisions 63/64, R-780) — the family gate's answerer and its sign-in pages, driven through the // handlers traefik and the browser reach (ServeFamilyGateAuth, ServeFamilyStart/Login/Logout) and through the // dashboard's own RequireAuth. Docker is a stub on PATH. func familyHarness(t *testing.T) (*Server, *family.Store) { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "bin") for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "fapp"), 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) write := func(app, name, body string) { if err := os.WriteFile(filepath.Join(dir, "stacks", app, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } write("fapp", "docker-compose.yml", "services:\n fapp:\n image: busybox\n") write("fapp", ".felhom.yml", "display_name: Family App\nslug: fapp\nfamily_gate: true\n") write("fapp", "app.yaml", "deployed: true\nfamily_gate:\n since: \"2026-10-02T00:00:00Z\"\n hosts: [fapp.example.hu]\n") write("gapp", "docker-compose.yml", "services:\n gapp:\n image: busybox\n") write("gapp", ".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n") write("gapp", "app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.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") h, _ := bcrypt.GenerateFromPassword([]byte("dashboard-pass"), bcrypt.MinCost) cfg.Web.PasswordHash = string(h) 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) } st, err := family.Open(cfg.Paths.DataDir) if err != nil { t.Fatal(err) } st.SetCost(bcrypt.MinCost) s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{}, loginAttempts: map[string]*loginAttempt{}, familyStoreOverride: st} s.loadTemplates() return s, st } func famAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, 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.ServeFamilyGateAuth(w, r) return w } func famCookie(w *httptest.ResponseRecorder, name string) *http.Cookie { for _, c := range w.Result().Cookies() { if c.Name == name { return c } } return nil } // famLogin posts the sign-in form as visitor `remote` (a direct peer — clientIP is the peer). func famLogin(s *Server, remote, name, pw, rd string) *httptest.ResponseRecorder { form := url.Values{"_ft": {s.familyFormToken()}, "name": {name}, "password": {pw}, "rd": {rd}} r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = remote + ":5000" w := httptest.NewRecorder() s.ServeFamilyLogin(w, r) return w } // famPass walks a signed-in browser (its family session cookie) through start → callback → app cookie. func famPass(t *testing.T, s *Server, sess *http.Cookie) *http.Cookie { t.Helper() rd := "https://fapp.example.hu/books" r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) r.AddCookie(sess) w := httptest.NewRecorder() s.ServeFamilyStart(w, r) loc := w.Header().Get("Location") if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://fapp.example.hu"+familyCallbackURI+"?t=") { t.Fatalf("start with a family session: %d %q", w.Code, loc) } u, _ := url.Parse(loc) cb := famAsk(s, "fapp.example.hu", "GET", u.RequestURI(), "text/html") app := famCookie(cb, familyAppCookie) if cb.Code != http.StatusFound || cb.Header().Get("Location") != rd || app == nil { t.Fatalf("callback: %d %q cookie %v", cb.Code, cb.Header().Get("Location"), app) } return app } // Exit item 1: a stranger reaches nothing — a browser is sent to the family sign-in, anything else is refused. func TestFamilyGate_StrangerReachesNothing(t *testing.T) { s, _ := familyHarness(t) w := famAsk(s, "fapp.example.hu", "GET", "/", "text/html") if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu"+familyStartPath+"?rd=") { t.Fatalf("a browser must be sent to the family sign-in: %d %q", w.Code, w.Header().Get("Location")) } for _, m := range []string{"GET", "POST"} { if w := famAsk(s, "fapp.example.hu", m, "/api/x", "application/json"); w.Code != http.StatusUnauthorized { t.Fatalf("%s API without a pass: %d", m, w.Code) } } if w := famAsk(s, "other.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden { t.Fatalf("a host no family app owns must be refused: %d", w.Code) } forged := &http.Cookie{Name: familyAppCookie, Value: "abc." + "99999999999" + ".deadbeef"} if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", forged); w.Code != http.StatusUnauthorized { t.Fatalf("a forged app cookie must be refused: %d", w.Code) } } // Exit item 2 + rule 1: a member's OWN login opens the app; the family session never opens the dashboard and the // family pages never set the dashboard cookie. // COMPANION RED-PROOF: make RequireAuth accept felhom_family → the dashboard assertion fails. func TestFamilyGate_MemberPassesButNeverTheDashboard(t *testing.T) { s, st := familyHarness(t) pw, _ := st.Add("anna") w := famLogin(s, "203.0.113.10", "anna", pw, "https://fapp.example.hu/books") sess := famCookie(w, familySessionCookie) if w.Code != http.StatusFound || sess == nil || sess.Path != familyCookiePath || sess.MaxAge < 7*24*3600 { t.Fatalf("sign-in: %d cookie %+v", w.Code, sess) } if famCookie(w, sessionCookieName) != nil { t.Fatal("the family sign-in must never set the dashboard cookie") } app := famPass(t, s, sess) if app.MaxAge < 7*24*3600 || app.Domain != "" { t.Fatalf("the app cookie must last days and be host-only: %+v", app) } if w := famAsk(s, "fapp.example.hu", "GET", "/books", "text/html", app); w.Code != http.StatusOK { t.Fatalf("the member's app cookie must pass: %d", w.Code) } // the same cookies at the dashboard: refused h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) })) for _, p := range []string{"/launcher", "/api/stacks", "/settings/security"} { r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil) r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: sess.Value}) r.AddCookie(&http.Cookie{Name: familyAppCookie, Value: app.Value}) rw := httptest.NewRecorder() h.ServeHTTP(rw, r) if rw.Code == 299 { t.Fatalf("a family cookie opened the dashboard at %s", p) } } // a cookie for another app host does not pass if w := famAsk(s, "fapp2.example.hu", "GET", "/", "application/json", app); w.Code == http.StatusOK { t.Fatal("an app cookie must not pass another host") } } // Rule 3 + exit item 2's logout: a reset, a removal and a logout each end access on the NEXT request. func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) { s, st := familyHarness(t) pw, _ := st.Add("anna") login := func() *http.Cookie { w := famLogin(s, "203.0.113.10", "anna", pw, "") return famCookie(w, familySessionCookie) } ok := func(app *http.Cookie) bool { return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK } sess := login() app := famPass(t, s, sess) pw, _ = st.Reset("anna") if ok(app) { t.Fatal("a reset password must end the member's app access at once") } sess = login() app = famPass(t, s, sess) r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil) r.AddCookie(sess) s.ServeFamilyLogout(httptest.NewRecorder(), r) if ok(app) { t.Fatal("logout must end the app access minted from that session") } sess = login() app = famPass(t, s, sess) st.Remove("anna") if ok(app) { t.Fatal("a removed member must lose access at once") } } // Rule 2: the sign-in is counted per VISITOR and per NAME — a stranger locks only himself, and a name under attack is // locked for minutes, never the household. func TestFamilyGate_LockPerVisitorAndPerName(t *testing.T) { s, st := familyHarness(t) now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) s.gateClock = func() time.Time { return now } pa, _ := st.Add("anna") pb, _ := st.Add("bela") for i := 0; i < familyVisitorMax; i++ { famLogin(s, "198.51.100.66", "anna", "wrong", "") } if w := famLogin(s, "198.51.100.66", "anna", pa, ""); w.Code != http.StatusTooManyRequests { t.Fatalf("the stranger's own address must be locked even with the right password: %d", w.Code) } if w := famLogin(s, "203.0.113.10", "anna", pa, ""); w.Code != http.StatusOK && w.Code != http.StatusFound { t.Fatalf("anna from her own address must get in at once: %d", w.Code) } // a name under a spread attack (many addresses) for i := 0; i < familyNameMax; i++ { famLogin(s, "198.51.100."+string(rune('a'+i)), "bela", "wrong", "") } if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code != http.StatusTooManyRequests { t.Fatalf("a name under a spread attack must be locked: %d", w.Code) } if w := famLogin(s, "203.0.113.20", "anna", pa, ""); w.Code == http.StatusTooManyRequests { t.Fatal("another member must not be locked by bela's attack") } now = now.Add(familyNameWindow + time.Second) if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code == http.StatusTooManyRequests { t.Fatal("the name lock must pass after its window (minutes, not forever)") } } // The household's dashboard session vouches (decision 46's rule) — as a household session in the family store. func TestFamilyGate_HouseholdPassesWithItsDashboardSession(t *testing.T) { s, _ := familyHarness(t) rd := "https://fapp.example.hu/" r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}) w := httptest.NewRecorder() s.ServeFamilyStart(w, r) if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), familyCallbackURI) { t.Fatalf("the household must pass: %d %q", w.Code, w.Header().Get("Location")) } // no session at all: the sign-in page, never a token r2 := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) w2 := httptest.NewRecorder() s.ServeFamilyStart(w2, r2) if w2.Code != http.StatusOK || strings.Contains(w2.Header().Get("Location"), "t=") || !strings.Contains(w2.Body.String(), `name="password"`) { t.Fatalf("no session → the sign-in page: %d", w2.Code) } } // A token is one-use, bound to its host, and refused once its session ended. func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) { s, st := familyHarness(t) st.Add("anna") sid, _ := st.NewSession("anna") tok := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/") if _, _, err := s.takeFamilyToken(tok, "other.example.hu"); err == nil { t.Fatal("a token for another host must be refused") } if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err != nil { t.Fatalf("first use: %v", err) } if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err == nil { t.Fatal("a token must be one-use") } tok2 := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/") st.EndSession(sid) if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok2, "text/html"); w.Code != http.StatusForbidden { t.Fatalf("a token whose session ended must be refused: %d", w.Code) } } // Rule 4: the family gate leaves the setup gate as it was — the setup-gated app answers the setup gate's handler, the // family handler knows nothing of it. func TestFamilyGate_SetupGateUntouched(t *testing.T) { s, _ := familyHarness(t) if w := famAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden { t.Fatalf("the family handler must not answer for a setup-gated app: %d", w.Code) } if w := gateAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusFound { t.Fatalf("the setup gate must still send a browser to its start: %d", w.Code) } if w := gateAsk(s, "fapp.example.hu", "GET", "/", "application/json"); w.Code != http.StatusForbidden { t.Fatalf("the setup gate must not answer for a family app: %d", w.Code) } } // The family sign-in's messages follow the reader (it has no session, so the language cookie decides). func TestFamilyGate_MessagesFollowTheReader(t *testing.T) { s, st := familyHarness(t) st.Add("anna") for _, c := range []struct{ lang, want, not string }{{"en", "Wrong name or password.", "Hibás név"}, {"hu", "Hibás név vagy jelszó.", "Wrong name"}} { form := url.Values{"_ft": {s.familyFormToken()}, "name": {"anna"}, "password": {"x"}} r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "192.168.0.7:1" r.AddCookie(&http.Cookie{Name: langCookieName, Value: c.lang}) w := httptest.NewRecorder() s.ServeFamilyLogin(w, r) body := html.UnescapeString(w.Body.String()) if !strings.Contains(body, c.want) || strings.Contains(body, c.not) { t.Errorf("%s: want %q not %q", c.lang, c.want, c.not) } } // an expired or forged form token is refused before any password check form := url.Values{"_ft": {"1.deadbeef"}, "name": {"anna"}, "password": {"x"}} r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.ServeFamilyLogin(w, r) if w.Code != http.StatusForbidden { t.Fatalf("a forged form token: %d", w.Code) } } // The dashboard card's acts: the password is in the answer ONCE (JSON), never in the page. func TestFamilyGate_CardActsAndNoPasswordInThePage(t *testing.T) { s, st := familyHarness(t) act := func(a, name string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, "/family/members/"+a, strings.NewReader(url.Values{"name": {name}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.familyMemberActionHandler(w, r, a) return w } w := act("add", "anna") m := regexp.MustCompile(`"password":"([a-z0-9-]{19})"`).FindStringSubmatch(w.Body.String()) if w.Code != http.StatusOK || m == nil || !st.Verify("anna", m[1]) || w.Header().Get("Cache-Control") != "no-store" { t.Fatalf("add: %d %s", w.Code, w.Body.String()) } if w := act("add", "anna"); w.Code != http.StatusConflict { t.Fatalf("duplicate: %d", w.Code) } if w := act("add", "Not Valid"); w.Code != http.StatusBadRequest { t.Fatalf("bad name: %d", w.Code) } if w := act("remove", "anna"); w.Code != http.StatusOK || strings.Contains(w.Body.String(), "password") { t.Fatalf("remove: %d %s", w.Code, w.Body.String()) } if w := act("reset", "anna"); w.Code != http.StatusNotFound { t.Fatalf("reset of a removed member: %d", w.Code) } }