package web import ( "encoding/json" "errors" "fmt" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "golang.org/x/crypto/bcrypt" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/family" ) // R-1111 (the share-password and escrow re-auth limiters reserve the try before the slow compare), R-1112 (family // sessions are stored as fingerprints) and R-1113 (the household's family session is reused, not minted on every pass). // Each test drives the handler the browser reaches. // slowWrongCompare makes s's password compare wait until `tries` callers have arrived (or a short deadline) and then // answer "wrong": with check-then-count every try reaches it; with the try reserved first only loginMaxAttempts do. // It returns the counter of tries that reached the compare. func slowWrongCompare(s *Server, tries int32) *int32 { var calls int32 all := make(chan struct{}) var once sync.Once s.passwordCompare = func(hash, pw []byte) error { if atomic.AddInt32(&calls, 1) == tries { once.Do(func() { close(all) }) } select { case <-all: case <-time.After(400 * time.Millisecond): } return errors.New("mismatch") } return &calls } // inParallel runs f(i) for i in [0,n) released at once, and waits for all. func inParallel(n int, f func(i int)) { var wg sync.WaitGroup start := make(chan struct{}) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start f(i) }(i) } close(start) wg.Wait() } // ── R-1111: parallel share-password tries are counted ──────────────────────────────────────────────────────── func TestR1111_ParallelSharePasswordsHitTheLimit(t *testing.T) { s := shareTestServer(t) mux := s.fullMux() s.settings.SetLauncherShareToken(testShareToken) hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost) s.settings.SetLauncherSharePasswordHash(string(hash)) path := "/s/" + testShareToken const tries = 20 calls := slowWrongCompare(s, tries) var locked int32 inParallel(tries, func(int) { if strings.Contains(s.sharePOST(mux, path, "wrong").Body.String(), "Túl sok sikertelen") { atomic.AddInt32(&locked, 1) } }) if got := atomic.LoadInt32(calls); got > loginMaxAttempts { t.Fatalf("%d parallel wrong share passwords reached the compare — the limit is %d per visitor", got, loginMaxAttempts) } if got := atomic.LoadInt32(&locked); got != tries-loginMaxAttempts { t.Fatalf("%d tries were told they were locked, want %d", got, tries-loginMaxAttempts) } // the share's counter is its own: the household's dashboard counter is untouched if len(s.loginAttempts) != 0 { t.Fatalf("the share password counted on the dashboard's counter: %v", s.loginAttempts) } // another visitor with the right password still gets in, and a right password clears that visitor's count s.passwordCompare = nil csrf := s.shareCSRFToken() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(url.Values{"_csrf": {csrf}, "password": {"guest-secret"}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf}) req.RemoteAddr = "203.0.113.77:4000" rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("another visitor with the right share password: %d", rr.Code) } if s.shareAttempts["203.0.113.77"] != nil { t.Fatal("a right share password must clear the visitor's count") } } // ── R-1111: parallel escrow re-auth tries are counted ──────────────────────────────────────────────────────── func TestR1111_ParallelEscrowReauthsHitTheLimit(t *testing.T) { s := rateLimitTestServer(t) const tries = 20 calls := slowWrongCompare(s, tries) var wrong, locked int32 inParallel(tries, func(i int) { r := httptest.NewRequest(http.MethodPost, "/api/escrow/start", strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = fmt.Sprintf("198.51.100.9:%d", 6000+i) w := httptest.NewRecorder() s.escrowStartAPIHandler(w, r) switch w.Code { case http.StatusUnauthorized: atomic.AddInt32(&wrong, 1) case http.StatusTooManyRequests: atomic.AddInt32(&locked, 1) } }) if got := atomic.LoadInt32(calls); got > loginMaxAttempts { t.Fatalf("%d parallel wrong re-auth passwords reached the compare — the limit is %d per visitor", got, loginMaxAttempts) } if wrong+locked != tries { t.Fatalf("every try must be answered as wrong or locked: %d + %d of %d", wrong, locked, tries) } // the re-auth counts on the login counter: the same visitor's dashboard sign-in is locked too s.passwordCompare = nil if body := doLogin(s, "198.51.100.9:7000", "", "correct-pass").Body.String(); !strings.Contains(body, "Túl sok hibás próbálkozás") { t.Fatalf("the re-auth tries must count on the login counter; the sign-in was not locked: %s", ex(body)) } } // ── R-1112: family.json holds no value a request can use as a family session ──────────────────────────────── // familyFileStrings is every string in family.json (keys and values, at any depth). func familyFileStrings(t *testing.T, s *Server) []string { t.Helper() b, err := os.ReadFile(filepath.Join(s.cfg.Paths.DataDir, "family.json")) if err != nil { t.Fatal(err) } var v any if err := json.Unmarshal(b, &v); err != nil { t.Fatal(err) } var out []string var walk func(any) walk = func(v any) { switch x := v.(type) { case string: out = append(out, x) case []any: for _, e := range x { walk(e) } case map[string]any: for k, e := range x { out = append(out, k) walk(e) } } } walk(v) return out } // familyStartMints reports whether /__family/start mints a pass for a browser holding cookie as its family session. func familyStartMints(s *Server, cookie string) bool { r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {"https://fapp.example.hu/"}}.Encode(), nil) r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: cookie}) w := httptest.NewRecorder() s.ServeFamilyStart(w, r) return strings.Contains(w.Header().Get("Location"), familyCallbackURI) } func assertFileHoldsNoSession(t *testing.T, s *Server, cookie string) { t.Helper() strs := familyFileStrings(t, s) if len(strs) < 5 { t.Fatalf("family.json holds too little to judge: %d strings", len(strs)) } for _, v := range strs { if v == cookie { t.Fatal("family.json holds the family session's cookie value itself") } if familyStartMints(s, v) { t.Fatalf("a string read from family.json (len %d) minted a pass when presented as the family session", len(v)) } } if !familyStartMints(s, cookie) { t.Fatal("the browser's own family session must still mint a pass") } } func TestR1112_FamilyFileHoldsNoUsableSession(t *testing.T) { s, st := familyHarness(t) pw, _ := st.Add("anna") sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie) if sess == nil { t.Fatal("no family session after the sign-in") } assertFileHoldsNoSession(t, s, sess.Value) // and after a restart (the store read back from the file) the cookie still holds st2, err := family.Open(s.cfg.Paths.DataDir) if err != nil { t.Fatal(err) } if _, ok := st2.Valid(sess.Value); !ok { t.Fatal("after a reopen the member's family session must still hold") } } // A family.json written before R-1112 (sessions stored as their cookie values) is converted on load and rewritten: the // members stay signed in, and the values leave the disk. func TestR1112_OldFamilyFileIsConvertedOnLoad(t *testing.T) { s, _ := familyHarness(t) h, _ := bcrypt.GenerateFromPassword([]byte("anna-pass"), bcrypt.MinCost) member := "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718" // 24 random bytes in hex: the old session id shape household := "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978" exp := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339) old := `{"members":[{"name":"anna","hash":"` + string(h) + `","gen":1,"created":"2026-10-01T00:00:00Z"}],` + `"sessions":[{"id":"` + member + `","member":"anna","gen":1,"exp":"` + exp + `"},` + `{"id":"` + household + `","member":"","gen":0,"exp":"` + exp + `"}]}` path := filepath.Join(s.cfg.Paths.DataDir, "family.json") if err := os.WriteFile(path, []byte(old), 0o600); err != nil { t.Fatal(err) } st, err := family.Open(s.cfg.Paths.DataDir) if err != nil { t.Fatal(err) } st.SetCost(bcrypt.MinCost) s.familyStoreOverride = st b, _ := os.ReadFile(path) if strings.Contains(string(b), member) || strings.Contains(string(b), household) { t.Fatal("the old session values are still in family.json after the load") } assertFileHoldsNoSession(t, s, member) // the member's existing sign-in goes on opening the app app := famPass(t, s, &http.Cookie{Name: familySessionCookie, Value: member}) if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK { t.Fatalf("the converted session's app cookie must pass: %d", w.Code) } } // ── R-1113: household passes reuse one family session ───────────────────────────────────────────────────────── func familyFileSessions(t *testing.T, s *Server) []map[string]any { t.Helper() b, err := os.ReadFile(filepath.Join(s.cfg.Paths.DataDir, "family.json")) if err != nil { t.Fatal(err) } var f struct { Sessions []map[string]any `json:"sessions"` } if err := json.Unmarshal(b, &f); err != nil { t.Fatal(err) } return f.Sessions } func TestR1113_HouseholdPassesLeaveOneSession(t *testing.T) { s, st := familyHarness(t) now := time.Now() st.SetClock(func() time.Time { return now }) dash := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)} var apps []*http.Cookie for i := 0; i < 6; i++ { apps = append(apps, famPass(t, s, dash)) } if got := familyFileSessions(t, s); len(got) != 1 { t.Fatalf("6 household passes left %d family sessions in family.json, want 1", len(got)) } for i, app := range apps { if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK { t.Fatalf("pass %d: the household's app cookie must pass: %d", i, w.Code) } } // past half its life the session is no longer handed out: a pass starts a fresh one, and the old one keeps the // app cookies already minted from it until it expires — the list holds two at most now = now.Add(family.SessionLife/2 + time.Hour) famPass(t, s, dash) famPass(t, s, dash) if got := familyFileSessions(t, s); len(got) != 2 { t.Fatalf("past half-life: %d household sessions, want 2", len(got)) } if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", apps[0]); w.Code != http.StatusOK { t.Fatalf("an app cookie minted from the older session must pass until it expires: %d", w.Code) } // once the older one has expired only the newer is live (an expired row leaves the file at the next save) now = now.Add(family.SessionLife / 2) famPass(t, s, dash) live := 0 for _, se := range familyFileSessions(t, s) { if exp, err := time.Parse(time.RFC3339Nano, fmt.Sprint(se["exp"])); err == nil && exp.After(now) { live++ } } if live != 1 { t.Fatalf("after the older one expired: %d live household sessions, want 1", live) } } // R-1112 at START: the conversion happens when the dashboard starts, not on the first family request (measured on demo-hp // 9201 with rc.1: up, family.json still in the old shape, because nobody had passed a family gate). OpenFamilyStore is // what main calls; the conversion is logged by count, never a value. // Red-proof: OpenFamilyStore with an empty body → the old values stay in the file and this fails. func TestR1112_StartConvertsTheFamilyFile(t *testing.T) { dir := t.TempDir() household := "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978" exp := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339) path := filepath.Join(dir, "family.json") if err := os.WriteFile(path, []byte(`{"members":[],"sessions":[{"id":"`+household+`","member":"","gen":0,"exp":"`+exp+`"}]}`), 0o600); err != nil { t.Fatal(err) } cfg := config.Default() cfg.Paths.DataDir = dir var logs strings.Builder s := &Server{cfg: cfg, logger: log.New(&logs, "", 0)} s.OpenFamilyStore() b, _ := os.ReadFile(path) if strings.Contains(string(b), household) { t.Fatalf("family.json still holds the session value after the start: %s", b) } if !strings.Contains(logs.String(), "1 session(s) stored the old way were rewritten as fingerprints") { t.Errorf("the conversion must be logged by count, got %q", logs.String()) } if strings.Contains(logs.String(), household) { t.Error("the log line carries the session value") } } // The escrow re-auth's wrong-password answer is in the box's language (it was a Hungarian Go literal; found by the // R-1111 helper session 2026-10-17). Red-proof: the literal back → the English case fails. func TestEscrowReauth_WrongPasswordInTheBoxLanguage(t *testing.T) { s := rateLimitTestServer(t) for _, c := range []struct{ lang, want string }{{"hu", "Hibás jelszó."}, {"en", "Wrong password."}} { r := httptest.NewRequest(http.MethodPost, "/api/escrow/start?lang="+c.lang, strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "198.51.100.77:6000" w := httptest.NewRecorder() s.escrowStartAPIHandler(w, r) if w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), c.want) { t.Errorf("%s: want 401 with %q, got %d %s", c.lang, c.want, w.Code, ex(w.Body.String())) } } }