From 0741baf60b44f44aa2aa7cda75fe56332c399ae5 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 10 Feb 2026 09:03:34 +0900 Subject: [PATCH] =?UTF-8?q?df=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/common_test.go | 20 ++- backend/internal/handler/dev_handler_test.go | 142 +++++++++++++++++++ docs/frontend_hydra_testing_guide.md | 45 ++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/dev_handler_test.go create mode 100644 docs/frontend_hydra_testing_guide.md diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 7934299b..4069e356 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -118,6 +118,24 @@ func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID s return nil, 0, nil } +// --- Mock Secret Repository --- + +type mockSecretRepo struct { + secrets map[string]string +} + +func (m *mockSecretRepo) Upsert(ctx context.Context, clientID, secret string) error { + if m.secrets == nil { m.secrets = make(map[string]string) } + m.secrets[clientID] = secret + return nil +} +func (m *mockSecretRepo) GetByID(ctx context.Context, clientID string) (string, error) { + return m.secrets[clientID], nil +} +func (m *mockSecretRepo) Delete(ctx context.Context, clientID string) error { + delete(m.secrets, clientID) + return nil +} // --- HTTP Mock Helpers --- @@ -146,4 +164,4 @@ func httpJSONAny(r *http.Request, code int, data any) *http.Response { Body: io.NopCloser(bytes.NewBuffer(body)), Request: r, } -} +} \ No newline at end of file diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go new file mode 100644 index 00000000..27311048 --- /dev/null +++ b/backend/internal/handler/dev_handler_test.go @@ -0,0 +1,142 @@ +package handler + +import ( + "baron-sso-backend/internal/service" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +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, map[string]string{"error":"not found"}), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + } + app := fiber.New() + 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 struct { + Items []clientSummary `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + assert.Equal(t, 2, len(res.Items)) + assert.Equal(t, "client-1", res.Items[0].ID) + assert.Equal(t, "App One", res.Items[0].Name) +} + +func TestGetClient_Success(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients/client-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-123", + "client_name": "Test App", + "metadata": map[string]interface{}{"status": "active"}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, map[string]string{"error":"not found"}), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + PublicURL: "http://hydra-public.test", // PublicURL 추가 + HTTPClient: &http.Client{Transport: transport}, + }, + } + app := fiber.New() + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res clientDetailResponse + json.NewDecoder(resp.Body).Decode(&res) + assert.Equal(t, "client-123", res.Client.ID) + assert.Equal(t, "Test App", res.Client.Name) + assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization) +} + +func TestGetClient_NotFound(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return httpJSONAny(r, http.StatusNotFound, map[string]string{"error":"not found"}), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + } + app := fiber.New() + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestCreateClient_Success(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusCreated, map[string]interface{}{ + "client_id": "new-client-123", + "client_name": "New App", + "client_secret": "secret-123", + }), nil + } + return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error":"hydra error"}), nil + }) + + secretRepo := &mockSecretRepo{secrets: make(map[string]string)} + redisRepo := &mockRedisRepo{data: make(map[string]string)} + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + SecretRepo: secretRepo, + Redis: redisRepo, + } + app := fiber.New() + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]interface{}{ + "client_name": "New App", + "type": "confidential", + "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) + + secret, _ := secretRepo.GetByID(nil, "new-client-123") + assert.Equal(t, "secret-123", secret) +} diff --git a/docs/frontend_hydra_testing_guide.md b/docs/frontend_hydra_testing_guide.md new file mode 100644 index 00000000..b302ec41 --- /dev/null +++ b/docs/frontend_hydra_testing_guide.md @@ -0,0 +1,45 @@ +# Frontend 기능과 백엔드 테스트 매핑 가이드 + +이 문서는 `devfront`와 `userfront`의 Hydra 관련 기능이 백엔드의 어떤 API를 호출하고, 해당 API가 어떤 테스트 코드로 검증되는지 설명합니다. 모든 기능은 백엔드에 이미 구현되어 있으며, '테스트' 열은 해당 기능을 검증하는 자동화 테스트의 존재 여부를 나타냅니다. + +## 1. `devfront` (개발자/관리자 포털) + +`devfront`는 OAuth2 클라이언트(RP)를 생성하고 관리하는 데 사용됩니다. + +| `devfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 | +| :--- | :--- | :--- | :--- | +| **클라이언트 목록 조회** | `GET /api/v1/dev/clients` | `dev_handler_test.go` | `TestListClients_Success` | +| **클라이언트 생성** | `POST /api/v1/dev/clients` | `dev_handler_test.go` | `TestCreateClient_Success` | +| **클라이언트 상세 조회** | `GET /api/v1/dev/clients/:id` | `dev_handler_test.go` | `TestGetClient_Success`, `TestGetClient_NotFound` | +| **클라이언트 정보 수정** | `PUT /api/v1/dev/clients/:id` | - | (테스트 미작성) | +| **클라이언트 상태 변경** | `PATCH /api/v1/dev/clients/:id/status`| - | (테스트 미작성) | +| **클라이언트 삭제** | `DELETE /api/v1/dev/clients/:id` | - | (테스트 미작성) | +| **시크릿 재발급** | `POST /api/v1/dev/clients/:id/rotate-secret`| - | (테스트 미작성) | +| **동의한 사용자 목록 조회**| `GET /api/v1/dev/consents` | - | (테스트 미작성) | +| **사용자 동의 철회** | `DELETE /api/v1/dev/consents` | - | (테스트 미작성) | + +*참고: `dev_handler.go` 내의 기능들은 백엔드에 구현되어 있으나, 이번 커버리지 90% 달성 목표(핵심 인증 로직 중심)에서 관리자 기능으로 분류되어 우선순위가 조정되었습니다.* + +--- + +## 2. `userfront` (사용자 포털) + +`userfront`는 최종 사용자가 애플리케이션(RP)의 정보 접근 요청을 승인하거나 거부하는 OIDC 동의 화면 및 연동 관리를 처리합니다. + +### 2.1. OIDC 동의 (Consent) 및 연동 관리 +| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 | +| :--- | :--- | :--- | :--- | +| **동의 정보 조회** | `GET /api/v1/auth/consent` | `auth_handler_consent_test.go` | `TestGetConsentRequest_Normal` | +| **동의 승인** | `POST /api/v1/auth/consent/accept` | `auth_handler_consent_test.go` | `TestAcceptConsentRequest_Normal` | +| **동의 거부** | `POST /api/v1/auth/consent/reject` | - | (테스트 미작성) | +| **연동된 앱 목록 조회** | `GET /api/v1/user/rp/linked` | `auth_handler_linked_test.go` | `TestListLinkedRps_PriorityAndAggregation` | +| **연동 해제 (Revoke)** | `DELETE /api/v1/user/rp/linked/:id`| `auth_handler_client_test.go` | `TestRevokeLinkedRp_Success` | +| **연동 이력 조회** | `GET /api/v1/user/rp/history` | `auth_handler_client_test.go` | `TestListRpHistory_Aggregation` | + +### 2.2. 인증 플로우 (Login Flows) +| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 | +| :--- | :--- | :--- | :--- | +| **QR 로그인 초기화** | `POST /api/v1/auth/qr/init` | `auth_handler_qr_test.go` | `TestQRLoginFlow_Success` | +| **QR 로그인 승인 (Scan)** | `POST /api/v1/auth/qr/approve` | `auth_handler_qr_test.go` | `TestScanQRLogin_Success` | +| **매직 링크 초기화** | `POST /api/v1/auth/enchanted-link/init`| `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` | +| **매직 링크 검증** | `POST /api/v1/auth/magic-link/verify` | `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` | \ No newline at end of file