forked from baron/baron-sso
테스트 보강 및 dev 충돌/CI 정책 정리
This commit is contained in:
@@ -1,30 +1,16 @@
|
||||
name: Code Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_lint:
|
||||
description: "Run linters for Go and Flutter"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_backend_tests:
|
||||
description: "Run backend Go tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_userfront_tests:
|
||||
description: "Run userfront Flutter tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -68,7 +54,7 @@ jobs:
|
||||
|
||||
backend-tests:
|
||||
needs: lint
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true }}
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
@@ -102,7 +88,7 @@ jobs:
|
||||
|
||||
userfront-tests:
|
||||
needs: lint
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true }}
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
266
backend/internal/service/tenant_service_test.go
Normal file
266
backend/internal/service/tenant_service_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type tenantServiceTenantRepoMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *tenantServiceTenantRepoMock) Create(ctx context.Context, tenant *domain.Tenant) error {
|
||||
args := m.Called(ctx, tenant)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *tenantServiceTenantRepoMock) Update(ctx context.Context, tenant *domain.Tenant) error {
|
||||
args := m.Called(ctx, tenant)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *tenantServiceTenantRepoMock) 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 *tenantServiceTenantRepoMock) 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 *tenantServiceTenantRepoMock) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, name)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *tenantServiceTenantRepoMock) 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 *tenantServiceTenantRepoMock) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, ids)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *tenantServiceTenantRepoMock) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
||||
args := m.Called(ctx, tenantID, domainName, verified)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type tenantServiceUserRepoMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) Create(ctx context.Context, user *domain.User) error {
|
||||
args := m.Called(ctx, user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) Update(ctx context.Context, user *domain.User) error {
|
||||
args := m.Called(ctx, user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
args := m.Called(ctx, email)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||
args := m.Called(ctx, ids)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
args := m.Called(ctx, tenantID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *tenantServiceUserRepoMock) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||
args := m.Called(ctx, offset, limit, search)
|
||||
if args.Get(0) == nil {
|
||||
return nil, 0, args.Error(2)
|
||||
}
|
||||
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
|
||||
func TestTenantService_RegisterTenant_AddsDomainsAsVerified(t *testing.T) {
|
||||
repo := new(tenantServiceTenantRepoMock)
|
||||
userRepo := new(tenantServiceUserRepoMock)
|
||||
svc := NewTenantService(repo, userRepo)
|
||||
|
||||
repo.On("FindBySlug", mock.Anything, "tenant-a").Return(nil, gorm.ErrRecordNotFound).Once()
|
||||
repo.On("Create", mock.Anything, mock.MatchedBy(func(tenant *domain.Tenant) bool {
|
||||
return tenant.Name == "Tenant A" &&
|
||||
tenant.Slug == "tenant-a" &&
|
||||
tenant.Status == domain.TenantStatusActive
|
||||
})).Run(func(args mock.Arguments) {
|
||||
args.Get(1).(*domain.Tenant).ID = "tenant-1"
|
||||
}).Return(nil).Once()
|
||||
repo.On("AddDomain", mock.Anything, "tenant-1", "a.example.com", true).Return(nil).Once()
|
||||
repo.On("AddDomain", mock.Anything, "tenant-1", "a.example.org", true).Return(nil).Once()
|
||||
repo.On("FindBySlug", mock.Anything, "tenant-a").Return(&domain.Tenant{
|
||||
ID: "tenant-1",
|
||||
Name: "Tenant A",
|
||||
Slug: "tenant-a",
|
||||
Status: domain.TenantStatusActive,
|
||||
}, nil).Once()
|
||||
|
||||
tenant, err := svc.RegisterTenant(context.Background(), "Tenant A", "tenant-a", "desc", []string{"a.example.com", "a.example.org"})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tenant)
|
||||
assert.Equal(t, "tenant-1", tenant.ID)
|
||||
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_RequestRegistration_AddsDomainAsUnverified(t *testing.T) {
|
||||
repo := new(tenantServiceTenantRepoMock)
|
||||
userRepo := new(tenantServiceUserRepoMock)
|
||||
svc := NewTenantService(repo, userRepo)
|
||||
|
||||
repo.On("Create", mock.Anything, mock.MatchedBy(func(tenant *domain.Tenant) bool {
|
||||
return tenant.Name == "Tenant B" &&
|
||||
tenant.Slug == "tenant-b" &&
|
||||
tenant.Status == domain.TenantStatusPending &&
|
||||
tenant.Config["adminEmail"] == "admin@tenant-b.com"
|
||||
})).Run(func(args mock.Arguments) {
|
||||
args.Get(1).(*domain.Tenant).ID = "tenant-2"
|
||||
}).Return(nil).Once()
|
||||
repo.On("AddDomain", mock.Anything, "tenant-2", "tenant-b.com", false).Return(nil).Once()
|
||||
|
||||
tenant, err := svc.RequestRegistration(
|
||||
context.Background(),
|
||||
"Tenant B",
|
||||
"tenant-b",
|
||||
"desc",
|
||||
"tenant-b.com",
|
||||
"admin@tenant-b.com",
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tenant)
|
||||
assert.Equal(t, "tenant-2", tenant.ID)
|
||||
assert.Equal(t, domain.TenantStatusPending, tenant.Status)
|
||||
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_RequestRegistration_RejectsDomainMismatch(t *testing.T) {
|
||||
repo := new(tenantServiceTenantRepoMock)
|
||||
userRepo := new(tenantServiceUserRepoMock)
|
||||
svc := NewTenantService(repo, userRepo)
|
||||
|
||||
tenant, err := svc.RequestRegistration(
|
||||
context.Background(),
|
||||
"Tenant B",
|
||||
"tenant-b",
|
||||
"desc",
|
||||
"tenant-b.com",
|
||||
"admin@other.com",
|
||||
)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "admin email domain must match the tenant domain")
|
||||
assert.Nil(t, tenant)
|
||||
|
||||
repo.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
|
||||
repo.AssertNotCalled(t, "AddDomain", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestTenantService_ApproveTenant_AssignsAdminRelationWhenUserExists(t *testing.T) {
|
||||
repo := new(tenantServiceTenantRepoMock)
|
||||
userRepo := new(tenantServiceUserRepoMock)
|
||||
keto := new(MockKetoService)
|
||||
svc := NewTenantService(repo, userRepo)
|
||||
svc.SetKetoService(keto)
|
||||
|
||||
tenant := &domain.Tenant{
|
||||
ID: "tenant-3",
|
||||
Slug: "tenant-c",
|
||||
Status: domain.TenantStatusPending,
|
||||
Config: domain.JSONMap{"adminEmail": "admin@tenant-c.com"},
|
||||
}
|
||||
|
||||
repo.On("FindByID", mock.Anything, "tenant-3").Return(tenant, nil).Once()
|
||||
repo.On("Update", mock.Anything, mock.MatchedBy(func(updated *domain.Tenant) bool {
|
||||
return updated.ID == "tenant-3" && updated.Status == domain.TenantStatusActive
|
||||
})).Return(nil).Once()
|
||||
userRepo.On("FindByEmail", mock.Anything, "admin@tenant-c.com").Return(&domain.User{
|
||||
ID: "user-1",
|
||||
Email: "admin@tenant-c.com",
|
||||
}, nil).Once()
|
||||
keto.On("CreateRelation", mock.Anything, "Tenant", "tenant-3", "admin", "User:user-1").Return(nil).Once()
|
||||
|
||||
err := svc.ApproveTenant(context.Background(), "tenant-3")
|
||||
assert.NoError(t, err)
|
||||
|
||||
repo.AssertExpectations(t)
|
||||
userRepo.AssertExpectations(t)
|
||||
keto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_ApproveTenant_DoesNotAssignWhenUserMissing(t *testing.T) {
|
||||
repo := new(tenantServiceTenantRepoMock)
|
||||
userRepo := new(tenantServiceUserRepoMock)
|
||||
keto := new(MockKetoService)
|
||||
svc := NewTenantService(repo, userRepo)
|
||||
svc.SetKetoService(keto)
|
||||
|
||||
tenant := &domain.Tenant{
|
||||
ID: "tenant-4",
|
||||
Slug: "tenant-d",
|
||||
Status: domain.TenantStatusPending,
|
||||
Config: domain.JSONMap{"adminEmail": "admin@tenant-d.com"},
|
||||
}
|
||||
|
||||
repo.On("FindByID", mock.Anything, "tenant-4").Return(tenant, nil).Once()
|
||||
repo.On("Update", mock.Anything, mock.MatchedBy(func(updated *domain.Tenant) bool {
|
||||
return updated.ID == "tenant-4" && updated.Status == domain.TenantStatusActive
|
||||
})).Return(nil).Once()
|
||||
userRepo.On("FindByEmail", mock.Anything, "admin@tenant-d.com").Return(nil, gorm.ErrRecordNotFound).Once()
|
||||
|
||||
err := svc.ApproveTenant(context.Background(), "tenant-4")
|
||||
assert.NoError(t, err)
|
||||
|
||||
keto.AssertNotCalled(t, "CreateRelation", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
repo.AssertExpectations(t)
|
||||
userRepo.AssertExpectations(t)
|
||||
}
|
||||
94
backend/internal/utils/slug_test.go
Normal file
94
backend/internal/utils/slug_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateSlug_Valid(t *testing.T) {
|
||||
ok, msg := ValidateSlug("tenant-2026")
|
||||
if !ok {
|
||||
t.Fatalf("expected valid slug, got error: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSlug_ReservedKeywords(t *testing.T) {
|
||||
cases := []string{
|
||||
"stage",
|
||||
"prod",
|
||||
"metrics",
|
||||
"prometheus",
|
||||
"webmaster",
|
||||
" Stage ",
|
||||
}
|
||||
|
||||
for _, slug := range cases {
|
||||
t.Run(slug, func(t *testing.T) {
|
||||
ok, msg := ValidateSlug(slug)
|
||||
if ok {
|
||||
t.Fatalf("expected reserved slug to be rejected: %q", slug)
|
||||
}
|
||||
if msg != "slug is a reserved keyword" {
|
||||
t.Fatalf("unexpected error message: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSlug_LengthRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slug string
|
||||
}{
|
||||
{name: "too short", slug: "ab"},
|
||||
{name: "too long", slug: strings.Repeat("a", 33)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ok, msg := ValidateSlug(tc.slug)
|
||||
if ok {
|
||||
t.Fatalf("expected invalid length slug: %q", tc.slug)
|
||||
}
|
||||
if msg != "slug must be between 3 and 32 characters" {
|
||||
t.Fatalf("unexpected error message: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSlug_FormatRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slug string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "invalid character",
|
||||
slug: "tenant_name",
|
||||
wantMsg: "slug can only contain lowercase letters, numbers, and hyphens",
|
||||
},
|
||||
{
|
||||
name: "leading hyphen",
|
||||
slug: "-tenant",
|
||||
wantMsg: "slug cannot start or end with a hyphen",
|
||||
},
|
||||
{
|
||||
name: "trailing hyphen",
|
||||
slug: "tenant-",
|
||||
wantMsg: "slug cannot start or end with a hyphen",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ok, msg := ValidateSlug(tc.slug)
|
||||
if ok {
|
||||
t.Fatalf("expected invalid slug: %q", tc.slug)
|
||||
}
|
||||
if msg != tc.wantMsg {
|
||||
t.Fatalf("unexpected error message: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
docs/trouble-shooting/dev-branch-conflict-policy.md
Normal file
53
docs/trouble-shooting/dev-branch-conflict-policy.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# dev 브랜치 충돌 대응 정책
|
||||
|
||||
## 현재 상태 점검 기준
|
||||
- `git status -sb` 기준으로 `unmerged paths`가 없으면 파일 단위 충돌은 없는 상태입니다.
|
||||
- `non-fast-forward` push 거절은 로컬/원격 히스토리 분기(diverged) 상태로 간주합니다.
|
||||
- 원격 확인 불가(DNS/네트워크 장애) 시, 로컬 기준 상태를 우선 공유하고 원격 fetch 가능 시점에 재확인합니다.
|
||||
|
||||
## 기본 원칙
|
||||
1. `dev` 반영 전 최신 원격 기준선 확보
|
||||
2. 충돌 해결은 기능 회귀 방지 우선
|
||||
3. 해결 후 CI 강제 검사 통과 확인
|
||||
|
||||
## CI 강제 검사 정책
|
||||
- `.gitea/workflows/code_check.yml`는 아래 이벤트에서 항상 실행됩니다.
|
||||
- `push` to `dev`
|
||||
- `pull_request` targeting `dev`
|
||||
- `workflow_dispatch` (수동 실행)
|
||||
- 수동 실행 입력으로 검사 항목을 끄는 방식은 사용하지 않습니다.
|
||||
- `backend-tests`, `userfront-tests`는 `lint` 결과와 무관하게 실행 시도하여 전체 실패 지점을 한 번에 확인합니다.
|
||||
|
||||
## 표준 절차
|
||||
1. 원격 최신화
|
||||
```bash
|
||||
git fetch origin dev
|
||||
git status -sb
|
||||
git rev-list --left-right --count origin/dev...dev
|
||||
```
|
||||
|
||||
2. 분기 상태별 처리
|
||||
- 로컬만 앞섬 (`0 N`): `git push origin dev`
|
||||
- 원격만 앞섬 (`N 0`): `git rebase origin/dev` 후 push
|
||||
- 상호 분기 (`N M`): `git rebase origin/dev`로 정렬 후 충돌 해결
|
||||
|
||||
3. 충돌 해결 후 검증
|
||||
```bash
|
||||
make validate-auth-config
|
||||
make verify-auth-config
|
||||
```
|
||||
|
||||
## 우선순위 정책 (이번 범위 #274 / #276)
|
||||
1. OIDC 리다이렉트/쿼리 전달 회귀 방지 로직 유지
|
||||
2. `Makefile` 기반 인증 설정 생성/검증 경로 유지
|
||||
3. `compose.ory.yaml`의 callback/allowed_return_urls env 연동 유지
|
||||
4. `.env` 값 형식 안정성 유지 (same-line 주석 금지)
|
||||
|
||||
## 주의 사항
|
||||
- `dev` 공유 브랜치에서는 `force push`를 사용하지 않습니다.
|
||||
- `.env`에서 `KEY=value #comment` 형태는 금지합니다. (URL 끝 공백으로 Hydra/Kratos 기동 실패 가능)
|
||||
- callback URL 끝 `/`는 `make validate-auth-config`에서 실패 처리됩니다.
|
||||
|
||||
## 관련 문서
|
||||
- `docs/oidc_redirect_mapping_validation_policy.md`
|
||||
- `README.md`
|
||||
Reference in New Issue
Block a user