package web import ( "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // v0.281.0 — the household's "Done" press asks the app first (Part A), and the sign-up block's answer (decision 47). func pressDone(s *Server) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, "/apps/gapp/setup-gate/open", nil) w := httptest.NewRecorder() s.appSetupGateOpenHandler(w, r, "gapp") return w } // The uptime-kuma shape of 2026-09-29: a press BEFORE the setup. With a probe it is refused while the app says // "not done", and when the probe cannot be read; it opens once the app says "done". // COMPANION RED-PROOF: remove the SetupGateProbe check in appSetupGateOpenHandler → the first press opens the app. func TestSetupGateButton_RefusedWhileTheAppSaysNotDone(t *testing.T) { s := gateHarness(t) app := filepath.Join(s.cfg.Paths.StacksDir, "gapp") if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Gated App\nslug: gapp\nsetup_gate: true\nsetup_done_probe:\n url: http://gapp:80/status\n field: isInit\n done: \"true\"\n"), 0o644); err != nil { t.Fatal(err) } if err := s.stackMgr.ScanStacks(); err != nil { t.Fatal(err) } answer, probeErr := `{"isInit":false}`, error(nil) defer stacks.SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(answer), probeErr })() if w := pressDone(s); w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "még nincs kész") { t.Fatalf("press before the setup: %d %s — want 409 and the sentence", w.Code, w.Body.String()) } if _, closed, _ := s.stackMgr.SetupGateHost("gapp.example.hu"); !closed { t.Fatal("the press before the setup opened the gate") } answer, probeErr = `{"isInit":true}`, errors.New("connection refused") if w := pressDone(s); w.Code != http.StatusConflict { t.Fatalf("an unreadable status: %d — want 409 (fail closed)", w.Code) } probeErr = nil if w := pressDone(s); w.Code != http.StatusOK { t.Fatalf("the app says done: %d %s", w.Code, w.Body.String()) } if _, closed, _ := s.stackMgr.SetupGateHost("gapp.example.hu"); closed { t.Fatal("still closed after a press the app agreed with") } } // The sign-up address answers "closed": a page for a browser, 403 JSON for anything else. func TestSignupClosed_TheAnswer(t *testing.T) { s := gateHarness(t) r := httptest.NewRequest(http.MethodGet, "https://gapp.example.hu"+signupClosedPath, nil) r.Host = "gapp.example.hu" r.Header.Set("Accept", "text/html") w := httptest.NewRecorder() s.ServeSignupClosed(w, r) if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "nem lehet regisztr") { t.Fatalf("browser: %d\n%s", w.Code, w.Body.String()) } r = httptest.NewRequest(http.MethodPost, signupClosedPath, strings.NewReader(`{"username":"x"}`)) w = httptest.NewRecorder() s.ServeSignupClosed(w, r) if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "sign-up is closed") { t.Fatalf("API: %d %s", w.Code, w.Body.String()) } // No block on this app (it is still gated): the window press says so. req := httptest.NewRequest(http.MethodPost, "/apps/gapp/signup-window", nil) rec := httptest.NewRecorder() s.appSignupWindowHandler(rec, req, "gapp") if rec.Code != http.StatusConflict { t.Fatalf("window on a gated app: %d", rec.Code) } } // The page: a probe app now has the press too; an app without one asks first, in the page (felhomConfirm); the // sign-up card with the app's own "how to add a family member" and the window button. func TestSetupGatePage_ConfirmAndSignupCard(t *testing.T) { noProbe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true}) if !strings.Contains(noProbe, "felhomConfirm(b, 'Csak akkor nyomd meg") { t.Fatal("no-probe press without the in-page confirm") } probe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true, "SetupGateHasProbe": true}) if !strings.Contains(probe, "/apps/gapp/setup-gate/open") || strings.Contains(probe, "felhomConfirm(b, 'Csak akkor") { t.Fatal("probe app: want the press, without the confirm") } closed := renderAppInfoWith(t, map[string]interface{}{"SignupClosed": true, "AppInfo": stacks.AppInfo{Tagline: "t", AddPeople: "Beállítások → Felhasználók → Meghívás."}}) if !strings.Contains(closed, `id="signup-card"`) || !strings.Contains(closed, "Meghívás") || !strings.Contains(closed, "/apps/gapp/signup-window") { t.Fatal("sign-up card: text, how-to or window button missing") } open := renderAppInfoWith(t, map[string]interface{}{"SignupOpenUntil": "14:05"}) if !strings.Contains(open, "14:05") || strings.Contains(open, "/apps/gapp/signup-window") { t.Fatal("open window: want the end time, no button") } if none := renderAppInfoWith(t, nil); strings.Contains(none, `id="signup-card"`) { t.Fatal("a sign-up card on an app with no block") } }