diff --git a/backend/internal/handler/api_key_handler_test.go b/backend/internal/handler/api_key_handler_test.go new file mode 100644 index 00000000..1e5bc5d5 --- /dev/null +++ b/backend/internal/handler/api_key_handler_test.go @@ -0,0 +1,59 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +// Mock DB for ApiKey tests using a real GORM instance but with a hijacked connection +// or just a simple mock if we only check nil. +// For ApiKeyHandler, it uses DB for Create/List/Delete. + +func TestApiKeyHandler_CreateApiKey(t *testing.T) { + app := fiber.New() + // ApiKeyHandler requires a valid DB connection to perform h.DB.Create + // Since we don't have a real DB here, we'll check if it fails gracefully + // or we can use sqlite in-memory for a more realistic test. + h := &ApiKeyHandler{DB: nil} // Testing ServiceUnavailable + + app.Post("/api-keys", h.CreateApiKey) + + input := map[string]interface{}{ + "name": "M2M Test", + "scopes": []string{"read", "write"}, + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest("POST", "/api-keys", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +func TestApiKeyHandler_Validation(t *testing.T) { + app := fiber.New() + // Using a dummy DB pointer to pass the nil check + h := &ApiKeyHandler{DB: &gorm.DB{}} + + app.Post("/api-keys", h.CreateApiKey) + + // Missing name + input := map[string]interface{}{ + "scopes": []string{"read"}, + } + body, _ := json.Marshal(input) + + req := httptest.NewRequest("POST", "/api-keys", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go new file mode 100644 index 00000000..b1261117 --- /dev/null +++ b/backend/internal/handler/tenant_handler_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +// MockTenantService is a mock for service.TenantService +type MockTenantService struct { + mock.Mock +} + +func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, description, domains) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantService) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantService) ApproveTenant(ctx context.Context, tenantID string) error { + args := m.Called(ctx, tenantID) + return args.Error(0) +} + +func (m *MockTenantService) RequestRegistration(ctx context.Context, name, slug, description, domainName, adminEmail string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, description, domainName, adminEmail) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + args := m.Called(ctx, slug) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantService) SetKetoService(keto service.KetoService) { + m.Called(keto) +} + +func TestTenantHandler_CreateTenant(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + // CreateTenant checks h.DB != nil + h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}} + + app.Post("/tenants", h.CreateTenant) + + input := map[string]interface{}{ + "name": "Test Tenant", + "slug": "test-tenant", + "domains": []string{"test.com"}, + } + body, _ := json.Marshal(input) + + mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}). + Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) + + req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + var got map[string]interface{} + json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "t1", got["id"]) +} + +func TestTenantHandler_ApproveTenant(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + h := &TenantHandler{Service: mockSvc} + + app.Post("/tenants/:id/approve", h.ApproveTenant) + + mockSvc.On("ApproveTenant", mock.Anything, "t1").Return(nil) + + req := httptest.NewRequest("POST", "/tenants/t1/approve", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/backend/internal/middleware/rbac_test.go b/backend/internal/middleware/rbac_test.go new file mode 100644 index 00000000..b4bd837f --- /dev/null +++ b/backend/internal/middleware/rbac_test.go @@ -0,0 +1,193 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockAuthProvider is a mock for AuthProvider interface +type MockAuthProvider struct { + mock.Mock +} + +func (m *MockAuthProvider) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { + args := m.Called(c) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.UserProfileResponse), args.Error(1) +} + +// MockKetoService is a mock for KetoService interface +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + args := m.Called(ctx, subject, namespace, object, relation) + return args.Bool(0), args.Error(1) +} + +func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} + +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} + +func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.RelationTuple), args.Error(1) +} + +// Fixed MockKetoService to match service.KetoService exactly if possible. +// Wait, middleware/rbac.go imports baron-sso-backend/internal/service. +// So I should use service.RelationTuple. + +func TestRequireRole_Success(t *testing.T) { + app := fiber.New() + mockAuth := new(MockAuthProvider) + config := RBACConfig{ + AllowedRoles: []string{"admin"}, + AuthHandler: mockAuth, + } + + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ + ID: "user1", + Role: "admin", + }, nil) + + app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/test", nil) + resp, _ := app.Test(req) + + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireRole_Forbidden(t *testing.T) { + app := fiber.New() + mockAuth := new(MockAuthProvider) + config := RBACConfig{ + AllowedRoles: []string{"admin"}, + AuthHandler: mockAuth, + } + + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ + ID: "user1", + Role: "user", + }, nil) + + app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/test", nil) + resp, _ := app.Test(req) + + assert.Equal(t, 403, resp.StatusCode) +} + +func TestRequireKetoPermission_Success(t *testing.T) { + app := fiber.New() + mockAuth := new(MockAuthProvider) + mockKeto := new(MockKetoService) + config := RBACConfig{ + AuthHandler: mockAuth, + KetoService: mockKeto, + } + + profile := &domain.UserProfileResponse{ID: "user1", Role: "user"} + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil) + mockKeto.On("CheckPermission", mock.Anything, "user1", "tenants", "tenant1", "read").Return(true, nil) + + app.Get("/tenants/:id", RequireKetoPermission(config, "tenants", "read"), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/tenants/tenant1", nil) + resp, _ := app.Test(req) + + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireTenantMatch_SuperAdmin(t *testing.T) { + app := fiber.New() + mockAuth := new(MockAuthProvider) + config := RBACConfig{ + AuthHandler: mockAuth, + } + + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ + ID: "admin1", + Role: domain.RoleSuperAdmin, + }, nil) + + app.Get("/tenants/:tenantId/data", RequireTenantMatch(config), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/tenants/any-tenant/data", nil) + resp, _ := app.Test(req) + + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireTenantMatch_Forbidden(t *testing.T) { + app := fiber.New() + mockAuth := new(MockAuthProvider) + config := RBACConfig{ + AuthHandler: mockAuth, + } + + tenant1 := "tenant1" + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ + ID: "user1", + Role: domain.RoleTenantAdmin, + TenantID: &tenant1, + }, nil) + + app.Get("/tenants/:tenantId/data", RequireTenantMatch(config), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/tenants/tenant2/data", nil) + resp, _ := app.Test(req) + + assert.Equal(t, 403, resp.StatusCode) +} + +func TestRequireRole_Unauthorized(t *testing.T) { + app := fiber.New() + mockAuth := new(MockAuthProvider) + config := RBACConfig{ + AuthHandler: mockAuth, + } + + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(nil, errors.New("unauthorized")) + + app.Get("/test", RequireRole(config), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/test", nil) + resp, _ := app.Test(req) + + assert.Equal(t, 401, resp.StatusCode) +} diff --git a/backend/internal/service/hydra_admin_service_test.go b/backend/internal/service/hydra_admin_service_test.go new file mode 100644 index 00000000..1a65bfc2 --- /dev/null +++ b/backend/internal/service/hydra_admin_service_test.go @@ -0,0 +1,312 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHydraAdminService_ListClients(t *testing.T) { + clients := []domain.HydraClient{ + {ClientID: "client1", ClientName: "Client 1"}, + {ClientID: "client2", ClientName: "Client 2"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients", r.URL.Path) + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + assert.Equal(t, "5", r.URL.Query().Get("offset")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(clients) + })) + defer server.Close() + + s := &HydraAdminService{ + AdminURL: server.URL, + } + + result, err := s.ListClients(context.Background(), 10, 5) + assert.NoError(t, err) + assert.Equal(t, clients, result) +} + +func TestHydraAdminService_GetClient(t *testing.T) { + client := domain.HydraClient{ClientID: "test-client", ClientName: "Test Client"} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients/test-client", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(client) + })) + defer server.Close() + + s := &HydraAdminService{ + AdminURL: server.URL, + } + + result, err := s.GetClient(context.Background(), "test-client") + assert.NoError(t, err) + assert.Equal(t, &client, result) +} + +func TestHydraAdminService_CreateClient(t *testing.T) { + client := domain.HydraClient{ClientName: "New Client"} + created := domain.HydraClient{ClientID: "new-id", ClientName: "New Client"} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + var received domain.HydraClient + json.NewDecoder(r.Body).Decode(&received) + assert.Equal(t, client.ClientName, received.ClientName) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(created) + })) + defer server.Close() + + s := &HydraAdminService{ + AdminURL: server.URL, + } + + result, err := s.CreateClient(context.Background(), client) + assert.NoError(t, err) + assert.Equal(t, &created, result) +} + +func TestHydraAdminService_DeleteClient(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients/to-delete", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + s := &HydraAdminService{ + AdminURL: server.URL, + } + + err := s.DeleteClient(context.Background(), "to-delete") + assert.NoError(t, err) +} + +func TestHydraAdminService_GetConsentRequest(t *testing.T) { + challenge := "challenge123" + consentReq := domain.HydraConsentRequest{Challenge: challenge, Subject: "user1"} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/requests/consent", r.URL.Path) + assert.Equal(t, challenge, r.URL.Query().Get("consent_challenge")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(consentReq) + })) + defer server.Close() + + s := &HydraAdminService{ + AdminURL: server.URL, + } + + result, err := s.GetConsentRequest(context.Background(), challenge) + assert.NoError(t, err) + assert.Equal(t, &consentReq, result) +} + +func TestHydraAdminService_PatchClientStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients/test-client", r.URL.Path) + assert.Equal(t, "PATCH", r.Method) + assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type")) + + var payload []map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + assert.Equal(t, "replace", payload[0]["op"]) + assert.Equal(t, "/metadata/status", payload[0]["path"]) + assert.Equal(t, "inactive", payload[0]["value"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(domain.HydraClient{ClientID: "test-client"}) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + _, err := s.PatchClientStatus(context.Background(), "test-client", "inactive") + assert.NoError(t, err) +} + +func TestHydraAdminService_UpdateClient(t *testing.T) { + client := domain.HydraClient{ClientName: "Updated Name"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/clients/test-client", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(client) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + _, err := s.UpdateClient(context.Background(), "test-client", client) + assert.NoError(t, err) +} + +func TestHydraAdminService_ListConsentSessions(t *testing.T) { + sessions := []domain.HydraConsentSession{{Subject: "user1"}} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/sessions/consent", r.URL.Path) + assert.Equal(t, "user1", r.URL.Query().Get("subject")) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sessions) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + result, err := s.ListConsentSessions(context.Background(), "user1", "") + assert.NoError(t, err) + assert.Equal(t, sessions, result) +} + +func TestHydraAdminService_RevokeConsentSessions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/sessions/consent", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + err := s.RevokeConsentSessions(context.Background(), "user1", "") + assert.NoError(t, err) +} + +func TestHydraAdminService_RejectConsentRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/requests/consent/reject", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://reject"}) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + resp, err := s.RejectConsentRequest(context.Background(), "challenge") + assert.NoError(t, err) + assert.Equal(t, "http://reject", resp.RedirectTo) +} + +func TestHydraAdminService_RejectLoginRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/requests/login/reject", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://reject-login"}) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + resp, err := s.RejectLoginRequest(context.Background(), "challenge", "error", "desc") + assert.NoError(t, err) + assert.Equal(t, "http://reject-login", resp.RedirectTo) +} + +func TestHydraAdminService_GetLoginRequest(t *testing.T) { + loginReq := domain.HydraLoginRequest{Challenge: "challenge"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/requests/login", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(loginReq) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + result, err := s.GetLoginRequest(context.Background(), "challenge") + assert.NoError(t, err) + assert.Equal(t, &loginReq, result) +} + +func TestHydraAdminService_AcceptConsentRequest(t *testing.T) { + grant := &domain.HydraConsentRequest{RequestedScope: []string{"openid"}} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/requests/consent/accept", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://accept"}) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + resp, err := s.AcceptConsentRequest(context.Background(), "challenge", grant, nil) + assert.NoError(t, err) + assert.Equal(t, "http://accept", resp.RedirectTo) +} + +func TestHydraAdminService_AcceptLoginRequest(t *testing.T) { + challenge := "login_challenge" + subject := "user@example.com" + redirectTo := "http://hydra/auth/confirm" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path) + assert.Equal(t, challenge, r.URL.Query().Get("login_challenge")) + + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, subject, body["subject"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"redirect_to": redirectTo}) + })) + defer server.Close() + + s := &HydraAdminService{ + AdminURL: server.URL, + } + + result, err := s.AcceptLoginRequest(context.Background(), challenge, subject) + assert.NoError(t, err) + assert.Equal(t, redirectTo, result.RedirectTo) +} + +func TestHydraAdminService_ErrorHandling(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("bad request")) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + + _, err := s.GetClient(context.Background(), "invalid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "status=400") + + err = s.DeleteClient(context.Background(), "invalid") + assert.Error(t, err) + + _, err = s.ListClients(context.Background(), 10, 0) + assert.Error(t, err) + + _, err = s.PatchClientStatus(context.Background(), "invalid", "active") + assert.Error(t, err) +} + +func TestHydraAdminService_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + s := &HydraAdminService{AdminURL: server.URL} + + _, err := s.GetClient(context.Background(), "none") + assert.Equal(t, ErrHydraNotFound, err) +} diff --git a/backend/internal/service/keto_service_test.go b/backend/internal/service/keto_service_test.go new file mode 100644 index 00000000..c4cb9dad --- /dev/null +++ b/backend/internal/service/keto_service_test.go @@ -0,0 +1,158 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKetoService_CheckPermission(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/relation-tuples/check", r.URL.Path) + assert.Equal(t, "user1", r.URL.Query().Get("subject_id")) + assert.Equal(t, "tenants", r.URL.Query().Get("namespace")) + assert.Equal(t, "tenant1", r.URL.Query().Get("object")) + assert.Equal(t, "admin", r.URL.Query().Get("relation")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(checkResponse{Allowed: true}) + })) + defer server.Close() + + s := &ketoService{ + readURL: server.URL, + client: &http.Client{}, + } + + allowed, err := s.CheckPermission(context.Background(), "user1", "tenants", "tenant1", "admin") + assert.NoError(t, err) + assert.True(t, allowed) +} + +func TestKetoService_CreateRelation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/admin/relation-tuples", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "tenants", body["namespace"]) + assert.Equal(t, "tenant1", body["object"]) + assert.Equal(t, "admin", body["relation"]) + assert.Equal(t, "user1", body["subject_id"]) + + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + s := &ketoService{ + writeURL: server.URL, + client: &http.Client{}, + } + + err := s.CreateRelation(context.Background(), "tenants", "tenant1", "admin", "user1") + assert.NoError(t, err) +} + +func TestKetoService_DeleteRelation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/relation-tuples", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + assert.Equal(t, "user1", r.URL.Query().Get("subject_id")) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + s := &ketoService{ + writeURL: server.URL, + client: &http.Client{}, + } + + err := s.DeleteRelation(context.Background(), "tenants", "tenant1", "admin", "user1") + assert.NoError(t, err) +} + +func TestKetoService_ListRelations(t *testing.T) { + tuples := []RelationTuple{ + {Namespace: "tenants", Object: "tenant1", Relation: "admin", SubjectID: "user1"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/relation-tuples", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(relationTuplesResponse{RelationTuples: tuples}) + })) + defer server.Close() + + s := &ketoService{ + readURL: server.URL, + client: &http.Client{}, + } + + result, err := s.ListRelations(context.Background(), "tenants", "tenant1", "admin", "user1") + assert.NoError(t, err) + assert.Equal(t, tuples, result) +} + +func TestKetoService_ErrorHandling(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + s := &ketoService{ + readURL: server.URL, + writeURL: server.URL, + client: &http.Client{}, + } + + _, err := s.CheckPermission(context.Background(), "u", "n", "o", "r") + assert.Error(t, err) + + err = s.DeleteRelation(context.Background(), "n", "o", "r", "s") + assert.Error(t, err) + + _, err = s.ListRelations(context.Background(), "n", "o", "r", "s") + assert.Error(t, err) +} + +func TestKetoService_CheckPermission_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + s := &ketoService{readURL: server.URL, client: &http.Client{}} + allowed, err := s.CheckPermission(context.Background(), "u", "n", "o", "r") + assert.NoError(t, err) + assert.False(t, allowed) +} + +func TestKetoService_CreateRelation_Retry(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts < 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + s := &ketoService{ + writeURL: server.URL, + client: &http.Client{}, + } + + err := s.CreateRelation(context.Background(), "n", "o", "r", "s") + assert.NoError(t, err) + assert.Equal(t, 2, attempts) +}