diff --git a/.gitignore b/.gitignore index 4f51d21..ef56624 100644 --- a/.gitignore +++ b/.gitignore @@ -73,5 +73,7 @@ temp/ # ========================= # Go workspace(本機多模組開發用) # ========================= -go.work -go.work.sum +# E2E 產物(make e2e-full / e2e-up 生成) +test/e2e/fixtures/state.json +test/e2e/fixtures/gateway.pid +.cache/ diff --git a/Makefile b/Makefile index e9ba5ea..537d579 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help -.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \ +.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test test-e2e e2e-full e2e-casbin e2e-up e2e-down fmt lint lint-fix fix check run \ deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local help: ## 顯示可用指令 @@ -58,6 +58,24 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML test: ## 執行測試 $(GO) test ./... +test-e2e: ## 對已啟動的 Gateway 跑 E2E(需 state.json;見 docs/e2e-testing.md) + GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'Test(Auth_|Health|Member|Permission)' + GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZ_AuthTokenRefreshAndLogout' + +e2e-full: ## 全新 Docker + index + seed + E2E + 關閉(一鍵完整測試) + bash scripts/e2e-run.sh + +e2e-casbin: ## 全新 Docker + Casbin enabled + E2E + 關閉(含 RBAC 403 / reload) + E2E_CASBIN=1 E2E_CONFIG=test/e2e/fixtures/e2e.casbin.yaml \ + E2E_TEST_PATTERN='Test(Auth_|Health|Member|Permission_(Catalog|Me|CasbinRBAC))' \ + bash scripts/e2e-run.sh + +e2e-up: ## 起 Docker + index + seed + Gateway(不跑測試、不 teardown) + bash scripts/e2e-up.sh + +e2e-down: ## 停止 E2E Gateway 並 docker compose down -v + bash scripts/e2e-down.sh + fmt: ## gofmt + goimports(不含 lint) $(GOFMT) -s -w $(GOFILES) @command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1) diff --git a/cmd/e2e-seed/main.go b/cmd/e2e-seed/main.go new file mode 100644 index 0000000..6508df8 --- /dev/null +++ b/cmd/e2e-seed/main.go @@ -0,0 +1,245 @@ +// Command e2e-seed prepares a fresh E2E tenant, member, permission roles, and JWT tokens. +// +// Usage (usually invoked by scripts/e2e-run.sh): +// +// go run ./cmd/e2e-seed -f test/e2e/fixtures/e2e.yaml -out test/e2e/fixtures/state.json +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "gateway/internal/config" + redislib "gateway/internal/library/redis" + domauth "gateway/internal/model/auth/domain/usecase" + authrepo "gateway/internal/model/auth/repository" + authusecase "gateway/internal/model/auth/usecase" + dommember "gateway/internal/model/member/domain/usecase" + memberusecase "gateway/internal/model/member/usecase" + "gateway/internal/model/permission/domain/enum" + domperm "gateway/internal/model/permission/domain/usecase" + permrepo "gateway/internal/model/permission/repository" + permseed "gateway/internal/model/permission/seed" + permusecase "gateway/internal/model/permission/usecase" + + "github.com/zeromicro/go-zero/core/conf" +) + +const ( + defaultTenantID = "e2e-tenant" + defaultSlug = "e2e" + defaultPrefix = "E2E" + defaultEmail = "e2e-owner@example.com" + defaultNoRoleEmail = "e2e-no-role@example.com" + defaultRoleKey = "tenant_owner" +) + +var ( + configFile = flag.String("f", "test/e2e/fixtures/e2e.yaml", "config file") + outFile = flag.String("out", "test/e2e/fixtures/state.json", "output fixture JSON") + tenantID = flag.String("tenant", defaultTenantID, "tenant_id") + slug = flag.String("slug", defaultSlug, "tenant slug") + uidPrefix = flag.String("prefix", defaultPrefix, "uid prefix") + email = flag.String("email", defaultEmail, "member email") + roleKey = flag.String("role", defaultRoleKey, "system role key to assign") +) + +// State is consumed by test/e2e HTTP tests. +type State struct { + BaseURL string `json:"base_url"` + TenantID string `json:"tenant_id"` + TenantSlug string `json:"tenant_slug"` + UID string `json:"uid"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + RoleKey string `json:"role_key"` + NoRoleUID string `json:"no_role_uid"` + NoRoleEmail string `json:"no_role_email"` + NoRoleAccessToken string `json:"no_role_access_token"` + NoRoleRefreshToken string `json:"no_role_refresh_token"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + flag.Parse() + + var c config.Config + conf.MustLoad(*configFile, &c) + if c.Mongo.Host == "" || c.Redis.Host == "" { + return fmt.Errorf("e2e-seed: Mongo and Redis are required") + } + if !c.Auth.Defaults().Enabled() { + return fmt.Errorf("e2e-seed: Auth JWT secrets are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + rds, err := redislib.NewClient(c.Redis) + if err != nil { + return fmt.Errorf("e2e-seed: redis: %w", err) + } + + memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ + Redis: rds, + MongoConf: &c.Mongo, + Config: c.Member, + }) + if err != nil { + return fmt.Errorf("e2e-seed: member module: %w", err) + } + if memberMod.Tenant == nil || memberMod.Lifecycle == nil || memberMod.Profile == nil { + return fmt.Errorf("e2e-seed: member module incomplete (need Mongo)") + } + + if _, err := memberMod.Tenant.Create(ctx, &dommember.CreateTenantRequest{ + TenantID: *tenantID, + Slug: *slug, + Name: "E2E Tenant", + UIDPrefix: *uidPrefix, + }); err != nil { + fmt.Printf("e2e-seed: tenant create skipped (may exist): %v\n", err) + } + + uid, err := ensureMember(ctx, memberMod, *tenantID, *email) + if err != nil { + return err + } + + if err := seedPermissionAndAssignRole(ctx, c, *tenantID, uid, *roleKey); err != nil { + return err + } + + noRoleUID, err := ensureMemberWithZitadel(ctx, memberMod, *tenantID, defaultNoRoleEmail, "e2e-no-role-sub", "E2E No Role") + if err != nil { + return err + } + + tokens := authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{ + Config: c.Auth, + Revoke: authrepo.NewRedisTokenRevokeStore(rds), + }) + pair, err := issueTokenPair(ctx, tokens, *tenantID, uid) + if err != nil { + return fmt.Errorf("e2e-seed: issue token: %w", err) + } + noRolePair, err := issueTokenPair(ctx, tokens, *tenantID, noRoleUID) + if err != nil { + return fmt.Errorf("e2e-seed: issue no-role token: %w", err) + } + + state := State{ + BaseURL: fmt.Sprintf("http://127.0.0.1:%d", c.Port), + TenantID: *tenantID, + TenantSlug: *slug, + UID: uid, + Email: *email, + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + RoleKey: *roleKey, + NoRoleUID: noRoleUID, + NoRoleEmail: defaultNoRoleEmail, + NoRoleAccessToken: noRolePair.AccessToken, + NoRoleRefreshToken: noRolePair.RefreshToken, + } + raw, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("e2e-seed: marshal state: %w", err) + } + if err := os.WriteFile(*outFile, raw, 0o600); err != nil { + return fmt.Errorf("e2e-seed: write %s: %w", *outFile, err) + } + + fmt.Printf("e2e-seed: tenant=%s uid=%s role=%s\n", state.TenantID, state.UID, state.RoleKey) + fmt.Printf("e2e-seed: wrote %s (base_url=%s)\n", *outFile, state.BaseURL) + return nil +} + +func issueTokenPair(ctx context.Context, tokens domauth.TokenUseCase, tenantID, uid string) (*domauth.TokenPair, error) { + return tokens.IssuePair(ctx, &domauth.IssuePairRequest{ + TenantID: tenantID, + UID: uid, + }) +} + +func ensureMember(ctx context.Context, mod *memberusecase.Module, tenantID, email string) (string, error) { + return ensureMemberWithZitadel(ctx, mod, tenantID, email, "", "E2E Owner") +} + +func ensureMemberWithZitadel(ctx context.Context, mod *memberusecase.Module, tenantID, email, zitadelUserID, displayName string) (string, error) { + m, err := mod.Lifecycle.CreateUnverified(ctx, &dommember.CreatePlatformMemberRequest{ + TenantID: tenantID, + Email: email, + ZitadelUserID: zitadelUserID, + DisplayName: displayName, + Language: "zh-tw", + }) + if err == nil { + if actErr := mod.Lifecycle.Activate(ctx, tenantID, m.UID); actErr != nil { + return "", fmt.Errorf("e2e-seed: activate member: %w", actErr) + } + return m.UID, nil + } + + // Idempotent re-run: find existing member by listing (dev tenant has one owner). + list, listErr := mod.Profile.List(ctx, &dommember.ListMembersRequest{ + TenantID: tenantID, + Limit: 50, + }) + if listErr != nil { + return "", fmt.Errorf("e2e-seed: create member: %w (list fallback: %v)", err, listErr) + } + for _, item := range list.Items { + if item.ZitadelEmail == email || item.BusinessEmail == email { + return item.UID, nil + } + } + return "", fmt.Errorf("e2e-seed: create member: %w", err) +} + +func seedPermissionAndAssignRole(ctx context.Context, c config.Config, tenantID, uid, roleKey string) error { + perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo}) + roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo}) + rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo}) + + if _, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{ + TenantIDs: []string{tenantID}, + }); err != nil { + return fmt.Errorf("e2e-seed: permission seed: %w", err) + } + + permMod, err := permusecase.NewModuleFromParam(permusecase.FactoryParam{ + MongoConf: &c.Mongo, + Redis: nil, + Config: c.Permission, + }) + if err != nil { + return fmt.Errorf("e2e-seed: permission module: %w", err) + } + + role, err := permMod.Role.GetByKey(ctx, tenantID, roleKey) + if err != nil { + return fmt.Errorf("e2e-seed: get role %q: %w", roleKey, err) + } + if _, err := permMod.UserRole.Assign(ctx, &domperm.AssignParam{ + TenantID: tenantID, + UID: uid, + RoleID: role.ID.Hex(), + Source: enum.RoleSourceManual, + }); err != nil { + // Idempotent re-run when role already assigned. + fmt.Printf("e2e-seed: assign role skipped: %v\n", err) + } + return nil +} diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md new file mode 100644 index 0000000..e2c756b --- /dev/null +++ b/docs/e2e-testing.md @@ -0,0 +1,265 @@ +# Gateway E2E 測試指南 + +本文件列出 **所有 HTTP API 測試情境** 與 **一鍵跑完整 E2E** 的方式。自動化測試程式在 `test/e2e/`(build tag: `e2e`)。 + +--- + +## 一鍵完整測試(推薦) + +從 **全新 Docker volume** 開始,依序:起 Mongo/Redis → 建 index → seed 資料 → 起 Gateway → 跑 E2E → **關閉並刪除 volume**。 + +```bash +cd gateway +make e2e-full +``` + +等同於: + +```bash +bash scripts/e2e-run.sh +``` + +成功時最後一行:`>> E2E OK` + +### 流程圖 + +```mermaid +flowchart LR + A[docker compose down -v] --> B[up mongo + redis] + B --> C[cmd/mongo-index] + C --> D[cmd/e2e-seed] + D --> E[gateway :18888] + E --> F[go test -tags=e2e] + F --> G[stop gateway + down -v] +``` + +### 其他指令 + +| 指令 | 用途 | +|------|------| +| `make e2e-up` | 起環境 + seed + Gateway,**不跑測試**(本機除錯) | +| `make test-e2e` | 對**已啟動**的 Gateway 跑 E2E(需先有 `state.json`) | +| `make e2e-casbin` | 以 `Permission.Casbin.Enabled: true` 跑 RBAC reload / deny E2E | +| `make e2e-down` | 停 Gateway + `docker compose down -v` | +| `E2E_KEEP_DOCKER=1 make e2e-full` | 測完**保留** Docker(方便查 Mongo/Redis) | + +### E2E 專用設定 + +| 檔案 | 說明 | +|------|------| +| `test/e2e/fixtures/e2e.yaml` | Port **18888**、DB **gateway_e2e**、Notification mock、Casbin **關閉** | +| `test/e2e/fixtures/e2e.casbin.yaml` | 同上,但 Casbin **開啟**,搭配 `make e2e-casbin` | +| `test/e2e/fixtures/state.json` | seed 產生的 tenant / uid / JWT(gitignore,執行後生成) | + +--- + +## 測試覆蓋矩陣(一目瞭然) + +圖例:**✅ 自動 E2E** · **⏭ 需 ZITADEL / 手動** · **🔧 基礎設施** + +### 基礎設施 + +| ID | 情境 | 自動 | 測試檔 | +|----|------|:----:|--------| +| INF-01 | Mongo + Redis docker healthy | ✅ | `scripts/e2e-run.sh` | +| INF-02 | 全模組 Mongo index | ✅ | `cmd/mongo-index` | +| INF-03 | E2E tenant + member + permission + JWT seed | ✅ | `cmd/e2e-seed` | +| INF-04 | Gateway 監聽 :18888 | ✅ | `scripts/e2e-run.sh` | + +### Normal + +| ID | Method | Path | 情境 | 自動 | 測試 | +|----|--------|------|------|:----:|------| +| N-01 | GET | `/api/v1/health` | Ping 200 | ✅ | `TestHealth_Ping` | +| N-02 | GET | `/api/v1/health` | 無需 Bearer | ✅ | `TestHealth_NoAuthRequired` | + +### Auth(`/api/v1/auth`) + +| ID | Method | Path | 情境 | 自動 | 測試 / 備註 | +|----|--------|------|------|:----:|-------------| +| A-01 | POST | `/register` | Email 註冊 | ⏭ | 需 **ZITADEL** + invite | +| A-02 | POST | `/register/confirm` | OTP 確認 | ⏭ | 同上 | +| A-03 | POST | `/register/resend` | 重發 OTP | ⏭ | 同上 | +| A-04 | POST | `/register/social/start` | 社交註冊 | ⏭ | 需 ZITADEL OAuth | +| A-05 | GET | `/register/social/callback` | 社交註冊 callback | ⏭ | 需 ZITADEL | +| A-06 | POST | `/login` | 密碼登入 | ⏭ | 需 ZITADEL ROPG | +| A-07 | POST | `/login/social/start` | 社交登入 | ⏭ | 需 ZITADEL | +| A-08 | GET | `/login/social/callback` | 社交登入 callback | ⏭ | 需 ZITADEL | +| A-09 | POST | `/token/exchange` | id_token 換 JWT | ⏭ | 需 ZITADEL | +| A-10 | POST | `/token/refresh` | 刷新 token | ✅ | `TestZZZ_AuthTokenRefreshAndLogout`(最後跑) | +| A-11 | POST | `/logout` | 登出黑名單 jti | ✅ | 同上(同一測試內連續驗證) | +| A-12 | GET | `/members/me`(無 Bearer) | 401 | ✅ | `TestAuth_MissingBearer_401` | +| A-13 | POST | `/register`、`/login`、`/token/refresh`、`/login/social/start` | 公開 Auth validation 400 | ✅ | `TestAuth_PublicValidationErrors`(不需 ZITADEL) | + +> E2E 透過 `cmd/e2e-seed` 直接核發 JWT,不走 ZITADEL,因此 A-01~A-09 列為手動/staging 測試。 + +### Member(`/api/v1/members`,需 Bearer) + +| ID | Method | Path | 情境 | 自動 | 測試 | +|----|--------|------|------|:----:|------| +| M-01 | GET | `/me` | 讀 profile | ✅ | `TestMember_GetMe` | +| M-02 | PATCH | `/me` | 更新 display_name | ✅ | `TestMember_UpdateMe` | +| M-03 | POST | `/me/verifications/email/start` | 發起 email OTP | ✅ | `TestMember_EmailVerification_FullFlow` | +| M-04 | POST | `/me/verifications/email/confirm` | 確認 email OTP | ✅ | 同上(`GATEWAY_E2E=1` 從 Redis 取碼) | +| M-05 | POST | `/me/verifications/phone/start` | 發起 phone OTP | ✅ | `TestMember_PhoneVerification_FullFlow` | +| M-06 | POST | `/me/verifications/phone/confirm` | 確認 phone OTP | ✅ | 同上(`GATEWAY_E2E=1` 從 Redis 取碼) | +| M-07 | GET | `/me/totp` | TOTP 狀態 | ✅ | `TestMember_TOTP_Status` | +| M-08 | POST | `/me/totp/enroll-start` | 開始綁定 | ✅ | `TestMember_TOTP_FullFlow`(解析 `otpauth_url`) | +| M-09 | POST | `/me/totp/enroll-confirm` | 確認綁定 | ✅ | 同上 | +| M-10 | POST | `/me/totp/verify` | Step-up 驗碼 + replay 防護 | ✅ | 同上 | +| M-11 | DELETE | `/me/totp` | 解除綁定 | ✅ | 同上 | +| M-12 | POST | `/me/totp/backup-codes` | 重產備援碼 | ✅ | 同上 | + +### Permission(`/api/v1/permissions`,需 Bearer) + +| ID | Method | Path | Middleware | 情境 | 自動 | 測試 | +|----|--------|------|------------|------|:----:|------| +| P-01 | GET | `/catalog` | AuthJWT | 權限樹 | ✅ | `TestPermission_Catalog` | +| P-02 | GET | `/me` | AuthJWT | 當前 user 權限 | ✅ | `TestPermission_Me` | +| P-03 | GET | `/roles` | AuthJWT+Casbin* | 列角色 | ✅ | `TestPermission_RoleCRUD` | +| P-04 | POST | `/roles` | AuthJWT+Casbin* | 建角色 | ✅ | 同上 | +| P-05 | PATCH | `/roles/:id` | AuthJWT+Casbin* | 更新角色 | ✅ | 同上 | +| P-06 | DELETE | `/roles/:id` | AuthJWT+Casbin* | 刪角色 | ✅ | 同上 | +| P-07 | GET | `/roles/:id/permissions` | AuthJWT+Casbin* | 讀角色權限 | ✅ | `TestPermission_RolePermissions` | +| P-08 | PUT | `/roles/:id/permissions` | AuthJWT+Casbin* | 取代角色權限 | ✅ | 同上 | +| P-09 | GET | `/users/:uid/roles` | AuthJWT+Casbin* | 列 user 角色 | ✅ | `TestPermission_AssignUserRole` | +| P-10 | POST | `/users/:uid/roles` | AuthJWT+Casbin* | 指派角色 | ✅ | 同上 | +| P-11 | DELETE | `/users/:uid/roles/:role_id` | AuthJWT+Casbin* | 撤銷角色 | ✅ | 同上 | +| P-12 | GET/PUT/DELETE | `/role-mappings` | AuthJWT+Casbin* | 外部映射 CRUD | ✅ | `TestPermission_RoleMappingCRUD` | +| P-13 | POST | `/policy/reload` | AuthJWT+Casbin | 重載 policy | ✅ | `TestPermission_CasbinRBAC`(`make e2e-casbin`) | +| P-14 | GET | `/roles` | AuthJWT+Casbin | no-role user RBAC denied 403 | ✅ | 同上 | + +\* 預設 `make e2e-full` 使用 `Permission.Casbin.Enabled: false`,Casbin middleware **放行**(rbac=nil passthrough)。`make e2e-casbin` 會改用 `e2e.casbin.yaml`,並額外驗證 policy reload 與 no-role 403。 + +### Notification(無 HTTP API) + +| ID | 情境 | 自動 | 備註 | +|----|------|:----:|------| +| NT-01 | 同步 Send(mock email) | 🔧 | `make notify-test METHOD=email-send MOCK=1` | +| NT-02 | 異步 Enqueue + Worker | 🔧 | `make notify-test METHOD=email-enqueue MOCK=1` | +| NT-03 | Member email OTP 寄送 | ✅ | 含在 M-03/M-04(mock provider) | + +--- + +## 統計摘要 + +| 類別 | 自動 E2E | 待擴充 / 手動 | +|------|:--------:|:-------------:| +| Normal | 2 | 0 | +| Auth | 4 | 9(ZITADEL) | +| Member | 12 | 0 | +| Permission | 14 | 0 | +| **合計** | **32** | **9** | + +> Auth refresh/logout 會撤銷 JWT,因此腳本分兩輪跑:`member/permission` 先用 seed token,最後才跑 `TestZZZ_AuthTokenRefreshAndLogout`。 + +--- + +## 手動 / 延伸測試 + +### Auth 全链路(需 ZITADEL) + +1. 設定 `etc/gateway.dev.yaml` 的 `Zitadel.*` +2. `make deps-up && make run-dev` +3. 依 `docs/auth-unified-registration.md` 跑 register → confirm → login + +### TOTP 互動測試 + +```bash +make deps-up +make totp-test STEP=flow +``` + +### Notification + +見 [`docs/notification-testing.md`](notification-testing.md)。 + +--- + +## 環境變數 + +| 變數 | 預設 | 說明 | +|------|------|------| +| `GATEWAY_E2E` | `1`(腳本內) | 開啟 OTP 寫入 Redis `e2e:otp:{challenge_id}` | +| `E2E_STATE_FILE` | `test/e2e/fixtures/state.json` | seed 輸出路徑 | +| `E2E_BASE_URL` | `http://127.0.0.1:18888` | 覆寫 Gateway URL | +| `E2E_KEEP_DOCKER` | — | `1` = 測完不 down -v | +| `GATEWAY_PORT` | `18888` | health check 用 | +| `E2E_ROLE` | `tenant_owner` | 覆寫 seed 指派給主測試使用者的 system role | +| `E2E_TEST_PATTERN` | `Test(Auth_\|Health\|Member\|Permission)` | 覆寫第一輪 `go test -run` pattern | +| `E2E_CASBIN` | — | `1` = 執行 Casbin 專用 assertion(`make e2e-casbin` 已設定) | + +--- + +## 目錄結構 + +``` +gateway/ +├── cmd/e2e-seed/ # E2E 資料 + JWT seed +├── scripts/ +│ ├── e2e-run.sh # 一鍵完整流程 +│ ├── e2e-up.sh # 只起環境 +│ └── e2e-down.sh # 關閉 +├── test/e2e/ +│ ├── fixtures/ +│ │ ├── e2e.yaml # E2E 設定 +│ │ ├── e2e.casbin.yaml # Casbin enabled E2E 設定 +│ │ └── state.json # 生成(gitignore) +│ ├── client.go # HTTP helper +│ ├── health_test.go +│ ├── auth_test.go +│ ├── member_test.go +│ └── permission_test.go +└── docs/e2e-testing.md # 本文件 +``` + +--- + +## CI 建議 + +```yaml +# 範例 GitHub Actions job +- name: E2E + run: make e2e-full + working-directory: gateway + +- name: E2E Casbin + run: make e2e-casbin + working-directory: gateway +``` + +PR 門檻:`make check`(unit)+ `make e2e-full`(整合)+ `make e2e-casbin`(RBAC enforcement)。 + +--- + +## 常見問題 + +**Q: `missing e2e otp for challenge`** + +A: Gateway 必須以 `GATEWAY_E2E=1` 啟動(`e2e-run.sh` 已設定)。 + +**Q: `connection refused :18888`** + +A: 先 `make e2e-up` 或確認沒有其他 process 佔用 18888。 + +**Q: 跑完 `make e2e-full` 後 Gateway 還在 :18888?** + +A: 舊版用 `go run` 背景跑,`kill $!` 只殺 wrapper、子行程會留著。現已改為編譯 `.cache/e2e-gateway` 再啟動,cleanup 會依 **pid 檔 + port + orphan** 三層關閉。若仍有殘留: + +```bash +make e2e-down +# 或 +lsof -ti tcp:18888 | xargs kill -9 +``` + +**Q: 與 `make run-dev`(:8888)衝突?** + +A: E2E 固定用 **18888** + **gateway_e2e** DB,互不影響。 + +**Q: 如何只跑單一測試?** + +```bash +make e2e-up +GATEWAY_E2E=1 go test -tags=e2e -v -count=1 ./test/e2e/ -run TestMember_GetMe +make e2e-down +``` diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go index f462993..a6f39b1 100644 --- a/internal/logic/member/verify_helper.go +++ b/internal/logic/member/verify_helper.go @@ -2,6 +2,8 @@ package member import ( "context" + "fmt" + "os" "time" memberdom "gateway/internal/model/member/domain" @@ -72,6 +74,10 @@ func startVerification( } return nil, sendErr } + if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil { + key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID) + _ = sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn) + } return &types.VerificationStartData{ ChallengeID: dto.ChallengeID, ExpiresIn: dto.ExpiresIn, diff --git a/internal/model/auth/SDD.md b/internal/model/auth/SDD.md new file mode 100644 index 0000000..8c175df --- /dev/null +++ b/internal/model/auth/SDD.md @@ -0,0 +1,309 @@ +# Auth Service + +# Auth Service — SRS/SDD Document + +| Version | Version Date | Editor | Memo | +|---------|--------------|--------|------| +| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Auth 模組 SDD | + +--- + +## 1. Introduction + +### 1.1 Purpose + +Auth 模組為 TianTing Gateway 的**認證領域層**,提供租戶範圍內的原子化認證原語:邀請碼、註冊稽核、OAuth 暫存 Session、CloudEP JWT 簽發/刷新/登出。完整 HTTP 流程由 `internal/logic/auth/` 編排,並與 ZITADEL(身份)、Member(會員)、Notification(通知)協作。 + +### 1.2 Scope + +**範圍內:** + +- 租戶邀請碼(Validate / Consume) +- 註冊稽核 metadata(條款版本、渠道、行銷 opt-in) +- OAuth 社交註冊/登入暫存 Session(Redis) +- CloudEP JWT access / refresh token 生命週期 +- JWT 黑名單與 jti pair 管理 + +**範圍外(委派其他模組):** + +- 使用者身份建立 → ZITADEL +- 會員 profile、OTP → Member 模組 +- 郵件/簡訊 → Notification 模組 +- RBAC 授權 → Permission 模組 + Casbin middleware + +### 1.3 Definitions, Acronyms, and Abbreviation + +| 縮寫 | 說明 | +|------|------| +| **JWT** | JSON Web Token,CloudEP 自簽 access / refresh token | +| **JTI** | JWT ID,用於黑名單與 pair 映射 | +| **ROPG** | Resource Owner Password Grant,密碼登入 | +| **OIDC** | OpenID Connect,社交登入/SSO | +| **OTP** | One-Time Password,由 Member 模組管理 | +| **Tenant** | 租戶,多租戶隔離單位 | +| **UID** | 租戶內可讀會員識別碼(如 `ACME-10000003`) | + +### 1.4 Technologies to be used + +| 項目 | 技術 | +|------|------| +| Application Language | Go 1.22+ | +| Framework | go-zero (`rest`, `logx`, `stores/redis`) | +| Cache / Session | Redis | +| Database | MongoDB | +| JWT | `github.com/golang-jwt/jwt/v4`(HS256) | +| Identity Provider | ZITADEL(`internal/library/zitadel`) | +| API Codegen | goctl / `.api` 檔 | + +### 1.5 Overview + +Auth 模組採 Clean Architecture 分層:`domain/` 定義介面與實體,`repository/` 實作 Mongo / Redis 適配器,`usecase/` 提供原子 UseCase。HTTP handler 不直接存取 repository,而是由 logic 層串接 ZITADEL + Member + Auth 原語。 + +主要流程: + +1. **Email 註冊**:Consume invite → ZITADEL 建 user → Member 建 unverified → 發 OTP → confirm 後 Activate + IssuePair +2. **密碼登入**:ZITADEL ROPG → 查 Member → IssuePair +3. **社交註冊/登入**:Redis Session 暫存 OAuth state → callback 換 token → Provision / Login +4. **Token 刷新/登出**:Refresh 黑名單舊 pair 後重簽;Logout 黑名單 access + refresh jti + +--- + +## 2. System Overview + +Auth 模組是 Gateway 認證子系統的核心,負責: + +- 控制誰可以註冊(邀請碼) +- 記錄註冊合規稽核 +- 管理短期 OAuth 流程狀態 +- 簽發與驗證 CloudEP JWT(middleware `AuthJWT`) + +對外透過 `/api/v1/auth/*` REST API 暴露;JWT 驗證後將 `(tenant_id, uid)` 注入 request context,供 Member / Permission 等下游使用。 + +--- + +## 3. System Architecture + +### 3.1 System Architecture + +```mermaid +flowchart TB + subgraph Client["Client / Frontend"] + Web[Web / Mobile App] + end + + subgraph Gateway["Gateway"] + Handler["handler/auth"] + Logic["logic/auth
(orchestration)"] + subgraph AuthModule["internal/model/auth"] + UC["usecase/"] + Repo["repository/"] + Domain["domain/"] + end + MW["middleware/AuthJWT"] + end + + subgraph External["External Services"] + Zitadel[ZITADEL IdP] + Mongo[(MongoDB)] + Redis[(Redis)] + end + + subgraph Sibling["Sibling Modules"] + Member[member] + Notif[notification] + end + + Web --> Handler + Handler --> Logic + Logic --> UC + Logic --> Zitadel + Logic --> Member + Logic --> Notif + UC --> Repo + Repo --> Mongo + Repo --> Redis + MW --> UC +``` + +### 3.2 Decomposition Description + +| 套件 | 職責 | +|------|------| +| `config/` | JWT TTL、Session TTL 設定 | +| `domain/entity/` | Mongo 文件模型(InviteCode、RegistrationMetadata) | +| `domain/enum/` | RegistrationChannel(email / google) | +| `domain/repository/` | Port 介面 + Redis Session struct | +| `domain/usecase/` | UseCase 介面 + DTO | +| `domain/const.go` | BSON 欄位名、Redis key、邀請碼 hash helper | +| `domain/errors.go` | 領域 sentinel errors | +| `repository/` | Mongo + Redis 適配器、`EnsureMongoIndexes` | +| `usecase/` | 實作 + `NewModuleFromParam` factory | +| `usecase/module.go` | 組裝 Invite / RegistrationMeta / Session UseCase | + +**ServiceContext 注入:** + +| 欄位 | UseCase | 條件 | +|------|---------|------| +| `AuthInvite` | InviteUseCase | Mongo + Redis | +| `AuthRegistrationMeta` | RegistrationMetaUseCase | Mongo | +| `AuthRegistrationSession` | RegistrationSessionUseCase | Redis | +| `AuthLoginSession` | LoginSessionUseCase | Redis | +| `AuthToken` | TokenUseCase | JWT secret + Redis(獨立於 Module) | + +### 3.3 Token + +CloudEP JWT 採 HS256,access / refresh 使用不同 secret。 + +**Claims 結構:** + +| Claim | 說明 | +|-------|------| +| `tenant_id` | 租戶 ID | +| `uid` | 會員 UID | +| `typ` | `access` 或 `refresh` | +| `auth_gen` | 簽發世代(用於強制登出) | +| `jti` | Token 唯一 ID | +| `iat` / `exp` | 簽發/過期時間 | + +**預設 TTL:** + +| Token | 預設 | +|-------|------| +| Access | 900 秒(15 分鐘) | +| Refresh | 604800 秒(7 天) | +| Registration Session | 600 秒(10 分鐘) | + +**Redis 映射:** + +| Key | 用途 | +|-----|------| +| `auth:jwt:pair:{jti}` | access ↔ refresh jti 雙向映射 | +| `auth:jwt:bl:{jti}` | 已登出/已刷新的 jti 黑名單 | + +**Refresh 流程:** + +```mermaid +sequenceDiagram + participant C as Client + participant L as TokenRefreshLogic + participant T as TokenUseCase + participant R as Redis + + C->>L: POST /auth/token/refresh {refresh_token} + L->>T: Refresh(refreshToken) + T->>T: Parse + verify typ=refresh + T->>R: Blacklist old refresh jti + paired access jti + T->>T: IssuePair(new access + refresh) + T->>R: SavePair(new jti mapping) + T-->>L: TokenPair + L-->>C: AuthTokenData +``` + +### 3.4 Invite Code + +邀請碼以 SHA-256 hash 儲存,明文永不落庫。 + +| 操作 | 行為 | +|------|------| +| **Validate** | 依 `(tenant_id, code_hash)` 查詢,檢查過期與剩餘次數 | +| **Consume** | Redis SETNX lock(30s)→ 原子 `$inc used_count` | + +**消費時機:** + +- Email 註冊:在 `/register` 起始即 Consume +- 社交註冊:Start 僅 Validate;Callback 才 Consume + +### 3.5 OAuth Session + +| Session 類型 | Redis Key | State 前綴 | 用途 | +|-------------|-----------|-----------|------| +| Registration | `auth:register:session:{id}` | `reg:` | 社交註冊暫存 invite / terms | +| Login | `auth:login:session:{id}` | `login:` | 社交登入暫存 tenant / redirect | + +--- + +## 4. Data Design + +### 4.1 Data Dictionary + +#### invite_codes(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, code_hash) | +| code_hash | String | SHA-256(normalized code) | Unique(tenant_id, code_hash) | +| max_uses | Int64 | 最大可用次數 | — | +| used_count | Int64 | 已使用次數 | — | +| expires_at | Int64 | 過期時間 ms(0=永不過期) | — | +| new_users_only | Bool | 僅限新用戶(社交註冊) | — | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | + +#### registration_metadata(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, uid) | +| uid | String | 會員 UID | Unique(tenant_id, uid) | +| invite_code_id | String | 使用的邀請碼 ID | — | +| accept_terms_version | String | 接受的服務條款版本 | — | +| marketing_opt_in | Bool | 行銷 opt-in | — | +| registration_channel | String | email / google | — | +| client_ip | String | 註冊 IP | — | +| user_agent | String | User-Agent | — | +| occurred_at | Int64 | 事件時間 ms | — | +| create_at | Int64 | 建立時間 ms | — | + +#### Redis Session / Token Keys + +| Key Pattern | Type | TTL | Comment | +|-------------|------|-----|---------| +| auth:register:session:{id} | String(JSON) | 600s | 社交註冊 OAuth session | +| auth:login:session:{id} | String(JSON) | 600s | 社交登入 OAuth session | +| auth:invite:consume:{tenant}:{hash} | String | 30s | 邀請碼消費鎖 | +| auth:jwt:pair:{jti} | String | token TTL | access↔refresh 映射 | +| auth:jwt:bl:{jti} | String | 至自然過期 | JWT 黑名單 | + +--- + +## 5. API Design + +**Base Path:** `/api/v1/auth` + +### 5.1 Public Endpoints(無 Bearer) + +| Method | Path | 說明 | +|--------|------|------| +| POST | `/register` | Email 註冊(回傳 challenge_id) | +| POST | `/register/confirm` | OTP 確認 → 核發 JWT | +| POST | `/register/resend` | 重發註冊 OTP | +| POST | `/register/social/start` | 社交註冊 OAuth 起始 | +| GET | `/register/social/callback` | 社交註冊 OAuth callback | +| POST | `/login` | 密碼登入 | +| POST | `/login/social/start` | 社交登入 OAuth 起始 | +| GET | `/login/social/callback` | 社交登入 OAuth callback | +| POST | `/token/refresh` | 刷新 token pair | +| POST | `/token/exchange` | ZITADEL id_token → CloudEP JWT | + +### 5.2 Protected Endpoints(需 Bearer JWT) + +| Method | Path | 說明 | +|--------|------|------| +| POST | `/logout` | 登出(黑名單 jti) | + +完整請求/回應 schema 見 `generate/api/auth.api`;成功 envelope `code=102000`。 + +--- + +## 6. Resource + +| 資源 | 路徑 | +|------|------| +| API 定義 | `generate/api/auth.api` | +| Logic 編排 | `internal/logic/auth/` | +| JWT Middleware | `internal/middleware/authjwt_*.go` | +| 模組原始碼 | `internal/model/auth/` | +| 設定範例 | `etc/gateway.dev.example.yaml` → `Auth` / `JWT` 區塊 | +| 設計參考 | `docs/identity-member-design.md` | diff --git a/internal/model/member/SDD.md b/internal/model/member/SDD.md new file mode 100644 index 0000000..1039ec7 --- /dev/null +++ b/internal/model/member/SDD.md @@ -0,0 +1,300 @@ +# Member Service + +# Member Service — SRS/SDD Document + +| Version | Version Date | Editor | Memo | +|---------|--------------|--------|------| +| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Member 模組 SDD | + +--- + +## 1. Introduction + +### 1.1 Purpose + +Member 模組為 TianTing Gateway 的**會員核心領域層**,管理多租戶下的 Tenant(租戶)、Member(會員 profile)、Identity(外部身份對映),以及 UID 生成、業務 Email/Phone OTP 驗證、TOTP step-up MFA、重發/每日配額等原子業務原語。 + +### 1.2 Scope + +**範圍內:** + +- 租戶建立與 slug 解析 +- 會員生命週期(unverified → active → suspended → deleted) +- 外部身份 Provision(OIDC / LDAP / SCIM) +- Profile 讀寫與業務 contact 驗證標記 +- OTP challenge(bcrypt + Redis) +- TOTP 綁定/驗證/備援碼(AES-GCM 保護 secret) +- 可讀 UID 序號生成 + +**範圍外:** + +- 密碼驗證、JWT 簽發 → Auth 模組 + ZITADEL +- 通知寄送 → Notification 模組(logic 層編排) +- RBAC → Permission 模組 + +**架構原則:** usecase **不可**呼叫其他 usecase;多步流程由 logic 層編排。 + +### 1.3 Definitions, Acronyms, and Abbreviation + +| 縮寫 | 說明 | +|------|------| +| **Tenant** | 租戶,B2B 客戶組織單位 | +| **Member** | 租戶範圍內的會員 profile | +| **Identity** | 外部 ID(LDAP/SCIM external_id 或 zitadel_sub)→ UID 對映 | +| **UID** | 可讀主鍵,格式 `{UIDPrefix}-{Sequence}`(如 `ACME-10000003`) | +| **OTP** | 一次性數字驗證碼(Email/Phone 業務驗證) | +| **TOTP** | RFC 6238 時間型 OTP(Google Authenticator) | +| **Origin** | 會員來源:platform_native / oidc / ldap / scim | + +### 1.4 Technologies to be used + +| 項目 | 技術 | +|------|------| +| Application Language | Go 1.22+ | +| Framework | go-zero | +| Cache | Redis(OTP、TOTP enroll、UID seq、verify rate) | +| Database | MongoDB | +| Crypto | bcrypt(OTP)、AES-GCM(TOTP secret)、RFC 6238 TOTP | +| API Codegen | goctl / `.api` 檔 | + +### 1.5 Overview + +Member 模組提供 7 個 atomic UseCase,由 `NewModuleFromParam` 依 Mongo / Redis / TOTP KEK 條件組裝: + +| UseCase | 條件 | +|---------|------| +| OTP、VerifyRate | Redis 必填 | +| Profile、Lifecycle、Tenant、Provisioning | Mongo 設定後 | +| TOTP | Mongo + `TOTP.SecretKEK` 設定後 | + +--- + +## 2. System Overview + +Member 模組是多租戶身份系統的資料核心: + +- 每個 Member 必屬一個 Tenant +- UID 以 `{TenantUIDPrefix}-{Sequence}` 生成,序號從 10,000,000 起跳 +- 平台註冊走 `unverified → active`;OIDC/LDAP/SCIM 首登直接 `active` +- 業務 Email/Phone 驗證與 TOTP 為 step-up MFA 能力 + +對外透過 `/api/v1/members/*` REST API 暴露(profile、verification、TOTP)。 + +--- + +## 3. System Architecture + +### 3.1 System Architecture + +```mermaid +flowchart TB + subgraph Client["Client"] + App[Frontend / API Client] + end + + subgraph Gateway["Gateway"] + Handler["handler/member"] + Logic["logic/member
(orchestration)"] + subgraph MemberModule["internal/model/member"] + UC["usecase/ (7 atomic)"] + Repo["repository/"] + TOTP["totp/ (RFC 6238)"] + end + end + + subgraph Storage["Storage"] + Mongo[(MongoDB)] + Redis[(Redis)] + end + + subgraph Sibling["Sibling Modules"] + Auth[auth] + Notif[notification] + end + + App --> Handler + Handler --> Logic + Logic --> UC + Logic --> Notif + Auth --> UC + UC --> Repo + UC --> TOTP + Repo --> Mongo + Repo --> Redis +``` + +### 3.2 Decomposition Description + +| 套件 | 職責 | +|------|------| +| `config/` | OTP / TOTP / Registration 設定 | +| `domain/entity/` | Member、Tenant、Identity Mongo doc | +| `domain/enum/` | MemberStatus、MemberOrigin、OTPPurpose、TenantStatus | +| `domain/repository/` | 7 個 repository 介面 | +| `domain/usecase/` | 7 個 usecase 介面 + DTO | +| `domain/redis.go` | Redis key helper(禁止字串拼接) | +| `repository/` | Mongo + Redis 實作 | +| `totp/` | RFC 6238 純函式(secret、verify、otpauth URL) | +| `usecase/` | 實作 + module factory + mapper | + +### 3.3 Member Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> unverified: Lifecycle.CreateUnverified
(platform 註冊) + [*] --> active: Provisioning.Ensure*
(OIDC/LDAP/SCIM) + + unverified --> active: Activate (OTP 通過) + unverified --> deleted: AbortPending (註冊逾時) + + active --> suspended: Suspend + suspended --> active: Reactivate + active --> deleted: SoftDelete + suspended --> deleted: SoftDelete + deleted --> [*] +``` + +### 3.4 UID Generation + +```mermaid +sequenceDiagram + participant UC as Lifecycle / Provisioning + participant Gen as UIDGenerator + participant R as Redis + + UC->>Gen: Next(tenant, uidPrefix) + Gen->>R: INCR member:seq:{tenant} + alt seq == 1 (首次) + Gen->>R: INCRBY (UIDSequenceStart - 1) + end + Gen-->>UC: "{PREFIX}-{seq}" +``` + +- `UIDSequenceStart = 10_000_000` +- `UIDPrefix` 限制 2~4 個大寫字母 + +### 3.5 OTP / TOTP + +**OTP:** bcrypt hash 存 Redis challenge;Verify 成功後立即刪除(一次性)。 + +**TOTP:** secret 以 AES-GCM cipher 存 Mongo;enroll 階段 staged secret 存 Redis(TTL 600s);VerifyCode 含 replay 保護(timestep 去重)。 + +--- + +## 4. Data Design + +### 4.1 Data Dictionary + +#### tenants(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | — | +| slug | String | URL slug | Unique | +| name | String | 顯示名稱 | — | +| uid_prefix | String | UID 前綴(2-4 大寫) | Unique | +| status | String | active / suspended | — | +| org_id | String | 外部 org ID | — | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | + +#### members(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, uid) | +| uid | String | 可讀 UID | Unique(tenant_id, uid) | +| zitadel_user_id | String | ZITADEL sub | Unique(tenant_id, zitadel_user_id) sparse | +| zitadel_email | String | IdP email | — | +| display_name | String | 顯示名稱 | — | +| avatar | String | 頭像 URL | — | +| phone | String | 聯絡電話 | — | +| language | String | 語系 | — | +| currency | String | 幣別 | — | +| member_status | String | unverified/active/suspended/deleted | — | +| origin | String | platform_native/oidc/ldap/scim | — | +| business_email | String | 業務 email | — | +| business_email_verified | Bool | 業務 email 已驗證 | — | +| business_phone | String | 業務 phone | — | +| business_phone_verified | Bool | 業務 phone 已驗證 | — | +| totp_enrolled | Bool | TOTP 已綁定 | — | +| totp_secret_cipher | Binary | AES-GCM 加密 secret | — | +| totp_backup_codes_hash | Array | bcrypt 備援碼 hash | — | +| suspend_reason | String | 停權原因 | — | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | +| deleted_at | Int64 | 軟刪時間 ms | — | + +#### identities(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, external_id) | +| external_id | String | LDAP/SCIM 外部 ID | Unique(tenant_id, external_id) | +| zitadel_user_id | String | ZITADEL sub | Unique(tenant_id, zitadel_user_id) | +| uid | String | 對應 Member UID | — | +| create_at | Int64 | 建立時間 ms | — | + +#### Redis Keys + +| Key Pattern | 用途 | TTL | +|-------------|------|-----| +| member:otp:challenge:{id} | OTP challenge(bcrypt hash) | 300s | +| member:otp:challenge:{id}:attempts | OTP 錯誤次數 | 同 challenge | +| member:verify:rate:{tenant}:{uid}:{kind} | 重發冷卻 | 60s | +| member:verify:daily:{tenant}:{uid}:{kind} | 每日上限計數 | 24h | +| member:totp:enroll:{tenant}:{uid} | TOTP 綁定 staged secret | 600s | +| member:totp:used:{tenant}:{uid}:{step} | TOTP 重放保護 | 90s | +| member:seq:{tenant} | UID 序號 INCR | 永久 | + +--- + +## 5. API Design + +**Base Path:** `/api/v1/members` + +### 5.1 Profile + +| Method | Path | 說明 | +|--------|------|------| +| GET | `/me` | 取得當前會員 profile | +| PATCH | `/me` | 更新 display_name / avatar / language 等 | + +### 5.2 Verification(OTP) + +| Method | Path | 說明 | +|--------|------|------| +| POST | `/me/verifications/email/start` | 發起業務 email OTP | +| POST | `/me/verifications/email/confirm` | 確認 email OTP | +| POST | `/me/verifications/phone/start` | 發起業務 phone OTP | +| POST | `/me/verifications/phone/confirm` | 確認 phone OTP | + +### 5.3 TOTP + +| Method | Path | 說明 | +|--------|------|------| +| GET | `/me/totp/status` | TOTP 綁定狀態 | +| POST | `/me/totp/enroll` | 開始綁定(回 otpauth_url) | +| POST | `/me/totp/enroll/confirm` | 確認綁定(回 backup codes) | +| POST | `/me/totp/verify` | Step-up 驗碼 | +| POST | `/me/totp/disable` | 解除綁定 | +| POST | `/me/totp/backup-codes/regenerate` | 重產備援碼 | + +完整 schema 見 `generate/api/member.api`。 + +--- + +## 6. Resource + +| 資源 | 路徑 | +|------|------| +| API 定義 | `generate/api/member.api` | +| Logic 編排 | `internal/logic/member/` | +| 模組原始碼 | `internal/model/member/` | +| 開發 README | `internal/model/member/README.md` | +| 設定範例 | `etc/gateway.dev.example.yaml` → `Member` 區塊 | +| 設計參考 | `docs/identity-member-design.md`、`docs/model.md` §6.1 | +| Seed CLI | `cmd/member-seed` | diff --git a/internal/model/notification/SDD.md b/internal/model/notification/SDD.md new file mode 100644 index 0000000..5947b15 --- /dev/null +++ b/internal/model/notification/SDD.md @@ -0,0 +1,297 @@ +# Notification Service + +# Notification Service — SRS/SDD Document + +| Version | Version Date | Editor | Memo | +|---------|--------------|--------|------| +| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Notification 模組 SDD | + +--- + +## 1. Introduction + +### 1.1 Purpose + +Notification 模組為 TianTing Gateway 的**統一對外通知入口**,支援 Email 與 SMS 的同步寄送、異步佇列、冪等、租戶配額、指數退避重試與 DLQ(Dead Letter Queue)管理。 + +### 1.2 Scope + +**範圍內:** + +- 同步 `Send`:渲染模板後立即送達 +- 異步 `Enqueue`:Mongo 寫入 pending + Redis ZSET 排程 + RetryWorker 背景投遞 +- 冪等(Redis cache + Mongo unique index) +- 租戶每日 Email/SMS 配額 +- 模板渲染(embed HTML / SMS / Subject,多語系) +- Provider chain(SMTP、SES、Mitake、Mock) +- Admin DLQ 查詢與手動重試 + +**範圍外:** + +- Push / Webhook(enum 已定義,尚未實作) +- 專用 HTTP API(目前僅內部 logic 呼叫;擴充路徑見 README) + +### 1.3 Definitions, Acronyms, and Abbreviation + +| 縮寫 | 說明 | +|------|------| +| **NotifyKind** | 通知類型(verify_email、tenant_welcome 等) | +| **Channel** | 通道:email / sms / push / webhook | +| **DLQ** | Dead Letter Queue,超過 MaxRetry 的失敗通知 | +| **IdempotencyKey** | 冪等鍵,同 tenant+kind+key 不重複寄送 | +| **TargetHash** | SHA-256(target),Mongo 不存明文 email/phone | +| **RetryJob** | Redis ZSET 成員,含 target(僅 Redis,不落 Mongo) | + +### 1.4 Technologies to be used + +| 項目 | 技術 | +|------|------| +| Application Language | Go 1.22+ | +| Framework | go-zero | +| Database | MongoDB(notifications、notification_dlq) | +| Cache / Queue | Redis(冪等、配額、retry ZSET) | +| Templates | go:embed + Go text/html template | +| Email Providers | SMTP、AWS SES、Mock | +| SMS Providers | Mitake(三竹)、Mock | + +### 1.5 Overview + +Notification 為 library-style domain package,嵌入 Gateway process 內執行: + +1. **Send**:Caller → 冪等檢查 → 配額 → Insert pending → Render → Provider chain → Update sent/failed +2. **Enqueue**:同上至 Insert → ZADD Redis → 回傳 pending;RetryWorker 背景 ClaimDue → 投遞 +3. **DLQ**:attempts ≥ MaxRetry → status=dropped + 寫入 notification_dlq + +--- + +## 2. System Overview + +Notification 模組解耦業務邏輯與實際寄送: + +- Auth 註冊 OTP、Member 業務驗證等 logic 層呼叫 `Notifier.Send` +- 歡迎信等可容忍延遲的通知可走 `Enqueue` + Worker +- 隱私設計:Mongo / DLQ 只存 `target_hash`,明文 target 僅在 Redis RetryJob 生命週期內存在 + +Worker 由 `ServiceContext.StartWorkers` 啟動,與 Gateway 同 process 運行。 + +--- + +## 3. System Architecture + +### 3.1 System Architecture + +```mermaid +flowchart TB + subgraph Callers["Internal Callers"] + AuthLogic[logic/auth] + MemberLogic[logic/member] + end + + subgraph NotificationModule["internal/model/notification"] + Notifier[NotifierUseCase] + Admin[AdminNotifierUseCase] + Worker[RetryWorker] + Factory[Provider Factory] + Renderer[Template Renderer] + end + + subgraph Providers["Providers"] + EmailChain[email.Chain
SMTP / SES / Mock] + SMSChain[sms.Chain
Mitake / Mock] + end + + subgraph Storage["Storage"] + Mongo[(MongoDB)] + Redis[(Redis)] + end + + AuthLogic --> Notifier + MemberLogic --> Notifier + Notifier --> Renderer + Notifier --> Factory + Notifier --> Mongo + Notifier --> Redis + Factory --> EmailChain + Factory --> SMSChain + Worker --> Redis + Worker --> Mongo + Worker --> EmailChain + Worker --> SMSChain + Admin --> Mongo + Admin --> Redis +``` + +### 3.2 Decomposition Description + +| 套件 | 職責 | +|------|------| +| `config/` | Async、Rate、Email、SMS 設定 | +| `domain/entity/` | Notification、NotificationDLQ | +| `domain/enum/` | Channel、NotifyKind、NotifyStatus、Severity | +| `domain/repository/` | Notification、DLQ、Store、RetryQueue 介面 | +| `domain/usecase/` | Notifier、Admin 介面 + DTO | +| `domain/template/` | 模板 Spec、Renderer、Registry 介面 | +| `repository/` | Mongo + Redis 實作 | +| `usecase/` | Notifier、Admin、RetryWorker、factory、module | +| `provider/email/` | SMTP、SES、Mock sender + chain | +| `provider/sms/` | Mitake、Mock sender + chain | +| `template/` | embed 模板 + render | + +### 3.3 Send(同步) + +```mermaid +sequenceDiagram + participant C as Caller + participant N as NotifierUseCase + participant R as Redis + participant M as Mongo + participant P as Provider + + C->>N: Send(SendRequest) + N->>R: Get idempotency + alt cache hit + N-->>C: cached DTO + else miss + N->>M: FindByIdempotency / Insert pending + N->>R: Incr quota + N->>N: Render template + N->>P: Email/SMS chain.Send + N->>M: UpdateDelivery (sent/failed) + N->>R: Set idempotency cache + N-->>C: NotificationDTO + end +``` + +### 3.4 Enqueue + RetryWorker(異步) + +```mermaid +sequenceDiagram + participant C as Caller + participant N as NotifierUseCase + participant M as Mongo + participant R as Redis + participant W as RetryWorker + participant P as Provider + + C->>N: Enqueue(SendRequest) + N->>M: Insert (pending) + N->>R: ZADD RetryJob (score=now) + N-->>C: pending DTO + + loop every 1s + W->>R: ClaimDue (ZREM) + W->>M: FindByID + W->>P: Render + deliver + alt success + W->>M: status=sent + else attempts < MaxRetry + W->>M: status=retrying + W->>R: ZADD (score=now+backoff) + else + W->>M: status=dropped + W->>M: Insert DLQ + end + end +``` + +**退避序列(預設):** `[1, 5, 30, 300, 1800]` 秒 + +--- + +## 4. Data Design + +### 4.1 Data Dictionary + +#### notifications(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, kind, idempotency_key) | +| uid | String | 會員 UID | (tenant_id, uid, occurred_at desc) | +| channel | String | email / sms | — | +| kind | String | NotifyKind | Unique(tenant_id, kind, idempotency_key) | +| target_hash | String | SHA-256(target) | — | +| template_key | String | 模板 key | — | +| locale | String | 語系 | — | +| body | String | 渲染後內容(OTP 類可省略) | — | +| provider | String | 實際使用的 provider | — | +| provider_message_id | String | 外部 message ID | — | +| status | String | pending/sent/failed/retrying/dropped | (status, attempts, occurred_at) | +| attempts | Int | 投遞嘗試次數 | — | +| last_error | String | 最後錯誤 | — | +| idempotency_key | String | 冪等鍵 | Unique(tenant_id, kind, idempotency_key) | +| severity | String | info/warn/critical | — | +| occurred_at | Int64 | 事件時間 ms | — | +| delivered_at | Int64 | 送達時間 ms | — | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | + +#### notification_dlq(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| notification_id | String | 原 notification ID | — | +| tenant_id | String | 租戶 ID | — | +| uid | String | 會員 UID | — | +| channel | String | 通道 | — | +| kind | String | 通知類型 | — | +| target_hash | String | target hash | — | +| last_error | String | 最後錯誤 | — | +| attempts | Int | 總嘗試次數 | — | +| payload | Object | 重試 metadata(locale、data 等,不含 target) | — | +| occurred_at | Int64 | 事件時間 ms | — | +| create_at | Int64 | 建立時間 ms | — | + +#### Redis Keys + +| Key Pattern | 用途 | TTL | +|-------------|------|-----| +| notif:idem:{tenant}:{kind}:{key} | 冪等 cache | 24h | +| notif:quota:{tenant}:{channel}:{yyyyMMdd} | 每日配額計數 | 25h | +| notif:retry:zset(可配置) | RetryJob ZSET(score=run_at_ms) | — | + +#### NotifyKind 一覽 + +| Kind | 通道 | 說明 | +|------|------|------| +| verify_email | email | 業務 email OTP | +| verify_phone | sms | 業務 phone OTP | +| verify_registration_email | email | 註冊 email OTP | +| step_up_email | email | Email step-up | +| step_up_phone | sms | Phone step-up | +| account_suspended | email | 帳號停權通知 | +| tenant_welcome | email | 租戶歡迎信 | + +--- + +## 5. API Design + +**現況:** 尚無專用 HTTP API。Notification 由內部 logic 呼叫: + +| 呼叫方 | 方法 | 用途 | +|--------|------|------| +| `logic/auth/register_logic.go` | `Notifier.Send` | 註冊 email OTP | +| `logic/member/verify_helper.go` | `Notifier.Send` | 業務 email/phone OTP | +| `cmd/notify-test` | Send / Enqueue / Admin | 本機測試 CLI | + +**擴充 HTTP API 路徑:** + +1. 在 `generate/api/` 定義路由 +2. `make gen-api` +3. `internal/logic` 映射 types ↔ DTO + +--- + +## 6. Resource + +| 資源 | 路徑 | +|------|------| +| 模組原始碼 | `internal/model/notification/` | +| 開發 README | `internal/model/notification/README.md` | +| 測試指南 | `docs/notification-testing.md` | +| 測試 CLI | `cmd/notify-test`(`make notify-test`) | +| Worker | `internal/worker/notification_retry/` | +| 設定範例 | `etc/gateway.dev.example.yaml` → `Notification` 區塊 | +| 設計參考 | `docs/identity-member-design.md` §11 | diff --git a/internal/model/permission/SDD.md b/internal/model/permission/SDD.md new file mode 100644 index 0000000..a12680b --- /dev/null +++ b/internal/model/permission/SDD.md @@ -0,0 +1,327 @@ +# Permission Service + +# Permission Service — SRS/SDD Document + +| Version | Version Date | Editor | Memo | +|---------|--------------|--------|------| +| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Permission 模組 SDD(對齊 frontend Permission Service SDD 格式) | +| 1.1.0 | — | — | 新增 category 樹狀 Permission Catalog | +| 1.2.0 | — | — | 多租戶 B2B RBAC + Casbin + RoleMapping | + +--- + +## 1. Introduction + +### 1.1 Purpose + +Permission 模組提供 Gateway **多租戶 B2B 自定義 RBAC**:平台級 Permission Catalog + 租戶級 Role / RolePermission / UserRole / RoleMapping,搭配 Casbin enforcer 進行 HTTP path/method 授權,並支援外部 IdP(ZITADEL / LDAP / SCIM)角色映射同步。 + +### 1.2 Scope + +**範圍內(Multiple tenants):** + +- 平台 Permission Catalog(樹狀,dot notation) +- 租戶 Role CRUD(含 system role 防呆) +- Role ↔ Permission 多對多(含 parent closure) +- User ↔ Role 多對多(source: manual / zitadel / ldap / scim) +- 外部 group → 內部 Role.Key 映射(RoleMapping) +- Casbin policy 物化(Redis Set)+ 多 pod Pub/Sub reload +- GET /permissions/me 前端選單渲染 + +**範圍外:** + +- JWT 簽發 → Auth 模組 +- 會員資料 → Member 模組 +- Platform admin bypass → middleware 預檢(保留 audit) + +### 1.3 Definitions, Acronyms, and Abbreviation + +| 縮寫 | 說明 | +|------|------| +| **RBAC** | Role-Based Access Control | +| **Permission** | 平台級權限節點(可為分類或 leaf API 權限) | +| **Role** | 租戶內角色,`key` 唯一且不可改 | +| **RolePermission** | Role 勾選的 Permission 集合 | +| **UserRole** | 使用者被指派的角色 | +| **RoleMapping** | 外部 IdP group/role → 內部 Role.Key | +| **Casbin** | 授權引擎,policy 存 Redis | +| **Leaf Permission** | 有 http_path + http_methods 的可 enforcement 節點 | + +### 1.4 Technologies to be used + +| 項目 | 技術 | +|------|------| +| Application Language | Go 1.22+ | +| Framework | go-zero | +| Cache / Policy Store | Redis(Casbin rules Set + Pub/Sub) | +| Database | MongoDB | +| Authorization Engine | Casbin(`keyMatch2` + `regexMatch`) | +| API Codegen | goctl / `.api` 檔 | + +### 1.5 Overview + +```mermaid +flowchart LR + subgraph Platform["平台層"] + Catalog[Permission Catalog] + end + subgraph Tenant["租戶層"] + Role[Role] + RP[RolePermission] + UR[UserRole] + RM[RoleMapping] + end + Catalog --> RP --> Role + Role --> UR + Role --> RM + Tenant --> Casbin[(Casbin Enforcer
Redis adapter)] + Casbin --> MW[CasbinRBAC Middleware] +``` + +- Permission **平台 seed 全局**,租戶不可新增,只能勾選 +- Role / RolePermission / UserRole **租戶獨立** +- Role.Key 一旦建立 **不可改**(外部 IdP 對應用) +- 多 pod 同步:**Redis Pub/Sub 即時 + cron 兜底** + +--- + +## 2. System Overview + +Permission 模組是 Gateway 授權子系統: + +1. 平台維護 Permission Catalog(`cmd/permission-seed`) +2. 租戶管理員建立 Role、勾選 Permission +3. 指派 UserRole(手動或 SyncFromX 同步) +4. RBACUseCase 物化 Casbin policy → middleware 檢查 `(tenant, role.key, path, method)` + +前端透過 `GET /permissions/me` 取得當前使用者的 role keys 與 permission map(含可選 tree)。 + +--- + +## 3. System Architecture + +### 3.1 System Architecture + +```mermaid +flowchart TD + Logic[logic/permission] --> SVC[svc.ServiceContext] + SVC --> AuthQ[AuthorizationQueryUseCase] + SVC --> Perm[PermissionUseCase] + SVC --> Role[RoleUseCase] + SVC --> RolePerm[RolePermissionUseCase] + SVC --> UserRole[UserRoleUseCase] + SVC --> Mapping[RoleMappingUseCase] + SVC --> RBAC[RBACUseCase] + + RBAC --> Adapter[Casbin Redis Adapter] + Adapter --> Redis[(Redis)] + RBAC --> Pub[Redis Pub/Sub casbin:reload] + + Perm --> PermR[(permissions)] + Role --> RoleR[(roles)] + RolePerm --> RPR[(role_permissions)] + UserRole --> URR[(user_roles)] + Mapping --> RMR[(role_mappings)] +``` + +### 3.2 Decomposition Description + +| 套件 | 職責 | +|------|------| +| `config/` | CasbinConfig、CacheConfig、ReloadConfig | +| `domain/entity/` | Permission、Role、RolePermission、UserRole、RoleMapping | +| `domain/enum/` | Status、PermissionType、RoleSource | +| `domain/repository/` | 5 個 Mongo repo 介面 + Casbin adapter port | +| `domain/usecase/` | 7 個 usecase 介面 + DTO | +| `repository/` | Mongo + Redis Casbin adapter | +| `usecase/` | 7 個 atomic usecase 實作 + permission_tree | +| `seed/` | catalog.json + Apply CLI | + +### 3.3 RBAC Model + +Casbin 模型(`etc/rbac.conf`): + +```ini +[request_definition] +r = tenant, role, path, method + +[policy_definition] +p = tenant, role, path, methods, name + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) +``` + +**授權檢查(any-allow):** 使用者所有 open role 逐一 EnforceEx,任一 allow 即通過。 + +```mermaid +sequenceDiagram + participant MW as CasbinRBAC Middleware + participant RBAC as RBACUseCase + participant URR as UserRoleRepository + participant Enf as casbin.Enforcer + + MW->>RBAC: Check{tenant, uid, path, method} + RBAC->>URR: ListByUser + loop each open role + RBAC->>Enf: EnforceEx(tenant, role.key, path, method) + alt allow + RBAC-->>MW: Allow + end + end + RBAC-->>MW: Deny → 403 +``` + +### 3.4 Permission Tree + +``` +member.info.management ← 分類(無 HTTP) +├── member.basic.info +│ ├── member.info.select GET /api/v1/members/me +│ └── member.info.update PATCH /api/v1/members/me +└── member.admin.list GET /api/v1/members + +permission.role.management +├── permission.role.read GET /api/v1/permissions/roles +└── permission.role.write POST/PUT/DELETE /api/v1/permissions/roles* +``` + +分類節點(無 `http_path`)不寫入 Casbin policy;Replace RolePermission 時自動補齊 parent closure。 + +### 3.5 Policy Reload(多 Pod) + +```mermaid +sequenceDiagram + participant PodA as Pod A + participant Redis + participant PodB as Pod B + + PodA->>PodA: RolePermission.Replace + LoadPolicy + PodA->>Redis: PUBLISH casbin:reload {tenant_id} + Redis-->>PodB: message + PodB->>PodB: LoadPolicy(tenant) +``` + +--- + +## 4. Data Design + +### 4.1 Data Dictionary + +#### permissions(MongoDB — 平台全局) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| parent | String | 父節點 ObjectId hex | parent | +| name | String | dot notation 唯一名稱 | Unique | +| http_methods | String | GET 或 GET\|POST\|PATCH | — | +| http_path | String | keyMatch2 path pattern | — | +| status | String | open / close | status | +| type | String | backend_user / frontend_user | type | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | + +#### roles(MongoDB — 租戶範圍) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, key) | +| key | String | 角色 key(不可改) | Unique(tenant_id, key) | +| display_name | String | 顯示名稱 | — | +| creator_uid | String | 建立者 UID | — | +| status | String | open / close | (tenant_id, is_system) | +| is_system | Bool | 平台預設角色 | (tenant_id, is_system) | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | + +#### role_permissions(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, role_id, permission_id) | +| role_id | String | Role ObjectId hex | (tenant_id, role_id) | +| permission_id | String | Permission ObjectId hex | (tenant_id, permission_id) | +| create_at | Int64 | 建立時間 ms | — | + +#### user_roles(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, uid, role_id) | +| uid | String | 會員 UID | (tenant_id, uid, source) | +| role_id | String | Role ObjectId hex | (tenant_id, role_id) | +| source | String | manual / zitadel / ldap / scim | (tenant_id, uid, source) | +| create_at | Int64 | 建立時間 ms | — | + +#### role_mappings(MongoDB) + +| Field | Type | Comment | Index | +|-------|------|---------|-------| +| _id | ObjectId | PK | PK | +| tenant_id | String | 租戶 ID | Unique(tenant_id, external_source, external_key) | +| external_source | String | zitadel / ldap / scim | Unique(tenant_id, external_source, external_key) | +| external_key | String | 外部 group/role key | Unique(tenant_id, external_source, external_key) | +| internal_role_id | String | 內部 Role ID | (tenant_id, internal_role_id) | +| create_at | Int64 | 建立時間 ms | — | +| update_at | Int64 | 更新時間 ms | — | + +#### Redis Keys + +| Key Pattern | 內容 | TTL | +|-------------|------|-----| +| permission:casbin:rules:{tenant_id} | Set of JSON Casbin rules | 永久 | +| perm:user_roles:{tenant_id}:{uid} | role keys 快取(預留) | 300s | +| perm:role_perms:{tenant_id}:{role_id} | permission names 快取(預留) | 300s | +| (channel) casbin:reload | Pub/Sub reload payload | — | + +**Casbin rule 格式:** `[tenant, role.key, http_path, http_methods, perm.name]` + +--- + +## 5. API Design + +**Base Path:** `/api/v1/permissions` + +| Method | Path | 說明 | +|--------|------|------| +| GET | `/catalog` | 全局 Permission Catalog(tree/list) | +| GET | `/me` | 當前 user 的 roles + permissions | +| GET | `/roles` | 租戶角色清單 | +| POST | `/roles` | 建立角色 | +| PATCH | `/roles/:id` | 更新 display_name / status | +| DELETE | `/roles/:id` | 刪除角色 | +| GET | `/roles/:id/permissions` | 角色 permission 集合 | +| PUT | `/roles/:id/permissions` | 全量取代 + parent closure + reload | +| GET | `/users/:uid/roles` | 使用者 role 列表 | +| POST | `/users/:uid/roles` | 指派角色 | +| DELETE | `/users/:uid/roles/:role_id` | 撤銷角色 | +| GET | `/role-mappings` | 外部映射列表 | +| PUT | `/role-mappings` | Upsert 外部映射 | +| DELETE | `/role-mappings` | 刪除外部映射 | +| POST | `/policy/reload` | 強制重載 policy(單租戶或 `*`) | + +完整 schema 見 `generate/api/permission.api`。 + +--- + +## 6. Resource + +| 資源 | 路徑 | +|------|------| +| API 定義 | `generate/api/permission.api` | +| Logic | `internal/logic/permission/` | +| Middleware | `internal/middleware/casbin_rbac.go` | +| 模組原始碼 | `internal/model/permission/` | +| 開發 README | `internal/model/permission/README.md` | +| Casbin 模型 | `etc/rbac.conf` | +| Seed 資料 | `internal/model/permission/seed/catalog.json` | +| Seed CLI | `cmd/permission-seed` | +| 設定範例 | `etc/gateway.dev.example.yaml` → `Permission` 區塊 | +| 設計參考 | `docs/identity-member-design.md` §6 / §7.3 / §13 | diff --git a/internal/model/permission/seed/catalog.go b/internal/model/permission/seed/catalog.go index 6a187d6..46116ff 100644 --- a/internal/model/permission/seed/catalog.go +++ b/internal/model/permission/seed/catalog.go @@ -7,6 +7,7 @@ import ( _ "embed" "encoding/json" "fmt" + "sort" "time" "gateway/internal/model/permission/domain/entity" @@ -129,12 +130,12 @@ func Apply( return report, nil } - idByName, err := loadCatalogIDIndex(ctx, perms) + allPerms, permByName, err := loadCatalogIndex(ctx, perms) if err != nil { return nil, err } for _, tenantID := range opts.TenantIDs { - if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, idByName, report); err != nil { + if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, allPerms, permByName, report); err != nil { return nil, err } } @@ -225,23 +226,42 @@ func loadCatalogIDIndex( ctx context.Context, perms domrepo.PermissionRepository, ) (map[string]string, error) { - all, err := perms.GetAll(ctx, nil) + all, byName, err := loadCatalogIndex(ctx, perms) if err != nil { return nil, err } idByName := make(map[string]string, len(all)) - for _, p := range all { - idByName[p.Name] = p.ID.Hex() + for name, perm := range byName { + idByName[name] = perm.ID.Hex() } return idByName, nil } +func loadCatalogIndex( + ctx context.Context, + perms domrepo.PermissionRepository, +) ([]*entity.Permission, map[string]*entity.Permission, error) { + all, err := perms.GetAll(ctx, nil) + if err != nil { + return nil, nil, err + } + sort.SliceStable(all, func(i, j int) bool { + return all[i].Name < all[j].Name + }) + byName := make(map[string]*entity.Permission, len(all)) + for _, p := range all { + byName[p.Name] = p + } + return all, byName, nil +} + func seedTenantRoles( ctx context.Context, roles domrepo.RoleRepository, rolePerms domrepo.RolePermissionRepository, tenantID string, - idByName map[string]string, + allPerms []*entity.Permission, + permByName map[string]*entity.Permission, report *Report, ) error { for _, def := range DefaultSystemRoles { @@ -260,13 +280,9 @@ func seedTenantRoles( } report.RolesUpserted++ } - permissionIDs := make([]string, 0, len(def.PermissionNames)) - for _, name := range def.PermissionNames { - id, ok := idByName[name] - if !ok { - return fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key) - } - permissionIDs = append(permissionIDs, id) + permissionIDs, err := resolveRolePermissionIDs(def, allPerms, permByName) + if err != nil { + return err } if err := rolePerms.SetForRole(ctx, tenantID, role.ID.Hex(), permissionIDs); err != nil { return fmt.Errorf("permission seed: tenant=%s set role perms %s: %w", tenantID, def.Key, err) @@ -275,3 +291,63 @@ func seedTenantRoles( } return nil } + +func resolveRolePermissionIDs( + def SystemRoleDefinition, + allPerms []*entity.Permission, + permByName map[string]*entity.Permission, +) ([]string, error) { + childrenByParent := make(map[string][]*entity.Permission, len(allPerms)) + parentByID := make(map[string]string, len(allPerms)) + for _, perm := range allPerms { + id := perm.ID.Hex() + parentByID[id] = perm.Parent + childrenByParent[perm.Parent] = append(childrenByParent[perm.Parent], perm) + } + + seen := make(map[string]struct{}, len(def.PermissionNames)*4) + for _, name := range def.PermissionNames { + perm, ok := permByName[name] + if !ok { + return nil, fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key) + } + markWithParents(perm.ID.Hex(), parentByID, seen) + if !perm.IsLeaf() { + markDescendantLeaves(perm.ID.Hex(), childrenByParent, parentByID, seen) + } + } + + out := make([]string, 0, len(seen)) + for _, perm := range allPerms { + id := perm.ID.Hex() + if _, ok := seen[id]; ok { + out = append(out, id) + } + } + return out, nil +} + +func markDescendantLeaves( + parentID string, + childrenByParent map[string][]*entity.Permission, + parentByID map[string]string, + seen map[string]struct{}, +) { + for _, child := range childrenByParent[parentID] { + if child.IsLeaf() { + markWithParents(child.ID.Hex(), parentByID, seen) + continue + } + markDescendantLeaves(child.ID.Hex(), childrenByParent, parentByID, seen) + } +} + +func markWithParents(id string, parentByID map[string]string, seen map[string]struct{}) { + for id != "" { + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + id = parentByID[id] + } +} diff --git a/scripts/e2e-down.sh b/scripts/e2e-down.sh new file mode 100755 index 0000000..02e02c8 --- /dev/null +++ b/scripts/e2e-down.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=scripts/e2e-lib.sh +source "${ROOT}/scripts/e2e-lib.sh" +cd "$ROOT" + +GATEWAY_PORT="${GATEWAY_PORT:-18888}" +PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}" + +e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}" + +if command -v lsof >/dev/null 2>&1 && lsof -ti tcp:"${GATEWAY_PORT}" >/dev/null 2>&1; then + echo "e2e-down: warning — port ${GATEWAY_PORT} still in use" >&2 + lsof -i tcp:"${GATEWAY_PORT}" >&2 || true + exit 1 +fi + +docker compose down -v +echo "e2e-down OK (gateway stopped, docker cleaned)" diff --git a/scripts/e2e-lib.sh b/scripts/e2e-lib.sh new file mode 100644 index 0000000..8ff00a9 --- /dev/null +++ b/scripts/e2e-lib.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Shared helpers for e2e-run / e2e-up / e2e-down. +# shellcheck disable=SC2034 + +e2e_root_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd +} + +# Stop gateway started for E2E: pid file → port listeners → stale go run orphans. +e2e_stop_gateway() { + local port="${1:-18888}" + local pid_file="${2:-}" + + if [[ -n "${pid_file}" && -f "${pid_file}" ]]; then + local pid + pid="$(cat "${pid_file}")" + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + echo ">> stopping gateway pid=${pid}" + kill "${pid}" 2>/dev/null || true + for _ in $(seq 1 10); do + kill -0 "${pid}" 2>/dev/null || break + sleep 0.2 + done + if kill -0 "${pid}" 2>/dev/null; then + kill -9 "${pid}" 2>/dev/null || true + fi + wait "${pid}" 2>/dev/null || true + fi + rm -f "${pid_file}" + fi + + if command -v lsof >/dev/null 2>&1; then + local pids + pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)" + if [[ -n "${pids// /}" ]]; then + echo ">> stopping listener(s) on :${port} (${pids})" + # shellcheck disable=SC2086 + kill ${pids} 2>/dev/null || true + sleep 0.5 + pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)" + if [[ -n "${pids// /}" ]]; then + # shellcheck disable=SC2086 + kill -9 ${pids} 2>/dev/null || true + fi + fi + fi + + # go run leaves a compiled binary under $TMPDIR; kill by e2e config path if still up. + if command -v pgrep >/dev/null 2>&1; then + while IFS= read -r orphan; do + [[ -z "${orphan}" ]] && continue + echo ">> stopping orphan gateway pid=${orphan}" + kill "${orphan}" 2>/dev/null || true + done < <(pgrep -f "gateway(-e2e)? .*${port}|gateway.go -f .*e2e.yaml" 2>/dev/null || true) + fi +} + +# Build a real binary so $! is the server PID (go run only tracks the wrapper). +e2e_start_gateway() { + local root="$1" + local config="$2" + local port="$3" + local pid_file="$4" + + local bin="${root}/.cache/e2e-gateway" + mkdir -p "${root}/.cache" + + e2e_stop_gateway "${port}" "${pid_file}" + + echo ">> building e2e gateway binary" + (cd "${root}" && go build -o "${bin}" gateway.go) + + echo ">> starting gateway on :${port}" + GATEWAY_E2E=1 "${bin}" -f "${config}" & + local pid=$! + echo "${pid}" > "${pid_file}" + echo "${pid}" +} + +e2e_wait_gateway() { + local port="$1" + local url="http://127.0.0.1:${port}/api/v1/health" + for i in $(seq 1 60); do + if curl -sf "${url}" >/dev/null; then + return 0 + fi + sleep 1 + done + echo "timeout waiting for gateway ${url}" >&2 + return 1 +} diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh new file mode 100755 index 0000000..d275f6d --- /dev/null +++ b/scripts/e2e-run.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# 一鍵 E2E:全新 Docker → index → seed → 起 Gateway → 跑測試 → 關閉並清 volume +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=scripts/e2e-lib.sh +source "${ROOT}/scripts/e2e-lib.sh" +cd "$ROOT" + +E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}" +E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}" +E2E_TEST_PATTERN="${E2E_TEST_PATTERN:-Test(Auth_|Health|Member|Permission)}" +GATEWAY_PORT="${GATEWAY_PORT:-18888}" +PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}" + +cleanup() { + e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}" + if [[ "${E2E_KEEP_DOCKER:-}" != "1" ]]; then + echo ">> docker compose down -v" + docker compose down -v + else + echo ">> E2E_KEEP_DOCKER=1: leaving containers running" + fi +} +trap cleanup EXIT + +echo ">> [1/6] fresh docker compose (mongo + redis)" +docker compose down -v >/dev/null 2>&1 || true +docker compose up -d mongo redis + +echo ">> [2/6] wait for mongo/redis healthy" +for i in $(seq 1 60); do + if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then + break + fi + sleep 1 + if [[ "$i" -eq 60 ]]; then + echo "timeout waiting for docker health" >&2 + docker compose ps >&2 + exit 1 + fi +done + +echo ">> [3/6] mongo indexes" +go run ./cmd/mongo-index -f "${E2E_CONFIG}" + +echo ">> [4/6] e2e seed (tenant + member + permission + JWT)" +rm -f "${E2E_STATE}" +seed_args=(-f "${E2E_CONFIG}" -out "${E2E_STATE}") +if [[ -n "${E2E_ROLE:-}" ]]; then + seed_args+=(-role "${E2E_ROLE}") +fi +go run ./cmd/e2e-seed "${seed_args[@]}" + +echo ">> [5/6] start gateway on :${GATEWAY_PORT}" +e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}" >/dev/null +e2e_wait_gateway "${GATEWAY_PORT}" + +echo ">> [6/6] run e2e tests (main suite, then auth teardown)" +GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \ + go test -tags=e2e -v -count=1 ./test/e2e/... -run "${E2E_TEST_PATTERN}" +GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \ + go test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZ_AuthTokenRefreshAndLogout' + +echo ">> E2E OK" diff --git a/scripts/e2e-up.sh b/scripts/e2e-up.sh new file mode 100755 index 0000000..4f28b58 --- /dev/null +++ b/scripts/e2e-up.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# 啟動 E2E 環境但不跑測試(方便本機除錯) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=scripts/e2e-lib.sh +source "${ROOT}/scripts/e2e-lib.sh" +cd "$ROOT" + +E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}" +E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}" +GATEWAY_PORT="${GATEWAY_PORT:-18888}" +PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}" + +docker compose down -v >/dev/null 2>&1 || true +docker compose up -d mongo redis + +for i in $(seq 1 60); do + if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then + break + fi + sleep 1 +done + +go run ./cmd/mongo-index -f "${E2E_CONFIG}" +go run ./cmd/e2e-seed -f "${E2E_CONFIG}" -out "${E2E_STATE}" + +if [[ -f "${PID_FILE}" ]] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null && curl -sf "http://127.0.0.1:${GATEWAY_PORT}/api/v1/health" >/dev/null; then + echo "gateway already running pid=$(cat "${PID_FILE}") url=http://127.0.0.1:${GATEWAY_PORT}" +else + pid="$(e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}")" + echo "gateway started pid=${pid} url=http://127.0.0.1:${GATEWAY_PORT}" +fi + +e2e_wait_gateway "${GATEWAY_PORT}" +echo "e2e-up OK — run: make test-e2e" diff --git a/test/e2e/auth_test.go b/test/e2e/auth_test.go new file mode 100644 index 0000000..e099de7 --- /dev/null +++ b/test/e2e/auth_test.go @@ -0,0 +1,91 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestZZZ_AuthTokenRefreshAndLogout runs last (separate go test invocation). +// It uses an isolated refresh so seed tokens used by member/permission stay valid. +func TestZZZ_AuthTokenRefreshAndLogout(t *testing.T) { + c := isolatedAuthClient(t) + + refreshEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/token/refresh", map[string]string{ + "refresh_token": c.Fixture.RefreshToken, + }, false) + var pair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + UID string `json:"uid"` + } + require.NoError(t, json.Unmarshal(refreshEnv.Data, &pair)) + require.Equal(t, c.Fixture.UID, pair.UID) + + c.Fixture.AccessToken = pair.AccessToken + c.Fixture.RefreshToken = pair.RefreshToken + + c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) + c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/logout", nil, true) + + resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, true) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.NotEqual(t, int64(successCode), env.Code) +} + +func TestAuth_MissingBearer_401(t *testing.T) { + c := NewClient(t) + resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, false) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.NotEqual(t, int64(successCode), env.Code) +} + +func TestAuth_PublicValidationErrors(t *testing.T) { + c := NewClient(t) + + cases := []struct { + name string + path string + body any + }{ + { + name: "register missing required fields", + path: "/api/v1/auth/register", + body: map[string]any{}, + }, + { + name: "login invalid email and password", + path: "/api/v1/auth/login", + body: map[string]any{ + "tenant_slug": c.Fixture.TenantSlug, + "email": "not-an-email", + "password": "short", + }, + }, + { + name: "token refresh missing token", + path: "/api/v1/auth/token/refresh", + body: map[string]any{}, + }, + { + name: "social login invalid provider", + path: "/api/v1/auth/login/social/start", + body: map[string]any{ + "tenant_slug": c.Fixture.TenantSlug, + "provider": "github", + "redirect_uri": "http://127.0.0.1/callback", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := c.DoExpectHTTP(t, http.MethodPost, tc.path, tc.body, false, http.StatusBadRequest) + require.NotEqual(t, int64(successCode), env.Code) + }) + } +} diff --git a/test/e2e/client.go b/test/e2e/client.go new file mode 100644 index 0000000..0fd22cc --- /dev/null +++ b/test/e2e/client.go @@ -0,0 +1,144 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +const successCode = 102000 + +// Fixture holds seed output from cmd/e2e-seed. +type Fixture struct { + BaseURL string `json:"base_url"` + TenantID string `json:"tenant_id"` + TenantSlug string `json:"tenant_slug"` + UID string `json:"uid"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + RoleKey string `json:"role_key"` + NoRoleUID string `json:"no_role_uid"` + NoRoleEmail string `json:"no_role_email"` + NoRoleAccessToken string `json:"no_role_access_token"` + NoRoleRefreshToken string `json:"no_role_refresh_token"` +} + +// Client is a thin HTTP helper for Gateway E2E tests. +type Client struct { + BaseURL string + HTTP *http.Client + Fixture Fixture +} + +// Envelope matches gateway/types.Status JSON. +type Envelope struct { + Code int64 `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + Error json.RawMessage `json:"error,omitempty"` +} + +// LoadFixture returns the shared bootstrap fixture (refreshed once in TestMain). +func LoadFixture(t *testing.T) Fixture { + t.Helper() + fx := loadFixture() + require.NotEmpty(t, fx.BaseURL) + require.NotEmpty(t, fx.AccessToken) + return fx +} + +// NewClient builds a client from the shared fixture. +func NewClient(t *testing.T) *Client { + t.Helper() + return freshClient(t) +} + +// NewNoRoleClient builds a client for the seeded member that intentionally +// has no role assignment. It is used by Casbin-enabled authorization tests. +func NewNoRoleClient(t *testing.T) *Client { + t.Helper() + c := freshClient(t) + require.NotEmpty(t, c.Fixture.NoRoleUID) + require.NotEmpty(t, c.Fixture.NoRoleAccessToken) + c.Fixture.UID = c.Fixture.NoRoleUID + c.Fixture.Email = c.Fixture.NoRoleEmail + c.Fixture.AccessToken = c.Fixture.NoRoleAccessToken + c.Fixture.RefreshToken = c.Fixture.NoRoleRefreshToken + return c +} + +func (c *Client) URL(path string) string { + return c.BaseURL + path +} + +func (c *Client) Do(t *testing.T, method, path string, body any, auth bool) (*http.Response, Envelope) { + t.Helper() + var r io.Reader + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + r = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, c.URL(path), r) + require.NoError(t, err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if auth { + req.Header.Set("Authorization", "Bearer "+c.Fixture.AccessToken) + } + resp, err := c.HTTP.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var env Envelope + require.NoError(t, json.Unmarshal(respBody, &env), "body=%s", string(respBody)) + return resp, env +} + +func (c *Client) DoExpectOK(t *testing.T, method, path string, body any, auth bool) Envelope { + t.Helper() + resp, env := c.Do(t, method, path, body, auth) + require.Equal(t, http.StatusOK, resp.StatusCode, "path=%s code=%d body=%s", path, env.Code, string(mustRaw(env))) + require.Equal(t, int64(successCode), env.Code, "path=%s message=%s", path, env.Message) + return env +} + +func (c *Client) DoExpectHTTP(t *testing.T, method, path string, body any, auth bool, httpStatus int) Envelope { + t.Helper() + resp, env := c.Do(t, method, path, body, auth) + require.Equal(t, httpStatus, resp.StatusCode, "path=%s env=%+v", path, env) + return env +} + +func mustRaw(env Envelope) []byte { + if len(env.Data) == 0 { + return []byte(env.Message) + } + return env.Data +} + +// FetchE2EOTP reads OTP plain code stashed by verify_helper when GATEWAY_E2E=1. +func FetchE2EOTP(t *testing.T, challengeID string) string { + t.Helper() + if cli := os.Getenv("REDISCLI"); cli != "" { + out, err := runCmd(cli, "-c", fmt.Sprintf("GET e2e:otp:%s", challengeID)) + require.NoError(t, err) + require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID) + return out + } + out, err := runCmd("docker", "exec", "gateway-redis", "redis-cli", "GET", "e2e:otp:"+challengeID) + require.NoError(t, err, "fetch otp via docker exec (is gateway-redis running?)") + require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID) + return out +} diff --git a/test/e2e/exec.go b/test/e2e/exec.go new file mode 100644 index 0000000..00e06f8 --- /dev/null +++ b/test/e2e/exec.go @@ -0,0 +1,17 @@ +//go:build e2e + +package e2e + +import ( + "os/exec" + "strings" +) + +func runCmd(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/test/e2e/fixtures/e2e.casbin.yaml b/test/e2e/fixtures/e2e.casbin.yaml new file mode 100644 index 0000000..6489c36 --- /dev/null +++ b/test/e2e/fixtures/e2e.casbin.yaml @@ -0,0 +1,93 @@ +# E2E 專用設定(Casbin enabled;make e2e-casbin 使用) +# 固定 Port 18888,避免與本機 dev server (8888) 衝突 + +Name: gateway-e2e-casbin +Host: 0.0.0.0 +Port: 18888 + +Mongo: + Schema: mongodb + Host: 127.0.0.1 + Port: 27017 + Database: gateway_e2e + AuthSource: "" + ReplicaName: "" + TLS: false + MaxPoolSize: 30 + MinPoolSize: 5 + MaxConnIdleTime: 30m + +Redis: + Host: localhost:6379 + Type: node + +Notification: + DefaultLocale: zh-tw + Email: + Provider: mock + From: e2e-noreply@example.com + SMTP: + Enable: false + SMS: + Provider: mock + Mitake: + Enable: false + Async: + Worker: 1 + MaxRetry: 3 + BackoffSeconds: [1, 2, 5] + RatePerTenant: + Email: 1000 + SMS: 500 + +Member: + OTP: + Length: 6 + TTLSeconds: 300 + MaxAttempts: 5 + ResendCooldownSeconds: 1 + DailyVerifyLimit: 100 + TOTP: + Issuer: CloudEP-E2E + Algorithm: SHA1 + Digits: 6 + PeriodSeconds: 30 + Window: 1 + BackupCodeCount: 5 + BackupCodeLength: 12 + EnrollTTLSeconds: 600 + ReplayTTLSeconds: 90 + SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + Registration: + RequireInviteCode: false + TrustSocialEmailVerified: true + +Auth: + AccessExpire: 900 + RefreshExpire: 604800 + ActiveKID: v1 + AccessSecret: "e2e-access-secret-32-bytes-min!!" + RefreshSecret: "e2e-refresh-secret-32-bytes-min!" + RegistrationSessionTTLSeconds: 600 + +Permission: + Casbin: + Enabled: true + ModelPath: etc/rbac.conf + PolicyAdapter: redis + Cache: + UserRolesTTLSeconds: 60 + RolePermsTTLSeconds: 60 + CatalogTTLSeconds: 120 + Reload: + Channel: casbin:reload:e2e + DebounceMilliseconds: 100 + HeartbeatSeconds: 30 + +Zitadel: + Issuer: "" + ServiceUserToken: "" + DefaultOrgID: "" + OAuthClientID: "" + OAuthClientSecret: "" + TimeoutSeconds: 5 diff --git a/test/e2e/fixtures/e2e.yaml b/test/e2e/fixtures/e2e.yaml new file mode 100644 index 0000000..3062d2b --- /dev/null +++ b/test/e2e/fixtures/e2e.yaml @@ -0,0 +1,93 @@ +# E2E 專用設定(make e2e-full 使用;勿與 gateway.dev.yaml 混用) +# 固定 Port 18888,避免與本機 dev server (8888) 衝突 + +Name: gateway-e2e +Host: 0.0.0.0 +Port: 18888 + +Mongo: + Schema: mongodb + Host: 127.0.0.1 + Port: 27017 + Database: gateway_e2e + AuthSource: "" + ReplicaName: "" + TLS: false + MaxPoolSize: 30 + MinPoolSize: 5 + MaxConnIdleTime: 30m + +Redis: + Host: localhost:6379 + Type: node + +Notification: + DefaultLocale: zh-tw + Email: + Provider: mock + From: e2e-noreply@example.com + SMTP: + Enable: false + SMS: + Provider: mock + Mitake: + Enable: false + Async: + Worker: 1 + MaxRetry: 3 + BackoffSeconds: [1, 2, 5] + RatePerTenant: + Email: 1000 + SMS: 500 + +Member: + OTP: + Length: 6 + TTLSeconds: 300 + MaxAttempts: 5 + ResendCooldownSeconds: 1 + DailyVerifyLimit: 100 + TOTP: + Issuer: CloudEP-E2E + Algorithm: SHA1 + Digits: 6 + PeriodSeconds: 30 + Window: 1 + BackupCodeCount: 5 + BackupCodeLength: 12 + EnrollTTLSeconds: 600 + ReplayTTLSeconds: 90 + SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + Registration: + RequireInviteCode: false + TrustSocialEmailVerified: true + +Auth: + AccessExpire: 900 + RefreshExpire: 604800 + ActiveKID: v1 + AccessSecret: "e2e-access-secret-32-bytes-min!!" + RefreshSecret: "e2e-refresh-secret-32-bytes-min!" + RegistrationSessionTTLSeconds: 600 + +Permission: + Casbin: + Enabled: false + ModelPath: etc/rbac.conf + PolicyAdapter: auto + Cache: + UserRolesTTLSeconds: 60 + RolePermsTTLSeconds: 60 + CatalogTTLSeconds: 120 + Reload: + Channel: casbin:reload:e2e + DebounceMilliseconds: 100 + HeartbeatSeconds: 30 + +Zitadel: + Issuer: "" + ServiceUserToken: "" + DefaultOrgID: "" + OAuthClientID: "" + OAuthClientSecret: "" + TimeoutSeconds: 5 diff --git a/test/e2e/health_test.go b/test/e2e/health_test.go new file mode 100644 index 0000000..da75406 --- /dev/null +++ b/test/e2e/health_test.go @@ -0,0 +1,25 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHealth_Ping(t *testing.T) { + c := NewClient(t) + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/health", nil, false) + var data map[string]any + require.NoError(t, json.Unmarshal(env.Data, &data)) +} + +func TestHealth_NoAuthRequired(t *testing.T) { + c := NewClient(t) + resp, env := c.Do(t, http.MethodGet, "/api/v1/health", nil, false) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, int64(successCode), env.Code) +} diff --git a/test/e2e/member_test.go b/test/e2e/member_test.go new file mode 100644 index 0000000..5f40fdb --- /dev/null +++ b/test/e2e/member_test.go @@ -0,0 +1,194 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + membertotp "gateway/internal/model/member/totp" + + "github.com/stretchr/testify/require" +) + +func TestMember_GetMe(t *testing.T) { + c := NewClient(t) + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) + var me struct { + TenantID string `json:"tenant_id"` + UID string `json:"uid"` + Status string `json:"status"` + } + require.NoError(t, json.Unmarshal(env.Data, &me)) + require.Equal(t, c.Fixture.TenantID, me.TenantID) + require.Equal(t, c.Fixture.UID, me.UID) + require.Equal(t, "active", me.Status) +} + +func TestMember_UpdateMe(t *testing.T) { + c := NewClient(t) + name := "E2E Updated Name" + env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{ + "display_name": name, + }, true) + var me struct { + DisplayName string `json:"display_name"` + } + require.NoError(t, json.Unmarshal(env.Data, &me)) + require.Equal(t, name, me.DisplayName) +} + +func TestMember_EmailVerification_FullFlow(t *testing.T) { + c := NewClient(t) + target := "verified-e2e@example.com" + + startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{ + "target": target, + }, true) + var start struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + } + require.NoError(t, json.Unmarshal(startEnv.Data, &start)) + require.NotEmpty(t, start.ChallengeID) + require.Positive(t, start.ExpiresIn) + + code := FetchE2EOTP(t, start.ChallengeID) + c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{ + "challenge_id": start.ChallengeID, + "code": code, + }, true) + + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) + var me struct { + BusinessEmail string `json:"business_email"` + BusinessEmailVerified bool `json:"business_email_verified"` + } + require.NoError(t, json.Unmarshal(env.Data, &me)) + require.Equal(t, target, me.BusinessEmail) + require.True(t, me.BusinessEmailVerified) +} + +func TestMember_PhoneVerification_FullFlow(t *testing.T) { + c := NewClient(t) + target := "+886912345678" + + startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{ + "target": target, + }, true) + var start struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + } + require.NoError(t, json.Unmarshal(startEnv.Data, &start)) + require.NotEmpty(t, start.ChallengeID) + require.Positive(t, start.ExpiresIn) + + code := FetchE2EOTP(t, start.ChallengeID) + c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{ + "challenge_id": start.ChallengeID, + "code": code, + }, true) + + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) + var me struct { + BusinessPhone string `json:"business_phone"` + BusinessPhoneVerified bool `json:"business_phone_verified"` + } + require.NoError(t, json.Unmarshal(env.Data, &me)) + require.Equal(t, target, me.BusinessPhone) + require.True(t, me.BusinessPhoneVerified) +} + +func TestMember_TOTP_Status(t *testing.T) { + c := NewClient(t) + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) + var st struct { + Enrolled bool `json:"enrolled"` + } + require.NoError(t, json.Unmarshal(env.Data, &st)) + require.False(t, st.Enrolled) +} + +func TestMember_TOTP_FullFlow(t *testing.T) { + c := NewClient(t) + + startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true) + var start struct { + OtpauthURL string `json:"otpauth_url"` + Digits int `json:"digits"` + PeriodSec int `json:"period_seconds"` + } + require.NoError(t, json.Unmarshal(startEnv.Data, &start)) + require.NotEmpty(t, start.OtpauthURL) + require.Positive(t, start.Digits) + require.Positive(t, start.PeriodSec) + + code := codeFromOtpauthURL(t, start.OtpauthURL, start.Digits, start.PeriodSec) + confirmEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{ + "code": code, + }, true) + var confirmed struct { + BackupCodes []string `json:"backup_codes"` + } + require.NoError(t, json.Unmarshal(confirmEnv.Data, &confirmed)) + require.NotEmpty(t, confirmed.BackupCodes) + + statusEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) + var status struct { + Enrolled bool `json:"enrolled"` + BackupCodesRemaining int `json:"backup_codes_remaining"` + } + require.NoError(t, json.Unmarshal(statusEnv.Data, &status)) + require.True(t, status.Enrolled) + require.Equal(t, len(confirmed.BackupCodes), status.BackupCodesRemaining) + + c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ + "code": code, + }, true) + + replayEnv := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ + "code": code, + }, true, http.StatusForbidden) + require.NotEqual(t, int64(successCode), replayEnv.Code) + + backupEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/backup-codes", nil, true) + var backup struct { + BackupCodes []string `json:"backup_codes"` + } + require.NoError(t, json.Unmarshal(backupEnv.Data, &backup)) + require.NotEmpty(t, backup.BackupCodes) + + c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true) + finalEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) + var finalStatus struct { + Enrolled bool `json:"enrolled"` + } + require.NoError(t, json.Unmarshal(finalEnv.Data, &finalStatus)) + require.False(t, finalStatus.Enrolled) +} + +func codeFromOtpauthURL(t *testing.T, rawURL string, digits, periodSec int) string { + t.Helper() + u, err := url.Parse(rawURL) + require.NoError(t, err) + require.Equal(t, "otpauth", u.Scheme) + require.Equal(t, "totp", u.Host) + + q := u.Query() + secret, err := membertotp.DecodeSecret(q.Get("secret")) + require.NoError(t, err) + if digits <= 0 { + digits, _ = strconv.Atoi(q.Get("digits")) + } + if periodSec <= 0 { + periodSec, _ = strconv.Atoi(q.Get("period")) + } + code, err := membertotp.Generate(secret, time.Now(), time.Duration(periodSec)*time.Second, digits) + require.NoError(t, err) + return code +} diff --git a/test/e2e/permission_test.go b/test/e2e/permission_test.go new file mode 100644 index 0000000..f28249d --- /dev/null +++ b/test/e2e/permission_test.go @@ -0,0 +1,249 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPermission_Catalog(t *testing.T) { + c := NewClient(t) + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=true", nil, true) + var data struct { + Tree []map[string]any `json:"tree"` + } + require.NoError(t, json.Unmarshal(env.Data, &data)) + require.NotEmpty(t, data.Tree) +} + +func TestPermission_Me(t *testing.T) { + c := NewClient(t) + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=true", nil, true) + var data struct { + UID string `json:"uid"` + TenantID string `json:"tenant_id"` + Roles []string `json:"roles"` + Permissions map[string]string `json:"permissions"` + Tree []map[string]any `json:"tree"` + } + require.NoError(t, json.Unmarshal(env.Data, &data)) + require.Equal(t, c.Fixture.UID, data.UID) + require.Equal(t, c.Fixture.TenantID, data.TenantID) + require.Contains(t, data.Roles, c.Fixture.RoleKey) + require.NotEmpty(t, data.Permissions) +} + +func TestPermission_RoleCRUD(t *testing.T) { + c := NewClient(t) + + createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ + "key": "e2e_custom_role", + "display_name": "E2E Custom", + "status": "open", + }, true) + var role struct { + ID string `json:"id"` + Key string `json:"key"` + } + require.NoError(t, json.Unmarshal(createEnv.Data, &role)) + require.Equal(t, "e2e_custom_role", role.Key) + require.NotEmpty(t, role.ID) + + listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles", nil, true) + var list struct { + Roles []struct { + Key string `json:"key"` + } `json:"roles"` + } + require.NoError(t, json.Unmarshal(listEnv.Data, &list)) + found := false + for _, r := range list.Roles { + if r.Key == "e2e_custom_role" { + found = true + break + } + } + require.True(t, found, "created role should appear in list") + + patchEnv := c.DoExpectOK(t, http.MethodPatch, "/api/v1/permissions/roles/"+role.ID, map[string]string{ + "display_name": "E2E Custom Renamed", + }, true) + var patched struct { + DisplayName string `json:"display_name"` + } + require.NoError(t, json.Unmarshal(patchEnv.Data, &patched)) + require.Equal(t, "E2E Custom Renamed", patched.DisplayName) + + c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) +} + +func TestPermission_RolePermissions(t *testing.T) { + c := NewClient(t) + permissionID := firstCatalogPermissionID(t, c) + + createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ + "key": "e2e_role_permissions", + "display_name": "E2E Role Permissions", + }, true) + var role struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(createEnv.Data, &role)) + require.NotEmpty(t, role.ID) + + c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+role.ID+"/permissions", map[string][]string{ + "permission_ids": {permissionID}, + }, true) + + listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+role.ID+"/permissions", nil, true) + var list struct { + Permissions []struct { + ID string `json:"id"` + } `json:"permissions"` + } + require.NoError(t, json.Unmarshal(listEnv.Data, &list)) + require.NotEmpty(t, list.Permissions) + found := false + for _, p := range list.Permissions { + if p.ID == permissionID { + found = true + break + } + } + require.True(t, found, "role should include requested permission") + + c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) +} + +func TestPermission_AssignUserRole(t *testing.T) { + c := NewClient(t) + + createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ + "key": "e2e_assign_role", + "display_name": "E2E Assign", + }, true) + var role struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(createEnv.Data, &role)) + + c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", map[string]string{ + "role_id": role.ID, + }, true) + + listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", nil, true) + var list struct { + UserRoles []struct { + RoleID string `json:"role_id"` + } `json:"user_roles"` + } + require.NoError(t, json.Unmarshal(listEnv.Data, &list)) + found := false + for _, r := range list.UserRoles { + if r.RoleID == role.ID { + found = true + break + } + } + require.True(t, found) + + c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles/"+role.ID, nil, true) + c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) +} + +func TestPermission_RoleMappingCRUD(t *testing.T) { + c := NewClient(t) + roleKey := "e2e_mapping_role" + externalKey := fmt.Sprintf("e2e-group-%s", c.Fixture.UID) + + createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ + "key": roleKey, + "display_name": "E2E Mapping Role", + }, true) + var role struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(createEnv.Data, &role)) + require.NotEmpty(t, role.ID) + + upsertEnv := c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/role-mappings", map[string]string{ + "external_source": "zitadel", + "external_key": externalKey, + "internal_role_key": roleKey, + }, true) + var mapping struct { + ID string `json:"id"` + ExternalSource string `json:"external_source"` + ExternalKey string `json:"external_key"` + InternalRoleID string `json:"internal_role_id"` + InternalRoleKey string `json:"internal_role_key"` + } + require.NoError(t, json.Unmarshal(upsertEnv.Data, &mapping)) + require.NotEmpty(t, mapping.ID) + require.Equal(t, "zitadel", mapping.ExternalSource) + require.Equal(t, externalKey, mapping.ExternalKey) + require.Equal(t, role.ID, mapping.InternalRoleID) + require.Equal(t, roleKey, mapping.InternalRoleKey) + + listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/role-mappings?source=zitadel", nil, true) + var list struct { + Mappings []struct { + ExternalKey string `json:"external_key"` + } `json:"mappings"` + } + require.NoError(t, json.Unmarshal(listEnv.Data, &list)) + found := false + for _, item := range list.Mappings { + if item.ExternalKey == externalKey { + found = true + break + } + } + require.True(t, found, "created role mapping should appear in list") + + c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ + "external_source": "zitadel", + "external_key": externalKey, + }, true) + c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) +} + +func TestPermission_CasbinRBAC(t *testing.T) { + if os.Getenv("E2E_CASBIN") != "1" { + t.Skip("set E2E_CASBIN=1 and use e2e.casbin.yaml to enable Casbin enforcement") + } + + owner := NewClient(t) + reloadEnv := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/policy/reload", nil, true) + var reload struct { + Tenant string `json:"tenant"` + TS int64 `json:"ts"` + } + require.NoError(t, json.Unmarshal(reloadEnv.Data, &reload)) + require.Equal(t, owner.Fixture.TenantID, reload.Tenant) + require.Positive(t, reload.TS) + + noRole := NewNoRoleClient(t) + denied := noRole.DoExpectHTTP(t, http.MethodGet, "/api/v1/permissions/roles", nil, true, http.StatusForbidden) + require.NotEqual(t, int64(successCode), denied.Code) +} + +func firstCatalogPermissionID(t *testing.T, c *Client) string { + t.Helper() + env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=false", nil, true) + var data struct { + List []struct { + ID string `json:"id"` + } `json:"list"` + } + require.NoError(t, json.Unmarshal(env.Data, &data)) + require.NotEmpty(t, data.List) + require.NotEmpty(t, data.List[0].ID) + return data.List[0].ID +} diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go new file mode 100644 index 0000000..21e8697 --- /dev/null +++ b/test/e2e/setup_test.go @@ -0,0 +1,132 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" +) + +var sharedFixture Fixture + +func TestMain(m *testing.M) { + if err := loadSharedFixture(); err != nil { + fmt.Fprintf(os.Stderr, "e2e bootstrap: %v\n", err) + os.Exit(1) + } + os.Exit(m.Run()) +} + +func loadSharedFixture() error { + path := os.Getenv("E2E_STATE_FILE") + if path == "" { + path = filepath.Join("fixtures", "state.json") + } + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w (run make e2e-full)", path, err) + } + if err := json.Unmarshal(raw, &sharedFixture); err != nil { + return err + } + if sharedFixture.BaseURL == "" || sharedFixture.AccessToken == "" { + return fmt.Errorf("invalid fixture in %s", path) + } + if override := os.Getenv("E2E_BASE_URL"); override != "" { + sharedFixture.BaseURL = override + } + return nil +} + +func refreshTokenPair(baseURL, refreshToken string) (Fixture, error) { + body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken}) + req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/token/refresh", bytes.NewReader(body)) + if err != nil { + return Fixture{}, err + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Fixture{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return Fixture{}, err + } + var env Envelope + if err := json.Unmarshal(raw, &env); err != nil { + return Fixture{}, err + } + if resp.StatusCode != http.StatusOK || env.Code != successCode { + return Fixture{}, fmt.Errorf("refresh failed: status=%d code=%d", resp.StatusCode, env.Code) + } + var data struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + if err := json.Unmarshal(env.Data, &data); err != nil { + return Fixture{}, err + } + out := Fixture{AccessToken: data.AccessToken, RefreshToken: data.RefreshToken} + if out.AccessToken == "" || out.RefreshToken == "" { + return Fixture{}, fmt.Errorf("refresh returned empty tokens") + } + return out, nil +} + +func loadFixture() Fixture { + return sharedFixture +} + +func freshClient(t *testing.T) *Client { + t.Helper() + fx := loadFixture() + base := fx.BaseURL + if override := os.Getenv("E2E_BASE_URL"); override != "" { + base = override + } + return &Client{ + BaseURL: base, + HTTP: &http.Client{Timeout: 15 * time.Second}, + Fixture: fx, + } +} + +func isolatedAuthClient(t *testing.T) *Client { + t.Helper() + path := os.Getenv("E2E_STATE_FILE") + if path == "" { + path = filepath.Join("fixtures", "state.json") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var fx Fixture + if err := json.Unmarshal(raw, &fx); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if override := os.Getenv("E2E_BASE_URL"); override != "" { + fx.BaseURL = override + } + pair, err := refreshTokenPair(fx.BaseURL, fx.RefreshToken) + if err != nil { + t.Fatalf("isolated refresh: %v", err) + } + fx.AccessToken = pair.AccessToken + fx.RefreshToken = pair.RefreshToken + return &Client{ + BaseURL: fx.BaseURL, + HTTP: &http.Client{Timeout: 15 * time.Second}, + Fixture: fx, + } +}