package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/testsupport" "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestTenantHandler_Relations(t *testing.T) { if !testsupport.DockerAvailable() { t.Skip("Docker provider is unavailable in this environment") } db := newTenantHandlerSeedDeleteDB(t) if err := db.AutoMigrate(&domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil { t.Fatalf("failed to migrate tenant domains or outbox: %v", err) } // Create a test tenant in DB with a valid UUID tenantID := "00000000-0000-0000-0000-000000000030" tenant := domain.Tenant{ ID: tenantID, Name: "Relation Test Tenant", Slug: "relation-test-tenant", Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, } if err := db.Create(&tenant).Error; err != nil { t.Fatalf("failed to create tenant: %v", err) } mockSvc := new(MockTenantService) mockKeto := new(devMockKetoService) realOutbox := repository.NewKetoOutboxRepository(db) h := &TenantHandler{ DB: db, Service: mockSvc, Keto: mockKeto, KetoOutbox: realOutbox, } userID := "user-relation-1" t.Run("ListRelations - Returns correct relations aggregated by user", func(t *testing.T) { app := fiber.New() app.Get("/tenants/:id/relations", h.ListRelations) mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "", "").Return([]service.RelationTuple{ { Namespace: "Tenant", Object: tenantID, Relation: "schema_managers", SubjectID: "User:" + userID, }, { Namespace: "Tenant", Object: tenantID, Relation: "profile_viewers", SubjectID: "User:" + userID, }, { Namespace: "Tenant", Object: tenantID, Relation: "unrelated_relation", // Should be filtered out SubjectID: "User:" + userID, }, }, nil).Once() req := httptest.NewRequest("GET", "/tenants/"+tenantID+"/relations", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } assert.Equal(t, http.StatusOK, resp.StatusCode) var got struct { Items []struct { UserID string `json:"userId"` Name string `json:"name"` Email string `json:"email"` Relations []string `json:"relations"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&got) if err != nil { t.Fatalf("failed to decode response: %v", err) } assert.Len(t, got.Items, 1) assert.Equal(t, userID, got.Items[0].UserID) assert.Contains(t, got.Items[0].Relations, "schema_managers") assert.Contains(t, got.Items[0].Relations, "profile_viewers") assert.NotContains(t, got.Items[0].Relations, "unrelated_relation") mockKeto.AssertExpectations(t) }) t.Run("AddRelation - Inserts into KetoOutbox DB table", func(t *testing.T) { app := fiber.New() app.Post("/tenants/:id/relations", h.AddRelation) mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once() mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once() body, _ := json.Marshal(map[string]string{ "userId": userID, "relation": "schema_managers", }) req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/relations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } assert.Equal(t, http.StatusOK, resp.StatusCode) // Verify row was written to the keto_outboxes DB table var outboxEntries []domain.KetoOutbox if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil { t.Fatalf("failed to query outbox: %v", err) } assert.Len(t, outboxEntries, 1) assert.Equal(t, "Tenant", outboxEntries[0].Namespace) assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status) mockKeto.AssertExpectations(t) }) t.Run("RemoveRelation - Inserts delete action into KetoOutbox DB table", func(t *testing.T) { app := fiber.New() app.Delete("/tenants/:id/relations", h.RemoveRelation) mockKeto.On("DeleteRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once() body, _ := json.Marshal(map[string]string{ "userId": userID, "relation": "schema_managers", }) req := httptest.NewRequest("DELETE", "/tenants/"+tenantID+"/relations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } assert.Equal(t, http.StatusOK, resp.StatusCode) // Verify delete action row was written to the keto_outboxes DB table var outboxEntries []domain.KetoOutbox if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionDelete).Find(&outboxEntries).Error; err != nil { t.Fatalf("failed to query outbox: %v", err) } assert.Len(t, outboxEntries, 1) assert.Equal(t, "Tenant", outboxEntries[0].Namespace) assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status) }) } func TestTenantHandler_SystemRelations(t *testing.T) { if !testsupport.DockerAvailable() { t.Skip("Docker provider is unavailable in this environment") } db := newTenantHandlerSeedDeleteDB(t) if err := db.AutoMigrate(&domain.KetoOutbox{}); err != nil { t.Fatalf("failed to migrate outbox: %v", err) } mockSvc := new(MockTenantService) mockKeto := new(devMockKetoService) realOutbox := repository.NewKetoOutboxRepository(db) h := &TenantHandler{ DB: db, Service: mockSvc, Keto: mockKeto, KetoOutbox: realOutbox, } userID := "user-system-1" t.Run("ListSystemRelations - Returns correct system relations", func(t *testing.T) { app := fiber.New() app.Get("/system/relations", h.ListSystemRelations) mockKeto.On("ListRelations", mock.Anything, "System", "system", "", "").Return([]service.RelationTuple{ { Namespace: "System", Object: "system", Relation: "ory_ssot_viewers", SubjectID: "User:" + userID, }, { Namespace: "System", Object: "system", Relation: "audit_logs_viewers", SubjectID: "User:" + userID, }, }, nil).Once() req := httptest.NewRequest("GET", "/system/relations", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } assert.Equal(t, http.StatusOK, resp.StatusCode) var got struct { Items []struct { UserID string `json:"userId"` Relations []string `json:"relations"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&got) if err != nil { t.Fatalf("failed to decode response: %v", err) } assert.Len(t, got.Items, 1) assert.Equal(t, userID, got.Items[0].UserID) assert.Contains(t, got.Items[0].Relations, "ory_ssot_viewers") assert.Contains(t, got.Items[0].Relations, "audit_logs_viewers") mockKeto.AssertExpectations(t) }) t.Run("AddSystemRelation - Inserts into KetoOutbox DB table with System namespace", func(t *testing.T) { app := fiber.New() app.Post("/system/relations", h.AddSystemRelation) mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once() mockKeto.On("CreateRelation", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return(nil).Once() body, _ := json.Marshal(map[string]string{ "userId": userID, "relation": "ory_ssot_viewers", }) req := httptest.NewRequest("POST", "/system/relations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil { t.Fatalf("request failed: %v", err) } assert.Equal(t, http.StatusOK, resp.StatusCode) var outboxEntries []domain.KetoOutbox if err := db.Where("object = ? AND relation = ? AND action = ?", "system", "ory_ssot_viewers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil { t.Fatalf("failed to query outbox: %v", err) } assert.Len(t, outboxEntries, 1) assert.Equal(t, "System", outboxEntries[0].Namespace) assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status) mockKeto.AssertExpectations(t) }) }