forked from baron/baron-sso
백엔드 핸들러 OIDC/Hydra 흐름 검즘
This commit is contained in:
287
backend/internal/handler/auth_handler_login_test.go
Normal file
287
backend/internal/handler/auth_handler_login_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user