From f16cb9a34473519f224b23bb0ed198fe381ae6e9 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 16:25:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Actions=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/coverage.json | 7 +++++ .gitea/workflows/backend_coverage_check.yml | 29 +++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 .gitea/coverage.json create mode 100644 .gitea/workflows/backend_coverage_check.yml diff --git a/.gitea/coverage.json b/.gitea/coverage.json new file mode 100644 index 00000000..7677f164 --- /dev/null +++ b/.gitea/coverage.json @@ -0,0 +1,7 @@ +{ + "Path": "./backend/coverage.out", + "Thresholds": { + "baron-sso-backend/internal/handler": 90, + "baron-sso-backend/internal/service": 90 + } +} diff --git a/.gitea/workflows/backend_coverage_check.yml b/.gitea/workflows/backend_coverage_check.yml new file mode 100644 index 00000000..01157924 --- /dev/null +++ b/.gitea/workflows/backend_coverage_check.yml @@ -0,0 +1,29 @@ +name: Backend Test Coverage Check + +on: + push: + branches: + - dev + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: backend/go.sum + + - name: Run tests with coverage + working-directory: ./backend + run: | + go test -v -coverprofile=coverage.out -covermode=atomic ./internal/handler/... ./internal/service/... + + - name: Check coverage + uses: vladopajic/go-test-coverage@v2 + with: + config: ./.gitea/coverage.json From 15a27a6620f87d4e70793189bdc713ae783947cc Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 16:27:13 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/models.go | 10 ++ backend/internal/handler/auth_handler.go | 6 +- backend/internal/handler/common_test.go | 149 +++++++++++++++++++++++ backend/internal/handler/dev_handler.go | 4 +- 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 backend/internal/handler/common_test.go diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index d5b749b8..fe911ac2 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -32,3 +32,13 @@ type AuditCursor struct { Timestamp time.Time EventID string } + +// RedisRepository defines interface for KV storage (Redis) +type RedisRepository interface { + Set(key string, value string, expiration time.Duration) error + Get(key string) (string, error) + Delete(key string) error + StoreVerificationCode(phone, code string) error + GetVerificationCode(phone string) (string, error) + DeleteVerificationCode(phone string) error +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 9941f64d..5bc2dc16 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -80,7 +80,7 @@ const ( type AuthHandler struct { SmsService domain.SmsService EmailService domain.EmailService - RedisService *service.RedisService + RedisService domain.RedisRepository KratosAdmin *service.KratosAdminService IdpProvider domain.IdentityProvider AuditRepo domain.AuditRepository @@ -132,7 +132,7 @@ func GenerateUserCode() string { ) } -func checkPollInterval(redis *service.RedisService, key string, interval time.Duration) (bool, int) { +func checkPollInterval(redis domain.RedisRepository, key string, interval time.Duration) (bool, int) { now := time.Now().UnixMilli() val, err := redis.Get(key) if err == nil && val != "" { @@ -147,7 +147,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du return false, int(interval.Seconds()) } -func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler { +func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler { return &AuthHandler{ SmsService: service.NewSmsService(), EmailService: service.NewEmailService(), diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go new file mode 100644 index 00000000..7934299b --- /dev/null +++ b/backend/internal/handler/common_test.go @@ -0,0 +1,149 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "context" + "encoding/json" + "io" + "net/http" +) + +// --- Mock IDP Provider --- + +type mockIdpProvider struct { + userExists bool + name string + signInInfo *domain.AuthInfo + issueSession *domain.AuthInfo + verifyCodeInfo *domain.AuthInfo + err error + initiateLinkErr error +} + +func (m *mockIdpProvider) Name() string { + if m.name != "" { + return m.name + } + return "mock-idp" +} + +func (m *mockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) { return nil, m.err } +func (m *mockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + return "mock-user-id", m.err +} +func (m *mockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + return m.signInInfo, m.err +} +func (m *mockIdpProvider) UserExists(loginID string) (bool, error) { return m.userExists, m.err } +func (m *mockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + if m.issueSession != nil { + return m.issueSession, m.err + } + return &domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "valid-sid"}, + }, m.err +} +func (m *mockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + if m.initiateLinkErr != nil { + return nil, m.initiateLinkErr + } + return &domain.LinkLoginInit{FlowID: "mock-flow-id", Mode: "code"}, m.err +} +func (m *mockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return m.verifyCodeInfo, m.err +} +func (m *mockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return nil, m.err } +func (m *mockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return m.err } +func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + return nil, m.err +} +func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + return m.err +} + +// --- Mock Audit Repository --- + +type mockAuditRepo struct { + logs []domain.AuditLog +} + +func (m *mockAuditRepo) Create(log *domain.AuditLog) error { + m.logs = append(m.logs, *log) + return nil +} +func (m *mockAuditRepo) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) { + return m.logs, nil +} +func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) { + var results []domain.AuditLog + for _, log := range m.logs { + if log.UserID == userID { + for _, et := range eventTypes { + if log.EventType == et { + results = append(results, log) + break + } + } + } + } + return results, nil +} +func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } + +// --- Mock Consent Repository --- + +type mockConsentRepo struct { + consents []domain.ClientConsent +} + +func (m *mockConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error { + m.consents = append(m.consents, *consent) + return nil +} +func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) { + var results []domain.ClientConsent + for _, c := range m.consents { + if c.Subject == subject { + results = append(results, c) + } + } + return results, nil +} +func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil } +func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { + return nil, 0, nil +} +func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { + return nil, 0, nil +} + + +// --- HTTP Mock Helpers --- + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func httpResponse(r *http.Request, code int, body string) *http.Response { + return &http.Response{ + StatusCode: code, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewBufferString(body)), + Request: r, + } +} + +func httpJSONAny(r *http.Request, code int, data any) *http.Response { + body, _ := json.Marshal(data) + return &http.Response{ + StatusCode: code, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: r, + } +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 0d05d87d..6514d741 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -18,13 +18,13 @@ import ( type DevHandler struct { Hydra *service.HydraAdminService - Redis *service.RedisService + Redis domain.RedisRepository SecretRepo domain.ClientSecretRepository KratosAdmin *service.KratosAdminService ConsentRepo repository.ClientConsentRepository } -func NewDevHandler(redis *service.RedisService, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler { +func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler { return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, From dd9e6394ad78495306edb2efd35444643d2f9245 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 16:27:45 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Hydra=20=EC=97=B0=EB=8F=99=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_client_test.go | 106 +++++++++ .../handler/auth_handler_consent_test.go | 197 +++++++++++++++++ .../handler/auth_handler_link_test.go | 121 +++++++++++ .../handler/auth_handler_linked_test.go | 144 +++++++++++++ .../handler/auth_handler_oidc_test.go | 28 +-- .../internal/handler/auth_handler_otp_test.go | 110 ++++++++++ .../internal/handler/auth_handler_qr_test.go | 203 ++++++++++++++++++ 7 files changed, 884 insertions(+), 25 deletions(-) create mode 100644 backend/internal/handler/auth_handler_client_test.go create mode 100644 backend/internal/handler/auth_handler_consent_test.go create mode 100644 backend/internal/handler/auth_handler_link_test.go create mode 100644 backend/internal/handler/auth_handler_linked_test.go create mode 100644 backend/internal/handler/auth_handler_otp_test.go create mode 100644 backend/internal/handler/auth_handler_qr_test.go diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go new file mode 100644 index 00000000..65bc7308 --- /dev/null +++ b/backend/internal/handler/auth_handler_client_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +func TestRevokeLinkedRp_Success(t *testing.T) { + // Mock Hydra transport for revocation + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // 1. Kratos whoami + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{"id": "user-123"}, + }), nil + } + // 2. Hydra Revoke + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpResponse(r, http.StatusNoContent, ""), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + AuditRepo: auditRepo, + } + app := fiber.New() + app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil) + req.Header.Set("Cookie", "valid") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, len(auditRepo.logs)) +} + +func TestListRpHistory_Aggregation(t *testing.T) { + now := time.Now() + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.revoked", // Newest + Timestamp: now, + Details: `{"client_id":"app-1"}`, + }, + { + UserID: "user-123", + EventType: "consent.granted", // Oldest + Timestamp: now.Add(-1 * time.Hour), + Details: `{"client_id":"app-1", "client_name":"App One"}`, + }, + }, + } + + h := &AuthHandler{ + AuditRepo: auditRepo, + } + app := fiber.New() + app.Get("/api/v1/user/rp/history", h.ListRpHistory) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{"id": "user-123"}, + }), nil + }) + http.DefaultClient = &http.Client{Transport: transport} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/history", nil) + req.Header.Set("Cookie", "valid") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []struct { + ClientID string `json:"client_id"` + Status string `json:"status"` + } `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, 1, len(res.Items)) + assert.Equal(t, "app-1", res.Items[0].ClientID) + // Newest event (revoked) should win + assert.Equal(t, "revoked", res.Items[0].Status) +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go new file mode 100644 index 00000000..8e6edf12 --- /dev/null +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -0,0 +1,197 @@ +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" +) + +// --- Test Helpers --- + +func newConsentTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Get("/api/v1/auth/consent", h.GetConsentRequest) + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + return app +} + +// --- Tests --- + +func TestGetConsentRequest_Normal(t *testing.T) { + // Mock Hydra transport + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-123", + "requested_scope": []string{"openid", "profile"}, + "skip": false, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + "client_name": "Test App", + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + app := newConsentTestApp(h) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-123", nil) + resp, err := app.Test(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + + assert.Equal(t, "challenge-123", body["challenge"]) + assert.Equal(t, false, body["skip"]) +} + +func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Hydra: Get Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-skip", + "requested_scope": []string{"openid"}, + "skip": true, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + }, + }), nil + } + // Kratos: Get Identity + if r.URL.Path == "/admin/identities/user-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + }, + }), nil + } + // Hydra: Accept Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + consentRepo := &mockConsentRepo{} + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: &service.KratosAdminService{ + AdminURL: "http://kratos.test", + HTTPClient: client, + }, + ConsentRepo: consentRepo, + } + + app := newConsentTestApp(h) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil) + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + assert.Equal(t, "http://rp/cb", body["redirectTo"]) +} + +func TestAcceptConsentRequest_Normal(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-accept", + "requested_scope": []string{"openid", "profile"}, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + }, + }), nil + } + if r.URL.Path == "/admin/identities/user-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + auditRepo := &mockAuditRepo{} + consentRepo := &mockConsentRepo{} + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: &service.KratosAdminService{ + AdminURL: "http://kratos.test", + HTTPClient: client, + }, + AuditRepo: auditRepo, + ConsentRepo: consentRepo, + } + + app := newConsentTestApp(h) + + body, _ := json.Marshal(map[string]interface{}{ + "consent_challenge": "challenge-accept", + "grant_scope": []string{"openid"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Equal(t, 1, len(auditRepo.logs)) +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go new file mode 100644 index 00000000..76139c85 --- /dev/null +++ b/backend/internal/handler/auth_handler_link_test.go @@ -0,0 +1,121 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// Mock services +type mockEmailService struct{} +func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil } + +type mockSmsService struct{} +func (m *mockSmsService) SendSms(to, content string) error { return nil } + +func TestEnchantedLinkFlow_Email_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + // Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic + idp := &mockIdpProvider{ + userExists: true, + initiateLinkErr: domain.ErrNotSupported, + } + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + EmailService: &mockEmailService{}, + SmsService: &mockSmsService{}, + } + app := fiber.New() + app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink) + app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) + app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) + + t.Setenv("USERFRONT_URL", "http://userfront.test") + + // 1. Init Enchanted Link (Email) + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "method": "email", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var initResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&initResp) + pendingRef := initResp["pendingRef"].(string) + assert.NotEmpty(t, pendingRef) + + // Find the token key "enchanted_token:..." in mock redis + var token string + for k := range redis.data { + if len(k) > 16 && k[:16] == "enchanted_token:" { + token = k[16:] + break + } + } + assert.NotEmpty(t, token) + + // 2. Verify Magic Link + verifyBody, _ := json.Marshal(map[string]interface{}{ + "token": token, + "verifyOnly": true, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // 3. Poll (Success) + pollBody, _ := json.Marshal(map[string]string{"pendingRef": pendingRef}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(pollBody)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var pollResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&pollResp) + assert.Equal(t, "ok", pollResp["status"]) + assert.Equal(t, "valid-jwt", pollResp["sessionJwt"]) +} + +func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + idp := &mockIdpProvider{ + userExists: true, + initiateLinkErr: domain.ErrNotSupported, + } + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + SmsService: &mockSmsService{}, + } + app := fiber.New() + app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink) + + // 1. Init Enchanted Link (SMS) + body, _ := json.Marshal(map[string]string{ + "loginId": "010-1234-5678", + "method": "sms", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var initResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&initResp) + assert.NotEmpty(t, initResp["userCode"]) +} diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go new file mode 100644 index 00000000..c80ab12b --- /dev/null +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -0,0 +1,144 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// --- Helper --- + +func newLinkedRpTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Get("/api/v1/user/rp/linked", h.ListLinkedRps) + return app +} + +// --- Tests --- + +func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" { + return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil + } + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + }, + }, + }), nil + } + case "hydra.test": + if r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + { + "client": map[string]interface{}{ + "client_id": "client-active", + "client_name": "Active App", + }, + "granted_scope": []string{"openid"}, + "handled_at": time.Now().Format(time.RFC3339), + }, + }), nil + } + if r.URL.Path == "/admin/clients/client-audit" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-audit", + "client_name": "Audit App", + }), nil + } + if r.URL.Path == "/admin/clients/client-consent" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-consent", + "client_name": "Consent App", + }), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { + http.DefaultClient = origDefault + }() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + Timestamp: time.Now().Add(-10 * time.Hour), + Details: `{"client_id":"client-audit", "scopes":["audit_scope"]}`, + }, + }, + } + + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + { + Subject: "user-123", + ClientID: "client-consent", + GrantedScopes: []string{"consent_scope"}, + UpdatedAt: time.Now().Add(-2 * time.Hour), + }, + }, + } + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + AuditRepo: auditRepo, + ConsentRepo: consentRepo, + KratosAdmin: &service.KratosAdminService{}, + } + + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + + app := newLinkedRpTestApp(h) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Scopes []string `json:"scopes"` + } `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, 3, len(res.Items)) + + statusMap := make(map[string]string) + for _, item := range res.Items { + statusMap[item.ID] = item.Status + } + + assert.Equal(t, "active", statusMap["client-active"]) + assert.Equal(t, "inactive", statusMap["client-consent"]) + assert.Equal(t, "inactive", statusMap["client-audit"]) +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_oidc_test.go b/backend/internal/handler/auth_handler_oidc_test.go index 828e8e39..1f5fb069 100644 --- a/backend/internal/handler/auth_handler_oidc_test.go +++ b/backend/internal/handler/auth_handler_oidc_test.go @@ -33,7 +33,7 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) { if r.Header.Get("Cookie") == "" { return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil } - return httpJSON(r, http.StatusOK, map[string]interface{}{ + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "identity": map[string]interface{}{ "id": "kratos-123", "traits": map[string]interface{}{}, @@ -117,7 +117,7 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) { if r.Header.Get("Cookie") == "" { return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil } - return httpJSON(r, http.StatusOK, map[string]interface{}{ + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "identity": map[string]interface{}{ "id": "kratos-456", "traits": map[string]interface{}{}, @@ -175,26 +175,4 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) { if gotSubject != "kratos-456" { t.Fatalf("unexpected subject: %v", gotSubject) } -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func httpResponse(req *http.Request, status int, body string) *http.Response { - return &http.Response{ - StatusCode: status, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewBufferString(body)), - Request: req, - } -} - -func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response { - data, _ := json.Marshal(payload) - resp := httpResponse(req, status, string(data)) - resp.Header.Set("Content-Type", "application/json") - return resp -} +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_otp_test.go b/backend/internal/handler/auth_handler_otp_test.go new file mode 100644 index 00000000..bda8fa47 --- /dev/null +++ b/backend/internal/handler/auth_handler_otp_test.go @@ -0,0 +1,110 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +func TestHandleKratosCourierRelay_Email(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + emailSvc := &mockEmailService{} + + h := &AuthHandler{ + RedisService: redis, + EmailService: emailSvc, + } + app := fiber.New() + app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay) + + // Simulate Kratos Courier Request for Email + reqBody := map[string]interface{}{ + "recipient": "user@example.com", + "template_type": "verification_code", + "template_data": map[string]interface{}{ + "verification_code": "123456", + }, + "subject": "Verify your email", + "body": "Your code is 123456", + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/kratos/courier", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestVerifySignupCode_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode) + + // Mock stored code in redis + // signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...} + state := map[string]interface{}{ + "code": "654321", + "verified": false, + "expires_at": 9999999999, // far future + } + stateJSON, _ := json.Marshal(state) + redis.data["signup:email:user@test.com"] = string(stateJSON) + + // Verify Code + verifyBody := map[string]string{ + "type": "email", + "target": "user@test.com", + "code": "654321", + } + body, _ := json.Marshal(verifyBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res map[string]interface{} + json.NewDecoder(resp.Body).Decode(&res) + assert.True(t, res["success"].(bool)) + + // Check redis state updated to verified + val, _ := redis.Get("signup:email:user@test.com") + var updatedState map[string]interface{} + json.Unmarshal([]byte(val), &updatedState) + assert.True(t, updatedState["verified"].(bool)) +} + +func TestVerifySignupCode_Invalid(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode) + + stateJSON, _ := json.Marshal(map[string]interface{}{ + "code": "111111", + "expires_at": 9999999999, + }) + redis.data["signup:email:user@test.com"] = string(stateJSON) + + verifyBody := map[string]string{ + "type": "email", + "target": "user@test.com", + "code": "000000", // wrong code + } + body, _ := json.Marshal(verifyBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/backend/internal/handler/auth_handler_qr_test.go b/backend/internal/handler/auth_handler_qr_test.go new file mode 100644 index 00000000..816cf39f --- /dev/null +++ b/backend/internal/handler/auth_handler_qr_test.go @@ -0,0 +1,203 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// --- Mock Redis --- + +type mockRedisRepo struct { + data map[string]string +} + +func (m *mockRedisRepo) Set(key, value string, ttl time.Duration) error { + if m.data == nil { m.data = make(map[string]string) } + m.data[key] = value + return nil +} + +func (m *mockRedisRepo) Get(key string) (string, error) { + // Bypass rate limiting for tests + if strings.HasPrefix(key, "poll_meta:") { + return "", nil + } + return m.data[key], nil +} + +func (m *mockRedisRepo) Delete(key string) error { + delete(m.data, key) + return nil +} + +func (m *mockRedisRepo) StoreVerificationCode(phone, code string) error { + return m.Set("sms:"+phone, code, time.Minute) +} + +func (m *mockRedisRepo) GetVerificationCode(phone string) (string, error) { + return m.Get("sms:"+phone) +} + +func (m *mockRedisRepo) DeleteVerificationCode(phone string) error { + return m.Delete("sms:"+phone) +} + +// --- Tests --- + +func TestQRLoginFlow_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/qr/init", h.InitQRLogin) + app.Post("/api/v1/auth/qr/poll", h.PollQRLogin) + + // 1. Init QR Login + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/init", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var initResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&initResp) + pendingRef := initResp["pendingRef"].(string) + + // 2. Poll (Pending) + body, _ := json.Marshal(map[string]string{"pendingRef": pendingRef}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + + // Expect authorization_pending (400) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + var pollResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&pollResp) + assert.Equal(t, "authorization_pending", pollResp["error"]) + + // 3. Mock Approval + sessionData, _ := json.Marshal(map[string]string{ + "status": "success", + "jwt": "mock-session-jwt", + }) + redis.data["enchanted_session:"+pendingRef] = string(sessionData) + + // 4. Poll (Success) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var successResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&successResp) + assert.Equal(t, "ok", successResp["status"]) + assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"]) +} + +func TestScanQRLogin_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + idp := &mockIdpProvider{userExists: true} + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := fiber.New() + app.Post("/api/v1/auth/qr/approve", h.ScanQRLogin) + + pendingRef := "test-ref" + redis.data["enchanted_session:"+pendingRef] = `{"status":"pending"}` + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@example.com", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + origDefault := http.DefaultClient + http.DefaultClient = &http.Client{Transport: transport} + defer func() { http.DefaultClient = origDefault }() + + body, _ := json.Marshal(map[string]string{ + "pendingRef": pendingRef, + "token": "valid-token", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/approve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) { + h := &AuthHandler{} + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("X-Session-Token") == "token-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-token", + "traits": map[string]interface{}{ + "email": "token@test.com", + }, + }, + }), nil + } + if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-cookie", + "traits": map[string]interface{}{ + "email": "cookie@test.com", + "phone": "010-1234-5678", + }, + }, + }), nil + } + return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil + }) + + origDefault := http.DefaultClient + http.DefaultClient = &http.Client{Transport: transport} + defer func() { http.DefaultClient = origDefault }() + + app := fiber.New() + + // Token case + app.Get("/test-token", func(c *fiber.Ctx) error { + subjects, err := h.resolveConsentSubjects(c) + assert.NoError(t, err) + assert.Contains(t, subjects, "user-token") + return c.SendStatus(200) + }) + req := httptest.NewRequest("GET", "/test-token", nil) + req.Header.Set("Authorization", "Bearer token-123") + app.Test(req, -1) + + // Cookie case + app.Get("/test-cookie", func(c *fiber.Ctx) error { + subjects, err := h.resolveConsentSubjects(c) + assert.NoError(t, err) + assert.Contains(t, subjects, "user-cookie") + return c.SendStatus(200) + }) + req = httptest.NewRequest("GET", "/test-cookie", nil) + req.Header.Set("Cookie", "ory_kratos_session=cookie-123") + app.Test(req, -1) +} From 68459151c3f8f6406603c744b514fb54a9deeefd Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 9 Feb 2026 16:28:00 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20Hydra=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hydra_be_test_guide.md | 144 ++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/hydra_be_test_guide.md diff --git a/docs/hydra_be_test_guide.md b/docs/hydra_be_test_guide.md new file mode 100644 index 00000000..963dec1e --- /dev/null +++ b/docs/hydra_be_test_guide.md @@ -0,0 +1,144 @@ +# Backend Hydra Test Guide + +이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다. + +## 1. 테스트 개요 + +백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다. +본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다. + +## 2. 테스트 환경 준비 + +테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다. + +```bash +cd backend +``` + +## 3. 테스트 파일 목록 및 실행 방법 + +Hydra와 직접적으로 연관된 백엔드 로직 테스트는 `internal/handler`와 `internal/service` 패키지에 집중되어 있습니다. + +### 3.1. 핸들러 레벨 통합 테스트 (Handler Level) +사용자 요청(HTTP)부터 Hydra 연동까지의 전체 흐름을 검증합니다. + +* **주요 파일:** + * `backend/internal/handler/auth_handler_consent_test.go` + * `backend/internal/handler/auth_handler_link_test.go` + * `backend/internal/handler/auth_handler_login_test.go` + * `backend/internal/handler/auth_handler_qr_test.go` + * `backend/internal/handler/auth_handler_client_test.go` +* **실행 (패키지 전체):** + ```bash + cd backend + go test -v ./internal/handler/... + ``` + +### 3.2. 서비스 레벨 단위 테스트 (Service Level) +백엔드 내부에서 Ory Hydra Admin API와 직접 통신하는 서비스의 단위 기능을 검증합니다. + +* **주요 파일:** `backend/internal/service/hydra_admin_service_test.go` +* **실행:** + ```bash + cd backend + go test -v ./internal/service -run TestHydraAdminService + ``` + +### 3.3. Relying Party Service 테스트 +`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다. + +* **위치:** `backend/internal/service/relying_party_service_test.go` +* **실행:** + ```bash + go test -v ./internal/service -run TestRelyingPartyService + ``` + +### 3.4. 전체 테스트 실행 (권장) +모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다. + +```bash +go test -v ./internal/service ./internal/handler +``` + +## 4. 테스트 커버리지 측정 + +`internal/handler` 패키지에 대한 커버리지를 측정하고 90% 임계값을 확인합니다. + +```bash +# 1. 커버리지 측정 및 coverage.out 파일 생성 +cd backend +go test -coverprofile=coverage.out ./internal/handler + +# 2. 함수별 커버리지 확인 (CLI) +go tool cover -func=coverage.out + +# 3. 상세 리포트 확인 (HTML) +go tool cover -html=coverage.out +``` + +## 5. 주요 테스트 항목 (Checklist) + +| 분류 | 핸들러/메서드 | 테스트 내용 | 파일 위치 | +| :--- | :--- | :--- | :--- | +| **핸들러: 인증 흐름** | `GetConsentRequest` | Consent Challenge 검증 및 자동 승인(`skip=true`) 처리 | `auth_handler_consent_test.go` | +| | `AcceptConsentRequest` | 사용자가 동의한 Scope 기반으로 Consent 승인 | `auth_handler_consent_test.go` | +| | `PasswordLogin` | OIDC 로그인 성공 및 비활성 클라이언트 차단 검증 | `auth_handler_login_test.go` | +| | `AcceptOidcLoginRequest` | 쿠키/토큰 기반 OIDC 로그인 요청 승인 | `auth_handler_oidc_test.go` | +| | `Init/Poll/ScanQRLogin` | QR 코드 생성, 폴링, 승인으로 이어지는 전체 흐름 | `auth_handler_qr_test.go` | +| | `Init/Verify/PollEnchantedLink` | Magic Link 생성, 검증, 세션 발행으로 이어지는 전체 흐름 | `auth_handler_link_test.go`| +| | `HandleKratosCourierRelay` | Kratos의 OTP 발송 요청(Email/SMS) 수신 및 처리 | `auth_handler_otp_test.go` | +| **핸들러: 세션/RP 관리** | `ListLinkedRps` | Hydra 세션, 로컬 DB, Audit Log 3-way 병합 로직 | `auth_handler_linked_test.go` | +| | `RevokeLinkedRp` | 특정 RP(클라이언트) 연동 해제 및 세션 종료 | `auth_handler_client_test.go` | +| | `ListRpHistory` | Audit Log 기반의 RP 연동 이력 조회 | `auth_handler_client_test.go` | +| | `resolveConsentSubjects` | 토큰/쿠키에서 다중 사용자 식별자(Subject) 추출 | `auth_handler_qr_test.go` | +| **서비스: 클라이언트 관리** | `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` | + + +## 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: 결과값 검증 +} +``` + From 0741baf60b44f44aa2aa7cda75fe56332c399ae5 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 10 Feb 2026 09:03:34 +0900 Subject: [PATCH 5/7] =?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 From 6569faee76a152888abf986a5ad60c5acc98d670 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 10 Feb 2026 09:58:29 +0900 Subject: [PATCH 6/7] =?UTF-8?q?App=20=ED=98=84=ED=99=A9=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20UI=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/dashboard_screen.dart | 183 ++++++------------ 1 file changed, 62 insertions(+), 121 deletions(-) diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 6d87dff4..6b8c42fd 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -835,136 +835,77 @@ class _DashboardScreenState extends ConsumerState { Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { if (activities.isEmpty) return const SizedBox.shrink(); - final shouldShowToggle = activities.length > 4; + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + // 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정 + int crossAxisCount; + if (maxWidth > 1200) { + crossAxisCount = 4; + } else if (maxWidth > 800) { + crossAxisCount = 3; + } else { + crossAxisCount = 2; + } - // 더보기를 누르지 않은 경우: 최대 4개 노출 (Grid/Wrap) - if (!_showAllActivities) { - final visibleActivities = activities.take(4).toList(); - Widget grid; - if (isMobile) { - grid = GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.05, - ), - itemCount: visibleActivities.length, - itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]), - ); - } else { - grid = Wrap( - spacing: 12, - runSpacing: 12, - children: visibleActivities.map(_buildActivityCard).toList(), - ); - } + // 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능) + final int initialVisibleCount = crossAxisCount; + final shouldShowToggle = activities.length > initialVisibleCount; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - grid, - if (shouldShowToggle) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => setState(() => _showAllActivities = true), - icon: const Icon(Icons.add, size: 18, color: Colors.blueAccent), - label: const Text('더보기', style: TextStyle(color: Colors.blueAccent, fontWeight: FontWeight.bold)), - ), - ), - ), - ], - ); - } + List<_ActivityItem> visibleActivities; + if (_showAllActivities) { + visibleActivities = activities; + } else { + visibleActivities = activities.take(initialVisibleCount).toList(); + } - // 더보기를 누른 경우: 가로 슬라이더/캐러셀 전환 - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - alignment: Alignment.center, + // 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려) + final double spacing = 12.0; + final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - height: 220, - child: ListView.separated( - controller: _rpScrollController, - scrollDirection: Axis.horizontal, - itemCount: activities.length, - separatorBuilder: (context, index) => const SizedBox(width: 12), - itemBuilder: (context, index) => UnconstrainedBox( - alignment: Alignment.topCenter, - child: _buildActivityCard(activities[index]), + Wrap( + spacing: spacing, + runSpacing: spacing, + children: visibleActivities.map((item) { + return SizedBox( + width: cardWidth, + child: _buildActivityCard(item, cardWidth: cardWidth), + ); + }).toList(), + ), + if (shouldShowToggle) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => setState(() => _showAllActivities = !_showAllActivities), + icon: Icon( + _showAllActivities ? Icons.keyboard_arrow_up : Icons.add, + size: 18, + color: _showAllActivities ? Colors.grey : Colors.blueAccent, + ), + label: Text( + _showAllActivities ? '접기' : '+ 더보기', + style: TextStyle( + color: _showAllActivities ? Colors.grey : Colors.blueAccent, + fontWeight: FontWeight.bold, + ), + ), + ), ), ), - ), - // 왼쪽 이동 버튼 - Positioned( - left: 0, - child: _buildScrollButton( - icon: Icons.chevron_left, - onPressed: () => _rpScrollController.animateTo( - (_rpScrollController.offset - 300).clamp(0, _rpScrollController.position.maxScrollExtent), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ), - ), - ), - // 오른쪽 이동 버튼 - Positioned( - right: 0, - child: _buildScrollButton( - icon: Icons.chevron_right, - onPressed: () => _rpScrollController.animateTo( - (_rpScrollController.offset + 300).clamp(0, _rpScrollController.position.maxScrollExtent), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ), - ), - ), ], - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => setState(() { - _showAllActivities = false; - _rpScrollController.jumpTo(0); // 접을 때 위치 초기화 - }), - icon: const Icon(Icons.close, size: 18, color: Colors.grey), - label: const Text('접기', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)), - ), - ), - ], + ); + }, ); } - Widget _buildScrollButton({required IconData icon, required VoidCallback onPressed}) { - return Container( - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.8), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: IconButton( - icon: Icon(icon, color: _ink), - onPressed: onPressed, - ), - ); - } - - Widget _buildActivityCard(_ActivityItem item) { + Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { final isActive = item.status == '활성'; final statusColor = isActive ? Colors.green : Colors.grey; final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border; @@ -975,7 +916,7 @@ class _DashboardScreenState extends ConsumerState { // 카드 컨텐츠 final cardContent = Container( - width: 260, + width: cardWidth ?? 260, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _surface, From 07aae642a726b016d6ea9f310ceac730cfc68bab Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 10 Feb 2026 10:15:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?golangci=20lint=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_client_test.go | 8 ++--- .../handler/auth_handler_consent_test.go | 8 ++--- .../handler/auth_handler_link_test.go | 18 ++++++----- .../handler/auth_handler_linked_test.go | 8 ++--- .../handler/auth_handler_oidc_test.go | 2 +- .../internal/handler/auth_handler_otp_test.go | 12 +++---- .../internal/handler/auth_handler_qr_test.go | 20 ++++++------ backend/internal/handler/common_test.go | 32 +++++++++++++------ backend/internal/handler/dev_handler_test.go | 16 +++++----- 9 files changed, 70 insertions(+), 54 deletions(-) diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go index 65bc7308..7263b358 100644 --- a/backend/internal/handler/auth_handler_client_test.go +++ b/backend/internal/handler/auth_handler_client_test.go @@ -47,7 +47,7 @@ func TestRevokeLinkedRp_Success(t *testing.T) { req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil) req.Header.Set("Cookie", "valid") - + resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) @@ -87,7 +87,7 @@ func TestListRpHistory_Aggregation(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/history", nil) req.Header.Set("Cookie", "valid") - + resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -98,9 +98,9 @@ func TestListRpHistory_Aggregation(t *testing.T) { } `json:"items"` } json.NewDecoder(resp.Body).Decode(&res) - + assert.Equal(t, 1, len(res.Items)) assert.Equal(t, "app-1", res.Items[0].ClientID) // Newest event (revoked) should win assert.Equal(t, "revoked", res.Items[0].Status) -} \ No newline at end of file +} diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 8e6edf12..72cdede1 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -42,7 +42,7 @@ func TestGetConsentRequest_Normal(t *testing.T) { }) client := &http.Client{Transport: transport} - + origDefault := http.DefaultClient http.DefaultClient = client defer func() { http.DefaultClient = origDefault }() @@ -104,7 +104,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { origDefault := http.DefaultClient http.DefaultClient = client defer func() { http.DefaultClient = origDefault }() - + consentRepo := &mockConsentRepo{} h := &AuthHandler{ @@ -121,7 +121,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { app := newConsentTestApp(h) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil) - + resp, err := app.Test(req) assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -194,4 +194,4 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) -} \ No newline at end of file +} diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 76139c85..331ac4f6 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -14,19 +14,21 @@ import ( // Mock services type mockEmailService struct{} + func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil } type mockSmsService struct{} + func (m *mockSmsService) SendSms(to, content string) error { return nil } func TestEnchantedLinkFlow_Email_Success(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} // Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic idp := &mockIdpProvider{ - userExists: true, + userExists: true, initiateLinkErr: domain.ErrNotSupported, } - + h := &AuthHandler{ RedisService: redis, IdpProvider: idp, @@ -48,9 +50,9 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) - + assert.Equal(t, http.StatusOK, resp.StatusCode) - + var initResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&initResp) pendingRef := initResp["pendingRef"].(string) @@ -81,7 +83,7 @@ func TestEnchantedLinkFlow_Email_Success(t *testing.T) { req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(pollBody)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) - + assert.Equal(t, http.StatusOK, resp.StatusCode) var pollResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&pollResp) @@ -95,7 +97,7 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { userExists: true, initiateLinkErr: domain.ErrNotSupported, } - + h := &AuthHandler{ RedisService: redis, IdpProvider: idp, @@ -112,9 +114,9 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) - + assert.Equal(t, http.StatusOK, resp.StatusCode) - + var initResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&initResp) assert.NotEmpty(t, initResp["userCode"]) diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go index c80ab12b..cc94931f 100644 --- a/backend/internal/handler/auth_handler_linked_test.go +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -70,7 +70,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { }) client := &http.Client{Transport: transport} - + origDefault := http.DefaultClient http.DefaultClient = client defer func() { @@ -87,7 +87,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { }, }, } - + consentRepo := &mockConsentRepo{ consents: []domain.ClientConsent{ { @@ -132,7 +132,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, 3, len(res.Items)) - + statusMap := make(map[string]string) for _, item := range res.Items { statusMap[item.ID] = item.Status @@ -141,4 +141,4 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { assert.Equal(t, "active", statusMap["client-active"]) assert.Equal(t, "inactive", statusMap["client-consent"]) assert.Equal(t, "inactive", statusMap["client-audit"]) -} \ No newline at end of file +} diff --git a/backend/internal/handler/auth_handler_oidc_test.go b/backend/internal/handler/auth_handler_oidc_test.go index 1f5fb069..8ba03109 100644 --- a/backend/internal/handler/auth_handler_oidc_test.go +++ b/backend/internal/handler/auth_handler_oidc_test.go @@ -175,4 +175,4 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) { if gotSubject != "kratos-456" { t.Fatalf("unexpected subject: %v", gotSubject) } -} \ No newline at end of file +} diff --git a/backend/internal/handler/auth_handler_otp_test.go b/backend/internal/handler/auth_handler_otp_test.go index bda8fa47..09f6a191 100644 --- a/backend/internal/handler/auth_handler_otp_test.go +++ b/backend/internal/handler/auth_handler_otp_test.go @@ -14,7 +14,7 @@ import ( func TestHandleKratosCourierRelay_Email(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} emailSvc := &mockEmailService{} - + h := &AuthHandler{ RedisService: redis, EmailService: emailSvc, @@ -35,7 +35,7 @@ func TestHandleKratosCourierRelay_Email(t *testing.T) { body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/kratos/courier", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - + resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) } @@ -67,10 +67,10 @@ func TestVerifySignupCode_Success(t *testing.T) { body, _ := json.Marshal(verifyBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - + resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - + var res map[string]interface{} json.NewDecoder(resp.Body).Decode(&res) assert.True(t, res["success"].(bool)) @@ -91,7 +91,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) { app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode) stateJSON, _ := json.Marshal(map[string]interface{}{ - "code": "111111", + "code": "111111", "expires_at": 9999999999, }) redis.data["signup:email:user@test.com"] = string(stateJSON) @@ -104,7 +104,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) { body, _ := json.Marshal(verifyBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - + resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } diff --git a/backend/internal/handler/auth_handler_qr_test.go b/backend/internal/handler/auth_handler_qr_test.go index 816cf39f..9089a948 100644 --- a/backend/internal/handler/auth_handler_qr_test.go +++ b/backend/internal/handler/auth_handler_qr_test.go @@ -20,7 +20,9 @@ type mockRedisRepo struct { } func (m *mockRedisRepo) Set(key, value string, ttl time.Duration) error { - if m.data == nil { m.data = make(map[string]string) } + if m.data == nil { + m.data = make(map[string]string) + } m.data[key] = value return nil } @@ -43,11 +45,11 @@ func (m *mockRedisRepo) StoreVerificationCode(phone, code string) error { } func (m *mockRedisRepo) GetVerificationCode(phone string) (string, error) { - return m.Get("sms:"+phone) + return m.Get("sms:" + phone) } func (m *mockRedisRepo) DeleteVerificationCode(phone string) error { - return m.Delete("sms:"+phone) + return m.Delete("sms:" + phone) } // --- Tests --- @@ -75,7 +77,7 @@ func TestQRLoginFlow_Success(t *testing.T) { req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) - + // Expect authorization_pending (400) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) var pollResp map[string]interface{} @@ -93,7 +95,7 @@ func TestQRLoginFlow_Success(t *testing.T) { req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) - + assert.Equal(t, http.StatusOK, resp.StatusCode) var successResp map[string]interface{} @@ -129,7 +131,7 @@ func TestScanQRLogin_Success(t *testing.T) { } return httpResponse(r, http.StatusNotFound, "not found"), nil }) - + origDefault := http.DefaultClient http.DefaultClient = &http.Client{Transport: transport} defer func() { http.DefaultClient = origDefault }() @@ -147,7 +149,7 @@ func TestScanQRLogin_Success(t *testing.T) { func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) { h := &AuthHandler{} - + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Header.Get("X-Session-Token") == "token-123" { return httpJSONAny(r, http.StatusOK, map[string]interface{}{ @@ -172,13 +174,13 @@ func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) { } return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil }) - + origDefault := http.DefaultClient http.DefaultClient = &http.Client{Transport: transport} defer func() { http.DefaultClient = origDefault }() app := fiber.New() - + // Token case app.Get("/test-token", func(c *fiber.Ctx) error { subjects, err := h.resolveConsentSubjects(c) diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 4069e356..80c7aff1 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -12,13 +12,13 @@ import ( // --- Mock IDP Provider --- type mockIdpProvider struct { - userExists bool - name string - signInInfo *domain.AuthInfo - issueSession *domain.AuthInfo - verifyCodeInfo *domain.AuthInfo - err error - initiateLinkErr error + userExists bool + name string + signInInfo *domain.AuthInfo + issueSession *domain.AuthInfo + verifyCodeInfo *domain.AuthInfo + err error + initiateLinkErr error } func (m *mockIdpProvider) Name() string { @@ -32,6 +32,7 @@ func (m *mockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) { return ni func (m *mockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { return "mock-user-id", m.err } + func (m *mockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { return m.signInInfo, m.err } @@ -44,20 +45,23 @@ func (m *mockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "valid-sid"}, }, m.err } + func (m *mockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { if m.initiateLinkErr != nil { return nil, m.initiateLinkErr } return &domain.LinkLoginInit{FlowID: "mock-flow-id", Mode: "code"}, m.err } + func (m *mockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { return m.verifyCodeInfo, m.err } -func (m *mockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return nil, m.err } +func (m *mockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return nil, m.err } func (m *mockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return m.err } func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { return nil, m.err } + func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { return m.err } @@ -72,9 +76,11 @@ func (m *mockAuditRepo) Create(log *domain.AuditLog) error { m.logs = append(m.logs, *log) return nil } + func (m *mockAuditRepo) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) { return m.logs, nil } + func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) { var results []domain.AuditLog for _, log := range m.logs { @@ -101,6 +107,7 @@ func (m *mockConsentRepo) Upsert(ctx context.Context, consent *domain.ClientCons m.consents = append(m.consents, *consent) return nil } + func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) { var results []domain.ClientConsent for _, c := range m.consents { @@ -114,6 +121,7 @@ func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { return nil, 0, nil } + func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { return nil, 0, nil } @@ -125,13 +133,17 @@ type mockSecretRepo struct { } func (m *mockSecretRepo) Upsert(ctx context.Context, clientID, secret string) error { - if m.secrets == nil { m.secrets = make(map[string]string) } + 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 @@ -164,4 +176,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 index 27311048..4c491c73 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -20,7 +20,7 @@ func TestListClients_Success(t *testing.T) { {"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 + return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) h := &DevHandler{ @@ -55,7 +55,7 @@ func TestGetClient_Success(t *testing.T) { "metadata": map[string]interface{}{"status": "active"}, }), nil } - return httpJSONAny(r, http.StatusNotFound, map[string]string{"error":"not found"}), nil + return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) h := &DevHandler{ @@ -82,7 +82,7 @@ func TestGetClient_Success(t *testing.T) { 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 + return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) h := &DevHandler{ @@ -109,12 +109,12 @@ func TestCreateClient_Success(t *testing.T) { "client_secret": "secret-123", }), nil } - return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error":"hydra error"}), 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", @@ -127,13 +127,13 @@ func TestCreateClient_Success(t *testing.T) { app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]interface{}{ - "client_name": "New App", - "type": "confidential", + "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)