forked from baron/baron-sso
test: ReBAC 권한 상속 및 미들웨어 검증 테스트 코드 추가 #244
This commit is contained in:
@@ -288,8 +288,8 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var got map[string]string
|
var got map[string]string
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
if got["sessionJwt"] != "valid-jwt" {
|
if got["sessionToken"] != "valid-jwt" {
|
||||||
t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"])
|
t.Errorf("expected jwt valid-jwt, got %s", got["sessionToken"])
|
||||||
}
|
}
|
||||||
// No redirectTo
|
// No redirectTo
|
||||||
if _, ok := got["redirectTo"]; ok {
|
if _, ok := got["redirectTo"]; ok {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -10,8 +12,65 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MockRPService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRPService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||||
|
args := m.Called(ctx, tenantID, client)
|
||||||
|
return args.Get(0).(*domain.RelyingParty), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) {
|
||||||
|
args := m.Called(ctx, clientID)
|
||||||
|
return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
|
||||||
|
args := m.Called(ctx, tenantID)
|
||||||
|
return args.Get(0).([]domain.RelyingParty), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
|
||||||
|
args := m.Called(ctx)
|
||||||
|
return args.Get(0).([]domain.RelyingParty), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
|
||||||
|
args := m.Called(ctx, tenantIDs)
|
||||||
|
return args.Get(0).([]domain.RelyingParty), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||||
|
args := m.Called(ctx, clientID, client)
|
||||||
|
return args.Get(0).(*domain.RelyingParty), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) Delete(ctx context.Context, clientID string) error {
|
||||||
|
args := m.Called(ctx, clientID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
|
||||||
|
args := m.Called(ctx, userID, clientID, relation)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error {
|
||||||
|
args := m.Called(ctx, clientID, subject)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error {
|
||||||
|
args := m.Called(ctx, clientID, subject)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, clientID)
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMockProfile(profile *domain.UserProfileResponse) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListClients_Success(t *testing.T) {
|
func TestListClients_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
@@ -30,7 +89,11 @@ func TestListClients_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Get("/api/v1/dev/clients", withMockProfile(adminProfile), h.ListClients)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -66,7 +129,11 @@ func TestGetClient_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -92,7 +159,11 @@ func TestGetClient_NotFound(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -109,30 +180,49 @@ func TestCreateClient_Success(t *testing.T) {
|
|||||||
"client_secret": "secret-123",
|
"client_secret": "secret-123",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/new-client-123" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": "new-client-123",
|
||||||
|
"client_name": "New App",
|
||||||
|
"client_secret": "secret-123",
|
||||||
|
"metadata": map[string]interface{}{"status": "active"},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error path: " + r.URL.Path}), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||||
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
||||||
|
mockRP := new(MockRPService)
|
||||||
|
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
|
PublicURL: "http://hydra-public.test",
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
},
|
},
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
Redis: redisRepo,
|
Redis: redisRepo,
|
||||||
|
RPService: mockRP,
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Post("/api/v1/dev/clients", withMockProfile(adminProfile), h.CreateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
"client_name": "New App",
|
"client_name": "New App",
|
||||||
"type": "confidential",
|
"type": "confidential",
|
||||||
"redirectUris": []string{"http://localhost/cb"},
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mockRP.On("Create", mock.Anything, "t1", mock.Anything).Return(&domain.RelyingParty{ClientID: "new-client-123"}, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Tenant-ID", "t1")
|
||||||
|
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) {
|
|||||||
m.Called(keto)
|
m.Called(keto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, userID)
|
||||||
|
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
args := m.Called(ctx, tenantID, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
args := m.Called(ctx, tenantID, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, tenantID)
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantHandler_CreateTenant(t *testing.T) {
|
func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
119
backend/internal/handler/tenant_rebac_handler_test.go
Normal file
119
backend/internal/handler/tenant_rebac_handler_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reusing MockKetoService from previous step or defining here if needed
|
||||||
|
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 {
|
||||||
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
return m.Called(ctx, namespace, object, relation, subject).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)
|
||||||
|
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, namespace, relation, subject)
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAuthHandler implements middleware.AuthProfileProvider
|
||||||
|
type MockAuthHandler struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||||
|
args := m.Called(c)
|
||||||
|
return args.Get(0).(*domain.UserProfileResponse), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireKetoPermission_Tenant_AuditContext(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
mockAuth := new(MockAuthHandler)
|
||||||
|
|
||||||
|
config := middleware.RBACConfig{
|
||||||
|
AuthHandler: mockAuth,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-1"
|
||||||
|
tenantID := "tenant-abc"
|
||||||
|
|
||||||
|
// Mock user profile
|
||||||
|
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||||
|
ID: userID,
|
||||||
|
Role: domain.RoleTenantAdmin,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Mock Keto: Allow access
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "manage").Return(true, nil)
|
||||||
|
|
||||||
|
// Route with middleware
|
||||||
|
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "manage"), func(c *fiber.Ctx) error {
|
||||||
|
// Verify that tenant_id was injected into Locals for audit log
|
||||||
|
assert.Equal(t, tenantID, c.Locals("tenant_id"))
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockAuth.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireKetoPermission_Deny(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
mockAuth := new(MockAuthHandler)
|
||||||
|
|
||||||
|
config := middleware.RBACConfig{
|
||||||
|
AuthHandler: mockAuth,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-bad"
|
||||||
|
tenantID := "tenant-secret"
|
||||||
|
|
||||||
|
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||||
|
ID: userID,
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Mock Keto: Deny access
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "view").Return(false, nil)
|
||||||
|
|
||||||
|
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "view"), func(c *fiber.Ctx) error {
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
125
backend/internal/service/tenant_rebac_service_test.go
Normal file
125
backend/internal/service/tenant_rebac_service_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockTenantRepository is a mock implementation of repository.TenantRepository
|
||||||
|
type MockTenantRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
|
||||||
|
return m.Called(ctx, tenant).Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
|
||||||
|
return m.Called(ctx, tenant).Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, slug)
|
||||||
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, name)
|
||||||
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, domainName)
|
||||||
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, ids)
|
||||||
|
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
|
||||||
|
return m.Called(ctx, tenantID, domainName).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockKetoService is a mock implementation of KetoService
|
||||||
|
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 {
|
||||||
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
|
}
|
||||||
|
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
|
||||||
|
args := m.Called(ctx, namespace, object, relation, subject)
|
||||||
|
return args.Get(0).([]RelationTuple), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, namespace, relation, subject)
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) {
|
||||||
|
mockRepo := new(MockTenantRepository)
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
svc := &tenantService{
|
||||||
|
repo: mockRepo,
|
||||||
|
keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-123"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Mock direct tenant management (admins relation)
|
||||||
|
mockKeto.On("ListObjects", ctx, "Tenant", "admins", userID).Return([]string{"t-direct-1"}, nil)
|
||||||
|
|
||||||
|
// 2. Mock group management (admins of a group)
|
||||||
|
mockKeto.On("ListObjects", ctx, "TenantGroup", "admins", userID).Return([]string{"g-1"}, nil)
|
||||||
|
|
||||||
|
// 3. Mock tenants belonging to group g-1
|
||||||
|
mockKeto.On("ListRelations", ctx, "Tenant", "", "parent_group", "TenantGroup:g-1").Return([]RelationTuple{
|
||||||
|
{Object: "t-inherited-1", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
|
||||||
|
{Object: "t-inherited-2", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// 4. Expect repository to fetch all unique IDs: t-direct-1, t-inherited-1, t-inherited-2
|
||||||
|
expectedIDs := []string{"t-direct-1", "t-inherited-1", "t-inherited-2"}
|
||||||
|
mockRepo.On("FindByIDs", ctx, mock.MatchedBy(func(ids []string) bool {
|
||||||
|
// Check if all expected IDs are present (order doesn't matter since we dedup via map)
|
||||||
|
foundCount := 0
|
||||||
|
for _, eid := range expectedIDs {
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == eid {
|
||||||
|
foundCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foundCount == len(expectedIDs) && len(ids) == len(expectedIDs)
|
||||||
|
})).Return([]domain.Tenant{
|
||||||
|
{ID: "t-direct-1", Name: "Direct Tenant"},
|
||||||
|
{ID: "t-inherited-1", Name: "Inherited Tenant 1"},
|
||||||
|
{ID: "t-inherited-2", Name: "Inherited Tenant 2"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
tenants, err := svc.ListManageableTenants(ctx, userID)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, tenants, 3)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user