/* 이 테스트 파일은 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" "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 { mock.Mock } func (m *MockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { args := m.Called(ctx, identifier) return args.String(0), args.Error(1) } func (m *MockKratosAdminService) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*service.KratosIdentity), args.Error(1) } func (m *MockKratosAdminService) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) { return nil, nil } func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { return nil, nil } func (m *MockKratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { return nil } func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error { return 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) } }) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } 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]interface{} json.NewDecoder(resp.Body).Decode(&got) if got["redirectTo"] != "http://rp/cb" { t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) } if _, ok := got["sessionJwt"]; ok { t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"]) } } 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) }) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } 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) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: mockKratos, Hydra: service.NewHydraAdminService(), } 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"]) } } func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다")) h := &AuthHandler{ IdpProvider: mockIdp, KratosAdmin: service.NewKratosAdminService(), Hydra: service.NewHydraAdminService(), } app := newAuthLoginTestApp(h) body, _ := json.Marshal(map[string]string{ "loginId": "user@example.com", "password": "wrong-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.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp.StatusCode) } var got map[string]any if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } if got["code"] != "password_or_email_mismatch" { t.Fatalf("expected code=password_or_email_mismatch, got=%v", got["code"]) } if got["error"] != "Invalid credentials" { t.Fatalf("expected error=Invalid credentials, got=%v", got["error"]) } }