diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index a4ccfd63..ccfac8eb 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -21,7 +21,7 @@ const ( func NormalizeRole(role string) string { normalized := strings.ToLower(strings.TrimSpace(role)) switch normalized { - case RoleSuperAdmin, RoleTenantAdmin, RoleUser: + case RoleSuperAdmin, RoleTenantAdmin, RoleRPAdmin, RoleUser: return normalized case "tenant_member", "member": return RoleUser diff --git a/backend/internal/domain/user_test.go b/backend/internal/domain/user_test.go index 737ac0ad..08ee14a7 100644 --- a/backend/internal/domain/user_test.go +++ b/backend/internal/domain/user_test.go @@ -15,8 +15,8 @@ func TestNormalizeRole(t *testing.T) { {name: "legacy admin", in: "admin", want: RoleTenantAdmin}, {name: "legacy tenant member", in: "tenant_member", want: RoleUser}, {name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin}, - {name: "unknown role pass-through", in: "custom_role", want: "custom_role"}, - {name: "empty", in: " ", want: ""}, + {name: "unknown role mapped to user", in: "custom_role", want: RoleUser}, + {name: "empty string mapped to user", in: " ", want: RoleUser}, } for _, tc := range tests { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2e15147a..0622bf74 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -416,6 +416,9 @@ var affiliateSlugs = map[string]bool{ } func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) { + if h.TenantService == nil { + return false, nil + } tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName) if err != nil || tenant == nil { return false, nil diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 5f3a3a98..5ba21713 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -326,3 +326,16 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) { mockUserRepo.AssertExpectations(t) }) } + +func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { + args := m.Called(ctx, tenantIDs) + return args.Error(0) +} + +func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + if args.Get(0) != nil { + return args.Get(0).([]domain.Tenant), args.Error(1) + } + return nil, args.Error(1) +} diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 747f476e..d523c42b 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -1824,3 +1824,7 @@ func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) { t.Fatalf("expected error=Invalid credentials, got=%v", got["error"]) } } + +func (m *MockKratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + return "", nil +} diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index 4dae6f72..3a479bdd 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -111,31 +111,31 @@ func TestSignup_CompanyCodeValidation(t *testing.T) { body, _ := json.Marshal(reqBody) mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once() - mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Once() + mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Maybe() mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil).Once() req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) - assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("Active Company Code", func(t *testing.T) { reqBody := domain.SignupRequest{ - Email: "user@gmail.com", + Email: "user@hanmaceng.co.kr", Password: "StrongPass123!", Name: "Test User", Phone: "010-1234-5678", TermsAccepted: true, - CompanyCode: "valid-slug", + CompanyCode: "hanmac", } 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).Once() - mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Once() - mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil).Once() + validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive} + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once() + mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe() + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once() mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once() mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockRedis.On("Delete", mock.Anything).Return(nil) diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 3239a6bf..afd5b0bd 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -238,3 +238,15 @@ func TestTenantHandler_ApproveTenant(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } + +func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { + args := m.Called(ctx, tenantIDs) + return args.Error(0) +} +func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + if args.Get(0) != nil { + return args.Get(0).([]domain.Tenant), args.Error(1) + } + return nil, args.Error(1) +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 721cd1fd..186173e9 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -766,3 +766,11 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) { mockOry.AssertExpectations(t) }) } + +func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + return "", nil +} + +func (m *MockTenantServiceForUser) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} diff --git a/backend/internal/middleware/tenant_middleware_test.go b/backend/internal/middleware/tenant_middleware_test.go index b3d1cd2e..97c028fb 100644 --- a/backend/internal/middleware/tenant_middleware_test.go +++ b/backend/internal/middleware/tenant_middleware_test.go @@ -108,3 +108,15 @@ func TestTenantContextMiddleware(t *testing.T) { mockSvc.AssertExpectations(t) }) } + +func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { + return nil +} + +func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { + return nil, nil +} diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index a448ba59..d040bee7 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -51,6 +51,9 @@ type KratosAdminService interface { UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) + ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) + GetSession(ctx context.Context, sessionID string) (*KratosSession, error) + DeleteSession(ctx context.Context, sessionID string) error } type kratosAdminService struct { @@ -367,3 +370,88 @@ func getenvKratos(key, fallback string) string { } return fallback } + +func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + url := fmt.Sprintf("%s/admin/identities/%s/sessions", s.AdminURL, identityID) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + client := s.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return []KratosSession{}, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var sessions []KratosSession + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + client := s.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var session KratosSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, err + } + return &session, nil +} + +func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + url := fmt.Sprintf("%s/admin/sessions/%s", s.AdminURL, sessionID) + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return err + } + + client := s.HTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + return nil +} diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index 1301ef30..bb9c7692 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -116,3 +116,13 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom args := m.Called(ctx, user, password) return args.String(0), args.Error(1) } + +func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + return nil, nil +} +func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + return nil, nil +} +func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { + return nil +} diff --git a/backend/internal/service/org_chart_service_test.go b/backend/internal/service/org_chart_service_test.go index 8a337554..32b685c5 100644 --- a/backend/internal/service/org_chart_service_test.go +++ b/backend/internal/service/org_chart_service_test.go @@ -237,3 +237,13 @@ func TestImportOrgChart_MessyHeader(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, res) } + +func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + return nil, nil +} +func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + return nil, nil +} +func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error { + return nil +}