// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package auth import ( "net/http" "net/http/httptest" "testing" "github.com/alexedwards/scs/v2" ) // newTestConfig builds a minimal Config whose Middleware only touches // SessionManager, so no Redis/OIDC wiring is needed. scs.New() defaults to // an in-memory store when Store is left nil. func newTestConfig() *Config { sm := scs.New() return &Config{SessionManager: sm} } // TestMiddleware_ExpiredSession_HTMXRequest_RespondsWithHXRedirect covers // finding #15: an HTMX request against an expired/unauthenticated session // used to get a 302 to the (cross-origin) Keycloak authorize URL, which the // XHR can't follow — error-handler.js showed a lying "Network error" toast. // The middleware must instead respond 401 with HX-Redirect: /login so htmx // performs a same-origin client-side redirect. func TestMiddleware_ExpiredSession_HTMXRequest_RespondsWithHXRedirect(t *testing.T) { cfg := newTestConfig() mw := cfg.Middleware() nextCalled := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true }) req := httptest.NewRequest(http.MethodGet, "/partials/operator/organizations/abc/enrollment", nil) req.Header.Set("HX-Request", "true") rec := httptest.NewRecorder() cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req) if nextCalled { t.Fatal("expected middleware to short-circuit an unauthenticated HTMX request, but next handler ran") } if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rec.Code) } if got := rec.Header().Get("HX-Redirect"); got != "/login" { t.Fatalf("expected HX-Redirect header %q, got %q", "/login", got) } } // TestMiddleware_ExpiredSession_NonHTMXRequest_RedirectsToLogin verifies a // plain browser navigation still gets the original 302-to-/login behavior // (unchanged by the finding #15 fix). func TestMiddleware_ExpiredSession_NonHTMXRequest_RedirectsToLogin(t *testing.T) { cfg := newTestConfig() mw := cfg.Middleware() next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("expected middleware to short-circuit an unauthenticated request, but next handler ran") }) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) rec := httptest.NewRecorder() cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("expected 302, got %d", rec.Code) } if got := rec.Header().Get("Location"); got != "/login" { t.Fatalf("expected Location header %q, got %q", "/login", got) } if got := rec.Header().Get("HX-Redirect"); got != "" { t.Fatalf("did not expect HX-Redirect on a non-HTMX request, got %q", got) } } // TestMiddleware_BareRoot_SetsNoSessionCookie covers design D3's anonymous // sessions requirement: a bare GET / would only ever store the default // return path, which resolveReturnTo already falls back to, so bounceToLogin // stores nothing for it and the session manager has nothing to commit — no // Set-Cookie at all, not even an empty session. func TestMiddleware_BareRoot_SetsNoSessionCookie(t *testing.T) { cfg := newTestConfig() mw := cfg.Middleware() next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("expected the middleware to redirect an unauthenticated request, not call next") }) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" { t.Fatalf("expected 302 to /login, got %d %q", rec.Code, rec.Header().Get("Location")) } for _, c := range rec.Result().Cookies() { if c.Name == cfg.SessionManager.Cookie.Name { t.Fatalf("a bare GET / set a session cookie (%q); the store should not have been written", c.Value) } } } // TestMiddleware_PublicPath_SkipsAuth guards against a regression where the // HX-Request branch swallows the public-path bypass. func TestMiddleware_PublicPath_SkipsAuth(t *testing.T) { cfg := newTestConfig() mw := cfg.Middleware() nextCalled := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest(http.MethodGet, "/login", nil) req.Header.Set("HX-Request", "true") rec := httptest.NewRecorder() cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req) if !nextCalled { t.Fatal("expected /login to bypass auth and reach the next handler") } if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } }