1
0
forked from baron/baron-sso

Dev API에 RP operator relation 조회/부여/회수 추가

This commit is contained in:
2026-04-15 16:04:25 +09:00
parent 91299b1a0a
commit dd93a3450a
9 changed files with 948 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
// --- Mocks with Unique Names to Avoid Collisions ---
@@ -51,6 +52,31 @@ type devMockRedisRepo struct {
data map[string]string
}
type devMockKetoOutboxRepository struct {
mock.Mock
}
func (m *devMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
return m.Called(ctx, entry).Error(0)
}
func (m *devMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
return m.Called(tx, entry).Error(0)
}
func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, limit)
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
}
func (m *devMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *devMockRedisRepo) Set(key, value string, exp time.Duration) error {
if m.data == nil {
m.data = make(map[string]string)
@@ -1223,3 +1249,175 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) {
assert.Len(t, result.Items, 1)
assert.Equal(t, "evt-1", result.Items[0].EventID)
}
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"metadata": map[string]any{
"tenant_id": "tenant-1",
"status": "active",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
}, nil)
for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} {
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
}
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
TenantID: &tenantID,
})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result clientRelationListResponse
_ = json.NewDecoder(resp.Body).Decode(&result)
assert.Len(t, result.Items, 1)
assert.Equal(t, "config_editor", result.Items[0].Relation)
assert.Equal(t, "User", result.Items[0].SubjectType)
assert.Equal(t, "user-2", result.Items[0].SubjectID)
}
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"metadata": map[string]any{
"tenant_id": "tenant-1",
"status": "active",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil)
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil)
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" &&
entry.Object == "client-1" &&
entry.Relation == "config_editor" &&
entry.Subject == "User:user-2" &&
entry.Action == domain.KetoOutboxActionCreate
})).Return(nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
TenantID: &tenantID,
})
return c.Next()
})
app.Post("/api/v1/dev/clients/:id/relations", h.AddClientRelation)
body, _ := json.Marshal(map[string]any{
"relation": "config_editor",
"userId": "user-2",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
mockOutbox.AssertExpectations(t)
}
func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-1",
"client_name": "App One",
"metadata": map[string]any{
"tenant_id": "tenant-1",
"status": "active",
},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
mockOutbox := new(devMockKetoOutboxRepository)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "RelyingParty" &&
entry.Object == "client-1" &&
entry.Relation == "config_editor" &&
entry.Subject == "User:user-2" &&
entry.Action == domain.KetoOutboxActionDelete
})).Return(nil)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
KetoOutbox: mockOutbox,
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
tenantID := "tenant-1"
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleRPAdmin,
TenantID: &tenantID,
})
return c.Next()
})
app.Delete("/api/v1/dev/clients/:id/relations", h.RemoveClientRelation)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1/relations?relation=config_editor&subject=User:user-2", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
mockOutbox.AssertExpectations(t)
}