package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "baron-sso-backend/internal/testsupport" "bytes" "encoding/json" "io" "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 { lastTo string lastSubject string lastBody string } func (m *mockEmailService) SendEmail(to, subject, body string) error { m.lastTo = to m.lastSubject = subject m.lastBody = body return nil } type mockSmsService struct { lastTo string lastContent string } func (m *mockSmsService) SendSms(to, content string) error { m.lastTo = to m.lastContent = content 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 TestResolveUserfrontURL_DevLocalhostUsesConfiguredPort(t *testing.T) { t.Setenv("APP_ENV", "dev") t.Setenv("USERFRONT_URL", "http://localhost:5000") h := &AuthHandler{} app := fiber.New() app.Get("/probe", func(c *fiber.Ctx) error { return c.SendString(h.resolveUserfrontURL(c)) }) req := httptest.NewRequest(http.MethodGet, "http://localhost/probe", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) assert.Equal(t, "http://localhost:5000", string(body)) } func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) { redis := &mockRedisRepo{data: map[string]string{ prefixLoginCode + "su-@samaneng.com": "flow-123", prefixLoginCodePending + "su-@samaneng.com": "pending-123", prefixLoginCodeSmsLookup + "+821041585840": "su-@samaneng.com", prefixLoginCodeSmsTarget + "su-@samaneng.com": "+821041585840", prefixLoginCodeValue + "pending-123": "569765", }} h := &AuthHandler{ RedisService: redis, IdpProvider: &mockIdpProvider{}, } app := fiber.New() app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) body, _ := json.Marshal(map[string]interface{}{ "loginId": "01041585840", "code": "569765", "pendingRef": "pending-123", "verifyOnly": true, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", 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, "approved", got["status"]) assert.Equal(t, "pending-123", got["pendingRef"]) } 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_HeadlessLoginClientSuccess(t *testing.T) { if !testsupport.PortBindingAvailable() { t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") } redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(jwksBody) })) defer jwksServer.Close() 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: "headless-login-client", TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", }, }, }) 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": "headless-login-client", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "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) { if !testsupport.PortBindingAvailable() { t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") } redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) jwksBody, _ := json.Marshal(jwks) 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: "headless-login-client", ClientName: "local-demo-rp", TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }, }) 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) auditRepo := &mockAuditRepo{} headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" { return httpResponse(r, http.StatusOK, string(jwksBody)), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil })} h := &AuthHandler{ RedisService: redis, IdpProvider: idp, SmsService: &mockSmsService{}, KratosAdmin: mockKratos, AuditRepo: auditRepo, HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient), 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": "headless-login-client", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "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": "headless-login-client", "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "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"]) if assert.Len(t, auditRepo.logs, 1) { assert.Contains(t, auditRepo.logs[0].EventType, "/api/v1/auth/") details, err := parseAuditDetails(auditRepo.logs[0].Details) if err != nil { t.Fatalf("failed to parse audit details: %v", err) } assert.Equal(t, "headless-login-client", details["client_id"]) assert.Equal(t, "local-demo-rp", details["client_name"]) assert.Equal(t, "challenge-123", details["login_challenge"]) } }