forked from baron/baron-sso
Hydra 연동 관련 핸들러 테스트 케이스 추가
This commit is contained in:
106
backend/internal/handler/auth_handler_client_test.go
Normal file
106
backend/internal/handler/auth_handler_client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
197
backend/internal/handler/auth_handler_consent_test.go
Normal file
197
backend/internal/handler/auth_handler_consent_test.go
Normal file
@@ -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))
|
||||
}
|
||||
121
backend/internal/handler/auth_handler_link_test.go
Normal file
121
backend/internal/handler/auth_handler_link_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
144
backend/internal/handler/auth_handler_linked_test.go
Normal file
144
backend/internal/handler/auth_handler_linked_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
110
backend/internal/handler/auth_handler_otp_test.go
Normal file
110
backend/internal/handler/auth_handler_otp_test.go
Normal file
@@ -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)
|
||||
}
|
||||
203
backend/internal/handler/auth_handler_qr_test.go
Normal file
203
backend/internal/handler/auth_handler_qr_test.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user