From 7c3295fdc73d9fea4d59c410d737cce32462c4f1 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:28:40 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Ory=20Hydra=20Admin=20API=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/hydra_admin_service_test.go | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 backend/internal/service/hydra_admin_service_test.go diff --git a/backend/internal/service/hydra_admin_service_test.go b/backend/internal/service/hydra_admin_service_test.go new file mode 100644 index 00000000..0004e590 --- /dev/null +++ b/backend/internal/service/hydra_admin_service_test.go @@ -0,0 +1,401 @@ +/* +이 테스트 파일은 HydraAdminService의 기능을 검증하기 위한 유닛 테스트입니다. +Hydra Admin API와의 통신을 시뮬레이션하기 위해 httptest 패키지를 사용하여 +실제 네트워크 호출 없이 HTTP 핸들러를 모킹(Mocking)하는 방식으로 작성되었습니다. + +주요 테스트 항목: +1. 클라이언트 관리: List, Get, Create, Update, Patch, Delete (성공 및 NotFound 케이스) +2. OIDC 인증/동의 흐름: GetConsentRequest, AcceptConsentRequest, RejectConsentRequest, GetLoginRequest, AcceptLoginRequest, RejectLoginRequest +3. 세션 관리: ListConsentSessions, RevokeConsentSessions +4. 존재하지 않는 리소스 접근 시 에러 처리 +*/ + +package service + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// clientForHandler는 주어진 핸들러로 요청을 보내는 http.Client를 반환합니다. +// ory_service_test.go에 정의된 것과 동일한 로직입니다. +func mockHydraClient(h http.Handler) *http.Client { + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var bodyBytes []byte + if req.Body != nil { + bodyBytes, _ = io.ReadAll(req.Body) + } + r := httptest.NewRequest(req.Method, req.URL.String(), bytes.NewReader(bodyBytes)) + r.Header = req.Header.Clone() + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Result(), nil + }), + } +} + +func TestHydraAdminService_ListClients(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/clients") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + return + } + + clients := []domain.HydraClient{ + {ClientID: "client-1", ClientName: "Client One"}, + {ClientID: "client-2", ClientName: "Client Two"}, + } + json.NewEncoder(w).Encode(clients) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + clients, err := svc.ListClients(context.Background(), 10, 0) + if err != nil { + t.Fatalf("ListClients failed: %v", err) + } + + if len(clients) != 2 { + t.Errorf("expected 2 clients, got %d", len(clients)) + } +} + +func TestHydraAdminService_GetClient_Success(t *testing.T) { + targetID := "test-client" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client := domain.HydraClient{ + ClientID: targetID, + ClientName: "Test Client", + } + json.NewEncoder(w).Encode(client) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + client, err := svc.GetClient(context.Background(), targetID) + if err != nil { + t.Fatalf("GetClient failed: %v", err) + } + + if client.ClientID != targetID { + t.Errorf("expected %s, got %s", targetID, client.ClientID) + } +} + +func TestHydraAdminService_GetClient_NotFound(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + _, err := svc.GetClient(context.Background(), "non-existent") + if err != ErrHydraNotFound { + t.Errorf("expected ErrHydraNotFound, got %v", err) + } +} + +func TestHydraAdminService_PatchClientStatus(t *testing.T) { + targetID := "test-client" + status := "inactive" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + var patch []map[string]interface{} + json.NewDecoder(r.Body).Decode(&patch) + if patch[0]["value"] != status { + t.Errorf("expected status %s, got %v", status, patch[0]["value"]) + } + + client := domain.HydraClient{ClientID: targetID} + json.NewEncoder(w).Encode(client) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + _, err := svc.PatchClientStatus(context.Background(), targetID, status) + if err != nil { + t.Fatalf("PatchClientStatus failed: %v", err) + } +} + +func TestHydraAdminService_CreateClient(t *testing.T) { + newClient := domain.HydraClient{ClientID: "new-client"} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(newClient) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + created, err := svc.CreateClient(context.Background(), newClient) + if err != nil { + t.Fatalf("CreateClient failed: %v", err) + } + if created.ClientID != newClient.ClientID { + t.Errorf("expected %s, got %s", newClient.ClientID, created.ClientID) + } +} + +func TestHydraAdminService_UpdateClient(t *testing.T) { + targetID := "test-client" + updatedClient := domain.HydraClient{ClientID: targetID, ClientName: "Updated"} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + json.NewEncoder(w).Encode(updatedClient) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + res, err := svc.UpdateClient(context.Background(), targetID, updatedClient) + if err != nil { + t.Fatalf("UpdateClient failed: %v", err) + } + if res.ClientName != "Updated" { + t.Errorf("expected Updated, got %s", res.ClientName) + } +} + +func TestHydraAdminService_DeleteClient(t *testing.T) { + targetID := "test-client" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + err := svc.DeleteClient(context.Background(), targetID) + if err != nil { + t.Fatalf("DeleteClient failed: %v", err) + } +} + +func TestHydraAdminService_ListConsentSessions(t *testing.T) { + subject := "user-123" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("subject") != subject { + t.Errorf("expected subject %s, got %s", subject, r.URL.Query().Get("subject")) + } + sessions := []domain.HydraConsentSession{{Subject: subject}} + json.NewEncoder(w).Encode(sessions) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + res, err := svc.ListConsentSessions(context.Background(), subject, "") + if err != nil { + t.Fatalf("ListConsentSessions failed: %v", err) + } + if len(res) != 1 { + t.Errorf("expected 1 session, got %d", len(res)) + } +} + +func TestHydraAdminService_RevokeConsentSessions(t *testing.T) { + subject := "user-123" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + err := svc.RevokeConsentSessions(context.Background(), subject, "client-1") + if err != nil { + t.Fatalf("RevokeConsentSessions failed: %v", err) + } +} + +func TestHydraAdminService_GetConsentRequest(t *testing.T) { + challenge := "consent-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("consent_challenge") != challenge { + t.Errorf("expected challenge %s, got %s", challenge, r.URL.Query().Get("consent_challenge")) + } + resp := domain.HydraConsentRequest{Challenge: challenge} + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + req, err := svc.GetConsentRequest(context.Background(), challenge) + if err != nil { + t.Fatalf("GetConsentRequest failed: %v", err) + } + if req.Challenge != challenge { + t.Errorf("expected %s, got %s", challenge, req.Challenge) + } +} + +func TestHydraAdminService_RejectConsentRequest(t *testing.T) { + challenge := "consent-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://redirect", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.RejectConsentRequest(context.Background(), challenge) + if err != nil { + t.Fatalf("RejectConsentRequest failed: %v", err) + } + if resp.RedirectTo != "http://redirect" { + t.Errorf("expected http://redirect, got %s", resp.RedirectTo) + } +} + +func TestHydraAdminService_RejectLoginRequest(t *testing.T) { + challenge := "login-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://redirect", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.RejectLoginRequest(context.Background(), challenge, "error", "desc") + if err != nil { + t.Fatalf("RejectLoginRequest failed: %v", err) + } + if resp.RedirectTo != "http://redirect" { + t.Errorf("expected http://redirect, got %s", resp.RedirectTo) + } +} + +func TestHydraAdminService_GetLoginRequest(t *testing.T) { + challenge := "login-challenge" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := domain.HydraLoginRequest{Challenge: challenge} + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + req, err := svc.GetLoginRequest(context.Background(), challenge) + if err != nil { + t.Fatalf("GetLoginRequest failed: %v", err) + } + if req.Challenge != challenge { + t.Errorf("expected %s, got %s", challenge, req.Challenge) + } +} + +func TestHydraAdminService_AcceptConsentRequest(t *testing.T) { + challenge := "consent-challenge" + grantInfo := &domain.HydraConsentRequest{RequestedScope: []string{"openid"}} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://callback", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.AcceptConsentRequest(context.Background(), challenge, grantInfo, nil) + if err != nil { + t.Fatalf("AcceptConsentRequest failed: %v", err) + } + if resp.RedirectTo != "http://callback" { + t.Errorf("expected http://callback, got %s", resp.RedirectTo) + } +} + +func TestHydraAdminService_AcceptLoginRequest(t *testing.T) { + challenge := "login-challenge" + subject := "user@example.com" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + RedirectTo string `json:"redirect_to"` + }{ + RedirectTo: "http://callback", + } + json.NewEncoder(w).Encode(resp) + }) + + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), + } + + resp, err := svc.AcceptLoginRequest(context.Background(), challenge, subject) + if err != nil { + t.Fatalf("AcceptLoginRequest failed: %v", err) + } + if resp.RedirectTo != "http://callback" { + t.Errorf("expected http://callback, got %s", resp.RedirectTo) + } +} \ No newline at end of file From f362e217744fe3a6c3cec3e69ae8722cf094c2f6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:29:40 +0900 Subject: [PATCH 2/5] =?UTF-8?q?HydraAdminService=EC=99=80=20RelyingParty?= =?UTF-8?q?=20=EA=B0=84=EC=9D=98=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/relying_party_service_test.go | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 backend/internal/service/relying_party_service_test.go diff --git a/backend/internal/service/relying_party_service_test.go b/backend/internal/service/relying_party_service_test.go new file mode 100644 index 00000000..e5c5fe3d --- /dev/null +++ b/backend/internal/service/relying_party_service_test.go @@ -0,0 +1,295 @@ +/* +이 테스트 파일은 RelyingPartyService의 기능을 검증하기 위한 유닛 테스트입니다. +RelyingPartyService는 HydraAdminService, KetoService, RelyingPartyRepository와 협력하므로 +각 의존성을 모킹(Mocking)하여 통합 로직을 검증합니다. + +주요 테스트 항목: +1. Create: Hydra 클라이언트 생성 -> DB 저장 -> Keto 권한 설정 (성공 및 롤백 시나리오) +2. Get: DB 및 Hydra에서 정보 조회 +3. Update: Hydra 및 DB 업데이트 +4. Delete: DB 및 Hydra 삭제 +*/ + +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/mock" +) + +// --- Mocks --- + +type MockRelyingPartyRepository struct { + mock.Mock +} + +func (m *MockRelyingPartyRepository) Create(ctx context.Context, rp *domain.RelyingParty) error { + args := m.Called(ctx, rp) + return args.Error(0) +} + +func (m *MockRelyingPartyRepository) Update(ctx context.Context, rp *domain.RelyingParty) error { + args := m.Called(ctx, rp) + return args.Error(0) +} + +func (m *MockRelyingPartyRepository) Delete(ctx context.Context, clientID string) error { + args := m.Called(ctx, clientID) + return args.Error(0) +} + +func (m *MockRelyingPartyRepository) FindByID(ctx context.Context, clientID string) (*domain.RelyingParty, error) { + args := m.Called(ctx, clientID) + if rp, ok := args.Get(0).(*domain.RelyingParty); ok { + return rp, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockRelyingPartyRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { + args := m.Called(ctx, tenantID) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} + +func (m *MockRelyingPartyRepository) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} + +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + args := m.Called(ctx, subject, namespace, object, relation) + return args.Bool(0), args.Error(1) +} + +func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} + +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Error(0) +} + +// --- Tests --- + +func TestRelyingPartyService_Create_Success(t *testing.T) { + // Setup + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + + tenantID := "tenant-1" + inputClient := domain.HydraClient{ + ClientName: "Test App", + } + + // Hydra Mock + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/clients") { + var req domain.HydraClient + json.NewDecoder(r.Body).Decode(&req) + + // Verify metadata injection + if req.Metadata["tenant_id"] != tenantID { + t.Errorf("expected tenant_id in metadata") + } + + req.ClientID = "generated-client-id" + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(req) + return + } + http.NotFound(w, r) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + // Expectations + mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(rp *domain.RelyingParty) bool { + return rp.ClientID == "generated-client-id" && rp.TenantID == tenantID + })).Return(nil) + + mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "generated-client-id", "parent_tenant", "Tenant:"+tenantID).Return(nil) + + // Execute + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + rp, err := svc.Create(context.Background(), tenantID, inputClient) + + // Verify + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if rp.ClientID != "generated-client-id" { + t.Errorf("expected client id generated-client-id, got %s", rp.ClientID) + } + + mockRepo.AssertExpectations(t) + mockKeto.AssertExpectations(t) +} + +func TestRelyingPartyService_Create_HydraFail(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) + + if err == nil { + t.Error("expected error from hydra") + } +} + +func TestRelyingPartyService_Create_DBFail_Rollback(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + + clientID := "rollback-client-id" + + // Hydra Mock: Create Succeeds, Delete Called + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID}) + return + } + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, clientID) { + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + // DB Fails + mockRepo.On("Create", mock.Anything, mock.Anything).Return(errors.New("db error")) + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) + + if err == nil { + t.Error("expected error from db") + } + + mockRepo.AssertExpectations(t) + // Keto should NOT be called + mockKeto.AssertNotCalled(t, "CreateRelation") +} + +func TestRelyingPartyService_Get_Success(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + clientID := "client-123" + + mockRepo.On("FindByID", mock.Anything, clientID).Return(&domain.RelyingParty{ClientID: clientID, Name: "DB Name"}, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID, ClientName: "Hydra Name"}) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + rp, hc, err := svc.Get(context.Background(), clientID) + + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if rp.Name != "DB Name" { + t.Errorf("expected DB Name, got %s", rp.Name) + } + if hc.ClientName != "Hydra Name" { + t.Errorf("expected Hydra Name, got %s", hc.ClientName) + } +} + +func TestRelyingPartyService_Update_Success(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + clientID := "client-123" + + // Hydra Update + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + var req domain.HydraClient + json.NewDecoder(r.Body).Decode(&req) + json.NewEncoder(w).Encode(req) + return + } + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + // DB Update + mockRepo.On("FindByID", mock.Anything, clientID).Return(&domain.RelyingParty{ClientID: clientID, Name: "Old Name"}, nil) + mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(rp *domain.RelyingParty) bool { + return rp.Name == "New Name" + })).Return(nil) + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + + updateReq := domain.HydraClient{ClientName: "New Name"} + rp, err := svc.Update(context.Background(), clientID, updateReq) + + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if rp.Name != "New Name" { + t.Errorf("expected New Name, got %s", rp.Name) + } + + mockRepo.AssertExpectations(t) +} + +func TestRelyingPartyService_Delete_Success(t *testing.T) { + mockRepo := new(MockRelyingPartyRepository) + mockKeto := new(MockKetoService) + clientID := "client-123" + + mockRepo.On("Delete", mock.Anything, clientID).Return(nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + } + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + err := svc.Delete(context.Background(), clientID) + + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + mockRepo.AssertExpectations(t) +} From 5710bea4f9b87b8aac64291f00b0d0e49f1164b6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:30:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20OIDC/Hydra=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_login_test.go | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 backend/internal/handler/auth_handler_login_test.go diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go new file mode 100644 index 00000000..ca2bff02 --- /dev/null +++ b/backend/internal/handler/auth_handler_login_test.go @@ -0,0 +1,287 @@ +/* +이 테스트 파일은 AuthHandler의 PasswordLogin 메서드 내 OIDC/Hydra 관련 로직을 검증합니다. +특히 Hydra Login Request 조회, 검증(Inactive 체크), 승인(Accept) 흐름을 중점적으로 테스트합니다. +*/ + +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/mock" +) + +// --- Mocks --- + +type MockIdentityProvider struct { + mock.Mock +} + +func (m *MockIdentityProvider) Name() string { + return "mock-idp" +} +func (m *MockIdentityProvider) GetMetadata() (*domain.IDPMetadata, error) { + return nil, nil +} +func (m *MockIdentityProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + return "", nil +} +func (m *MockIdentityProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + args := m.Called(loginID, password) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.AuthInfo), args.Error(1) +} +func (m *MockIdentityProvider) UserExists(loginID string) (bool, error) { + return true, nil +} +func (m *MockIdentityProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdentityProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, nil +} +func (m *MockIdentityProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdentityProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return nil, nil +} +func (m *MockIdentityProvider) InitiatePasswordReset(loginID, redirectUrl string) error { + return nil +} +func (m *MockIdentityProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + return nil, nil +} +func (m *MockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + return nil +} + +type MockKratosAdminService struct { + // Simple mock for FindIdentityIDByIdentifier +} + +func (m *MockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { + // Always return a static ID for simplicity in this test + if identifier == "fail" { + return "", errors.New("not found") + } + return "kratos-identity-id", nil +} + +// --- Helper --- + +func newAuthLoginTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Post("/api/v1/auth/login", h.PasswordLogin) + return app +} + +// mockHydraTransport simulates Hydra API responses +func mockHydraTransport(handler http.Handler) http.RoundTripper { + return roundTripFunc(func(req *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w.Result(), nil + }) +} + +// --- Tests --- + +func TestPasswordLogin_OIDC_Success(t *testing.T) { + mockIdp := new(MockIdentityProvider) + + // Mock IDP SignIn Success + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-identity-id", + }, nil) + + // Mock Hydra Responses + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + // GetLoginRequest + challenge := r.URL.Query().Get("login_challenge") + if challenge != "challenge-123" { + http.Error(w, "invalid challenge", http.StatusBadRequest) + return + } + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: challenge, + Client: domain.HydraClient{ClientID: "client-1", Metadata: map[string]interface{}{"status": "active"}}, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + // AcceptLoginRequest + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), // We need to mock this better if resolveKratosIdentityIDFromLoginID calls real API + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + // Inject Mock Kratos (Hack: overwrite the service field if it was an interface, but it's a struct pointer) + // AuthHandler uses *service.KratosAdminService struct pointer. + // KratosAdminService methods are real. We need to mock HTTP client inside KratosAdminService too. + + kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Mock FindIdentityIDByIdentifier response + if strings.Contains(r.URL.Path, "/identities") { + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": "kratos-identity-id"}, + }) + return + } + http.NotFound(w, r) + }) + h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)} + h.KratosAdmin.AdminURL = "http://kratos.test" + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + var got map[string]string + json.NewDecoder(resp.Body).Decode(&got) + if got["redirectTo"] != "http://rp/cb" { + t.Errorf("expected redirectTo http://rp/cb, got %s", got["redirectTo"]) + } +} + +func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-identity-id", + }, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Client: domain.HydraClient{ClientID: "client-inactive", Metadata: map[string]interface{}{"status": "inactive"}}, + }) + return + } + http.NotFound(w, r) + }) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}}) + }) + h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)} + h.KratosAdmin.AdminURL = "http://kratos.test" + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-inactive", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Should be Forbidden (403) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("expected 403 Forbidden, got %d", resp.StatusCode) + } +} + +func TestPasswordLogin_NoOIDC_Success(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-identity-id", + }, nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), + Hydra: service.NewHydraAdminService(), + } + + kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}}) + }) + h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)} + h.KratosAdmin.AdminURL = "http://kratos.test" + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + var got map[string]string + json.NewDecoder(resp.Body).Decode(&got) + if got["sessionJwt"] != "valid-jwt" { + t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"]) + } + // No redirectTo + if _, ok := got["redirectTo"]; ok { + t.Errorf("expected no redirectTo, got %s", got["redirectTo"]) + } +} From b50f7b7436fcc9fe5d3bc0e86e9e89cefd100318 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:31:24 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test=20corverage=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend_hydra_test_guide.md | 147 +++++++++++++++++++++++++++++++ docs/test_concepts_guide.md | 62 +++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 docs/backend_hydra_test_guide.md create mode 100644 docs/test_concepts_guide.md diff --git a/docs/backend_hydra_test_guide.md b/docs/backend_hydra_test_guide.md new file mode 100644 index 00000000..d9271b07 --- /dev/null +++ b/docs/backend_hydra_test_guide.md @@ -0,0 +1,147 @@ +# Backend Hydra Test Guide + +이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다. + +## 1. 테스트 개요 + +백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다. +본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다. +(주의: 본 가이드는 관리자용 UI인 `adminfront` 테스트가 아닌, 백엔드 내부의 API 연동 코드 테스트를 다룹니다.) + +## 2. 테스트 환경 준비 + +테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다. + +```bash +cd backend +``` + +## 3. 테스트 파일 목록 및 실행 방법 + +작성된 Hydra 관련 테스트 코드는 크게 3가지 파일로 나뉩니다. + +### 3.1. HydraAdminService (백엔드 내부 서비스) 테스트 +백엔드 내부에서 Ory Hydra Admin API와 통신하는 최하단 로직(클라이언트 관리, OIDC 흐름, 세션 관리)을 검증합니다. + +* **위치:** `backend/internal/service/hydra_admin_service_test.go` +* **실행:** + ```bash + go test -v ./internal/service -run TestHydraAdminService + ``` + +### 3.2. Relying Party Service 테스트 +`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다. + +* **위치:** `backend/internal/service/relying_party_service_test.go` +* **실행:** + ```bash + go test -v ./internal/service -run TestRelyingPartyService + ``` + +### 3.3. Auth Handler 로그인 테스트 +로그인 요청 시 백엔드 핸들러 레벨에서 발생하는 OIDC/Hydra 흐름(Login Challenge 처리 등)을 검증합니다. + +* **위치:** `backend/internal/handler/auth_handler_login_test.go` +* **실행:** + ```bash + go test -v ./internal/handler -run TestPasswordLogin + ``` + +### 3.4. 전체 테스트 실행 (권장) +모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다. + +```bash +go test -v ./internal/service ./internal/handler -run "TestHydraAdminService|TestRelyingPartyService|TestPasswordLogin" +``` + +## 4. 테스트 커버리지 측정 + +테스트가 코드의 어느 부분을 검증했는지 수치로 확인합니다. + +### 4.1. 수치로 확인 (CLI) + +```bash +# 1. HydraAdminService (백엔드 서비스) 커버리지 +go test -v ./internal/service -run TestHydraAdminService -coverprofile=coverage_hydra.out +go tool cover -func=coverage_hydra.out | grep hydra_admin_service.go + +# 2. RelyingPartyService (통합 서비스) 커버리지 +go test -v ./internal/service -run TestRelyingPartyService -coverprofile=coverage_rp.out +go tool cover -func=coverage_rp.out | grep relying_party_service.go +``` + +**예시 출력:** +```text +baron-sso-backend/internal/service/hydra_admin_service.go:35: ListClients 100.0% +baron-sso-backend/internal/service/hydra_admin_service.go:70: GetClient 85.7% +... +total: (statements) 78.5% +``` + +### 4.2. 시각적으로 확인 (HTML) + +어떤 코드가 테스트되지 않았는지(Missing Branch) 브라우저에서 색상으로 확인합니다. + +```bash +go tool cover -html=coverage_hydra.out +``` + +* **초록색**: 테스트에 의해 실행된 코드 +* **빨간색**: 테스트되지 않은 코드 (추가 테스트 케이스 필요) +* **회색**: 테스트 대상이 아닌 코드 (선언부 등) + +## 5. 주요 테스트 항목 (Checklist) + +| 분류 | 메서드 | 테스트 내용 | 파일 위치 | +| :--- | :--- | :--- | :--- | +| **클라이언트 관리** | `ListClients` | 클라이언트 목록 페이징 조회 | `hydra_admin_service_test.go` | +| | `GetClient` | 특정 클라이언트 상세 조회 (성공/실패) | `hydra_admin_service_test.go` | +| | `CreateClient` | 신규 클라이언트 생성 및 메타데이터 검증 | `hydra_admin_service_test.go` | +| | `UpdateClient` | 클라이언트 정보 수정 (PUT) | `hydra_admin_service_test.go` | +| | `PatchClientStatus` | 클라이언트 상태 변경 (JSON Patch) | `hydra_admin_service_test.go` | +| | `DeleteClient` | 클라이언트 삭제 | `hydra_admin_service_test.go` | +| **인증/동의** | `GetConsentRequest` | Consent Challenge 검증 및 요청 정보 조회 | `hydra_admin_service_test.go` | +| | `AcceptConsentRequest` | 동의 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` | +| | `RejectConsentRequest` | 동의 거부 처리 | `hydra_admin_service_test.go` | +| | `GetLoginRequest` | Login Challenge 검증 | `hydra_admin_service_test.go` | +| | `AcceptLoginRequest` | 로그인 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` | +| | `RejectLoginRequest` | 로그인 거부 처리 | `hydra_admin_service_test.go` | +| **세션 관리** | `ListConsentSessions` | 특정 사용자의 활성 세션 목록 조회 | `hydra_admin_service_test.go` | +| | `RevokeConsentSessions` | 특정 사용자/클라이언트의 세션 만료 처리 | `hydra_admin_service_test.go` | +| **서비스 통합** | `Create` (RP) | Hydra 생성 -> DB 생성 -> Keto 권한 부여 | `relying_party_service_test.go` | +| | `Create` (Rollback) | DB 실패 시 Hydra 롤백(삭제) 검증 | `relying_party_service_test.go` | +| **핸들러 연동** | `PasswordLogin` | OIDC 로그인 성공 및 Challenge 승인 | `auth_handler_login_test.go` | +| | `PasswordLogin` | 비활성(Inactive) 클라이언트 로그인 차단 | `auth_handler_login_test.go` | + +## 6. 테스트 코드 작성 가이드 + +새로운 기능을 추가하거나 커버리지를 높일 때 다음 패턴을 참고하세요. + +```go +func TestHydraAdminService_NewFeature(t *testing.T) { + // 1. Mock 핸들러 정의 (예상되는 요청 검증 및 가짜 응답 반환) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Assert: 요청 메서드, URL, 바디 검증 + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + // Response: 가짜 응답 작성 + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedResponse) + }) + + // 2. 서비스 초기화 (Mock Client 주입) + svc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(handler), // ory_service_test.go의 헬퍼 사용 + } + + // 3. 테스트 실행 및 검증 + result, err := svc.NewFeature(context.Background(), args) + if err != nil { + t.Fatalf("failed: %v", err) + } + // Assert: 결과값 검증 +} +``` diff --git a/docs/test_concepts_guide.md b/docs/test_concepts_guide.md new file mode 100644 index 00000000..87810b5b --- /dev/null +++ b/docs/test_concepts_guide.md @@ -0,0 +1,62 @@ +# 테스트 커버리지 및 모킹 가이드 + +이 문서는 백엔드 테스트 코드 작성 시 필수적인 개념인 **테스트 커버리지**와 **모킹(Mocking)**에 대해 설명합니다. + +--- + +## 1. 테스트 커버리지 (Test Coverage) + +**정의:** 테스트 코드가 전체 소스 코드 중 얼마나 많은 부분을 실행(검증)했는지를 나타내는 백분율(%) 지표입니다. + +### 왜 중요한가요? +* **안정성 지표:** 커버리지가 높을수록 코드의 많은 부분이 자동화된 검증을 거쳤음을 의미합니다. +* **사각지대 발견:** 테스트되지 않은 '죽은 코드(Dead Code)'나 누락된 예외 처리 분기(if-else)를 찾아낼 수 있습니다. +* **리팩토링 자신감:** 커버리지가 높으면 기존 기능을 깨뜨리지 않고 코드를 수정하기가 훨씬 수월합니다. + +### 어떻게 확인하나요? (Go 기준) +```bash +# 1. 테스트 실행 및 기록 저장 +go test -coverprofile=coverage.out ./... + +# 2. 브라우저에서 시각적으로 확인 (어느 줄이 안 됐는지 색상으로 표시됨) +go tool cover -html=coverage.out +``` + +--- + +## 2. 모킹 (Mocking) + +**정의:** 테스트하려는 대상이 의존하는 외부 요소(데이터베이스, 외부 API, 다른 서비스 등)를 **동작만 흉내 내는 가짜 객체(Mock)**로 대체하는 기술입니다. + +### 왜 필요한가요? +* **독립적 환경:** 실제 Ory Hydra 서버가 꺼져 있어도 백엔드 로직만 따로 테스트할 수 있습니다. +* **결정적 테스트:** 네트워크 상태에 상관없이 항상 동일한 결과를 얻을 수 있습니다. +* **예외 상황 시뮬레이션:** "서버 점검 중(503 에러)"과 같은 특수한 상황을 강제로 만들어 내 코드가 잘 대응하는지 확인할 수 있습니다. + +### `MockRelyingPartyRepository`는 무엇인가요? +사용자께서 보신 `MockRelyingPartyRepository`는 라이브러리가 제공하는 함수가 아니라, **테스트를 위해 직접 만든 구조체**입니다. + +1. **구조체 정의:** `RelyingPartyRepository`라는 인터페이스를 똑같이 흉내 내도록 우리가 직접 코드를 짭니다. +2. **라이브러리 활용:** 이때 `github.com/stretchr/testify/mock` 라이브러리를 사용하여 "이 함수가 호출되면 어떤 값을 반환하라"는 지시를 쉽게 내릴 수 있게 만듭니다. + +**예시 코드:** +```go +// 1. 우리가 직접 만든 가짜 객체 (이름은 마음대로 정할 수 있음) +type MockRelyingPartyRepository struct { + mock.Mock // testify 라이브러리의 기능을 빌려옴 +} + +// 2. 실제 DB 저장소인 척 함수를 구현 +func (m *MockRelyingPartyRepository) Create(ctx context.Context, rp *domain.RelyingParty) error { + args := m.Called(ctx, rp) // 호출 기록 + return args.Error(0) // 설정된 에러 반환 +} +``` + +--- + +## 3. 요약 및 원칙 + +1. **모킹을 통해 커버리지를 높이세요:** 실제 환경에서 만들기 힘든 에러 상황을 모킹으로 구현하여 코드의 예외 처리 로직(Red line)을 검증하세요. +2. **핵심 비즈니스 로직 우선:** 단순한 설정값 조회보다는 **로그인, 권한 체크, 데이터 통합** 등 서비스의 핵심 기능 커버리지를 **80% 이상**으로 유지하는 것을 권장합니다. +3. **수치에 집착하지 마세요:** 커버리지 100%가 "버그 없음"을 보장하지는 않습니다. 의미 있는 테스트 케이스를 작성하는 것이 더 중요합니다. From c8510fee4e3ab3d05720ef90bfcf49fbd90a7fc2 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 13:31:52 +0900 Subject: [PATCH 5/5] =?UTF-8?q?.out=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2e4a11ea..131ce960 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .codex/ *.swp *.log +*.out # Docker Services Data (Volumes) postgres_data/