package web import ( "bytes" "io" "log" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "golang.org/x/crypto/bcrypt" ) // Guest launcher share (v0.165.0). These tests drive the pure core (token match, guest-app mapping), // the standalone templates, and the real routes through the production mux composition (fullMux = // RequireAuth + CsrfProtect + ServeHTTP, exactly as main.go wires it). Each security item carries a // companion red-proof recorded in REPORT. // shareTestServer builds a CLAIMED box (admin password set → authEnabled, claim gate off) with a // SessionSecret, so the /s/ pre-auth pass-through and the admin-auth gate on /launcher/share/* are // both exercised as they run in production. func shareTestServer(t *testing.T) *Server { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Customer.ID = "c1" cfg.Customer.Name = "Teszt" cfg.Customer.Domain = "demo-felhom.eu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Stacks.ComposeCommand = "docker compose" cfg.Web.SessionSecret = "test-session-secret-share" ph, _ := bcrypt.GenerateFromPassword([]byte("admin-pw-123456"), bcrypt.MinCost) cfg.Web.PasswordHash = string(ph) sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatalf("settings: %v", err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatalf("stacks: %v", err) } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "9.9.9-test", sessions: map[string]*session{}, loginAttempts: map[string]*loginAttempt{}, shareAttempts: map[string]*loginAttempt{}} s.loadTemplates() return s } const testShareToken = "TESTTOKEN0AAAAAAAAAAAAAAAAAA" // ── Group B core: constant-time token match (Scenario B) ────────────────────────────────────────── // COMPANION red-proof (REPORT): replace subtle.ConstantTimeCompare with strings.HasPrefix(presented, // stored) → the superstring case ("abcd" vs stored "abc") is accepted and this test FAILS. func TestShareTokenMatches(t *testing.T) { if !shareTokenMatches("abc", "abc") { t.Error("exact match must pass") } if shareTokenMatches("", "") || shareTokenMatches("", "anything") { t.Error("an empty stored token must never match (sharing disabled)") } if shareTokenMatches("abc", "ab") { t.Error("a prefix must not match") } if shareTokenMatches("abc", "abcd") { t.Error("a superstring must not match") } if shareTokenMatches("abc", "abx") { t.Error("a different token must not match") } } func TestNewShareToken_EntropyAndCharset(t *testing.T) { a, err := newShareToken() if err != nil { t.Fatal(err) } b, _ := newShareToken() if a == b { t.Error("two tokens collided — not random") } if len(a) != 27 { // 20 bytes → base64.RawURLEncoding = 27 chars t.Errorf("token length = %d, want 27 (160 bits, no padding)", len(a)) } if strings.ContainsAny(a, "+/=") { t.Errorf("token %q contains non-URL-safe chars", a) } } // ── Group A: guest happy path — headers triple, tiles, no admin chrome (Scenario A) ─────────────── // COMPANION red-proof (REPORT): render the guest page through the admin layout template → the // no-"nav-links"/no-version absence assertions FAIL. func TestShareGuest_HeadersTilesNoAdminChrome(t *testing.T) { s := shareTestServer(t) if err := s.settings.SetLauncherShareToken(testShareToken); err != nil { t.Fatal(err) } writeStack(t, s.cfg.Paths.StacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true) _ = s.stackMgr.ScanStacks() rr := httptest.NewRecorder() s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) if rr.Code != http.StatusOK { t.Fatalf("GET /s/ = %d: %s", rr.Code, rr.Body.String()) } if got := rr.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" { t.Errorf("X-Robots-Tag = %q", got) } if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" { t.Errorf("Referrer-Policy = %q", got) } if got := rr.Header().Get("Cache-Control"); got != "no-store" { t.Errorf("Cache-Control = %q", got) } body := rr.Body.String() if !strings.Contains(body, "Receptek") { t.Error("guest page must render the openable app tile") } for _, chrome := range []string{`class="sidebar"`, "nav-links", "/logout", "9.9.9-test", "alert-banner"} { if strings.Contains(body, chrome) { t.Errorf("guest page leaked admin chrome: %q", chrome) } } } // ── Group B: wrong / disabled / empty token → byte-identical to the mux default 404 (Scenario B) ── func TestShareGuest_WrongTokenIs404LikeDefault(t *testing.T) { s := shareTestServer(t) if err := s.settings.SetLauncherShareToken(testShareToken); err != nil { t.Fatal(err) } // Reference: the mux default case. /s/ bypasses auth, so both reach ServeHTTP's switch directly; // calling ServeHTTP is the apples-to-apples comparison against the default 404 branch. ref := httptest.NewRecorder() s.ServeHTTP(ref, httptest.NewRequest(http.MethodGet, "/no-such-route-xyz", nil)) wantCode, wantBody := ref.Code, ref.Body.String() cases := []string{"/s/WRONGTOKEN", "/s/"} for _, p := range cases { rr := httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) if rr.Code != wantCode || rr.Body.String() != wantBody { t.Errorf("GET %s = %d %q; want default-404 %d %q", p, rr.Code, rr.Body.String(), wantCode, wantBody) } } // Disabled (empty stored token): the previously-valid token now 404s identically. if err := s.settings.SetLauncherShareToken(""); err != nil { t.Fatal(err) } rr := httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) if rr.Code != wantCode || rr.Body.String() != wantBody { t.Errorf("disabled-token GET = %d %q; want default-404 %d %q", rr.Code, rr.Body.String(), wantCode, wantBody) } } // sharePOST posts the guest password form with a valid pre-auth HMAC CSRF pair (form field + cookie). func (s *Server) sharePOST(mux http.Handler, path, password string, extra ...*http.Cookie) *httptest.ResponseRecorder { csrf := s.shareCSRFToken() form := url.Values{"_csrf": {csrf}, "password": {password}} req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf}) for _, c := range extra { req.AddCookie(c) } rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) return rr } func cookieNamed(rr *httptest.ResponseRecorder, name string) *http.Cookie { for _, c := range rr.Result().Cookies() { if c.Name == name { return c } } return nil } // ── Group C: optional password gate (Scenario C) ────────────────────────────────────────────────── // COMPANION red-proof (REPORT): drop passwordHash from shareCookieValue's HMAC input → the // "changing the password invalidates the cookie" assertion FAILS. func TestShareGuest_PasswordGate(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 // GET with no cookie → the password gate, not the launcher. rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Ez az oldal jelszóval védett") { t.Fatalf("expected password gate, got %d: %s", rr.Code, rr.Body.String()) } // 5 wrong attempts → "Hibás jelszó"; the 6th within the window → rate-limited. for i := 0; i < 5; i++ { rr := s.sharePOST(mux, path, "wrong") if !strings.Contains(rr.Body.String(), "Hibás jelszó") { t.Fatalf("wrong attempt %d: want 'Hibás jelszó', got: %s", i+1, rr.Body.String()) } } rr = s.sharePOST(mux, path, "wrong") if !strings.Contains(rr.Body.String(), "Túl sok sikertelen") { t.Fatalf("6th attempt must be rate-limited, got: %s", rr.Body.String()) } // New IP, correct password → 303 + a signed gate cookie is set. s2 := shareTestServer(t) mux2 := s2.fullMux() s2.settings.SetLauncherShareToken(testShareToken) s2.settings.SetLauncherSharePasswordHash(string(hash)) rr = s2.sharePOST(mux2, path, "guest-secret") if rr.Code != http.StatusSeeOther { t.Fatalf("correct password: want 303, got %d: %s", rr.Code, rr.Body.String()) } gate := cookieNamed(rr, shareCookieName) if gate == nil || gate.Value == "" { t.Fatal("correct password must set the signed gate cookie") } // The cookie lets a subsequent GET through directly (no gate). req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(gate) rr = httptest.NewRecorder() mux2.ServeHTTP(rr, req) if strings.Contains(rr.Body.String(), "jelszóval védett") { t.Error("a valid gate cookie must skip the password page") } // Changing the password invalidates the outstanding cookie (it binds the hash). newHash, _ := bcrypt.GenerateFromPassword([]byte("new-secret"), bcrypt.MinCost) s2.settings.SetLauncherSharePasswordHash(string(newHash)) req = httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(gate) rr = httptest.NewRecorder() mux2.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "jelszóval védett") { t.Error("changing the password must invalidate the old gate cookie") } } // ── Group D: rotation + disable (Scenario D) ────────────────────────────────────────────────────── func TestShareGuest_RotateAndDisable(t *testing.T) { s := shareTestServer(t) s.settings.SetLauncherShareToken(testShareToken) hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) s.settings.SetLauncherSharePasswordHash(string(hash)) oldGate := &http.Cookie{Name: shareCookieName, Value: s.shareCookieValue(testShareToken, string(hash))} // Rotate: mint a new token; the old one 404s, the old gate cookie no longer passes. newTok, _ := newShareToken() s.settings.SetLauncherShareToken(newTok) rr := httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) if rr.Code != http.StatusNotFound { t.Errorf("old token after rotation: want 404, got %d", rr.Code) } // New token + old cookie → the cookie is bound to the OLD token, so the gate re-prompts. req := httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil) req.AddCookie(oldGate) rr = httptest.NewRecorder() s.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "jelszóval védett") { t.Error("rotation must invalidate the old gate cookie") } // Disable: every /s/ path 404s. s.settings.SetLauncherShareToken("") rr = httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil)) if rr.Code != http.StatusNotFound { t.Errorf("after disable: want 404, got %d", rr.Code) } } // ── Group E: guest state labels (Scenario E) ────────────────────────────────────────────────────── func TestBuildGuestApps_Labels(t *testing.T) { apps := []LauncherApp{ {Name: "a", DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning}, {Name: "b", DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped}, {Name: "c", DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited}, {Name: "d", DisplayName: "Delta", Slug: "d", Subdomain: "d", State: stacks.StateDegraded}, } g := buildGuestApps(apps, "demo-felhom.eu") if !g[0].Clickable || g[0].Href != "https://a.demo-felhom.eu" { t.Errorf("running app must be clickable with a public href, got %+v", g[0]) } if g[1].Clickable || g[1].Label != "A tulajdonos leállította" { t.Errorf("stopped app: want greyed 'A tulajdonos leállította', got %+v", g[1]) } if g[2].Clickable || g[2].Label != "Átmenetileg nem elérhető" { t.Errorf("exited app: want 'Átmenetileg nem elérhető', got %+v", g[2]) } if g[3].Clickable || g[3].Label != "Átmenetileg nem elérhető" { t.Errorf("degraded app: want 'Átmenetileg nem elérhető', got %+v", g[3]) } } // The rendered guest page shows the calm labels and NEVER the internal state vocabulary. func TestShareGuestTemplate_LabelsNoInternalWords(t *testing.T) { g := buildGuestApps([]LauncherApp{ {DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning}, {DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped}, {DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited}, }, "demo-felhom.eu") html := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": g}) if !strings.Contains(html, "A tulajdonos leállította") || !strings.Contains(html, "Átmenetileg nem elérhető") { t.Error("guest labels missing from rendered page") } for _, word := range []string{"stopped", "exited", "degraded", "unhealthy"} { if strings.Contains(html, word) { t.Errorf("internal state word %q leaked to the guest page", word) } } // Empty state. empty := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": []GuestLauncherApp{}}) if !strings.Contains(empty, "Jelenleg nincs elérhető alkalmazás.") { t.Error("empty guest launcher must show the calm empty-state copy") } } // ── Group F: claim gate supreme + admin surfaces stay admin (Scenario F) ────────────────────────── func TestShare_ClaimGateInterceptsGuestPage(t *testing.T) { s, _, _ := claimTestServer(t) // unclaimed: claim code, no password → claimGateActive s.settings.SetLauncherShareToken(testShareToken) rr := httptest.NewRecorder() s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" { t.Errorf("on an unclaimed box /s/ must hit the claim gate: got %d loc=%q", rr.Code, rr.Header().Get("Location")) } } func TestShare_AdminSurfacesRequireAuthAndCSRF(t *testing.T) { s := shareTestServer(t) mux := s.fullMux() // Unauthenticated QR + share POSTs → login redirect. for _, tc := range []struct { method, path string }{ {http.MethodGet, "/launcher/share/qr.png"}, {http.MethodPost, "/launcher/share/enable"}, {http.MethodPost, "/launcher/share/rotate"}, {http.MethodPost, "/launcher/share/disable"}, } { rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) if rr.Code != http.StatusFound || !strings.HasPrefix(rr.Header().Get("Location"), "/login") { t.Errorf("%s %s unauthenticated: want login redirect, got %d loc=%q", tc.method, tc.path, rr.Code, rr.Header().Get("Location")) } } // Authenticated but CSRF-missing POST → 403. sessTok := s.createSession() req := httptest.NewRequest(http.MethodPost, "/launcher/share/enable", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessTok}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("CSRF-missing admin POST: want 403, got %d", rr.Code) } } // ── Group G: the token never reaches the logs (Scenario G) ──────────────────────────────────────── // COMPANION red-proof (REPORT): revert the /s/ redaction in ServeHTTP (log the raw path) → the // "token absent from logs" assertion FAILS. func TestShareGuest_TokenNeverLogged(t *testing.T) { s := shareTestServer(t) var buf bytes.Buffer s.logger = log.New(&buf, "", 0) s.cfg.Logging.Level = "debug" s.settings.SetLauncherShareToken(testShareToken) // A valid GET (debug ServeHTTP line) and a wrong-token 404 must both keep the token out of logs. for _, p := range []string{"/s/" + testShareToken, "/s/WRONGSECRET123"} { rr := httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) } logs := buf.String() if strings.Contains(logs, testShareToken) || strings.Contains(logs, "WRONGSECRET123") { t.Errorf("a share token leaked into the logs:\n%s", logs) } if !strings.Contains(logs, "/s/") { t.Errorf("expected a redacted /s/ path in the debug log, got:\n%s", logs) } }