From 05c33249d9f77d7131c0ec6abcbfa3e51b887e58 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 16:43:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_signup_test.go | 139 ++++++++++++++ .../internal/service/tenant_service_test.go | 178 ++++++++++++++++++ backend/internal/utils/slug_test.go | 56 ++++++ 3 files changed, 373 insertions(+) create mode 100644 backend/internal/handler/auth_handler_signup_test.go create mode 100644 backend/internal/service/tenant_service_test.go create mode 100644 backend/internal/utils/slug_test.go diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go new file mode 100644 index 00000000..ed86dd1b --- /dev/null +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -0,0 +1,139 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// --- Local Mocks for Signup Test --- + +type MockRedisForSignup struct { + mock.Mock +} + +func (m *MockRedisForSignup) Set(key string, value string, ttl time.Duration) error { + return m.Called(key, value, ttl).Error(0) +} +func (m *MockRedisForSignup) Get(key string) (string, error) { + args := m.Called(key) + return args.String(0), args.Error(1) +} +func (m *MockRedisForSignup) Delete(key string) error { + return m.Called(key).Error(0) +} +func (m *MockRedisForSignup) StoreVerificationCode(phone, code string) error { return nil } +func (m *MockRedisForSignup) GetVerificationCode(phone string) (string, error) { return "", nil } +func (m *MockRedisForSignup) DeleteVerificationCode(phone string) error { return nil } +func (m *MockRedisForSignup) Ping(ctx context.Context) error { return nil } + +type MockIdpForSignup struct { + mock.Mock +} + +func (m *MockIdpForSignup) Name() string { return "mock-idp" } +func (m *MockIdpForSignup) GetMetadata() (*domain.IDPMetadata, error) { + return &domain.IDPMetadata{SupportedFields: []string{"email", "name", "phoneNumber", "grade", "department"}}, nil +} +func (m *MockIdpForSignup) CreateUser(user *domain.BrokerUser, password string) (string, error) { + args := m.Called(user, password) + return args.String(0), args.Error(1) +} +func (m *MockIdpForSignup) SignIn(loginID, password string) (*domain.AuthInfo, error) { return nil, nil } +func (m *MockIdpForSignup) UserExists(loginID string) (bool, error) { return false, nil } +func (m *MockIdpForSignup) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil } +func (m *MockIdpForSignup) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, nil +} +func (m *MockIdpForSignup) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdpForSignup) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return &domain.PasswordPolicy{MinLength: 12}, nil +} +func (m *MockIdpForSignup) InitiatePasswordReset(loginID, redirectUrl string) error { return nil } +func (m *MockIdpForSignup) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + return nil +} + +func TestSignup_CompanyCodeValidation(t *testing.T) { + app := fiber.New() + mockTenantSvc := new(MockTenantService) + mockRedis := new(MockRedisForSignup) + mockIdp := new(MockIdpForSignup) + + h := &AuthHandler{ + TenantService: mockTenantSvc, + RedisService: mockRedis, + IdpProvider: mockIdp, + } + + app.Post("/signup", h.Signup) + + // Prepare mock state (already verified email/phone) + verifiedState, _ := json.Marshal(map[string]interface{}{ + "verified": true, + "expires_at": time.Now().Add(time.Hour).Unix(), + }) + mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil) + + t.Run("Invalid Company Code", func(t *testing.T) { + reqBody := domain.SignupRequest{ + Email: "user@gmail.com", // General domain + Password: "StrongPass123!", + Name: "Test User", + Phone: "010-1234-5678", + TermsAccepted: true, + CompanyCode: "non-existent-code", + } + body, _ := json.Marshal(reqBody) + + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "non-existent-code").Return(nil, nil) + + req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + var res map[string]interface{} + json.NewDecoder(resp.Body).Decode(&res) + assert.Equal(t, "Invalid company code.", res["error"]) + }) + + t.Run("Active Company Code", func(t *testing.T) { + reqBody := domain.SignupRequest{ + Email: "user@gmail.com", + Password: "StrongPass123!", + Name: "Test User", + Phone: "010-1234-5678", + TermsAccepted: true, + CompanyCode: "valid-slug", + } + body, _ := json.Marshal(reqBody) + + validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive} + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil) + mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil) + mockRedis.On("Delete", mock.Anything).Return(nil) + + req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go new file mode 100644 index 00000000..0c1fa4d4 --- /dev/null +++ b/backend/internal/service/tenant_service_test.go @@ -0,0 +1,178 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// --- Local Mocks to avoid collisions --- + +type MockTenantRepoForSvc struct { + mock.Mock +} + +func (m *MockTenantRepoForSvc) Create(ctx context.Context, tenant *domain.Tenant) error { + return m.Called(ctx, tenant).Error(0) +} +func (m *MockTenantRepoForSvc) Update(ctx context.Context, tenant *domain.Tenant) error { + return m.Called(ctx, tenant).Error(0) +} +func (m *MockTenantRepoForSvc) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepoForSvc) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + args := m.Called(ctx, slug) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepoForSvc) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { + return nil, nil +} +func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { + return nil, nil +} +func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { + return m.Called(ctx, tenantID, domainName, verified).Error(0) +} + +type MockKetoSvcForTenant struct { + mock.Mock +} + +func (m *MockKetoSvcForTenant) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} +func (m *MockKetoSvcForTenant) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} +func (m *MockKetoSvcForTenant) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Get(0).([]RelationTuple), args.Error(1) +} +func (m *MockKetoSvcForTenant) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + args := m.Called(ctx, namespace, relation, subject) + return args.Get(0).([]string), args.Error(1) +} +func (m *MockKetoSvcForTenant) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Bool(0), args.Error(1) +} + +type MockUserRepoForTenant struct { + mock.Mock +} + +func (m *MockUserRepoForTenant) Create(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForTenant) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForTenant) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + args := m.Called(email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.User), args.Error(1) +} +func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForTenant) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { + return nil, 0, nil +} + +// --- Tests --- + +func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + svc := NewTenantService(mockRepo, nil) + + ctx := context.Background() + name := "New Tenant" + slug := "new-tenant" + domains := []string{"example.com"} + + // Use .Once() to ensure correct return values for sequential calls to FindBySlug + mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once() + mockRepo.On("Create", ctx, mock.Anything).Return(nil) + mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) + mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() + + tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains) + assert.NoError(t, err) + assert.NotNil(t, tenant) + assert.Equal(t, "t1", tenant.ID) + mockRepo.AssertExpectations(t) +} + +func TestTenantService_RequestRegistration_NoVerify(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + svc := NewTenantService(mockRepo, nil) + + ctx := context.Background() + name := "Public Tenant" + slug := "public-tenant" + domainName := "public.com" + adminEmail := "admin@public.com" + + mockRepo.On("Create", ctx, mock.MatchedBy(func(tenant *domain.Tenant) bool { + return tenant.Status == domain.TenantStatusPending + })).Return(nil) + mockRepo.On("AddDomain", ctx, mock.Anything, domainName, false).Return(nil) + + tenant, err := svc.RequestRegistration(ctx, name, slug, "", domainName, adminEmail) + assert.NoError(t, err) + assert.NotNil(t, tenant) + mockRepo.AssertExpectations(t) +} + +func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + mockUserRepo := new(MockUserRepoForTenant) + mockKeto := new(MockKetoSvcForTenant) + + svc := NewTenantService(mockRepo, mockUserRepo) + svc.SetKetoService(mockKeto) + + ctx := context.Background() + tenantID := "t1" + adminEmail := "admin@tenant.com" + userID := "user-uuid" + + tenant := &domain.Tenant{ + ID: tenantID, + Slug: "tenant-slug", + Config: domain.JSONMap{"adminEmail": adminEmail}, + } + + mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil) + mockRepo.On("Update", ctx, mock.Anything).Return(nil) + mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil) + mockKeto.On("CreateRelation", ctx, "Tenant", tenantID, "admin", "User:"+userID).Return(nil) + + err := svc.ApproveTenant(ctx, tenantID) + assert.NoError(t, err) + mockRepo.AssertExpectations(t) + mockUserRepo.AssertExpectations(t) + mockKeto.AssertExpectations(t) +} diff --git a/backend/internal/utils/slug_test.go b/backend/internal/utils/slug_test.go new file mode 100644 index 00000000..9a08ac40 --- /dev/null +++ b/backend/internal/utils/slug_test.go @@ -0,0 +1,56 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateSlug_ReservedKeywords(t *testing.T) { + tests := []struct { + slug string + valid bool + }{ + {"my-tenant", true}, + {"admin", false}, + {"api", false}, + {"static", false}, + {"security", false}, + {"billing", false}, + {"ns", false}, + {"mx", false}, + {"webmaster", false}, + {"status", false}, + } + + for _, tt := range tests { + t.Run(tt.slug, func(t *testing.T) { + valid, msg := ValidateSlug(tt.slug) + assert.Equal(t, tt.valid, valid, "Slug: "+tt.slug+" - "+msg) + }) + } +} + +func TestValidateSlug_Format(t *testing.T) { + tests := []struct { + slug string + valid bool + }{ + {"abc", true}, + {"a-b-c", true}, + {"123", true}, + {"ab", false}, // Too short + {"-abc", false}, // Starts with hyphen + {"abc-", false}, // Ends with hyphen + {"Abc", true}, // Case insensitive check (converted to lower) + {"invalid_slug", false}, // Contains underscore + {"too-long-slug-name-that-exceeds-thirty-two-chars", false}, + } + + for _, tt := range tests { + t.Run(tt.slug, func(t *testing.T) { + valid, _ := ValidateSlug(tt.slug) + assert.Equal(t, tt.valid, valid) + }) + } +} From 19c67a0d91bfda0c92326e60b73fb63b0307fb8f Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 17:00:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=802?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_signup_test.go | 20 +++++++++++++------ .../internal/service/tenant_service_test.go | 15 ++++++++++++++ backend/internal/utils/slug_test.go | 2 +- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index ed86dd1b..da6a0fcf 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -24,17 +24,19 @@ type MockRedisForSignup struct { func (m *MockRedisForSignup) Set(key string, value string, ttl time.Duration) error { return m.Called(key, value, ttl).Error(0) } + func (m *MockRedisForSignup) Get(key string) (string, error) { args := m.Called(key) return args.String(0), args.Error(1) } + func (m *MockRedisForSignup) Delete(key string) error { return m.Called(key).Error(0) } -func (m *MockRedisForSignup) StoreVerificationCode(phone, code string) error { return nil } +func (m *MockRedisForSignup) StoreVerificationCode(phone, code string) error { return nil } func (m *MockRedisForSignup) GetVerificationCode(phone string) (string, error) { return "", nil } -func (m *MockRedisForSignup) DeleteVerificationCode(phone string) error { return nil } -func (m *MockRedisForSignup) Ping(ctx context.Context) error { return nil } +func (m *MockRedisForSignup) DeleteVerificationCode(phone string) error { return nil } +func (m *MockRedisForSignup) Ping(ctx context.Context) error { return nil } type MockIdpForSignup struct { mock.Mock @@ -44,19 +46,24 @@ func (m *MockIdpForSignup) Name() string { return "mock-idp" } func (m *MockIdpForSignup) GetMetadata() (*domain.IDPMetadata, error) { return &domain.IDPMetadata{SupportedFields: []string{"email", "name", "phoneNumber", "grade", "department"}}, nil } + func (m *MockIdpForSignup) CreateUser(user *domain.BrokerUser, password string) (string, error) { args := m.Called(user, password) return args.String(0), args.Error(1) } -func (m *MockIdpForSignup) SignIn(loginID, password string) (*domain.AuthInfo, error) { return nil, nil } -func (m *MockIdpForSignup) UserExists(loginID string) (bool, error) { return false, nil } -func (m *MockIdpForSignup) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil } +func (m *MockIdpForSignup) SignIn(loginID, password string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdpForSignup) UserExists(loginID string) (bool, error) { return false, nil } +func (m *MockIdpForSignup) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil } func (m *MockIdpForSignup) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { return nil, nil } + func (m *MockIdpForSignup) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { return nil, nil } + func (m *MockIdpForSignup) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return &domain.PasswordPolicy{MinLength: 12}, nil } @@ -64,6 +71,7 @@ func (m *MockIdpForSignup) InitiatePasswordReset(loginID, redirectUrl string) er func (m *MockIdpForSignup) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { return nil, nil } + func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { return nil } diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 0c1fa4d4..a83fa3cb 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -18,9 +18,11 @@ type MockTenantRepoForSvc struct { func (m *MockTenantRepoForSvc) Create(ctx context.Context, tenant *domain.Tenant) error { return m.Called(ctx, tenant).Error(0) } + func (m *MockTenantRepoForSvc) Update(ctx context.Context, tenant *domain.Tenant) error { return m.Called(ctx, tenant).Error(0) } + func (m *MockTenantRepoForSvc) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { args := m.Called(ctx, id) if args.Get(0) == nil { @@ -28,6 +30,7 @@ func (m *MockTenantRepoForSvc) FindByID(ctx context.Context, id string) (*domain } return args.Get(0).(*domain.Tenant), args.Error(1) } + func (m *MockTenantRepoForSvc) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { args := m.Called(ctx, slug) if args.Get(0) == nil { @@ -35,9 +38,11 @@ func (m *MockTenantRepoForSvc) FindBySlug(ctx context.Context, slug string) (*do } return args.Get(0).(*domain.Tenant), args.Error(1) } + func (m *MockTenantRepoForSvc) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { return nil, nil } + func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { args := m.Called(ctx, domainName) if args.Get(0) == nil { @@ -45,9 +50,11 @@ func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName stri } return args.Get(0).(*domain.Tenant), args.Error(1) } + func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { return nil, nil } + func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { return m.Called(ctx, tenantID, domainName, verified).Error(0) } @@ -59,17 +66,21 @@ type MockKetoSvcForTenant struct { func (m *MockKetoSvcForTenant) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { return m.Called(ctx, namespace, object, relation, subject).Error(0) } + func (m *MockKetoSvcForTenant) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { return m.Called(ctx, namespace, object, relation, subject).Error(0) } + func (m *MockKetoSvcForTenant) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) { args := m.Called(ctx, namespace, object, relation, subject) return args.Get(0).([]RelationTuple), args.Error(1) } + func (m *MockKetoSvcForTenant) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { args := m.Called(ctx, namespace, relation, subject) return args.Get(0).([]string), args.Error(1) } + func (m *MockKetoSvcForTenant) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) { args := m.Called(ctx, namespace, object, relation, subject) return args.Bool(0), args.Error(1) @@ -88,15 +99,19 @@ func (m *MockUserRepoForTenant) FindByEmail(ctx context.Context, email string) ( } return args.Get(0).(*domain.User), args.Error(1) } + func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) { return nil, nil } + func (m *MockUserRepoForTenant) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } + func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } + func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { return nil, 0, nil } diff --git a/backend/internal/utils/slug_test.go b/backend/internal/utils/slug_test.go index 9a08ac40..15dad8ef 100644 --- a/backend/internal/utils/slug_test.go +++ b/backend/internal/utils/slug_test.go @@ -39,7 +39,7 @@ func TestValidateSlug_Format(t *testing.T) { {"abc", true}, {"a-b-c", true}, {"123", true}, - {"ab", false}, // Too short + {"ab", false}, // Too short {"-abc", false}, // Starts with hyphen {"abc-", false}, // Ends with hyphen {"Abc", true}, // Case insensitive check (converted to lower) From ba7cca3c60e05c5869e02c297475b7390c9841da Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 17:14:41 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EB=A6=B0=ED=8A=B8=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/auth_handler_signup_test.go | 1 + backend/internal/service/tenant_service_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index da6a0fcf..b8c92275 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -51,6 +51,7 @@ func (m *MockIdpForSignup) CreateUser(user *domain.BrokerUser, password string) args := m.Called(user, password) return args.String(0), args.Error(1) } + func (m *MockIdpForSignup) SignIn(loginID, password string) (*domain.AuthInfo, error) { return nil, nil } diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 0c1fa4d4..a83fa3cb 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -18,9 +18,11 @@ type MockTenantRepoForSvc struct { func (m *MockTenantRepoForSvc) Create(ctx context.Context, tenant *domain.Tenant) error { return m.Called(ctx, tenant).Error(0) } + func (m *MockTenantRepoForSvc) Update(ctx context.Context, tenant *domain.Tenant) error { return m.Called(ctx, tenant).Error(0) } + func (m *MockTenantRepoForSvc) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { args := m.Called(ctx, id) if args.Get(0) == nil { @@ -28,6 +30,7 @@ func (m *MockTenantRepoForSvc) FindByID(ctx context.Context, id string) (*domain } return args.Get(0).(*domain.Tenant), args.Error(1) } + func (m *MockTenantRepoForSvc) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { args := m.Called(ctx, slug) if args.Get(0) == nil { @@ -35,9 +38,11 @@ func (m *MockTenantRepoForSvc) FindBySlug(ctx context.Context, slug string) (*do } return args.Get(0).(*domain.Tenant), args.Error(1) } + func (m *MockTenantRepoForSvc) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { return nil, nil } + func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { args := m.Called(ctx, domainName) if args.Get(0) == nil { @@ -45,9 +50,11 @@ func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName stri } return args.Get(0).(*domain.Tenant), args.Error(1) } + func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { return nil, nil } + func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { return m.Called(ctx, tenantID, domainName, verified).Error(0) } @@ -59,17 +66,21 @@ type MockKetoSvcForTenant struct { func (m *MockKetoSvcForTenant) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { return m.Called(ctx, namespace, object, relation, subject).Error(0) } + func (m *MockKetoSvcForTenant) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { return m.Called(ctx, namespace, object, relation, subject).Error(0) } + func (m *MockKetoSvcForTenant) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) { args := m.Called(ctx, namespace, object, relation, subject) return args.Get(0).([]RelationTuple), args.Error(1) } + func (m *MockKetoSvcForTenant) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { args := m.Called(ctx, namespace, relation, subject) return args.Get(0).([]string), args.Error(1) } + func (m *MockKetoSvcForTenant) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) { args := m.Called(ctx, namespace, object, relation, subject) return args.Bool(0), args.Error(1) @@ -88,15 +99,19 @@ func (m *MockUserRepoForTenant) FindByEmail(ctx context.Context, email string) ( } return args.Get(0).(*domain.User), args.Error(1) } + func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) { return nil, nil } + func (m *MockUserRepoForTenant) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } + func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } + func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { return nil, 0, nil } From e40dd8120e075d146d996264953b676f373130f5 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 18:02:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?adminfront=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 6 +- adminfront/package-lock.json | 36 ++++ adminfront/package.json | 2 + .../components/common/LanguageSelector.tsx | 8 +- .../src/components/layout/AppLayout.tsx | 16 +- adminfront/src/components/ui/badge.tsx | 3 +- adminfront/src/components/ui/button.tsx | 3 +- adminfront/src/components/ui/dialog.tsx | 52 ++--- adminfront/src/components/ui/input.tsx | 3 +- adminfront/src/components/ui/select.tsx | 56 +++--- adminfront/src/components/ui/textarea.tsx | 3 +- .../src/features/auth/AuthCallbackPage.tsx | 28 ++- adminfront/src/features/auth/LoginPage.tsx | 55 +----- .../routes/GlobalUserGroupListPage.tsx | 34 +++- .../routes/UserGroupDetailPage.tsx | 181 ++++++++++++------ adminfront/src/index.css | 3 +- adminfront/src/lib/adminApi.ts | 4 +- adminfront/src/lib/auth.ts | 20 ++ adminfront/src/main.tsx | 10 +- compose.ory.yaml | 4 +- devfront/src/app/routes.tsx | 2 +- devfront/src/lib/auth.ts | 2 +- 22 files changed, 317 insertions(+), 214 deletions(-) create mode 100644 adminfront/src/lib/auth.ts diff --git a/.env.sample b/.env.sample index 9bbadab2..5734649e 100644 --- a/.env.sample +++ b/.env.sample @@ -110,7 +110,7 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc # OIDC 클라이언트 callback (콤마 구분) ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback -DEVFRONT_CALLBACK_URLS=http://localhost:5174/callback,https://sso.hmac.kr/devfront/callback +DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. @@ -134,9 +134,9 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf CSRF_COOKIE_SECRET=localcsrf123 # AdminFront OIDC 설정 -ADMINFRONT_CALLBACK_URLS=http://localhost:5000/callback,https://sso.hmac.kr/devfront/callback +ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback # DevFront OIDC 설정 VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc -DEVFRONT_CALLBACK_URLS=http://localhost:5174/callback,https://sso.hmac.kr/devfront/callback \ No newline at end of file +DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback \ No newline at end of file diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json index e26a6fad..f206ce9b 100644 --- a/adminfront/package-lock.json +++ b/adminfront/package-lock.json @@ -20,9 +20,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "oidc-client-ts": "^3.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-oidc-context": "^3.3.0", "react-router-dom": "^6.28.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -3163,6 +3165,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -3605,6 +3616,18 @@ "node": ">= 6" } }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3926,6 +3949,19 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-oidc-context": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", + "integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/adminfront/package.json b/adminfront/package.json index d91f2c03..fa57ca92 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -26,9 +26,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "oidc-client-ts": "^3.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-oidc-context": "^3.3.0", "react-router-dom": "^6.28.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/adminfront/src/components/common/LanguageSelector.tsx b/adminfront/src/components/common/LanguageSelector.tsx index fe77df72..7f905cd0 100644 --- a/adminfront/src/components/common/LanguageSelector.tsx +++ b/adminfront/src/components/common/LanguageSelector.tsx @@ -44,12 +44,8 @@ function LanguageSelector() { className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20" aria-label={t("ui.common.language", "언어")} > - - + + ); } diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 7baec948..6fda7c27 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import { Users, } from "lucide-react"; import { useEffect, useState } from "react"; +import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { t } from "../../lib/i18n"; import LanguageSelector from "../common/LanguageSelector"; @@ -40,6 +41,7 @@ const navItems = [ { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, ]; function AppLayout() { + const auth = useAuth(); const navigate = useNavigate(); const [theme, setTheme] = useState<"light" | "dark">(() => { const stored = window.localStorage.getItem("admin_theme"); @@ -51,16 +53,16 @@ function AppLayout() { window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?")) ) { window.localStorage.removeItem("admin_session"); + auth.removeUser(); navigate("/login"); } }; useEffect(() => { - const session = window.localStorage.getItem("admin_session"); - if (!session) { + if (!auth.isLoading && !auth.isAuthenticated) { navigate("/login"); } - }, [navigate]); + }, [auth.isLoading, auth.isAuthenticated, navigate]); useEffect(() => { const root = document.documentElement; @@ -77,6 +79,14 @@ function AppLayout() { setTheme((prev) => (prev === "light" ? "dark" : "light")); }; + if (auth.isLoading) { + return ( +
+
+
+ ); + } + return (