package httpapi import ( "io" "net/http" "net/http/httptest" "testing" "time" "github.com/golang-jwt/jwt/v5" ) const ( testJWTSecret = "test-secret-32-bytes-long-abcdef" testIssuer = "https://auth.test.local" ) func signTestJWT(t *testing.T, claims jwt.MapClaims) string { t.Helper() tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) s, err := tok.SignedString([]byte(testJWTSecret)) if err != nil { t.Fatalf("sign jwt: %v", err) } return s } func newJWTTestServer(t *testing.T) (*Server, string) { t.Helper() srv, err := New(Options{ SeedDemo: true, BundleSeedHex: testBundleSeed, JWTSecret: testJWTSecret, AuthIssuer: testIssuer, AuthPortalURL: "https://portal.test.local", AuthRequired: true, PortalTenantID: "", // filled after DemoIDs }) if err != nil { t.Fatal(err) } tenant, _, _, _, _ := srv.Store().DemoIDs() // Override tenant to match seed. srv.portalTenantID = tenant return srv, tenant } func TestAuthJWTAcceptedWithBGPApp(t *testing.T) { srv, _ := newJWTTestServer(t) defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": testIssuer, "sub": "user-1", "email": "user@test.local", "apps": []string{"bgp"}, "permissions": []string{"bgp:modules:read"}, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("status=%d body=%s", resp.StatusCode, b) } } func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) { srv, _ := newJWTTestServer(t) defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": "https://other.example.com", "sub": "user-1", "apps": []string{"bgp"}, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status=%d want 401", resp.StatusCode) } } func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) { srv, _ := newJWTTestServer(t) defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": testIssuer, "sub": "user-1", "apps": []string{"cfdm", "portal"}, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status=%d want 403", resp.StatusCode) } } func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) { srv, _ := newJWTTestServer(t) defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": testIssuer, "sub": "admin-1", "apps": []string{"bgp"}, "is_admin": true, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("status=%d body=%s", resp.StatusCode, b) } } func TestAuthJWTMissingPermissionRejected(t *testing.T) { srv, _ := newJWTTestServer(t) defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": testIssuer, "sub": "user-1", "apps": []string{"bgp"}, "permissions": []string{"bgp:modules:read"}, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status=%d want 403", resp.StatusCode) } } func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) { srv, err := New(Options{ SeedDemo: true, BundleSeedHex: testBundleSeed, JWTSecret: testJWTSecret, AuthIssuer: testIssuer, AuthPortalURL: "https://portal.test.local", AuthRequired: true, // No PortalTenantID — must come from JWT claim. }) if err != nil { t.Fatal(err) } defer srv.Close() tenant, _, _, _, _ := srv.Store().DemoIDs() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": testIssuer, "sub": "user-1", "apps": []string{"bgp"}, "permissions": []string{"bgp:modules:read"}, "bgp_tenant_id": tenant, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("status=%d body=%s", resp.StatusCode, b) } } func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) { srv, err := New(Options{ SeedDemo: true, BundleSeedHex: testBundleSeed, JWTSecret: testJWTSecret, AuthIssuer: testIssuer, AuthPortalURL: "https://portal.test.local", AuthRequired: true, }) if err != nil { t.Fatal(err) } defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() token := signTestJWT(t, jwt.MapClaims{ "iss": testIssuer, "sub": "user-1", "apps": []string{"bgp"}, "exp": time.Now().Add(time.Hour).Unix(), }) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status=%d want 503", resp.StatusCode) } } func TestAuthConfigPublic(t *testing.T) { srv, _ := newJWTTestServer(t) defer srv.Close() ts := httptest.NewServer(srv.Handler()) defer ts.Close() req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("status=%d body=%s", resp.StatusCode, b) } } func TestHasPermissionSupersets(t *testing.T) { cases := []struct { name string granted []string want string ok bool }{ {"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true}, {"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true}, {"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true}, {"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false}, {"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false}, {"empty-granted", nil, "bgp:modules:read", false}, {"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := HasPermission(tc.granted, tc.want); got != tc.ok { t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok) } }) } }