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"]) + } +}