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("