diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go new file mode 100644 index 00000000..65bc7308 --- /dev/null +++ b/backend/internal/handler/auth_handler_client_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +func TestRevokeLinkedRp_Success(t *testing.T) { + // Mock Hydra transport for revocation + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // 1. Kratos whoami + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{"id": "user-123"}, + }), nil + } + // 2. Hydra Revoke + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpResponse(r, http.StatusNoContent, ""), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + AuditRepo: auditRepo, + } + app := fiber.New() + app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil) + req.Header.Set("Cookie", "valid") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, len(auditRepo.logs)) +} + +func TestListRpHistory_Aggregation(t *testing.T) { + now := time.Now() + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.revoked", // Newest + Timestamp: now, + Details: `{"client_id":"app-1"}`, + }, + { + UserID: "user-123", + EventType: "consent.granted", // Oldest + Timestamp: now.Add(-1 * time.Hour), + Details: `{"client_id":"app-1", "client_name":"App One"}`, + }, + }, + } + + h := &AuthHandler{ + AuditRepo: auditRepo, + } + app := fiber.New() + app.Get("/api/v1/user/rp/history", h.ListRpHistory) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{"id": "user-123"}, + }), nil + }) + http.DefaultClient = &http.Client{Transport: transport} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/history", nil) + req.Header.Set("Cookie", "valid") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []struct { + ClientID string `json:"client_id"` + Status string `json:"status"` + } `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, 1, len(res.Items)) + assert.Equal(t, "app-1", res.Items[0].ClientID) + // Newest event (revoked) should win + assert.Equal(t, "revoked", res.Items[0].Status) +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go new file mode 100644 index 00000000..8e6edf12 --- /dev/null +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -0,0 +1,197 @@ +package handler + +import ( + "baron-sso-backend/internal/service" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// --- Test Helpers --- + +func newConsentTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Get("/api/v1/auth/consent", h.GetConsentRequest) + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + return app +} + +// --- Tests --- + +func TestGetConsentRequest_Normal(t *testing.T) { + // Mock Hydra transport + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-123", + "requested_scope": []string{"openid", "profile"}, + "skip": false, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + "client_name": "Test App", + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + app := newConsentTestApp(h) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-123", nil) + resp, err := app.Test(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + + assert.Equal(t, "challenge-123", body["challenge"]) + assert.Equal(t, false, body["skip"]) +} + +func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Hydra: Get Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-skip", + "requested_scope": []string{"openid"}, + "skip": true, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + }, + }), nil + } + // Kratos: Get Identity + if r.URL.Path == "/admin/identities/user-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + }, + }), nil + } + // Hydra: Accept Consent Request + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-skip" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + consentRepo := &mockConsentRepo{} + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: &service.KratosAdminService{ + AdminURL: "http://kratos.test", + HTTPClient: client, + }, + ConsentRepo: consentRepo, + } + + app := newConsentTestApp(h) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil) + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + assert.Equal(t, "http://rp/cb", body["redirectTo"]) +} + +func TestAcceptConsentRequest_Normal(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-accept" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-accept", + "requested_scope": []string{"openid", "profile"}, + "subject": "user-123", + "client": map[string]interface{}{ + "client_id": "client-app", + }, + }), nil + } + if r.URL.Path == "/admin/identities/user-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-accept" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { http.DefaultClient = origDefault }() + + auditRepo := &mockAuditRepo{} + consentRepo := &mockConsentRepo{} + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: &service.KratosAdminService{ + AdminURL: "http://kratos.test", + HTTPClient: client, + }, + AuditRepo: auditRepo, + ConsentRepo: consentRepo, + } + + app := newConsentTestApp(h) + + body, _ := json.Marshal(map[string]interface{}{ + "consent_challenge": "challenge-accept", + "grant_scope": []string{"openid"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Equal(t, 1, len(auditRepo.logs)) +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go new file mode 100644 index 00000000..76139c85 --- /dev/null +++ b/backend/internal/handler/auth_handler_link_test.go @@ -0,0 +1,121 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// 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 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"]) +} diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go new file mode 100644 index 00000000..c80ab12b --- /dev/null +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -0,0 +1,144 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// --- Helper --- + +func newLinkedRpTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Get("/api/v1/user/rp/linked", h.ListLinkedRps) + return app +} + +// --- Tests --- + +func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + if r.Header.Get("X-Session-Token") == "" && r.Header.Get("Cookie") == "" { + return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil + } + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@test.com", + }, + }, + }), nil + } + case "hydra.test": + if r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + { + "client": map[string]interface{}{ + "client_id": "client-active", + "client_name": "Active App", + }, + "granted_scope": []string{"openid"}, + "handled_at": time.Now().Format(time.RFC3339), + }, + }), nil + } + if r.URL.Path == "/admin/clients/client-audit" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-audit", + "client_name": "Audit App", + }), nil + } + if r.URL.Path == "/admin/clients/client-consent" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-consent", + "client_name": "Consent App", + }), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { + http.DefaultClient = origDefault + }() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + Timestamp: time.Now().Add(-10 * time.Hour), + Details: `{"client_id":"client-audit", "scopes":["audit_scope"]}`, + }, + }, + } + + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + { + Subject: "user-123", + ClientID: "client-consent", + GrantedScopes: []string{"consent_scope"}, + UpdatedAt: time.Now().Add(-2 * time.Hour), + }, + }, + } + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + AuditRepo: auditRepo, + ConsentRepo: consentRepo, + KratosAdmin: &service.KratosAdminService{}, + } + + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + + app := newLinkedRpTestApp(h) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Scopes []string `json:"scopes"` + } `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, 3, len(res.Items)) + + statusMap := make(map[string]string) + for _, item := range res.Items { + statusMap[item.ID] = item.Status + } + + assert.Equal(t, "active", statusMap["client-active"]) + assert.Equal(t, "inactive", statusMap["client-consent"]) + assert.Equal(t, "inactive", statusMap["client-audit"]) +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_oidc_test.go b/backend/internal/handler/auth_handler_oidc_test.go index 828e8e39..1f5fb069 100644 --- a/backend/internal/handler/auth_handler_oidc_test.go +++ b/backend/internal/handler/auth_handler_oidc_test.go @@ -33,7 +33,7 @@ func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) { if r.Header.Get("Cookie") == "" { return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil } - return httpJSON(r, http.StatusOK, map[string]interface{}{ + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "identity": map[string]interface{}{ "id": "kratos-123", "traits": map[string]interface{}{}, @@ -117,7 +117,7 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) { if r.Header.Get("Cookie") == "" { return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil } - return httpJSON(r, http.StatusOK, map[string]interface{}{ + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "identity": map[string]interface{}{ "id": "kratos-456", "traits": map[string]interface{}{}, @@ -175,26 +175,4 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) { if gotSubject != "kratos-456" { t.Fatalf("unexpected subject: %v", gotSubject) } -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func httpResponse(req *http.Request, status int, body string) *http.Response { - return &http.Response{ - StatusCode: status, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewBufferString(body)), - Request: req, - } -} - -func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response { - data, _ := json.Marshal(payload) - resp := httpResponse(req, status, string(data)) - resp.Header.Set("Content-Type", "application/json") - return resp -} +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler_otp_test.go b/backend/internal/handler/auth_handler_otp_test.go new file mode 100644 index 00000000..bda8fa47 --- /dev/null +++ b/backend/internal/handler/auth_handler_otp_test.go @@ -0,0 +1,110 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +func TestHandleKratosCourierRelay_Email(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + emailSvc := &mockEmailService{} + + h := &AuthHandler{ + RedisService: redis, + EmailService: emailSvc, + } + app := fiber.New() + app.Post("/api/v1/auth/kratos/courier", h.HandleKratosCourierRelay) + + // Simulate Kratos Courier Request for Email + reqBody := map[string]interface{}{ + "recipient": "user@example.com", + "template_type": "verification_code", + "template_data": map[string]interface{}{ + "verification_code": "123456", + }, + "subject": "Verify your email", + "body": "Your code is 123456", + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/kratos/courier", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestVerifySignupCode_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode) + + // Mock stored code in redis + // signup:email:user@test.com -> {"code":"654321", "verified":false, "expires_at":...} + state := map[string]interface{}{ + "code": "654321", + "verified": false, + "expires_at": 9999999999, // far future + } + stateJSON, _ := json.Marshal(state) + redis.data["signup:email:user@test.com"] = string(stateJSON) + + // Verify Code + verifyBody := map[string]string{ + "type": "email", + "target": "user@test.com", + "code": "654321", + } + body, _ := json.Marshal(verifyBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res map[string]interface{} + json.NewDecoder(resp.Body).Decode(&res) + assert.True(t, res["success"].(bool)) + + // Check redis state updated to verified + val, _ := redis.Get("signup:email:user@test.com") + var updatedState map[string]interface{} + json.Unmarshal([]byte(val), &updatedState) + assert.True(t, updatedState["verified"].(bool)) +} + +func TestVerifySignupCode_Invalid(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/signup/verify", h.VerifySignupCode) + + stateJSON, _ := json.Marshal(map[string]interface{}{ + "code": "111111", + "expires_at": 9999999999, + }) + redis.data["signup:email:user@test.com"] = string(stateJSON) + + verifyBody := map[string]string{ + "type": "email", + "target": "user@test.com", + "code": "000000", // wrong code + } + body, _ := json.Marshal(verifyBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/backend/internal/handler/auth_handler_qr_test.go b/backend/internal/handler/auth_handler_qr_test.go new file mode 100644 index 00000000..816cf39f --- /dev/null +++ b/backend/internal/handler/auth_handler_qr_test.go @@ -0,0 +1,203 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +// --- Mock Redis --- + +type mockRedisRepo struct { + data map[string]string +} + +func (m *mockRedisRepo) Set(key, value string, ttl time.Duration) error { + if m.data == nil { m.data = make(map[string]string) } + m.data[key] = value + return nil +} + +func (m *mockRedisRepo) Get(key string) (string, error) { + // Bypass rate limiting for tests + if strings.HasPrefix(key, "poll_meta:") { + return "", nil + } + return m.data[key], nil +} + +func (m *mockRedisRepo) Delete(key string) error { + delete(m.data, key) + return nil +} + +func (m *mockRedisRepo) StoreVerificationCode(phone, code string) error { + return m.Set("sms:"+phone, code, time.Minute) +} + +func (m *mockRedisRepo) GetVerificationCode(phone string) (string, error) { + return m.Get("sms:"+phone) +} + +func (m *mockRedisRepo) DeleteVerificationCode(phone string) error { + return m.Delete("sms:"+phone) +} + +// --- Tests --- + +func TestQRLoginFlow_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + h := &AuthHandler{ + RedisService: redis, + } + app := fiber.New() + app.Post("/api/v1/auth/qr/init", h.InitQRLogin) + app.Post("/api/v1/auth/qr/poll", h.PollQRLogin) + + // 1. Init QR Login + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/init", nil) + 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) + + // 2. Poll (Pending) + body, _ := json.Marshal(map[string]string{"pendingRef": pendingRef}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + + // Expect authorization_pending (400) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + var pollResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&pollResp) + assert.Equal(t, "authorization_pending", pollResp["error"]) + + // 3. Mock Approval + sessionData, _ := json.Marshal(map[string]string{ + "status": "success", + "jwt": "mock-session-jwt", + }) + redis.data["enchanted_session:"+pendingRef] = string(sessionData) + + // 4. Poll (Success) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var successResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&successResp) + assert.Equal(t, "ok", successResp["status"]) + assert.Equal(t, "mock-session-jwt", successResp["sessionJwt"]) +} + +func TestScanQRLogin_Success(t *testing.T) { + redis := &mockRedisRepo{data: make(map[string]string)} + idp := &mockIdpProvider{userExists: true} + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := fiber.New() + app.Post("/api/v1/auth/qr/approve", h.ScanQRLogin) + + pendingRef := "test-ref" + redis.data["enchanted_session:"+pendingRef] = `{"status":"pending"}` + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-123", + "traits": map[string]interface{}{ + "email": "user@example.com", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + origDefault := http.DefaultClient + http.DefaultClient = &http.Client{Transport: transport} + defer func() { http.DefaultClient = origDefault }() + + body, _ := json.Marshal(map[string]string{ + "pendingRef": pendingRef, + "token": "valid-token", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/qr/approve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestResolveConsentSubjects_TokenAndCookie(t *testing.T) { + h := &AuthHandler{} + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("X-Session-Token") == "token-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-token", + "traits": map[string]interface{}{ + "email": "token@test.com", + }, + }, + }), nil + } + if r.Header.Get("Cookie") == "ory_kratos_session=cookie-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-cookie", + "traits": map[string]interface{}{ + "email": "cookie@test.com", + "phone": "010-1234-5678", + }, + }, + }), nil + } + return httpResponse(r, http.StatusUnauthorized, "unauthorized"), nil + }) + + origDefault := http.DefaultClient + http.DefaultClient = &http.Client{Transport: transport} + defer func() { http.DefaultClient = origDefault }() + + app := fiber.New() + + // Token case + app.Get("/test-token", func(c *fiber.Ctx) error { + subjects, err := h.resolveConsentSubjects(c) + assert.NoError(t, err) + assert.Contains(t, subjects, "user-token") + return c.SendStatus(200) + }) + req := httptest.NewRequest("GET", "/test-token", nil) + req.Header.Set("Authorization", "Bearer token-123") + app.Test(req, -1) + + // Cookie case + app.Get("/test-cookie", func(c *fiber.Ctx) error { + subjects, err := h.resolveConsentSubjects(c) + assert.NoError(t, err) + assert.Contains(t, subjects, "user-cookie") + return c.SendStatus(200) + }) + req = httptest.NewRequest("GET", "/test-cookie", nil) + req.Header.Set("Cookie", "ory_kratos_session=cookie-123") + app.Test(req, -1) +}