v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
gates / gates (push) Successful in 23s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-29 09:57:58 +02:00
parent 3a12c7341a
commit 149467c795
28 changed files with 1928 additions and 15 deletions
@@ -0,0 +1,105 @@
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")
}
}