From 3025be52d57c0999afddbc8af63a6251e9877eb5 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 19 Feb 2026 16:09:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?ory=20stack=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80.=20make=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9C=BC=EB=A1=9C=20=EC=8B=A4=ED=96=89=20=ED=95=84?= =?UTF-8?q?=EC=9A=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 6 ++-- Makefile | 14 ++++---- README.md | 53 +++++++++++++++++++++++++---- compose.ory.yaml | 60 ++++++++++++++++----------------- scripts/auth_config.sh | 76 ++++++++++++++++++++++++++++++++++++++---- 5 files changed, 156 insertions(+), 53 deletions(-) diff --git a/.env.sample b/.env.sample index 33a261d8..d2bf6df6 100644 --- a/.env.sample +++ b/.env.sample @@ -111,12 +111,12 @@ HYDRA_ADMIN_URL=http://hydra:4445 HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc # OIDC 클라이언트 callback (콤마 구분) -ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback -DEVFRONT_CALLBACK_URLS=http://localhost:5174/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 # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. -KRATOS_ALLOWED_RETURN_URLS_EXTRA= +KRATOS_ALLOWED_RETURN_URLS_EXTRA=[] # Oathkeeper JWKS (내부 통신용) JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json diff --git a/Makefile b/Makefile index b3d390b5..cab616ed 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,11 @@ COMPOSE_ORY := compose.ory.yaml COMPOSE_APP := docker-compose.yaml AUTH_CONFIG_ENV := .generated/auth-config.env -COMPOSE_ENV_FILES := +COMPOSE_CLI_ENV_ARGS := ifneq (,$(wildcard ./.env)) -COMPOSE_ENV_FILES += --env-file .env +COMPOSE_CLI_ENV_ARGS += --env-file .env endif -COMPOSE_ENV_FILES += --env-file $(AUTH_CONFIG_ENV) +COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV) # --- 인증 설정 빌드/검증 --- build-auth-config: @@ -36,7 +36,7 @@ verify-auth-config: validate-auth-config # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) up-all: validate-auth-config @echo "Starting ALL stacks (infra + ory + app)..." - docker compose $(COMPOSE_ENV_FILES) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d # --- 개별 스택 실행 --- up-infra: @@ -45,15 +45,15 @@ up-infra: up-ory: validate-auth-config @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." - docker compose $(COMPOSE_ENV_FILES) -f $(COMPOSE_ORY) up -d + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d up-app: validate-auth-config @echo "Starting App stack (backend/userfront/adminfront/devfront)..." - docker compose $(COMPOSE_ENV_FILES) -f $(COMPOSE_APP) up -d + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d up-backend: validate-auth-config @echo "Starting Backend only..." - docker compose $(COMPOSE_ENV_FILES) -f $(COMPOSE_APP) up -d backend + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend up-dev: up-infra up-ory @echo "Dev stack is up (infra + ory)." diff --git a/README.md b/README.md index c65edbc1..f0ed1c14 100644 --- a/README.md +++ b/README.md @@ -155,13 +155,43 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비 ```bash cp .env.sample .env ``` -2. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 입니다 - ``` - IDP_PROVIDER=ory - KRATOS_ADMIN_URL=http://kratos:4434 - HYDRA_ADMIN_URL=http://hydra:4445 - HYDRA_PUBLIC_URL=http://hydra:4444 - ``` +2. `.env`를 작성합니다. (아래 작성 규칙 필수) + +### `.env` 작성 규칙 (중요) +- `KEY=value` 한 줄만 사용하고, **값 뒤에 같은 줄 주석을 붙이지 않습니다.** +- 주석이 필요하면 반드시 **윗줄에 별도 주석 라인**으로 작성합니다. +- URL 값 끝에 공백이 들어가면 Hydra/Kratos 기동 실패로 이어질 수 있습니다. + +잘못된 예: +```env +USERFRONT_URL=https://sso.example.com # 이렇게 같은 줄 주석 금지 +``` + +올바른 예: +```env +# UserFront 공개 URL +USERFRONT_URL=https://sso.example.com +``` + +### `.env` 핵심 변수 가이드 +- `IDP_PROVIDER`: 기본 `ory` +- `USERFRONT_URL`: 브라우저 기준 공개 도메인 (예: `https://sso.example.com`) +- `OATHKEEPER_PUBLIC_URL`: 보통 `${USERFRONT_URL}` +- `HYDRA_PUBLIC_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/oidc` +- `KRATOS_BROWSER_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/auth` +- `KRATOS_UI_URL`: UserFront UI URL (로컬 예: `http://localhost:5000`) +- `ADMINFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5173/auth/callback`) +- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/callback`) + - 주의: callback URL 끝에 `/`가 붙으면 `make validate-auth-config`에서 실패 처리됩니다. +- `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택) + - 빈값: `[]` + - 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback` + +### `.env` 작성 후 권장 점검 +```bash +make validate-auth-config +``` +위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다. ### 전체 스택 실행 (Running the Stack) @@ -207,6 +237,15 @@ make verify-auth-config - 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다. - 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md` +### 권장 실행 순서 +```bash +cp .env.sample .env +# .env 편집 +make validate-auth-config +make up-dev +make up-app +``` + 직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요. ```bash docker compose --env-file .env --env-file .generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d diff --git a/compose.ory.yaml b/compose.ory.yaml index 991842fa..bffcbd99 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -198,40 +198,40 @@ services: # 기본 RP (Admin Front 등) 자동 등록 컨테이너 init-rp: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} - environment: - - HYDRA_ADMIN_URL=http://hydra:4445 - - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} - - ADMINFRONT_CALLBACK_URLS=${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback} - - DEVFRONT_CALLBACK_URLS=${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback} - command: | - hydra clients create \ - --endpoint http://hydra:4445 \ - --id adminfront \ - --secret admin-secret \ - --grant-types authorization_code,refresh_token \ - --response-types code \ - --scope openid,offline_access,profile,email \ - --callbacks "$ADMINFRONT_CALLBACK_URLS"; + image: oryd/hydra:v25.4.0 + entrypoint: ["/bin/sh"] + command: + - -ec + - | + hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true + hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true + hydra delete oauth2-client --endpoint http://hydra:4445 ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true - hydra clients create \ - --endpoint http://hydra:4445 \ - --id devfront \ - --grant-types authorization_code,refresh_token \ - --response-types code \ + hydra create oauth2-client \ + --endpoint http://hydra:4445 \ + --id adminfront \ + --secret admin-secret \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback} + + hydra create oauth2-client \ + --endpoint http://hydra:4445 \ + --id devfront \ + --grant-type authorization_code,refresh_token \ + --response-type code \ --scope openid,offline_access,profile,email \ --token-endpoint-auth-method none \ - --response-types code \ - --callbacks "$DEVFRONT_CALLBACK_URLS"; + --redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback} - hydra clients create \ - --endpoint http://hydra:4445 \ - --id "$OATHKEEPER_INTROSPECT_CLIENT_ID" \ - --secret "$OATHKEEPER_INTROSPECT_CLIENT_SECRET" \ - --grant-types client_credentials \ - --response-types token \ - --scope openid,offline_access,profile,email; + hydra create oauth2-client \ + --endpoint http://hydra:4445 \ + --id ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} \ + --secret ${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} \ + --grant-type client_credentials \ + --response-type token \ + --scope openid,offline_access,profile,email depends_on: ory_stack_check: condition: service_completed_successfully diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh index f4c3e4e5..0a9f3bee 100755 --- a/scripts/auth_config.sh +++ b/scripts/auth_config.sh @@ -32,6 +32,24 @@ warn() { WARNINGS+=("$1") } +validate_dotenv_line_safety() { + local key="$1" + local env_file="$ROOT_DIR/.env" + [[ -f "$env_file" ]] || return 0 + + local raw_line + raw_line="$(grep -E "^${key}=" "$env_file" | tail -n 1 || true)" + [[ -n "$raw_line" ]] || return 0 + + if [[ "$raw_line" == *" #"* ]]; then + fail ".env line for $key contains inline comment. Use comment-only line above the key." + fi + + if [[ "$raw_line" =~ [[:space:]]+$ ]]; then + fail ".env line for $key has trailing whitespace." + fi +} + trim() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" @@ -50,6 +68,38 @@ csv_to_lines() { done } +list_to_lines() { + local raw="$1" + raw="$(trim "$raw")" + if [[ -z "$raw" || "$raw" == "[]" ]]; then + return 0 + fi + + if [[ "$raw" =~ ^\[(.*)\]$ ]]; then + local inner="${BASH_REMATCH[1]}" + inner="$(trim "$inner")" + if [[ -z "$inner" ]]; then + return 0 + fi + + printf '%s\n' "$inner" | tr ',' '\n' | while IFS= read -r token; do + local item + item="$(trim "$token")" + item="${item#\"}" + item="${item%\"}" + item="${item#\'}" + item="${item%\'}" + item="$(trim "$item")" + if [[ -n "$item" ]]; then + printf '%s\n' "$item" + fi + done + return 0 + fi + + csv_to_lines "$raw" +} + is_http_url() { local url="$1" [[ "$url" =~ ^https?://[^[:space:]]+$ ]] @@ -144,7 +194,7 @@ collect_values() { while IFS= read -r item; do EXTRA_ALLOWED_RETURNS+=("$item") - done < <(csv_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA") + done < <(list_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA") } validate_urls() { @@ -169,8 +219,13 @@ validate_callback_group() { local has_path=0 for url in "${urls[@]}"; do validate_urls "$group_name entry" "$url" + local canonical + canonical="$(canonicalize_url "$url")" + if [[ "$url" != "$canonical" ]]; then + fail "$group_name entry must not end with trailing slash: $url" + fi local path - path="$(url_path "$url")" + path="$(url_path "$canonical")" if [[ -n "$path" && "$path" != "/" ]]; then has_path=1 fi @@ -283,9 +338,9 @@ EOF validate_compose_wiring() { grep -Eq 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=\$\{KRATOS_ALLOWED_RETURN_URLS_JSON' "$ROOT_DIR/compose.ory.yaml" \ || fail "compose.ory.yaml is not wired to KRATOS_ALLOWED_RETURN_URLS_JSON" - grep -Eq 'ADMINFRONT_CALLBACK_URLS=\$\{ADMINFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \ + grep -Eq 'ADMINFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \ || fail "compose.ory.yaml is not wired to ADMINFRONT_CALLBACK_URLS" - grep -Eq 'DEVFRONT_CALLBACK_URLS=\$\{DEVFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \ + grep -Eq 'DEVFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \ || fail "compose.ory.yaml is not wired to DEVFRONT_CALLBACK_URLS" } @@ -301,10 +356,10 @@ verify_runtime_hydra_clients() { fi local admin_info dev_info - if ! admin_info="$(docker exec ory_hydra hydra clients get --endpoint http://hydra:4445 adminfront 2>/dev/null)"; then + if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 adminfront 2>/dev/null)"; then fail "failed to read hydra client 'adminfront' from running container" fi - if ! dev_info="$(docker exec ory_hydra hydra clients get --endpoint http://hydra:4445 devfront 2>/dev/null)"; then + if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 devfront 2>/dev/null)"; then fail "failed to read hydra client 'devfront' from running container" fi @@ -321,6 +376,15 @@ verify_runtime_hydra_clients() { } run_validation() { + validate_dotenv_line_safety "USERFRONT_URL" + validate_dotenv_line_safety "BACKEND_URL" + validate_dotenv_line_safety "OATHKEEPER_PUBLIC_URL" + validate_dotenv_line_safety "HYDRA_PUBLIC_URL" + validate_dotenv_line_safety "KRATOS_BROWSER_URL" + validate_dotenv_line_safety "KRATOS_UI_URL" + validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS" + validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS" + collect_values validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}" From 85998bd82ecc8870af3c260f4a6f251ea1d12231 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 19 Feb 2026 16:26:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20=EB=B0=8F=20dev=20=EC=B6=A9=EB=8F=8C/CI=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/code_check.yml | 24 +- .../internal/service/tenant_service_test.go | 266 ++++++++++++++++++ backend/internal/utils/slug_test.go | 94 +++++++ .../dev-branch-conflict-policy.md | 53 ++++ 4 files changed, 418 insertions(+), 19 deletions(-) create mode 100644 backend/internal/service/tenant_service_test.go create mode 100644 backend/internal/utils/slug_test.go create mode 100644 docs/trouble-shooting/dev-branch-conflict-policy.md diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 189e86e3..71672809 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -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 diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go new file mode 100644 index 00000000..d67f55e4 --- /dev/null +++ b/backend/internal/service/tenant_service_test.go @@ -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) +} diff --git a/backend/internal/utils/slug_test.go b/backend/internal/utils/slug_test.go new file mode 100644 index 00000000..cd78fb87 --- /dev/null +++ b/backend/internal/utils/slug_test.go @@ -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) + } + }) + } +} diff --git a/docs/trouble-shooting/dev-branch-conflict-policy.md b/docs/trouble-shooting/dev-branch-conflict-policy.md new file mode 100644 index 00000000..cfa148f2 --- /dev/null +++ b/docs/trouble-shooting/dev-branch-conflict-policy.md @@ -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`