forked from baron/baron-sso
344 lines
10 KiB
Go
344 lines
10 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"
|
|
"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"])
|
|
}
|
|
}
|