1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/auth_handler_login_test.go

299 lines
9.0 KiB
Go

/*
이 테스트 파일은 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"])
}
}