forked from baron/baron-sso
Merge pull request 'feature/hydra-content' (#226) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#226
This commit is contained in:
7
.gitea/coverage.json
Normal file
7
.gitea/coverage.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"Path": "./backend/coverage.out",
|
||||||
|
"Thresholds": {
|
||||||
|
"baron-sso-backend/internal/handler": 90,
|
||||||
|
"baron-sso-backend/internal/service": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
29
.gitea/workflows/backend_coverage_check.yml
Normal file
29
.gitea/workflows/backend_coverage_check.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Backend Test Coverage Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
working-directory: ./backend
|
||||||
|
run: |
|
||||||
|
go test -v -coverprofile=coverage.out -covermode=atomic ./internal/handler/... ./internal/service/...
|
||||||
|
|
||||||
|
- name: Check coverage
|
||||||
|
uses: vladopajic/go-test-coverage@v2
|
||||||
|
with:
|
||||||
|
config: ./.gitea/coverage.json
|
||||||
@@ -32,3 +32,13 @@ type AuditCursor struct {
|
|||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
EventID string
|
EventID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RedisRepository defines interface for KV storage (Redis)
|
||||||
|
type RedisRepository interface {
|
||||||
|
Set(key string, value string, expiration time.Duration) error
|
||||||
|
Get(key string) (string, error)
|
||||||
|
Delete(key string) error
|
||||||
|
StoreVerificationCode(phone, code string) error
|
||||||
|
GetVerificationCode(phone string) (string, error)
|
||||||
|
DeleteVerificationCode(phone string) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const (
|
|||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
SmsService domain.SmsService
|
SmsService domain.SmsService
|
||||||
EmailService domain.EmailService
|
EmailService domain.EmailService
|
||||||
RedisService *service.RedisService
|
RedisService domain.RedisRepository
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
IdpProvider domain.IdentityProvider
|
IdpProvider domain.IdentityProvider
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
@@ -132,7 +132,7 @@ func GenerateUserCode() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPollInterval(redis *service.RedisService, key string, interval time.Duration) (bool, int) {
|
func checkPollInterval(redis domain.RedisRepository, key string, interval time.Duration) (bool, int) {
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
val, err := redis.Get(key)
|
val, err := redis.Get(key)
|
||||||
if err == nil && val != "" {
|
if err == nil && val != "" {
|
||||||
@@ -147,7 +147,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
|||||||
return false, int(interval.Seconds())
|
return false, int(interval.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
|
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
SmsService: service.NewSmsService(),
|
SmsService: service.NewSmsService(),
|
||||||
EmailService: service.NewEmailService(),
|
EmailService: service.NewEmailService(),
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
123
backend/internal/handler/auth_handler_link_test.go
Normal file
123
backend/internal/handler/auth_handler_link_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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") == "" {
|
if r.Header.Get("Cookie") == "" {
|
||||||
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
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{}{
|
"identity": map[string]interface{}{
|
||||||
"id": "kratos-123",
|
"id": "kratos-123",
|
||||||
"traits": map[string]interface{}{},
|
"traits": map[string]interface{}{},
|
||||||
@@ -117,7 +117,7 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
|
|||||||
if r.Header.Get("Cookie") == "" {
|
if r.Header.Get("Cookie") == "" {
|
||||||
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
|
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{}{
|
"identity": map[string]interface{}{
|
||||||
"id": "kratos-456",
|
"id": "kratos-456",
|
||||||
"traits": map[string]interface{}{},
|
"traits": map[string]interface{}{},
|
||||||
@@ -176,25 +176,3 @@ func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
|
|||||||
t.Fatalf("unexpected subject: %v", gotSubject)
|
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)
|
||||||
|
}
|
||||||
205
backend/internal/handler/auth_handler_qr_test.go
Normal file
205
backend/internal/handler/auth_handler_qr_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
179
backend/internal/handler/common_test.go
Normal file
179
backend/internal/handler/common_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mock IDP Provider ---
|
||||||
|
|
||||||
|
type mockIdpProvider struct {
|
||||||
|
userExists bool
|
||||||
|
name string
|
||||||
|
signInInfo *domain.AuthInfo
|
||||||
|
issueSession *domain.AuthInfo
|
||||||
|
verifyCodeInfo *domain.AuthInfo
|
||||||
|
err error
|
||||||
|
initiateLinkErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIdpProvider) Name() string {
|
||||||
|
if m.name != "" {
|
||||||
|
return m.name
|
||||||
|
}
|
||||||
|
return "mock-idp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIdpProvider) GetMetadata() (*domain.IDPMetadata, error) { return nil, m.err }
|
||||||
|
func (m *mockIdpProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||||
|
return "mock-user-id", m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIdpProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||||
|
return m.signInInfo, m.err
|
||||||
|
}
|
||||||
|
func (m *mockIdpProvider) UserExists(loginID string) (bool, error) { return m.userExists, m.err }
|
||||||
|
func (m *mockIdpProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||||
|
if m.issueSession != nil {
|
||||||
|
return m.issueSession, m.err
|
||||||
|
}
|
||||||
|
return &domain.AuthInfo{
|
||||||
|
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "valid-sid"},
|
||||||
|
}, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIdpProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||||
|
if m.initiateLinkErr != nil {
|
||||||
|
return nil, m.initiateLinkErr
|
||||||
|
}
|
||||||
|
return &domain.LinkLoginInit{FlowID: "mock-flow-id", Mode: "code"}, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIdpProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||||
|
return m.verifyCodeInfo, m.err
|
||||||
|
}
|
||||||
|
func (m *mockIdpProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return nil, m.err }
|
||||||
|
func (m *mockIdpProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return m.err }
|
||||||
|
func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Audit Repository ---
|
||||||
|
|
||||||
|
type mockAuditRepo struct {
|
||||||
|
logs []domain.AuditLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAuditRepo) Create(log *domain.AuditLog) error {
|
||||||
|
m.logs = append(m.logs, *log)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAuditRepo) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) {
|
||||||
|
return m.logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAuditRepo) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) {
|
||||||
|
var results []domain.AuditLog
|
||||||
|
for _, log := range m.logs {
|
||||||
|
if log.UserID == userID {
|
||||||
|
for _, et := range eventTypes {
|
||||||
|
if log.EventType == et {
|
||||||
|
results = append(results, log)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// --- Mock Consent Repository ---
|
||||||
|
|
||||||
|
type mockConsentRepo struct {
|
||||||
|
consents []domain.ClientConsent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
|
||||||
|
m.consents = append(m.consents, *consent)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
|
||||||
|
var results []domain.ClientConsent
|
||||||
|
for _, c := range m.consents {
|
||||||
|
if c.Subject == subject {
|
||||||
|
results = append(results, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil }
|
||||||
|
func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock Secret Repository ---
|
||||||
|
|
||||||
|
type mockSecretRepo struct {
|
||||||
|
secrets map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSecretRepo) Upsert(ctx context.Context, clientID, secret string) error {
|
||||||
|
if m.secrets == nil {
|
||||||
|
m.secrets = make(map[string]string)
|
||||||
|
}
|
||||||
|
m.secrets[clientID] = secret
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSecretRepo) GetByID(ctx context.Context, clientID string) (string, error) {
|
||||||
|
return m.secrets[clientID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSecretRepo) Delete(ctx context.Context, clientID string) error {
|
||||||
|
delete(m.secrets, clientID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP Mock Helpers ---
|
||||||
|
|
||||||
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpResponse(r *http.Request, code int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: code,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||||
|
Request: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpJSONAny(r *http.Request, code int, data any) *http.Response {
|
||||||
|
body, _ := json.Marshal(data)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: code,
|
||||||
|
Header: http.Header{
|
||||||
|
"Content-Type": []string{"application/json"},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(bytes.NewBuffer(body)),
|
||||||
|
Request: r,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,13 @@ import (
|
|||||||
|
|
||||||
type DevHandler struct {
|
type DevHandler struct {
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
Redis *service.RedisService
|
Redis domain.RedisRepository
|
||||||
SecretRepo domain.ClientSecretRepository
|
SecretRepo domain.ClientSecretRepository
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDevHandler(redis *service.RedisService, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
|
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
|
||||||
return &DevHandler{
|
return &DevHandler{
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
|
|||||||
142
backend/internal/handler/dev_handler_test.go
Normal file
142
backend/internal/handler/dev_handler_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListClients_Success(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/clients" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
|
{"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}},
|
||||||
|
{"client_id": "client-2", "client_name": "App Two", "metadata": map[string]interface{}{"status": "inactive"}},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Items []clientSummary `json:"items"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
assert.Equal(t, 2, len(res.Items))
|
||||||
|
assert.Equal(t, "client-1", res.Items[0].ID)
|
||||||
|
assert.Equal(t, "App One", res.Items[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClient_Success(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path == "/clients/client-123" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": "client-123",
|
||||||
|
"client_name": "Test App",
|
||||||
|
"metadata": map[string]interface{}{"status": "active"},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
PublicURL: "http://hydra-public.test", // PublicURL 추가
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res clientDetailResponse
|
||||||
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
assert.Equal(t, "client-123", res.Client.ID)
|
||||||
|
assert.Equal(t, "Test App", res.Client.Name)
|
||||||
|
assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClient_NotFound(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateClient_Success(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||||
|
return httpJSONAny(r, http.StatusCreated, map[string]interface{}{
|
||||||
|
"client_id": "new-client-123",
|
||||||
|
"client_name": "New App",
|
||||||
|
"client_secret": "secret-123",
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||||
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
||||||
|
|
||||||
|
h := &DevHandler{
|
||||||
|
Hydra: &service.HydraAdminService{
|
||||||
|
AdminURL: "http://hydra.test",
|
||||||
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
|
},
|
||||||
|
SecretRepo: secretRepo,
|
||||||
|
Redis: redisRepo,
|
||||||
|
}
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"client_name": "New App",
|
||||||
|
"type": "confidential",
|
||||||
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req, -1)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
secret, _ := secretRepo.GetByID(nil, "new-client-123")
|
||||||
|
assert.Equal(t, "secret-123", secret)
|
||||||
|
}
|
||||||
45
docs/frontend_hydra_testing_guide.md
Normal file
45
docs/frontend_hydra_testing_guide.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Frontend 기능과 백엔드 테스트 매핑 가이드
|
||||||
|
|
||||||
|
이 문서는 `devfront`와 `userfront`의 Hydra 관련 기능이 백엔드의 어떤 API를 호출하고, 해당 API가 어떤 테스트 코드로 검증되는지 설명합니다. 모든 기능은 백엔드에 이미 구현되어 있으며, '테스트' 열은 해당 기능을 검증하는 자동화 테스트의 존재 여부를 나타냅니다.
|
||||||
|
|
||||||
|
## 1. `devfront` (개발자/관리자 포털)
|
||||||
|
|
||||||
|
`devfront`는 OAuth2 클라이언트(RP)를 생성하고 관리하는 데 사용됩니다.
|
||||||
|
|
||||||
|
| `devfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **클라이언트 목록 조회** | `GET /api/v1/dev/clients` | `dev_handler_test.go` | `TestListClients_Success` |
|
||||||
|
| **클라이언트 생성** | `POST /api/v1/dev/clients` | `dev_handler_test.go` | `TestCreateClient_Success` |
|
||||||
|
| **클라이언트 상세 조회** | `GET /api/v1/dev/clients/:id` | `dev_handler_test.go` | `TestGetClient_Success`, `TestGetClient_NotFound` |
|
||||||
|
| **클라이언트 정보 수정** | `PUT /api/v1/dev/clients/:id` | - | (테스트 미작성) |
|
||||||
|
| **클라이언트 상태 변경** | `PATCH /api/v1/dev/clients/:id/status`| - | (테스트 미작성) |
|
||||||
|
| **클라이언트 삭제** | `DELETE /api/v1/dev/clients/:id` | - | (테스트 미작성) |
|
||||||
|
| **시크릿 재발급** | `POST /api/v1/dev/clients/:id/rotate-secret`| - | (테스트 미작성) |
|
||||||
|
| **동의한 사용자 목록 조회**| `GET /api/v1/dev/consents` | - | (테스트 미작성) |
|
||||||
|
| **사용자 동의 철회** | `DELETE /api/v1/dev/consents` | - | (테스트 미작성) |
|
||||||
|
|
||||||
|
*참고: `dev_handler.go` 내의 기능들은 백엔드에 구현되어 있으나, 이번 커버리지 90% 달성 목표(핵심 인증 로직 중심)에서 관리자 기능으로 분류되어 우선순위가 조정되었습니다.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `userfront` (사용자 포털)
|
||||||
|
|
||||||
|
`userfront`는 최종 사용자가 애플리케이션(RP)의 정보 접근 요청을 승인하거나 거부하는 OIDC 동의 화면 및 연동 관리를 처리합니다.
|
||||||
|
|
||||||
|
### 2.1. OIDC 동의 (Consent) 및 연동 관리
|
||||||
|
| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **동의 정보 조회** | `GET /api/v1/auth/consent` | `auth_handler_consent_test.go` | `TestGetConsentRequest_Normal` |
|
||||||
|
| **동의 승인** | `POST /api/v1/auth/consent/accept` | `auth_handler_consent_test.go` | `TestAcceptConsentRequest_Normal` |
|
||||||
|
| **동의 거부** | `POST /api/v1/auth/consent/reject` | - | (테스트 미작성) |
|
||||||
|
| **연동된 앱 목록 조회** | `GET /api/v1/user/rp/linked` | `auth_handler_linked_test.go` | `TestListLinkedRps_PriorityAndAggregation` |
|
||||||
|
| **연동 해제 (Revoke)** | `DELETE /api/v1/user/rp/linked/:id`| `auth_handler_client_test.go` | `TestRevokeLinkedRp_Success` |
|
||||||
|
| **연동 이력 조회** | `GET /api/v1/user/rp/history` | `auth_handler_client_test.go` | `TestListRpHistory_Aggregation` |
|
||||||
|
|
||||||
|
### 2.2. 인증 플로우 (Login Flows)
|
||||||
|
| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **QR 로그인 초기화** | `POST /api/v1/auth/qr/init` | `auth_handler_qr_test.go` | `TestQRLoginFlow_Success` |
|
||||||
|
| **QR 로그인 승인 (Scan)** | `POST /api/v1/auth/qr/approve` | `auth_handler_qr_test.go` | `TestScanQRLogin_Success` |
|
||||||
|
| **매직 링크 초기화** | `POST /api/v1/auth/enchanted-link/init`| `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` |
|
||||||
|
| **매직 링크 검증** | `POST /api/v1/auth/magic-link/verify` | `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` |
|
||||||
144
docs/hydra_be_test_guide.md
Normal file
144
docs/hydra_be_test_guide.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Backend Hydra Test Guide
|
||||||
|
|
||||||
|
이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다.
|
||||||
|
|
||||||
|
## 1. 테스트 개요
|
||||||
|
|
||||||
|
백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다.
|
||||||
|
본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다.
|
||||||
|
|
||||||
|
## 2. 테스트 환경 준비
|
||||||
|
|
||||||
|
테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 테스트 파일 목록 및 실행 방법
|
||||||
|
|
||||||
|
Hydra와 직접적으로 연관된 백엔드 로직 테스트는 `internal/handler`와 `internal/service` 패키지에 집중되어 있습니다.
|
||||||
|
|
||||||
|
### 3.1. 핸들러 레벨 통합 테스트 (Handler Level)
|
||||||
|
사용자 요청(HTTP)부터 Hydra 연동까지의 전체 흐름을 검증합니다.
|
||||||
|
|
||||||
|
* **주요 파일:**
|
||||||
|
* `backend/internal/handler/auth_handler_consent_test.go`
|
||||||
|
* `backend/internal/handler/auth_handler_link_test.go`
|
||||||
|
* `backend/internal/handler/auth_handler_login_test.go`
|
||||||
|
* `backend/internal/handler/auth_handler_qr_test.go`
|
||||||
|
* `backend/internal/handler/auth_handler_client_test.go`
|
||||||
|
* **실행 (패키지 전체):**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test -v ./internal/handler/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. 서비스 레벨 단위 테스트 (Service Level)
|
||||||
|
백엔드 내부에서 Ory Hydra Admin API와 직접 통신하는 서비스의 단위 기능을 검증합니다.
|
||||||
|
|
||||||
|
* **주요 파일:** `backend/internal/service/hydra_admin_service_test.go`
|
||||||
|
* **실행:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test -v ./internal/service -run TestHydraAdminService
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3. Relying Party Service 테스트
|
||||||
|
`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다.
|
||||||
|
|
||||||
|
* **위치:** `backend/internal/service/relying_party_service_test.go`
|
||||||
|
* **실행:**
|
||||||
|
```bash
|
||||||
|
go test -v ./internal/service -run TestRelyingPartyService
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4. 전체 테스트 실행 (권장)
|
||||||
|
모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v ./internal/service ./internal/handler
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 테스트 커버리지 측정
|
||||||
|
|
||||||
|
`internal/handler` 패키지에 대한 커버리지를 측정하고 90% 임계값을 확인합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 커버리지 측정 및 coverage.out 파일 생성
|
||||||
|
cd backend
|
||||||
|
go test -coverprofile=coverage.out ./internal/handler
|
||||||
|
|
||||||
|
# 2. 함수별 커버리지 확인 (CLI)
|
||||||
|
go tool cover -func=coverage.out
|
||||||
|
|
||||||
|
# 3. 상세 리포트 확인 (HTML)
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 주요 테스트 항목 (Checklist)
|
||||||
|
|
||||||
|
| 분류 | 핸들러/메서드 | 테스트 내용 | 파일 위치 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **핸들러: 인증 흐름** | `GetConsentRequest` | Consent Challenge 검증 및 자동 승인(`skip=true`) 처리 | `auth_handler_consent_test.go` |
|
||||||
|
| | `AcceptConsentRequest` | 사용자가 동의한 Scope 기반으로 Consent 승인 | `auth_handler_consent_test.go` |
|
||||||
|
| | `PasswordLogin` | OIDC 로그인 성공 및 비활성 클라이언트 차단 검증 | `auth_handler_login_test.go` |
|
||||||
|
| | `AcceptOidcLoginRequest` | 쿠키/토큰 기반 OIDC 로그인 요청 승인 | `auth_handler_oidc_test.go` |
|
||||||
|
| | `Init/Poll/ScanQRLogin` | QR 코드 생성, 폴링, 승인으로 이어지는 전체 흐름 | `auth_handler_qr_test.go` |
|
||||||
|
| | `Init/Verify/PollEnchantedLink` | Magic Link 생성, 검증, 세션 발행으로 이어지는 전체 흐름 | `auth_handler_link_test.go`|
|
||||||
|
| | `HandleKratosCourierRelay` | Kratos의 OTP 발송 요청(Email/SMS) 수신 및 처리 | `auth_handler_otp_test.go` |
|
||||||
|
| **핸들러: 세션/RP 관리** | `ListLinkedRps` | Hydra 세션, 로컬 DB, Audit Log 3-way 병합 로직 | `auth_handler_linked_test.go` |
|
||||||
|
| | `RevokeLinkedRp` | 특정 RP(클라이언트) 연동 해제 및 세션 종료 | `auth_handler_client_test.go` |
|
||||||
|
| | `ListRpHistory` | Audit Log 기반의 RP 연동 이력 조회 | `auth_handler_client_test.go` |
|
||||||
|
| | `resolveConsentSubjects` | 토큰/쿠키에서 다중 사용자 식별자(Subject) 추출 | `auth_handler_qr_test.go` |
|
||||||
|
| **서비스: 클라이언트 관리** | `ListClients` | 클라이언트 목록 페이징 조회 | `hydra_admin_service_test.go` |
|
||||||
|
| | `GetClient` | 특정 클라이언트 상세 조회 (성공/실패) | `hydra_admin_service_test.go` |
|
||||||
|
| | `CreateClient` | 신규 클라이언트 생성 및 메타데이터 검증 | `hydra_admin_service_test.go` |
|
||||||
|
| | `UpdateClient` | 클라이언트 정보 수정 (PUT) | `hydra_admin_service_test.go` |
|
||||||
|
| | `PatchClientStatus` | 클라이언트 상태 변경 (JSON Patch) | `hydra_admin_service_test.go` |
|
||||||
|
| | `DeleteClient` | 클라이언트 삭제 | `hydra_admin_service_test.go` |
|
||||||
|
| **서비스: 인증/동의** | `GetConsentRequest` | Consent Challenge 검증 및 요청 정보 조회 | `hydra_admin_service_test.go` |
|
||||||
|
| | `AcceptConsentRequest` | 동의 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
|
||||||
|
| | `RejectConsentRequest` | 동의 거부 처리 | `hydra_admin_service_test.go` |
|
||||||
|
| | `GetLoginRequest` | Login Challenge 검증 | `hydra_admin_service_test.go` |
|
||||||
|
| | `AcceptLoginRequest` | 로그인 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
|
||||||
|
| | `RejectLoginRequest` | 로그인 거부 처리 | `hydra_admin_service_test.go` |
|
||||||
|
| **서비스: 세션 관리** | `ListConsentSessions` | 특정 사용자의 활성 세션 목록 조회 | `hydra_admin_service_test.go` |
|
||||||
|
| | `RevokeConsentSessions` | 특정 사용자/클라이언트의 세션 만료 처리 | `hydra_admin_service_test.go` |
|
||||||
|
| **서비스: 통합** | `Create` (RP) | Hydra 생성 -> DB 생성 -> Keto 권한 부여 | `relying_party_service_test.go` |
|
||||||
|
| | `Create` (Rollback) | DB 실패 시 Hydra 롤백(삭제) 검증 | `relying_party_service_test.go` |
|
||||||
|
|
||||||
|
|
||||||
|
## 6. 테스트 코드 작성 가이드
|
||||||
|
|
||||||
|
새로운 기능을 추가하거나 커버리지를 높일 때 다음 패턴을 참고하세요.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestHydraAdminService_NewFeature(t *testing.T) {
|
||||||
|
// 1. Mock 핸들러 정의 (예상되는 요청 검증 및 가짜 응답 반환)
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Assert: 요청 메서드, URL, 바디 검증
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response: 가짜 응답 작성
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(expectedResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 서비스 초기화 (Mock Client 주입)
|
||||||
|
svc := &HydraAdminService{
|
||||||
|
AdminURL: "http://hydra:4445",
|
||||||
|
HTTPClient: mockHydraClient(handler), // ory_service_test.go의 헬퍼 사용
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 테스트 실행 및 검증
|
||||||
|
result, err := svc.NewFeature(context.Background(), args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed: %v", err)
|
||||||
|
}
|
||||||
|
// Assert: 결과값 검증
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -835,136 +835,77 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
||||||
if (activities.isEmpty) return const SizedBox.shrink();
|
if (activities.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
final shouldShowToggle = activities.length > 4;
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth;
|
||||||
|
|
||||||
// 더보기를 누르지 않은 경우: 최대 4개 노출 (Grid/Wrap)
|
// 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정
|
||||||
if (!_showAllActivities) {
|
int crossAxisCount;
|
||||||
final visibleActivities = activities.take(4).toList();
|
if (maxWidth > 1200) {
|
||||||
Widget grid;
|
crossAxisCount = 4;
|
||||||
if (isMobile) {
|
} else if (maxWidth > 800) {
|
||||||
grid = GridView.builder(
|
crossAxisCount = 3;
|
||||||
shrinkWrap: true,
|
} else {
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
crossAxisCount = 2;
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
}
|
||||||
crossAxisCount: 2,
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
childAspectRatio: 1.05,
|
|
||||||
),
|
|
||||||
itemCount: visibleActivities.length,
|
|
||||||
itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
grid = Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: visibleActivities.map(_buildActivityCard).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
// 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final int initialVisibleCount = crossAxisCount;
|
||||||
children: [
|
final shouldShowToggle = activities.length > initialVisibleCount;
|
||||||
grid,
|
|
||||||
if (shouldShowToggle)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () => setState(() => _showAllActivities = true),
|
|
||||||
icon: const Icon(Icons.add, size: 18, color: Colors.blueAccent),
|
|
||||||
label: const Text('더보기', style: TextStyle(color: Colors.blueAccent, fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 더보기를 누른 경우: 가로 슬라이더/캐러셀 전환
|
List<_ActivityItem> visibleActivities;
|
||||||
return Column(
|
if (_showAllActivities) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
visibleActivities = activities;
|
||||||
children: [
|
} else {
|
||||||
Stack(
|
visibleActivities = activities.take(initialVisibleCount).toList();
|
||||||
alignment: Alignment.center,
|
}
|
||||||
|
|
||||||
|
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
|
||||||
|
final double spacing = 12.0;
|
||||||
|
final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Wrap(
|
||||||
height: 220,
|
spacing: spacing,
|
||||||
child: ListView.separated(
|
runSpacing: spacing,
|
||||||
controller: _rpScrollController,
|
children: visibleActivities.map((item) {
|
||||||
scrollDirection: Axis.horizontal,
|
return SizedBox(
|
||||||
itemCount: activities.length,
|
width: cardWidth,
|
||||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
child: _buildActivityCard(item, cardWidth: cardWidth),
|
||||||
itemBuilder: (context, index) => UnconstrainedBox(
|
);
|
||||||
alignment: Alignment.topCenter,
|
}).toList(),
|
||||||
child: _buildActivityCard(activities[index]),
|
),
|
||||||
|
if (shouldShowToggle)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => setState(() => _showAllActivities = !_showAllActivities),
|
||||||
|
icon: Icon(
|
||||||
|
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
|
||||||
|
size: 18,
|
||||||
|
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_showAllActivities ? '접기' : '+ 더보기',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// 왼쪽 이동 버튼
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
child: _buildScrollButton(
|
|
||||||
icon: Icons.chevron_left,
|
|
||||||
onPressed: () => _rpScrollController.animateTo(
|
|
||||||
(_rpScrollController.offset - 300).clamp(0, _rpScrollController.position.maxScrollExtent),
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 오른쪽 이동 버튼
|
|
||||||
Positioned(
|
|
||||||
right: 0,
|
|
||||||
child: _buildScrollButton(
|
|
||||||
icon: Icons.chevron_right,
|
|
||||||
onPressed: () => _rpScrollController.animateTo(
|
|
||||||
(_rpScrollController.offset + 300).clamp(0, _rpScrollController.position.maxScrollExtent),
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 12),
|
},
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_showAllActivities = false;
|
|
||||||
_rpScrollController.jumpTo(0); // 접을 때 위치 초기화
|
|
||||||
}),
|
|
||||||
icon: const Icon(Icons.close, size: 18, color: Colors.grey),
|
|
||||||
label: const Text('접기', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScrollButton({required IconData icon, required VoidCallback onPressed}) {
|
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.8),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.1),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
icon: Icon(icon, color: _ink),
|
|
||||||
onPressed: onPressed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActivityCard(_ActivityItem item) {
|
|
||||||
final isActive = item.status == '활성';
|
final isActive = item.status == '활성';
|
||||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
final statusColor = isActive ? Colors.green : Colors.grey;
|
||||||
final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border;
|
final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border;
|
||||||
@@ -975,7 +916,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
// 카드 컨텐츠
|
// 카드 컨텐츠
|
||||||
final cardContent = Container(
|
final cardContent = Container(
|
||||||
width: 260,
|
width: cardWidth ?? 260,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _surface,
|
color: _surface,
|
||||||
|
|||||||
Reference in New Issue
Block a user