forked from baron/baron-sso
1424 lines
50 KiB
Go
1424 lines
50 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) {
|
|
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 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 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 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_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 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 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_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 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_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 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_TenantMemberForbidden(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.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
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 TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"metadata": map[string]any{
|
|
"tenant_id": "tenant-1",
|
|
"status": "active",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
|
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
|
|
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
|
|
}, nil)
|
|
for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} {
|
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
|
}
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
tenantID := "tenant-1"
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result clientRelationListResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
assert.Len(t, result.Items, 1)
|
|
assert.Equal(t, "config_editor", result.Items[0].Relation)
|
|
assert.Equal(t, "User", result.Items[0].SubjectType)
|
|
assert.Equal(t, "user-2", result.Items[0].SubjectID)
|
|
}
|
|
|
|
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"metadata": map[string]any{
|
|
"tenant_id": "tenant-1",
|
|
"status": "active",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil)
|
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil)
|
|
|
|
mockOutbox := new(devMockKetoOutboxRepository)
|
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
|
return entry.Namespace == "RelyingParty" &&
|
|
entry.Object == "client-1" &&
|
|
entry.Relation == "config_editor" &&
|
|
entry.Subject == "User:user-2" &&
|
|
entry.Action == domain.KetoOutboxActionCreate
|
|
})).Return(nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
KetoOutbox: mockOutbox,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
tenantID := "tenant-1"
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Post("/api/v1/dev/clients/:id/relations", h.AddClientRelation)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"relation": "config_editor",
|
|
"userId": "user-2",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/relations", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
mockOutbox.AssertExpectations(t)
|
|
}
|
|
|
|
func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
|
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
|
"client_id": "client-1",
|
|
"client_name": "App One",
|
|
"metadata": map[string]any{
|
|
"tenant_id": "tenant-1",
|
|
"status": "active",
|
|
},
|
|
}), nil
|
|
}
|
|
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
|
})
|
|
|
|
mockKeto := new(devMockKetoService)
|
|
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
|
|
|
mockOutbox := new(devMockKetoOutboxRepository)
|
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
|
return entry.Namespace == "RelyingParty" &&
|
|
entry.Object == "client-1" &&
|
|
entry.Relation == "config_editor" &&
|
|
entry.Subject == "User:user-2" &&
|
|
entry.Action == domain.KetoOutboxActionDelete
|
|
})).Return(nil)
|
|
|
|
h := &DevHandler{
|
|
Hydra: &service.HydraAdminService{
|
|
AdminURL: "http://hydra.test",
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
},
|
|
Keto: mockKeto,
|
|
KetoOutbox: mockOutbox,
|
|
}
|
|
|
|
app := fiber.New()
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
tenantID := "tenant-1"
|
|
c.Locals("user_profile", &domain.UserProfileResponse{
|
|
ID: "user-1",
|
|
Role: domain.RoleRPAdmin,
|
|
TenantID: &tenantID,
|
|
})
|
|
return c.Next()
|
|
})
|
|
app.Delete("/api/v1/dev/clients/:id/relations", h.RemoveClientRelation)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1/relations?relation=config_editor&subject=User:user-2", nil)
|
|
resp, _ := app.Test(req, -1)
|
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
mockOutbox.AssertExpectations(t)
|
|
}
|