diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go new file mode 100644 index 0000000..b4c94bd --- /dev/null +++ b/backend/internal/middleware/auth_test.go @@ -0,0 +1,403 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + logpkg "Noooste/garage-ui/pkg/logger" + + "github.com/gofiber/fiber/v3" + "github.com/rs/zerolog" +) + +// newAuthTestApp builds a fiber.App with RequestID + Logging (buffer-backed) + +// AuthMiddleware + a trivial /protected handler that echoes username/auth +// method into the JSON body so tests can assert on locals. +func newAuthTestApp(t *testing.T, buf *bytes.Buffer, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App { + t.Helper() + base := zerolog.New(buf) + app := fiber.New() + app.Use(RequestID()) + app.Use(Logging(base)) + app.Use(AuthMiddleware(authCfg, svc)) + app.Get("/protected", func(c fiber.Ctx) error { + uname, _ := c.Locals("username").(string) + email, _ := c.Locals("email").(string) + logpkg.FromCtx(c.Context()).Info().Msg("in_handler") + return c.JSON(fiber.Map{ + "ok": true, + "username": uname, + "email": email, + }) + }) + return app +} + +// newAuthSvc returns an *auth.Service with the given auth config, JWT +// service initialized, OIDC disabled unless the caller wires it. +func newAuthSvc(t *testing.T, authCfg *config.AuthConfig) *auth.Service { + t.Helper() + svc, err := auth.NewAuthService(authCfg, &config.ServerConfig{}) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + return svc +} + +// findLine returns the first parsed log line whose "message" field equals msg, +// failing the test if no such line is present. +func findLine(t *testing.T, buf *bytes.Buffer, msg string) map[string]any { + t.Helper() + for _, line := range parseLines(t, buf) { + if line["message"] == msg { + return line + } + } + t.Fatalf("no %q log line: %s", msg, buf.String()) + return nil +} + +func TestAuthMiddleware_BothDisabled_AllowsRequest(t *testing.T) { + authCfg := &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{Enabled: false}, + } + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["ok"] != true { + t.Errorf("ok = %v, want true", body["ok"]) + } + if body["username"] != "" { + t.Errorf("username should be empty when auth disabled, got %q", body["username"]) + } +} + +func newAdminCfg() *config.AuthConfig { + return &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"}, + OIDC: config.OIDCConfig{Enabled: false}, + } +} + +func TestAuthMiddleware_Admin_BearerValid_AllowsAndEnrichesLogger(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin", Email: "a@b"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if body["username"] != "admin" { + t.Errorf("username = %v, want admin", body["username"]) + } + if body["email"] != "a@b" { + t.Errorf("email = %v, want a@b", body["email"]) + } + + // Enriched handler log line should carry user_id and auth_method=admin. + access := findLine(t, &buf, "in_handler") + if access["user_id"] != "admin" { + t.Errorf("user_id = %v, want admin", access["user_id"]) + } + if access["auth_method"] != "admin" { + t.Errorf("auth_method = %v, want admin", access["auth_method"]) + } +} + +func TestAuthMiddleware_Admin_BearerInvalid_Returns401(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer not-a-real-token") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + var env struct { + Success bool `json:"success"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&env) + if env.Success { + t.Error("success should be false") + } + if env.Error.Code != "UNAUTHORIZED" { + t.Errorf("error.code = %q, want UNAUTHORIZED", env.Error.Code) + } + + // Warn log should carry reason=no_valid_credentials without leaking the token. + warn := findLine(t, &buf, "authentication_failed") + if warn["reason"] != "no_valid_credentials" { + t.Errorf("reason = %v", warn["reason"]) + } + if warn["level"] != "warn" { + t.Errorf("level = %v, want warn", warn["level"]) + } + if strings.Contains(buf.String(), "not-a-real-token") { + t.Error("token value must not appear in logs") + } +} + +func TestAuthMiddleware_Admin_NoAuthHeader_Returns401(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestAuthMiddleware_Admin_NonBearerScheme_Returns401(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwdw==") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func newOIDCCfg(cookieName string) *config.AuthConfig { + return &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{ + Enabled: true, + CookieName: cookieName, + }, + } +} + +// newOIDCSvc returns a Service whose OIDC is *not* initialized (no dialing +// needed), which is fine because AuthMiddleware's OIDC branch only calls +// ValidateSessionToken — a pure JWT operation. +func newOIDCSvc(t *testing.T) *auth.Service { + t.Helper() + return newAuthSvc(t, &config.AuthConfig{OIDC: config.OIDCConfig{Enabled: false}}) +} + +func TestAuthMiddleware_OIDC_ValidCookie_Allows(t *testing.T) { + cfg := newOIDCCfg("session") + svc := newOIDCSvc(t) + + tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice", Email: "a@x"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: tok}) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + access := findLine(t, &buf, "in_handler") + if access["auth_method"] != "oidc" { + t.Errorf("auth_method = %v, want oidc", access["auth_method"]) + } + if access["user_id"] != "alice" { + t.Errorf("user_id = %v, want alice", access["user_id"]) + } +} + +func TestAuthMiddleware_OIDC_InvalidCookie_Returns401(t *testing.T) { + cfg := newOIDCCfg("session") + svc := newOIDCSvc(t) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "garbage"}) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestAuthMiddleware_OIDC_NoCookie_Returns401(t *testing.T) { + cfg := newOIDCCfg("session") + svc := newOIDCSvc(t) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func newBothCfg(cookieName string) *config.AuthConfig { + return &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"}, + OIDC: config.OIDCConfig{ + Enabled: true, + CookieName: cookieName, + }, + } +} + +func TestAuthMiddleware_Both_BearerValid_AdminPathWins(t *testing.T) { + cfg := newBothCfg("session") + // OIDC disabled on the Service is fine — see Task 3 rationale. + svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin}) + + tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+tok) + // Also set a valid OIDC cookie; admin should still win. + cookieTok, _ := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"}) + req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok}) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + access := findLine(t, &buf, "in_handler") + if access["auth_method"] != "admin" { + t.Errorf("auth_method = %v, want admin", access["auth_method"]) + } + if access["user_id"] != "admin" { + t.Errorf("user_id = %v, want admin", access["user_id"]) + } +} + +func TestAuthMiddleware_Both_BearerInvalid_FallsThroughToOIDCCookie(t *testing.T) { + cfg := newBothCfg("session") + svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin}) + + cookieTok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer bogus") + req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok}) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200 (OIDC fallback)", resp.StatusCode) + } + + access := findLine(t, &buf, "in_handler") + if access["auth_method"] != "oidc" { + t.Errorf("auth_method = %v, want oidc", access["auth_method"]) + } +} + +func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *testing.T) { + cfg := newBothCfg("session") + svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin}) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer bogus") + req.AddCookie(&http.Cookie{Name: "session", Value: "also-bogus"}) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + + warn := findLine(t, &buf, "authentication_failed") + if warn["auth_method"] != "admin+oidc" { + t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"]) + } +} diff --git a/backend/internal/middleware/cors_test.go b/backend/internal/middleware/cors_test.go new file mode 100644 index 0000000..d9e057d --- /dev/null +++ b/backend/internal/middleware/cors_test.go @@ -0,0 +1,282 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/config" + + "github.com/gofiber/fiber/v3" +) + +func newCORSApp(t *testing.T, cfg *config.CORSConfig) *fiber.App { + t.Helper() + app := fiber.New() + app.Use(CORSMiddleware(cfg)) + app.Get("/x", func(c fiber.Ctx) error { + return c.SendString("ok") + }) + return app +} + +func TestCORS_Disabled_NoHeadersSet(t *testing.T) { + cfg := &config.CORSConfig{Enabled: false} + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://foo.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Allow-Origin = %q, want empty", got) + } +} + +func TestCORS_Enabled_AllowedOriginEchoes(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + MaxAge: 300, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://ok.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" { + t.Errorf("Allow-Origin = %q", got) + } + if got := resp.Header.Get("Vary"); got != "Origin" { + t.Errorf("Vary = %q, want Origin", got) + } +} + +func TestCORS_Enabled_OriginNotInList_NoHeaders(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://evil.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Allow-Origin = %q, want empty", got) + } +} + +func TestCORS_Enabled_NoOriginHeader_NoCORSHeaders(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"*"}, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Allow-Origin = %q, want empty (no Origin header)", got) + } +} + +func TestCORS_Wildcard_NoCredentials_AllowsAnyOrigin(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"*"}, + AllowCredentials: false, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://anywhere.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" { + t.Errorf("Allow-Origin = %q, want echo", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Allow-Credentials set unexpectedly: %q", got) + } +} + +func TestCORS_Wildcard_WithCredentials_RejectsUnlistedOrigin(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://evil.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Allow-Origin = %q, want empty (wildcard+creds must not honor *)", got) + } +} + +func TestCORS_ExactMatch_WithCredentials_SetsAllowCredentials(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + AllowCredentials: true, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://ok.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" { + t.Errorf("Allow-Origin = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Allow-Credentials = %q, want true", got) + } +} + +func TestCORS_Preflight_AllowedOrigin_Returns204WithHeaders(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + AllowedMethods: []string{"GET", "POST", "PUT"}, + AllowedHeaders: []string{"Authorization"}, + MaxAge: 600, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("OPTIONS", "/x", nil) + req.Header.Set("Origin", "https://ok.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 204 { + t.Fatalf("status = %d, want 204", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT" { + t.Errorf("Allow-Methods = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "Authorization" { + t.Errorf("Allow-Headers = %q", got) + } + if got := resp.Header.Get("Access-Control-Max-Age"); got != "600" { + t.Errorf("Max-Age = %q", got) + } +} + +func TestCORS_Preflight_DisallowedOrigin_Returns204NoHeaders(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("OPTIONS", "/x", nil) + req.Header.Set("Origin", "https://evil.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 204 { + t.Fatalf("status = %d, want 204", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Allow-Origin set for disallowed preflight: %q", got) + } +} + +func TestCORS_EmptyAllowedMethods_NoAllowMethodsHeader(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + // AllowedMethods intentionally nil + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://ok.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "" { + t.Errorf("Allow-Methods set when list empty: %q", got) + } +} + +func TestCORS_MaxAgeZero_NoMaxAgeHeader(t *testing.T) { + cfg := &config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ok.example"}, + MaxAge: 0, + } + app := newCORSApp(t, cfg) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Origin", "https://ok.example") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if got := resp.Header.Get("Access-Control-Max-Age"); got != "" { + t.Errorf("Max-Age set when zero: %q", got) + } +} + +func TestIsAllowedOrigin(t *testing.T) { + cases := []struct { + name string + origin string + allowed []string + allowCredentials bool + want bool + }{ + {"exact match", "https://ok.example", []string{"https://ok.example"}, false, true}, + {"exact match with creds", "https://ok.example", []string{"https://ok.example"}, true, true}, + {"wildcard without creds matches", "https://any.example", []string{"*"}, false, true}, + {"wildcard with creds rejected", "https://any.example", []string{"*"}, true, false}, + {"no match", "https://evil.example", []string{"https://ok.example"}, false, false}, + {"empty allowed list", "https://ok.example", nil, false, false}, + {"multiple entries — exact hit", "https://b.example", []string{"https://a.example", "https://b.example"}, false, true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if got := isAllowedOrigin(tc.origin, tc.allowed, tc.allowCredentials); got != tc.want { + t.Errorf("isAllowedOrigin(%q, %v, %v) = %v, want %v", + tc.origin, tc.allowed, tc.allowCredentials, got, tc.want) + } + }) + } +} diff --git a/backend/internal/routes/routes_oidc_issuer_test.go b/backend/internal/routes/routes_oidc_issuer_test.go new file mode 100644 index 0000000..d29eae4 --- /dev/null +++ b/backend/internal/routes/routes_oidc_issuer_test.go @@ -0,0 +1,209 @@ +package routes + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// testIssuer is a test-only fake OIDC provider. Fields with Fn suffix are +// per-request hooks a test can swap; defaults implement a happy path. +type testIssuer struct { + Server *httptest.Server + ClientID string + Key *rsa.PrivateKey + KeyID string + + mu sync.Mutex + + // TokenEndpointFn, if set, overrides the default /token handler. + TokenEndpointFn func(w http.ResponseWriter, r *http.Request) + // UserInfoFn, if set, overrides the default /userinfo handler. + UserInfoFn func(w http.ResponseWriter, r *http.Request) + + // Default token response state — used by the default TokenEndpointFn. + // Tests mutate these between requests rather than overriding the handler. + DefaultAccessClaims map[string]any + DefaultIDClaims map[string]any + // When true the default /token handler omits id_token from the response. + OmitIDToken bool + // When non-empty, /token returns the specified HTTP status with a JSON + // error payload — simulating code-exchange failures. + TokenError string + // When true the default /token handler returns an id_token signed with a + // rogue key, forcing ID-token verification to fail. + SignIDTokenWithWrongKey bool +} + +// newTestIssuer spins up the fake issuer and registers cleanup. +func newTestIssuer(t *testing.T) *testIssuer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + iss := &testIssuer{ + ClientID: "test-client", + Key: key, + KeyID: "test-key-1", + } + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "userinfo_endpoint": srv.URL + "/userinfo", + "jwks_uri": srv.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(iss.jwks()) + }) + + mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) { + // Tests exercise /auth/oidc/login which only reads the redirect URL; + // no need to implement the full authorize flow here. + w.WriteHeader(http.StatusOK) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if iss.TokenEndpointFn != nil { + iss.TokenEndpointFn(w, r) + return + } + iss.defaultTokenHandler(w, r) + }) + + mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { + if iss.UserInfoFn != nil { + iss.UserInfoFn(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sub": "user-1", + "preferred_username": "alice", + "email": "alice@example.com", + }) + }) + + srv = httptest.NewServer(mux) + iss.Server = srv + t.Cleanup(srv.Close) + + // Happy-path defaults; tests override fields before exercising callback. + iss.DefaultIDClaims = map[string]any{ + "iss": srv.URL, + "sub": "user-1", + "aud": iss.ClientID, + "exp": time.Now().Add(10 * time.Minute).Unix(), + "iat": time.Now().Unix(), + "preferred_username": "alice", + "email": "alice@example.com", + } + iss.DefaultAccessClaims = map[string]any{ + "iss": srv.URL, + "sub": "user-1", + "exp": time.Now().Add(10 * time.Minute).Unix(), + } + return iss +} + +func (iss *testIssuer) defaultTokenHandler(w http.ResponseWriter, r *http.Request) { + iss.mu.Lock() + defer iss.mu.Unlock() + + if iss.TokenError != "" { + http.Error(w, iss.TokenError, http.StatusBadRequest) + return + } + + access := iss.signJWT(iss.DefaultAccessClaims, iss.Key) + resp := map[string]any{ + "access_token": access, + "token_type": "Bearer", + "expires_in": 600, + } + if !iss.OmitIDToken { + key := iss.Key + if iss.SignIDTokenWithWrongKey { + rogue, _ := rsa.GenerateKey(rand.Reader, 2048) + key = rogue + } + resp["id_token"] = iss.signJWT(iss.DefaultIDClaims, key) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// signJWT signs the given claims with the given RSA key using RS256. +func (iss *testIssuer) signJWT(claims map[string]any, key *rsa.PrivateKey) string { + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims)) + tok.Header["kid"] = iss.KeyID + signed, err := tok.SignedString(key) + if err != nil { + panic(fmt.Sprintf("signJWT: %v", err)) + } + return signed +} + +// jwks returns a JWKS document exposing the issuer's RSA public key. +func (iss *testIssuer) jwks() map[string]any { + pub := iss.Key.PublicKey + return map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": iss.KeyID, + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }, + }, + } +} + +// IDToken signs an ID token with the issuer key. Tests use this to mint +// access tokens carrying nested role claims for fallback-path assertions. +func (iss *testIssuer) IDToken(claims map[string]any) string { + return iss.signJWT(claims, iss.Key) +} + +// AccessToken signs an access token carrying Keycloak-shaped resource_access +// roles at resource_access.test-client.roles. +func (iss *testIssuer) AccessToken(roles []string) string { + rolesAny := make([]any, 0, len(roles)) + for _, r := range roles { + rolesAny = append(rolesAny, r) + } + return iss.signJWT(map[string]any{ + "iss": iss.Server.URL, + "sub": "user-1", + "exp": time.Now().Add(10 * time.Minute).Unix(), + "resource_access": map[string]any{ + "test-client": map[string]any{"roles": rolesAny}, + }, + }, iss.Key) +} diff --git a/backend/internal/routes/routes_test.go b/backend/internal/routes/routes_test.go new file mode 100644 index 0000000..4f56780 --- /dev/null +++ b/backend/internal/routes/routes_test.go @@ -0,0 +1,604 @@ +package routes + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/handlers" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +// routeFixture bundles everything a routes test needs. +type routeFixture struct { + App *fiber.App + Admin *mocks.AdminMock + S3 *mocks.S3Mock + Auth *auth.Service + Cfg *config.Config +} + +// newTestApp builds a fully-wired fiber.App via SetupRoutes. The cfgMutator +// lets each test flip Admin/OIDC/CORS flags before the auth.Service is +// constructed. If the mutator sets OIDC.Enabled=true it MUST set +// OIDC.IssuerURL + Scopes + AdminRole + ClientID so NewAuthService can dial +// the issuer — typically via the testIssuer fixture. +func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture { + t.Helper() + + cfg := &config.Config{ + Server: config.ServerConfig{ + Port: 8080, + Environment: "test", + }, + Auth: config.AuthConfig{}, + CORS: config.CORSConfig{}, + } + if cfgMutator != nil { + cfgMutator(cfg) + } + + svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + admin := &mocks.AdminMock{} + s3 := &mocks.S3Mock{} + + app := fiber.New() + SetupRoutes( + app, + cfg, + svc, + handlers.NewHealthHandler("test"), + handlers.NewBucketHandler(admin, s3), + handlers.NewObjectHandler(s3), + handlers.NewUserHandler(admin), + handlers.NewClusterHandler(admin), + handlers.NewMonitoringHandler(admin, s3), + ) + + return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg} +} + +// expectStatus sends req and asserts the status code. +func expectStatus(t *testing.T, app *fiber.App, req *http.Request, want int) *http.Response { + t.Helper() + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test(%s %s): %v", req.Method, req.URL.Path, err) + } + if resp.StatusCode != want { + t.Fatalf("%s %s: status = %d, want %d", req.Method, req.URL.Path, resp.StatusCode, want) + } + return resp +} + +func TestRoutes_Registered_NoAuth(t *testing.T) { + // No auth: every route resolves; auth-specific routes return 404. + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = false + c.Auth.OIDC.Enabled = false + }) + + // Public routes reachable → not 404. Status may be anything except 404. + for _, tc := range []struct { + method, path string + }{ + {"GET", "/health"}, + {"GET", "/api/v1/health"}, + {"GET", "/auth/config"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%s %s: %v", tc.method, tc.path, err) + } + if resp.StatusCode == 404 { + t.Errorf("%s %s returned 404 — route not registered", tc.method, tc.path) + } + } + + // Auth-specific routes must 404 when both auth methods disabled. + for _, tc := range []struct { + method, path string + }{ + {"POST", "/auth/login"}, + {"GET", "/auth/me"}, + {"GET", "/auth/oidc/login"}, + {"GET", "/auth/oidc/callback"}, + {"POST", "/auth/oidc/logout"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + expectStatus(t, f.App, req, 404) + } +} + +func TestRoutes_Registered_AdminOnly(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + + // /auth/login and /auth/me present; /auth/oidc/* not. + for _, tc := range []struct { + method, path string + }{ + {"POST", "/auth/login"}, + {"GET", "/auth/me"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 404 { + t.Errorf("%s %s returned 404", tc.method, tc.path) + } + } + for _, tc := range []struct { + method, path string + }{ + {"GET", "/auth/oidc/login"}, + {"GET", "/auth/oidc/callback"}, + {"POST", "/auth/oidc/logout"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + expectStatus(t, f.App, req, 404) + } +} + +func TestRoutes_UnknownPath_Returns404(t *testing.T) { + f := newTestApp(t, nil) + req := httptest.NewRequest("GET", "/this/does/not/exist", nil) + expectStatus(t, f.App, req, 404) +} + +func TestRoutes_AllAPIRoutesRegistered(t *testing.T) { + // With Admin enabled, hit every path/method declared in SetupRoutes. + // A route being "registered" = status != 404. Specific behavior depends + // on auth (401) or mock setup (500 from errNotConfigured) — we only + // care that fiber routed the request. + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + + cases := []struct { + method, path string + }{ + // Buckets + {"GET", "/api/v1/buckets/"}, + {"POST", "/api/v1/buckets/"}, + {"GET", "/api/v1/buckets/b1"}, + {"DELETE", "/api/v1/buckets/b1"}, + {"POST", "/api/v1/buckets/b1/permissions"}, + {"PUT", "/api/v1/buckets/b1/website"}, + // Objects (listing + uploads) + {"GET", "/api/v1/buckets/b1/objects/"}, + {"POST", "/api/v1/buckets/b1/objects/"}, + {"POST", "/api/v1/buckets/b1/objects/upload-multiple"}, + {"POST", "/api/v1/buckets/b1/objects/delete-multiple"}, + // Object wildcard routes + {"GET", "/api/v1/buckets/b1/objects/folder/file.txt"}, + {"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"}, + {"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"}, + {"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"}, + {"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"}, + // Users + {"GET", "/api/v1/users/"}, + {"POST", "/api/v1/users/"}, + {"GET", "/api/v1/users/AKIA"}, + {"GET", "/api/v1/users/AKIA/secret"}, + {"DELETE", "/api/v1/users/AKIA"}, + {"PATCH", "/api/v1/users/AKIA"}, + // Cluster + {"GET", "/api/v1/cluster/health"}, + {"GET", "/api/v1/cluster/status"}, + {"GET", "/api/v1/cluster/statistics"}, + {"GET", "/api/v1/cluster/nodes/n1"}, + {"GET", "/api/v1/cluster/nodes/n1/statistics"}, + // Monitoring + {"GET", "/api/v1/monitoring/metrics"}, + {"GET", "/api/v1/monitoring/admin-health"}, + {"GET", "/api/v1/monitoring/dashboard"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 404 { + t.Errorf("route not registered (404): %s %s", tc.method, tc.path) + } + }) + } +} + +func TestRoutes_AuthRequired_For_APIRoutes(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + + // Unauthenticated requests to /api/v1/* must return 401 — auth + // middleware must run before the handler. + for _, tc := range []struct{ method, path string }{ + {"GET", "/api/v1/buckets/"}, + {"GET", "/api/v1/users/"}, + {"GET", "/api/v1/cluster/health"}, + {"GET", "/api/v1/monitoring/metrics"}, + // Object wildcard routes register AuthMiddleware separately — prove + // that wiring isn't missed for any of GET/DELETE/HEAD. + {"GET", "/api/v1/buckets/b/objects/k"}, + {"DELETE", "/api/v1/buckets/b/objects/k"}, + {"HEAD", "/api/v1/buckets/b/objects/k"}, + } { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + expectStatus(t, f.App, req, 401) + }) + } +} + +func TestRoutes_NoAuth_On_PublicRoutes(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + for _, tc := range []struct{ method, path string }{ + {"GET", "/health"}, + {"GET", "/api/v1/health"}, + {"GET", "/auth/config"}, + } { + tc := tc + t.Run(tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 401 { + t.Errorf("public route unexpectedly returned 401: %s", tc.path) + } + }) + } +} + +func TestRoutes_CORS_Preflight_PassesBeforeAuth(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + c.CORS = config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ui.example"}, + AllowedMethods: []string{"GET", "POST"}, + } + }) + + req := httptest.NewRequest("OPTIONS", "/api/v1/buckets/", nil) + req.Header.Set("Origin", "https://ui.example") + resp := expectStatus(t, f.App, req, 204) + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ui.example" { + t.Errorf("Allow-Origin = %q — CORS should have run before auth", got) + } +} + +// newOIDCFixture builds a route fixture with OIDC enabled pointing at a +// running testIssuer. The `adminRole` is applied to cfg.Auth.OIDC.AdminRole +// (pass empty to disable the role gate). +func newOIDCFixture(t *testing.T, adminRole string) (*routeFixture, *testIssuer) { + t.Helper() + iss := newTestIssuer(t) + f := newTestApp(t, func(c *config.Config) { + c.Server.RootURL = "https://app.example" + c.Auth.OIDC = config.OIDCConfig{ + Enabled: true, + ClientID: iss.ClientID, + ClientSecret: "secret", + IssuerURL: iss.Server.URL, + Scopes: []string{"openid", "profile", "email"}, + AdminRole: adminRole, + UsernameAttribute: "preferred_username", + EmailAttribute: "email", + NameAttribute: "name", + RoleAttributePath: "resource_access.test-client.roles", + CookieName: "session", + CookieSecure: false, + CookieHTTPOnly: true, + CookieSameSite: "Lax", + SessionMaxAge: 3600, + } + }) + return f, iss +} + +func TestRoutes_OIDCLogin_RedirectsToAuthorizeEndpoint(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + + req := httptest.NewRequest("GET", "/auth/oidc/login", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + loc := resp.Header.Get("Location") + if !strings.HasPrefix(loc, iss.Server.URL+"/authorize") { + t.Errorf("Location = %q, want prefix %s/authorize", loc, iss.Server.URL) + } + if !strings.Contains(loc, "state=") { + t.Errorf("Location missing state param: %s", loc) + } + if !strings.Contains(loc, "client_id=test-client") { + t.Errorf("Location missing client_id: %s", loc) + } +} + +func TestRoutes_Registered_OIDCOnly(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + + // /auth/oidc/* registered + for _, tc := range []struct{ method, path string }{ + {"GET", "/auth/oidc/login"}, + {"GET", "/auth/oidc/callback"}, + {"POST", "/auth/oidc/logout"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 404 { + t.Errorf("%s %s not registered", tc.method, tc.path) + } + } + + // /auth/login must be 404 — admin disabled. + req := httptest.NewRequest("POST", "/auth/login", nil) + expectStatus(t, f.App, req, 404) +} + +// oidcState returns a fresh state token minted by the fixture's auth service. +func oidcState(t *testing.T, f *routeFixture) string { + t.Helper() + s, err := f.Auth.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + return s +} + +func TestRoutes_OIDCCallback_MissingState_Returns400(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + req := httptest.NewRequest("GET", "/auth/oidc/callback", nil) + expectStatus(t, f.App, req, 400) +} + +func TestRoutes_OIDCCallback_InvalidState_Returns400(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + req := httptest.NewRequest("GET", "/auth/oidc/callback?state=not-a-valid-state&code=c", nil) + expectStatus(t, f.App, req, 400) +} + +func TestRoutes_OIDCCallback_MissingCode_Returns400(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state, nil) + expectStatus(t, f.App, req, 400) +} + +func TestRoutes_OIDCCallback_TokenExchangeFails_Returns401(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + iss.TokenError = "invalid_grant" + defer func() { iss.TokenError = "" }() + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 401) +} + +func TestRoutes_OIDCCallback_MissingIDToken_Returns401(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + iss.OmitIDToken = true + defer func() { iss.OmitIDToken = false }() + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 401) +} + +func TestRoutes_OIDCCallback_BadIDTokenSignature_Returns401(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + iss.SignIDTokenWithWrongKey = true + defer func() { iss.SignIDTokenWithWrongKey = false }() + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 401) +} + +func TestRoutes_OIDCCallback_RoleGateDenies_Returns403(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") // AdminRole set; no roles anywhere + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 403) +} + +func TestRoutes_OIDCCallback_NoRoleGate_HappyPathSetsCookieAndRedirects(t *testing.T) { + f, _ := newOIDCFixture(t, "") // AdminRole empty → role gate skipped + state := oidcState(t, f) + + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/login?login=success" { + t.Errorf("Location = %q", loc) + } + + cookies := resp.Cookies() + var sess *http.Cookie + for _, c := range cookies { + if c.Name == "session" { + sess = c + break + } + } + if sess == nil { + t.Fatalf("no session cookie set: %+v", cookies) + } + if sess.Value == "" { + t.Error("session cookie value empty") + } + if !sess.HttpOnly { + t.Error("session cookie should be HttpOnly") + } + if sess.MaxAge != 3600 { + t.Errorf("MaxAge = %d, want 3600", sess.MaxAge) + } +} + +func TestRoutes_OIDCCallback_RoleMatchedViaAccessTokenFallback_Succeeds(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + // Inject role into the access token so ExtractRolesFromAccessToken returns [admin]. + iss.DefaultAccessClaims = map[string]any{ + "iss": iss.Server.URL, + "sub": "user-1", + "exp": time.Now().Add(10 * time.Minute).Unix(), + "resource_access": map[string]any{ + "test-client": map[string]any{"roles": []any{"admin"}}, + }, + } + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303 (access-token role fallback should match)", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/login?login=success" { + t.Errorf("Location = %q", loc) + } + var sess *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "session" { + sess = c + } + } + if sess == nil || sess.Value == "" { + t.Fatalf("expected session cookie with value, got %+v", sess) + } +} + +func TestRoutes_OIDCLogout_ClearsCookieAndReturns200(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + + req := httptest.NewRequest("POST", "/auth/oidc/logout", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if body["success"] != true { + t.Errorf("success = %v, want true", body["success"]) + } + if body["message"] != "Logged out successfully" { + t.Errorf("message = %v", body["message"]) + } + + var sess *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "session" { + sess = c + } + } + if sess == nil { + t.Fatalf("expected session cookie in response") + } + if sess.MaxAge != -1 { + t.Errorf("MaxAge = %d, want -1 (cookie cleared)", sess.MaxAge) + } + if sess.Value != "" { + t.Errorf("cookie Value = %q, want empty", sess.Value) + } +} + +func TestRoutes_SPAFallback_NoFrontendDir_DoesNotMount(t *testing.T) { + // Chdir into an empty temp dir — frontend/dist does not exist, so the + // SPA fallback is not registered. + t.Chdir(t.TempDir()) + + f := newTestApp(t, nil) + + req := httptest.NewRequest("GET", "/random/spa/path", nil) + expectStatus(t, f.App, req, 404) +} + +func TestRoutes_SPAFallback_WithFrontend_ServesIndexForUnknownPath(t *testing.T) { + if runtime.GOOS == "windows" { + // Fiber's c.SendFile holds a handle on the served file; Windows refuses + // t.TempDir RemoveAll cleanup. Behavior itself is platform-agnostic and + // covered on Linux in CI. + t.Skip("SPA fallback test skipped on Windows due to file-handle cleanup race") + } + dir := t.TempDir() + t.Chdir(dir) + + // Create ./frontend/dist/index.html + if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + index := filepath.Join(dir, "frontend", "dist", "index.html") + if err := os.WriteFile(index, []byte("spa"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + f := newTestApp(t, nil) + + // Unknown SPA path → serves index.html. + req := httptest.NewRequest("GET", "/deep/spa/route", nil) + resp := expectStatus(t, f.App, req, 200) + body := make([]byte, 64) + n, _ := resp.Body.Read(body) + if !strings.Contains(string(body[:n]), "spa") { + t.Errorf("body = %q, want index.html content", string(body[:n])) + } + + // API prefix is skipped by the fallback → still 404 for unknown API path. + req2 := httptest.NewRequest("GET", "/api/v1/definitely-not-a-route", nil) + expectStatus(t, f.App, req2, 404) +}