Files
member-console/internal/auth/auth_test.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- Rotate the session token at the OIDC callback and restore the full
  lifetime; cap pre-auth sessions at 15 minutes and write no session
  for bare anonymous requests
- Treat db-dsn as a secret: accept db-dsn-file, log only host, port,
  database and user, and never echo a malformed DSN in an error
- Guard the logout callback with a state cookie so a forged visit
  cannot end a live session
- Collapse FedWiki site actions on a foreign tenant's domain to the
  not-found answer, as for a domain that does not exist
2026-09-09 20:53:31 -05:00

133 lines
4.6 KiB
Go

// 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)
}
}