forked from baron/baron-sso
2748 lines
94 KiB
Go
2748 lines
94 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/service"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 ---
|
|
|
|
type devMockKetoService struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *devMockKetoService) 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 *devMockKetoService) CreateRelation(ctx context.Context, ns, obj, rel, sub string) error {
|
|
return m.Called(ctx, ns, obj, rel, sub).Error(0)
|
|
}
|
|
|
|
func (m *devMockKetoService) DeleteRelation(ctx context.Context, ns, obj, rel, sub string) error {
|
|
return m.Called(ctx, ns, obj, rel, sub).Error(0)
|
|
}
|
|
|
|
func (m *devMockKetoService) ListRelations(ctx context.Context, ns, obj, rel, sub string) ([]service.RelationTuple, error) {
|
|
if len(m.ExpectedCalls) == 0 {
|
|
return []service.RelationTuple{}, nil
|
|
}
|
|
hasListRelationsExpectation := false
|
|
for _, call := range m.ExpectedCalls {
|
|
if call.Method == "ListRelations" {
|
|
hasListRelationsExpectation = true
|
|
break
|
|
}
|
|
}
|
|
if !hasListRelationsExpectation {
|
|
return []service.RelationTuple{}, nil
|
|
}
|
|
args := m.Called(ctx, ns, obj, rel, sub)
|
|
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKetoService) ListObjects(ctx context.Context, ns, rel, sub string) ([]string, error) {
|
|
args := m.Called(ctx, ns, rel, sub)
|
|
return args.Get(0).([]string), args.Error(1)
|
|
}
|
|
|
|
type devMockRedisRepo struct {
|
|
data map[string]string
|
|
}
|
|
|
|
type devMockKratosAdmin struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
|
|
args := m.Called(ctx)
|
|
return args.Get(0).([]service.KratosIdentity), args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
|
args := m.Called(ctx, identifier)
|
|
return args.String(0), args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string) (*service.KratosIdentity, error) {
|
|
args := m.Called(ctx, identityID)
|
|
if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
|
|
return identity, args.Error(1)
|
|
}
|
|
return nil, args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
|
|
args := m.Called(ctx, identityID, traits, state)
|
|
if identity, ok := args.Get(0).(*service.KratosIdentity); ok {
|
|
return identity, args.Error(1)
|
|
}
|
|
return nil, args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
|
return m.Called(ctx, identityID, newPassword).Error(0)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) DeleteIdentity(ctx context.Context, identityID string) error {
|
|
return m.Called(ctx, identityID).Error(0)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
|
args := m.Called(ctx, user, password)
|
|
return args.String(0), args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
|
|
args := m.Called(ctx, identityID)
|
|
return args.Get(0).([]service.KratosSession), args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
|
|
args := m.Called(ctx, sessionID)
|
|
if session, ok := args.Get(0).(*service.KratosSession); ok {
|
|
return session, args.Error(1)
|
|
}
|
|
return nil, args.Error(1)
|
|
}
|
|
|
|
func (m *devMockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error {
|
|
return m.Called(ctx, sessionID).Error(0)
|
|
}
|
|
|
|
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)
|
|
}
|
|
m.data[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *devMockRedisRepo) Get(key string) (string, error) {
|
|
v, ok := m.data[key]
|
|
if !ok {
|
|
return "", fmt.Errorf("not found")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (m *devMockRedisRepo) Delete(key string) error {
|
|
delete(m.data, key)
|
|
return nil
|
|
}
|
|
func (m *devMockRedisRepo) StoreVerificationCode(p, c string) error { return nil }
|
|
func (m *devMockRedisRepo) GetVerificationCode(p string) (string, error) { return "", nil }
|
|
func (m *devMockRedisRepo) DeleteVerificationCode(p string) error { return nil }
|
|
|
|
type devEnhancedMockAuditRepo struct {
|
|
mockAuditRepo
|
|
countFailures int64
|
|
countSessions int64
|
|
}
|
|
|
|
func (m *devEnhancedMockAuditRepo) CountFailuresSince(ctx context.Context, s time.Time, t string) (int64, error) {
|
|
return m.countFailures, nil
|
|
}
|
|
|
|
func (m *devEnhancedMockAuditRepo) CountActiveSessionsSince(ctx context.Context, s time.Time, t string) (int64, error) {
|
|
return m.countSessions, nil
|
|
}
|
|
|
|
func devTestJWKSFirstKeyString(t *testing.T, jwks map[string]any, field string) string {
|
|
t.Helper()
|
|
|
|
keys, ok := jwks["keys"].([]any)
|
|
if !ok || len(keys) == 0 {
|
|
t.Fatalf("expected jwks keys")
|
|
}
|
|
key, ok := keys[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected jwks key object")
|
|
}
|
|
value, ok := key[field].(string)
|
|
if !ok {
|
|
t.Fatalf("expected jwks field %s", field)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestListClients_Success(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
|
{"client_id": "client-2", "client_name": "App Two", "metadata": map[string]interface{}{"status": "inactive"}},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, 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 {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
|
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}},
|
|
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil)
|
|
mockKeto.On(
|
|
"ListRelations",
|
|
mock.Anything,
|
|
"RelyingParty",
|
|
mock.Anything,
|
|
mock.Anything,
|
|
mock.Anything,
|
|
).Return([]service.RelationTuple{}, nil).Maybe()
|
|
|
|
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-a"
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result clientListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
if assert.Len(t, result.Items, 1) {
|
|
assert.Equal(t, "client-allowed", result.Items[0].ID)
|
|
}
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
t.Fatalf("hydra should not be called when reserved system name is rejected")
|
|
return nil, nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "AdminFront",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"http://localhost/cb"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func TestUpdateClient_ReservedSystemNameForbidden(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",
|
|
"redirect_uris": []string{
|
|
"http://localhost/cb",
|
|
},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile email offline_access",
|
|
"token_endpoint_auth_method": "none",
|
|
"metadata": map[string]any{"status": "active"},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
|
t.Fatalf("hydra update should not be called when reserved system name is rejected")
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "DevFront",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(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",
|
|
"redirect_uris": []string{
|
|
"http://localhost/cb",
|
|
},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile email offline_access",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"tenant_id": "tenant-1",
|
|
},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-1",
|
|
"client_name": "App One Updated",
|
|
"redirect_uris": []string{
|
|
"http://localhost/cb",
|
|
},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile email offline_access",
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"tenant_id": "tenant-1",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "App One Updated",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result clientDetailResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Equal(t, "App One Updated", result.Client.Name)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
|
{"client_id": "oathkeeper-introspect", "client_name": "Internal Client"},
|
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, 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 {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var res clientListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&res)
|
|
assert.Len(t, res.Items, 1)
|
|
assert.Equal(t, "client-1", res.Items[0].ID)
|
|
}
|
|
|
|
func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
|
{"client_id": "adminfront", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}},
|
|
{"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}},
|
|
{"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}},
|
|
{"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}},
|
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, 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 {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result clientListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Len(t, result.Items, 3)
|
|
assert.Equal(t, "adminfront", result.Items[0].ID)
|
|
assert.Equal(t, "devfront", result.Items[1].ID)
|
|
assert.Equal(t, "client-1", result.Items[2].ID)
|
|
}
|
|
|
|
func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
|
"client_id": "4f2c9fd6-1111-2222-3333-444444444444",
|
|
"client_name": "AdminFront",
|
|
"metadata": map[string]interface{}{"status": "active"},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/4f2c9fd6-1111-2222-3333-444444444444", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
}
|
|
|
|
func TestUpdateClientStatus_Success(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]interface{}{
|
|
"client_id": "client-1", "metadata": map[string]interface{}{"status": "active"},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
|
"client_id": "client-1", "metadata": map[string]interface{}{"status": "inactive"},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var res clientDetailResponse
|
|
json.NewDecoder(resp.Body).Decode(&res)
|
|
assert.Equal(t, "inactive", res.Client.Status)
|
|
}
|
|
|
|
func TestUpdateClientStatus_UserAllowedByStatusPermission(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]interface{}{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"metadata": map[string]interface{}{
|
|
"tenant_id": "tenant-1",
|
|
"status": "active",
|
|
},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"metadata": map[string]interface{}{
|
|
"tenant_id": "tenant-1",
|
|
"status": "inactive",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
app := fiber.New()
|
|
tenantID := "tenant-1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var res clientDetailResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&res)
|
|
assert.Equal(t, "inactive", res.Client.Status)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
|
"client_id": "oathkeeper-introspect",
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"status": "inactive"})
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func TestDeleteClient_Success(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]interface{}{"client_id": "client-1"}), nil
|
|
}
|
|
if r.Method == http.MethodDelete && r.URL.Path == "/clients/client-1" {
|
|
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
secretRepo := &mockSecretRepo{secrets: map[string]string{"client-1": "secret"}}
|
|
redisRepo := &devMockRedisRepo{data: map[string]string{"client_secret:client-1": "secret"}}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
s, _ := secretRepo.GetByID(nil, "client-1")
|
|
assert.Empty(t, s)
|
|
_, err := redisRepo.Get("client_secret:client-1")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}},
|
|
Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Delete("/api/v1/dev/clients/:id", h.DeleteClient)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func TestGetClient_ProtectedSystemClientHidden(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
|
"client_id": "oathkeeper-introspect",
|
|
"client_name": "Internal Client",
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
}
|
|
|
|
func TestGetClient_RPAdminAllowedByKetoViewPermission(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]interface{}{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"metadata": map[string]interface{}{
|
|
"tenant_id": "tenant-b",
|
|
"status": "active",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-a"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "rp-1",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestGetClient_RedactsSecretWithoutViewSecretPermission(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]interface{}{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"client_secret": "stored-secret",
|
|
"metadata": map[string]interface{}{
|
|
"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").Return(true, nil)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(false, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
app := fiber.New()
|
|
tenantID := "tenant-1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result clientDetailResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Empty(t, result.Client.ClientSecret)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestGetClient_UserAllowedToViewSecretByPermission(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]interface{}{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"client_secret": "stored-secret",
|
|
"metadata": map[string]interface{}{
|
|
"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").Return(true, nil)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(true, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
app := fiber.New()
|
|
tenantID := "tenant-1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result clientDetailResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Equal(t, "stored-secret", result.Client.ClientSecret)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestRotateClientSecret_Success(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]interface{}{"client_id": "client-1"}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
|
var body map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
return httpJSONAny(r, http.StatusOK, body), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
|
redisRepo := &devMockRedisRepo{data: make(map[string]string)}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients/:id/secret/rotate", h.RotateClientSecret)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/secret/rotate", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var res clientDetailResponse
|
|
json.NewDecoder(resp.Body).Decode(&res)
|
|
assert.NotEmpty(t, res.Client.ClientSecret)
|
|
|
|
dbS, _ := secretRepo.GetByID(nil, "client-1")
|
|
assert.Equal(t, res.Client.ClientSecret, dbS)
|
|
}
|
|
|
|
func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
var body map[string]interface{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
body["client_secret"] = "generated-secret"
|
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
|
|
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
|
redisRepo := &devMockRedisRepo{data: make(map[string]string)}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: secretRepo,
|
|
Redis: redisRepo,
|
|
Keto: mockKeto,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-a"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "rp-1",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"id": "client-1",
|
|
"name": "App One",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"http://localhost/cb"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
var body map[string]interface{}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
body["client_secret"] = "generated-secret"
|
|
return httpJSONAny(r, http.StatusCreated, body), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
|
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(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 == "admins" &&
|
|
entry.Subject == "User:user-1" &&
|
|
entry.Action == domain.KetoOutboxActionCreate
|
|
})).Return(nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
|
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
|
Keto: mockKeto,
|
|
KetoOutbox: mockOutbox,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-a"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"id": "client-1",
|
|
"name": "App One",
|
|
"type": "private",
|
|
"redirectUris": []string{"http://localhost/cb"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
mockKeto.AssertExpectations(t)
|
|
mockOutbox.AssertExpectations(t)
|
|
}
|
|
|
|
func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) {
|
|
mockKeto := new(devMockKetoService)
|
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
|
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Once()
|
|
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
|
|
}
|
|
|
|
mockOutbox := new(devMockKetoOutboxRepository)
|
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
|
expectedRelation := relation
|
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
|
return entry.Namespace == "Tenant" &&
|
|
entry.Object == "tenant-a" &&
|
|
entry.Relation == expectedRelation &&
|
|
entry.Subject == "User:user-1" &&
|
|
entry.Action == domain.KetoOutboxActionCreate
|
|
})).Return(nil).Once()
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Keto: mockKeto,
|
|
KetoOutbox: mockOutbox,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/test", func(c *fiber.Ctx) error {
|
|
h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a")
|
|
return c.SendStatus(fiber.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
mockKeto.AssertExpectations(t)
|
|
mockOutbox.AssertExpectations(t)
|
|
}
|
|
|
|
func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) {
|
|
mockKeto := new(devMockKetoService)
|
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
|
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").
|
|
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Once()
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Keto: mockKeto,
|
|
KetoOutbox: new(devMockKetoOutboxRepository),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/test", func(c *fiber.Ctx) error {
|
|
h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a")
|
|
return c.SendStatus(fiber.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) {
|
|
mockKeto := new(devMockKetoService)
|
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
|
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
|
|
}
|
|
|
|
mockOutbox := new(devMockKetoOutboxRepository)
|
|
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
|
expectedRelation := relation
|
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
|
return entry.Namespace == "Tenant" &&
|
|
entry.Object == "tenant-a" &&
|
|
entry.Relation == expectedRelation &&
|
|
entry.Subject == "User:user-1" &&
|
|
entry.Action == domain.KetoOutboxActionDelete
|
|
})).Return(nil).Once()
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Keto: mockKeto,
|
|
KetoOutbox: mockOutbox,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Get("/test", func(c *fiber.Ctx) error {
|
|
h.revokeDeveloperGrantRelation(c, "user-1", "tenant-a")
|
|
return c.SendStatus(fiber.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
mockKeto.AssertExpectations(t)
|
|
mockOutbox.AssertExpectations(t)
|
|
}
|
|
|
|
func TestGetStats_Success(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
|
{"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
|
{"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
|
{"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}},
|
|
{"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
auditRepo := &devEnhancedMockAuditRepo{
|
|
countFailures: 7,
|
|
countSessions: 3,
|
|
}
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
AuditRepo: auditRepo,
|
|
Keto: mockKeto,
|
|
}
|
|
app := fiber.New()
|
|
tenantID := "t1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "u1", Role: domain.RoleTenantAdmin, TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/stats", h.GetStats)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/stats", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var res devStatsResponse
|
|
json.NewDecoder(resp.Body).Decode(&res)
|
|
|
|
assert.Equal(t, int64(2), res.TotalClients)
|
|
assert.Equal(t, int64(7), res.AuthFailures)
|
|
assert.Equal(t, int64(3), res.ActiveSessions)
|
|
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all")
|
|
}
|
|
|
|
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
|
AuditRepo: nil, // Missing
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
t.Run("Mutating action fails when audit log is unavailable", func(t *testing.T) {
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
if h.AuditRepo == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
|
|
}
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader([]byte("{}")))
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
err = json.Unmarshal(body, &captured)
|
|
assert.NoError(t, err)
|
|
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"jwks": captured.JWKS,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Headless Login App",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
|
"scopes": []string{"openid", "profile"},
|
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
|
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
|
"metadata": map[string]any{
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
|
|
assert.Nil(t, captured.JWKS)
|
|
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
|
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
|
assert.True(t, captured.IsHeadlessLoginEnabled())
|
|
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
|
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
|
|
assert.False(t, hasRequestObjectAlg)
|
|
}
|
|
|
|
func TestCreateClient_DefaultsSkipConsentToTrue(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
err = json.Unmarshal(body, &captured)
|
|
assert.NoError(t, err)
|
|
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"skip_consent": captured.SkipConsent,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Trusted App",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
assert.NotNil(t, captured.SkipConsent)
|
|
assert.True(t, *captured.SkipConsent)
|
|
}
|
|
|
|
func TestNormalizeClientAutoLoginMetadata(t *testing.T) {
|
|
t.Run("keeps supported flag and URL", func(t *testing.T) {
|
|
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
|
"auto_login_supported": true,
|
|
"auto_login_url": "https://rp.example.com/login?auto=1",
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, true, metadata["auto_login_supported"])
|
|
assert.Equal(t, "https://rp.example.com/login?auto=1", metadata["auto_login_url"])
|
|
})
|
|
|
|
t.Run("requires URL when supported", func(t *testing.T) {
|
|
_, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
|
"auto_login_supported": true,
|
|
})
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("removes URL when unsupported", func(t *testing.T) {
|
|
metadata, err := normalizeClientAutoLoginMetadata(map[string]interface{}{
|
|
"auto_login_supported": false,
|
|
"auto_login_url": "https://rp.example.com/login?auto=1",
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, false, metadata["auto_login_supported"])
|
|
_, exists := metadata["auto_login_url"]
|
|
assert.False(t, exists)
|
|
})
|
|
}
|
|
|
|
func TestCreateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
err = json.Unmarshal(body, &captured)
|
|
assert.NoError(t, err)
|
|
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"skip_consent": captured.SkipConsent,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Consent Required App",
|
|
"type": "pkce",
|
|
"skipConsent": false,
|
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
assert.NotNil(t, captured.SkipConsent)
|
|
assert.False(t, *captured.SkipConsent)
|
|
}
|
|
|
|
func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
|
var hydraCalled bool
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
hydraCalled = true
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
|
"client_id": "client-headless-login",
|
|
"client_name": "Headless Login App",
|
|
"redirect_uris": []string{"https://rp.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "none",
|
|
"metadata": map[string]any{
|
|
"headless_login_enabled": true,
|
|
},
|
|
}), nil
|
|
})},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Headless Login App",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
|
"scopes": []string{"openid", "profile"},
|
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
|
"jwks": map[string]any{
|
|
"keys": []map[string]any{{
|
|
"kty": "RSA",
|
|
"alg": "RS256",
|
|
"n": "AQIDBAUGBw",
|
|
"e": "AQAB",
|
|
}},
|
|
},
|
|
"metadata": map[string]any{
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
assert.Contains(t, string(bodyBytes), "headless login supports jwksUri only")
|
|
assert.False(t, hydraCalled)
|
|
}
|
|
|
|
func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, json.Unmarshal(body, &captured))
|
|
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Claims App",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
|
"metadata": map[string]any{
|
|
"id_token_claims": []map[string]any{
|
|
{
|
|
"id": "claim-1",
|
|
"namespace": "top_level",
|
|
"key": "locale",
|
|
"value": " ko-KR ",
|
|
"valueType": "text",
|
|
},
|
|
{
|
|
"id": "claim-2",
|
|
"namespace": "rp_claims",
|
|
"key": "tier",
|
|
"value": "2",
|
|
"valueType": "number",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
|
|
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]interface{})
|
|
if assert.True(t, ok) && assert.Len(t, claims, 2) {
|
|
first, ok := claims[0].(map[string]interface{})
|
|
if assert.True(t, ok) {
|
|
assert.Equal(t, "top_level", first["namespace"])
|
|
assert.Equal(t, "locale", first["key"])
|
|
assert.Equal(t, "ko-KR", first["value"])
|
|
assert.Equal(t, "text", first["valueType"])
|
|
_, hasID := first["id"]
|
|
assert.False(t, hasID)
|
|
}
|
|
|
|
second, ok := claims[1].(map[string]interface{})
|
|
if assert.True(t, ok) {
|
|
assert.Equal(t, "rp_claims", second["namespace"])
|
|
assert.Equal(t, "tier", second["key"])
|
|
assert.Equal(t, "2", second["value"])
|
|
assert.Equal(t, "number", second["valueType"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateClient_RejectsInvalidIDTokenClaimsMetadata(t *testing.T) {
|
|
hydraCalled := false
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
hydraCalled = true
|
|
return httpJSONAny(r, http.StatusCreated, map[string]any{}), nil
|
|
})},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Claims App",
|
|
"type": "pkce",
|
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
|
"metadata": map[string]any{
|
|
"id_token_claims": []map[string]any{
|
|
{
|
|
"namespace": "top_level",
|
|
"key": "rp_claims",
|
|
"value": "forbidden",
|
|
"valueType": "text",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
assert.Contains(t, string(bodyBytes), "top-level key rp_claims is reserved")
|
|
assert.False(t, hydraCalled)
|
|
}
|
|
|
|
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-headless-login",
|
|
"client_name": "Headless Login Before",
|
|
"redirect_uris": []string{"https://before.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "none",
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"headless_jwks": map[string]any{"keys": []map[string]any{}},
|
|
"headless_jwks_uri": "https://stale.example.com/old.json",
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
err = json.Unmarshal(body, &captured)
|
|
assert.NoError(t, err)
|
|
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"jwks_uri": captured.JWKSUri,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Headless Login After",
|
|
"type": "pkce",
|
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
|
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
|
"metadata": map[string]any{
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
|
|
assert.Equal(t, "", captured.JWKSUri)
|
|
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
|
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
|
_, hasInlineJWKS := captured.Metadata["headless_jwks"]
|
|
assert.False(t, hasInlineJWKS)
|
|
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
|
|
assert.False(t, hasRequestObjectAlg)
|
|
assert.True(t, captured.IsHeadlessLoginEnabled())
|
|
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
|
}
|
|
|
|
func TestUpdateClient_AllowsExplicitSkipConsentFalse(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
currentSkipConsent := true
|
|
|
|
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, domain.HydraClient{
|
|
ClientID: "client-1",
|
|
ClientName: "Trusted Before",
|
|
RedirectURIs: []string{"https://rp.example.com/callback"},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
ResponseTypes: []string{"code"},
|
|
Scope: "openid profile",
|
|
TokenEndpointAuthMethod: "none",
|
|
SkipConsent: ¤tSkipConsent,
|
|
Metadata: map[string]interface{}{"status": "active"},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
err = json.Unmarshal(body, &captured)
|
|
assert.NoError(t, err)
|
|
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"skip_consent": captured.SkipConsent,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Consent Required After",
|
|
"type": "pkce",
|
|
"skipConsent": false,
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.NotNil(t, captured.SkipConsent)
|
|
assert.False(t, *captured.SkipConsent)
|
|
}
|
|
|
|
func TestUpdateClient_HeadlessLoginIgnoresExistingTopLevelJWKS(t *testing.T) {
|
|
var captured domain.HydraClient
|
|
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-headless-login",
|
|
"client_name": "Headless Login Before",
|
|
"redirect_uris": []string{"https://before.example.com/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"scope": "openid profile",
|
|
"token_endpoint_auth_method": "none",
|
|
"jwks": map[string]any{
|
|
"keys": []map[string]any{{
|
|
"kty": "RSA",
|
|
"alg": "RS256",
|
|
"n": "AQIDBAUGBw",
|
|
"e": "AQAB",
|
|
}},
|
|
},
|
|
"metadata": map[string]any{
|
|
"status": "active",
|
|
"headless_login_enabled": true,
|
|
"headless_jwks_uri": "https://stale.example.com/old.json",
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
err = json.Unmarshal(body, &captured)
|
|
assert.NoError(t, err)
|
|
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": captured.ClientID,
|
|
"client_name": captured.ClientName,
|
|
"redirect_uris": captured.RedirectURIs,
|
|
"grant_types": captured.GrantTypes,
|
|
"response_types": captured.ResponseTypes,
|
|
"scope": captured.Scope,
|
|
"token_endpoint_auth_method": captured.TokenEndpointAuthMethod,
|
|
"jwks_uri": captured.JWKSUri,
|
|
"metadata": captured.Metadata,
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Headless Login After",
|
|
"type": "pkce",
|
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
|
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
|
"metadata": map[string]any{
|
|
"headless_login_enabled": true,
|
|
"request_object_signing_alg": "RS256",
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.Nil(t, captured.JWKS)
|
|
assert.Equal(t, "", captured.JWKSUri)
|
|
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
|
_, hasRequestObjectAlg := captured.Metadata["request_object_signing_alg"]
|
|
assert.False(t, hasRequestObjectAlg)
|
|
}
|
|
|
|
func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.T) {
|
|
var revokedSubjects []string
|
|
|
|
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, domain.HydraClient{
|
|
ClientID: "client-1",
|
|
ClientName: "Tenant Guarded App",
|
|
RedirectURIs: []string{"https://rp.example.com/callback"},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
ResponseTypes: []string{"code"},
|
|
Scope: "openid tenant profile email",
|
|
TokenEndpointAuthMethod: "none",
|
|
Metadata: map[string]interface{}{
|
|
"tenant_access_restricted": true,
|
|
"allowed_tenants": []string{"tenant-a"},
|
|
},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
|
var updated domain.HydraClient
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, json.Unmarshal(body, &updated))
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": updated.ClientID,
|
|
"client_name": updated.ClientName,
|
|
"redirect_uris": updated.RedirectURIs,
|
|
"grant_types": updated.GrantTypes,
|
|
"response_types": updated.ResponseTypes,
|
|
"scope": updated.Scope,
|
|
"token_endpoint_auth_method": updated.TokenEndpointAuthMethod,
|
|
"metadata": updated.Metadata,
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
|
revokedSubjects = append(revokedSubjects, r.URL.Query().Get("subject"))
|
|
assert.Equal(t, "client-1", r.URL.Query().Get("client"))
|
|
return httpResponse(r, http.StatusNoContent, ""), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
consentRepo := &mockConsentRepo{
|
|
consents: []domain.ClientConsent{
|
|
{ClientID: "client-1", Subject: "user-1"},
|
|
{ClientID: "client-1", Subject: "user-2"},
|
|
{ClientID: "other-client", Subject: "user-3"},
|
|
},
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
ConsentRepo: consentRepo,
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"metadata": map[string]any{
|
|
"tenant_access_restricted": true,
|
|
"allowed_tenants": []string{"tenant-b"},
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.ElementsMatch(t, []string{"user-1", "user-2"}, revokedSubjects)
|
|
assert.Len(t, consentRepo.consents, 1)
|
|
assert.Equal(t, "other-client", consentRepo.consents[0].ClientID)
|
|
}
|
|
|
|
func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.T) {
|
|
revoked := false
|
|
|
|
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, domain.HydraClient{
|
|
ClientID: "client-1",
|
|
ClientName: "Tenant Guarded App",
|
|
RedirectURIs: []string{"https://rp.example.com/callback"},
|
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
ResponseTypes: []string{"code"},
|
|
Scope: "openid tenant profile email",
|
|
TokenEndpointAuthMethod: "none",
|
|
Metadata: map[string]interface{}{
|
|
"tenant_access_restricted": true,
|
|
"allowed_tenants": []string{"tenant-a"},
|
|
},
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" {
|
|
var updated domain.HydraClient
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, json.Unmarshal(body, &updated))
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": updated.ClientID,
|
|
"client_name": updated.ClientName,
|
|
"redirect_uris": updated.RedirectURIs,
|
|
"grant_types": updated.GrantTypes,
|
|
"response_types": updated.ResponseTypes,
|
|
"scope": updated.Scope,
|
|
"token_endpoint_auth_method": updated.TokenEndpointAuthMethod,
|
|
"metadata": updated.Metadata,
|
|
}), nil
|
|
}
|
|
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
|
|
revoked = true
|
|
return httpResponse(r, http.StatusNoContent, ""), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
consentRepo := &mockConsentRepo{
|
|
consents: []domain.ClientConsent{
|
|
{ClientID: "client-1", Subject: "user-1"},
|
|
},
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
ConsentRepo: consentRepo,
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Renamed App",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
assert.False(t, revoked)
|
|
assert.Len(t, consentRepo.consents, 1)
|
|
}
|
|
|
|
func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
|
|
privateKey, jwks := mustHeadlessRSAJWK(t)
|
|
_ = privateKey
|
|
jwksBody, _ := json.Marshal(jwks)
|
|
expectedN := devTestJWKSFirstKeyString(t, jwks, "n")
|
|
redisRepo := &devMockRedisRepo{data: map[string]string{}}
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
|
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
|
|
ClientID: "client-headless-login",
|
|
Metadata: map[string]any{
|
|
"status": "active",
|
|
"headless_login_enabled": true,
|
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
|
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})},
|
|
},
|
|
Redis: redisRepo,
|
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisRepo, &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", r.URL.String())
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(jwksBody, &payload)
|
|
return httpJSONAny(r, http.StatusOK, payload), nil
|
|
})}),
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients/:id/headless-jwks/refresh", h.RefreshHeadlessJWKSCache)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-headless-login/headless-jwks/refresh", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var got clientDetailResponse
|
|
err := json.NewDecoder(resp.Body).Decode(&got)
|
|
assert.NoError(t, err)
|
|
if assert.NotNil(t, got.HeadlessJWKSCache) {
|
|
assert.Equal(t, "success", got.HeadlessJWKSCache.LastRefreshStatus)
|
|
assert.Equal(t, []string{"test-kid"}, got.HeadlessJWKSCache.CachedKids)
|
|
if assert.Len(t, got.HeadlessJWKSCache.ParsedKeys, 1) {
|
|
assert.Equal(t, "test-kid", got.HeadlessJWKSCache.ParsedKeys[0].Kid)
|
|
assert.Equal(t, "RSA", got.HeadlessJWKSCache.ParsedKeys[0].Kty)
|
|
assert.Equal(t, "sig", got.HeadlessJWKSCache.ParsedKeys[0].Use)
|
|
assert.Equal(t, "RS256", got.HeadlessJWKSCache.ParsedKeys[0].Alg)
|
|
assert.Equal(t, expectedN, got.HeadlessJWKSCache.ParsedKeys[0].N)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) {
|
|
redisRepo := &devMockRedisRepo{data: map[string]string{}}
|
|
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, nil)
|
|
now := time.Now()
|
|
expiresAt := now.Add(30 * time.Minute)
|
|
err := cacheService.SaveState("client-headless-login", domain.HeadlessJWKSCacheState{
|
|
ClientID: "client-headless-login",
|
|
JWKSURI: "https://rp.example.com/.well-known/jwks.json",
|
|
CachedAt: &now,
|
|
ExpiresAt: &expiresAt,
|
|
LastRefreshStatus: "success",
|
|
ConsecutiveFailures: 0,
|
|
RawJWKS: `{"keys":[{"kid":"cached-key","kty":"RSA","n":"AQIDBAUGBw","e":"AQAB"}]}`,
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
PublicURL: "http://hydra.public",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
|
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
|
|
ClientID: "client-headless-login",
|
|
Metadata: map[string]any{
|
|
"status": "active",
|
|
"headless_login_enabled": true,
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})},
|
|
},
|
|
Redis: redisRepo,
|
|
HeadlessJWKS: cacheService,
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
|
return c.Next()
|
|
})
|
|
app.Delete("/api/v1/dev/clients/:id/headless-jwks/cache", h.RevokeHeadlessJWKSCache)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-headless-login/headless-jwks/cache", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
|
|
stored, err := cacheService.GetState("client-headless-login")
|
|
assert.Error(t, err)
|
|
assert.Nil(t, stored)
|
|
}
|
|
|
|
func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) {
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
|
AuditRepo: &mockAuditRepo{},
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-a"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "u-member",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result devAuditListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Empty(t, result.Items)
|
|
}
|
|
|
|
func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
|
auditRepo := &mockAuditRepo{
|
|
logs: []domain.AuditLog{
|
|
{
|
|
EventID: "evt-1",
|
|
EventType: "POST /api/v1/dev/clients",
|
|
Status: "success",
|
|
Timestamp: time.Now().UTC(),
|
|
Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"CREATE_CLIENT"}`,
|
|
},
|
|
{
|
|
EventID: "evt-2",
|
|
EventType: "POST /api/v1/dev/clients",
|
|
Status: "success",
|
|
Timestamp: time.Now().UTC().Add(-time.Minute),
|
|
Details: `{"target_id":"client-other","tenant_id":"tenant-a","action":"CREATE_CLIENT"}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
|
AuditRepo: auditRepo,
|
|
Keto: new(devMockKetoService),
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-a"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "u-rp-admin",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
Metadata: map[string]any{
|
|
"managed_client_ids": []any{"client-allowed"},
|
|
},
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result devAuditListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Len(t, result.Items, 1)
|
|
assert.Equal(t, "evt-1", result.Items[0].EventID)
|
|
}
|
|
|
|
func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
|
|
auditRepo := &mockAuditRepo{
|
|
logs: []domain.AuditLog{
|
|
{
|
|
EventID: "evt-allowed",
|
|
EventType: "POST /api/v1/dev/clients/client-allowed/secret/rotate",
|
|
Status: "success",
|
|
Timestamp: time.Now().UTC(),
|
|
Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"ROTATE_SECRET"}`,
|
|
},
|
|
{
|
|
EventID: "evt-allowed-path",
|
|
EventType: "GET /api/v1/dev/clients/client-allowed/relations",
|
|
Status: "success",
|
|
Timestamp: time.Now().UTC().Add(-30 * time.Second),
|
|
Details: `{"request_id":"req-1"}`,
|
|
},
|
|
{
|
|
EventID: "evt-denied",
|
|
EventType: "POST /api/v1/dev/clients/client-denied/secret/rotate",
|
|
Status: "success",
|
|
Timestamp: time.Now().UTC().Add(-time.Minute),
|
|
Details: `{"target_id":"client-denied","tenant_id":"tenant-b","action":"ROTATE_SECRET"}`,
|
|
},
|
|
},
|
|
}
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.URL.Path == "/clients" {
|
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
|
{"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
|
{"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "audit_viewer").Return(true, nil)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "audit_viewer").Return(false, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
AuditRepo: auditRepo,
|
|
Keto: mockKeto,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-a"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/audit-logs", h.ListAuditLogs)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result devAuditListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
if assert.Len(t, result.Items, 2) {
|
|
assert.Equal(t, "evt-allowed", result.Items[0].EventID)
|
|
assert.Equal(t, "evt-allowed-path", result.Items[1].EventID)
|
|
}
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
func TestListConsents_UserAllowedByRPAdminsRelation(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_consents").Return(true, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
ConsentRepo: &mockConsentRepo{
|
|
consents: []domain.ClientConsent{
|
|
{
|
|
ClientID: "client-1",
|
|
Subject: "subject-1",
|
|
GrantedScopes: []string{"openid", "profile"},
|
|
CreatedAt: time.Now().UTC(),
|
|
},
|
|
},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/consents", h.ListConsents)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result consentListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
if assert.Len(t, result.Items, 1) {
|
|
assert.Equal(t, "client-1", result.Items[0].ClientID)
|
|
assert.Equal(t, "subject-1", result.Items[0].Subject)
|
|
}
|
|
mockKeto.AssertExpectations(t)
|
|
}
|
|
|
|
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_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} {
|
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
|
}
|
|
mockKratos := new(devMockKratosAdmin)
|
|
mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{
|
|
ID: "user-2",
|
|
Traits: map[string]interface{}{
|
|
"name": "김용연",
|
|
"email": "kyy@example.com",
|
|
"id": "kyy01",
|
|
},
|
|
}, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
KratosAdmin: mockKratos,
|
|
}
|
|
|
|
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)
|
|
assert.Equal(t, "김용연", result.Items[0].UserName)
|
|
assert.Equal(t, "kyy@example.com", result.Items[0].UserEmail)
|
|
assert.Equal(t, "kyy01", result.Items[0].UserLoginID)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(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
|
|
})
|
|
|
|
mockKratos := new(devMockKratosAdmin)
|
|
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
|
{
|
|
ID: "user-1",
|
|
Traits: map[string]interface{}{
|
|
"name": "Alice Kim",
|
|
"email": "alice@example.com",
|
|
"id": "alice01",
|
|
"tenant_id": "tenant-1",
|
|
},
|
|
},
|
|
{
|
|
ID: "user-2",
|
|
Traits: map[string]interface{}{
|
|
"name": "Bob Lee",
|
|
"email": "bob@example.com",
|
|
"id": "bob01",
|
|
"tenant_id": "tenant-2",
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
KratosAdmin: mockKratos,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
tenantID := "tenant-1"
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-9",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
ManageableTenants: []domain.Tenant{
|
|
{ID: "tenant-1", Slug: "tenant-one"},
|
|
},
|
|
Metadata: map[string]any{
|
|
"managed_client_ids": []any{"client-1"},
|
|
},
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/users", h.SearchUsers)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=alice", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result devUserListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
if assert.Len(t, result.Items, 1) {
|
|
assert.Equal(t, "user-1", result.Items[0].ID)
|
|
assert.Equal(t, "Alice Kim", result.Items[0].Name)
|
|
assert.Equal(t, "alice@example.com", result.Items[0].Email)
|
|
}
|
|
mockKratos.AssertExpectations(t)
|
|
}
|
|
|
|
func TestSearchUsers_UserAllowedByRPAdminRelation(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)
|
|
|
|
mockKratos := new(devMockKratosAdmin)
|
|
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
|
{
|
|
ID: "target-user",
|
|
Traits: map[string]interface{}{
|
|
"name": "김용연",
|
|
"email": "kyy@example.com",
|
|
"id": "kyy01",
|
|
"tenant_id": "tenant-1",
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
KratosAdmin: mockKratos,
|
|
}
|
|
|
|
app := fiber.New()
|
|
tenantID := "tenant-1"
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleUser,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/users", h.SearchUsers)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=김용연", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result devUserListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
if assert.Len(t, result.Items, 1) {
|
|
assert.Equal(t, "target-user", result.Items[0].ID)
|
|
assert.Equal(t, "김용연", result.Items[0].Name)
|
|
}
|
|
mockKeto.AssertExpectations(t)
|
|
mockKratos.AssertExpectations(t)
|
|
}
|