package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) // Mock services type mockEmailService struct{} func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil } type mockSmsService struct{} func (m *mockSmsService) SendSms(to, content string) error { return nil } func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/auth/headless/link/init", h.HeadlessLinkInit) app.Post("/api/v1/auth/headless/link/poll", h.HeadlessLinkPoll) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) return app } func TestEnchantedLinkFlow_Email_Success(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} // Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } h := &AuthHandler{ RedisService: redis, IdpProvider: idp, EmailService: &mockEmailService{}, SmsService: &mockSmsService{}, } app := fiber.New() app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink) app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) t.Setenv("USERFRONT_URL", "http://userfront.test") // 1. Init Enchanted Link (Email) body, _ := json.Marshal(map[string]string{ "loginId": "user@example.com", "method": "email", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var initResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&initResp) pendingRef := initResp["pendingRef"].(string) assert.NotEmpty(t, pendingRef) // Find the token key "enchanted_token:..." in mock redis var token string for k := range redis.data { if len(k) > 16 && k[:16] == "enchanted_token:" { token = k[16:] break } } assert.NotEmpty(t, token) // 2. Verify Magic Link verifyBody, _ := json.Marshal(map[string]interface{}{ "token": token, "verifyOnly": true, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) // 3. Poll (Success) pollBody, _ := json.Marshal(map[string]string{"pendingRef": pendingRef}) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(pollBody)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var pollResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&pollResp) assert.Equal(t, "ok", pollResp["status"]) assert.Equal(t, "valid-jwt", pollResp["sessionJwt"]) } func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } h := &AuthHandler{ RedisService: redis, IdpProvider: idp, SmsService: &mockSmsService{}, } app := fiber.New() app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink) // 1. Init Enchanted Link (SMS) body, _ := json.Marshal(map[string]string{ "loginId": "010-1234-5678", "method": "sms", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var initResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&initResp) assert.NotEmpty(t, initResp["userCode"]) } func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} h := &AuthHandler{ RedisService: redis, } app := fiber.New() app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) body, _ := json.Marshal(map[string]string{ "pendingRef": "missing-ref", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]interface{} json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, "expired_token", got["error"]) assert.Equal(t, "expired_token", got["code"]) } func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } 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{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", TokenEndpointAuthMethod: "private_key_jwt", JWKS: jwks, Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, }, }, }) return } http.NotFound(w, r) }) h := &AuthHandler{ RedisService: redis, IdpProvider: idp, SmsService: &mockSmsService{}, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessLinkTestApp(h) t.Setenv("USERFRONT_URL", "http://userfront.test") body, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), "loginId": "010-1234-5678", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&got) assert.NotEmpty(t, got["pendingRef"]) _, hasUserCode := got["userCode"] assert.False(t, hasUserCode) } func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } 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: _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", TokenEndpointAuthMethod: "private_key_jwt", JWKS: jwks, Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, }, }, }) return case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) return } http.NotFound(w, r) }) mockKratos := new(MockKratosAdminService) mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-identity-id", nil) h := &AuthHandler{ RedisService: redis, IdpProvider: idp, SmsService: &mockSmsService{}, KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessLinkTestApp(h) t.Setenv("USERFRONT_URL", "http://userfront.test") initBody, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), "loginId": "010-1234-5678", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(initBody)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var initResp map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&initResp) pendingRef := initResp["pendingRef"].(string) assert.NotEmpty(t, pendingRef) var token string for k := range redis.data { if len(k) > 16 && k[:16] == "enchanted_token:" { token = k[16:] break } } assert.NotEmpty(t, token) verifyBody, _ := json.Marshal(map[string]interface{}{ "token": token, "verifyOnly": true, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) pollBody, _ := json.Marshal(map[string]string{ "client_id": "trusted-rp", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"), "pendingRef": pendingRef, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var pollResp map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&pollResp) assert.Equal(t, "http://rp/cb", pollResp["redirectTo"]) assert.Equal(t, "ok", pollResp["status"]) }