add member totp

This commit is contained in:
王性驊 2026-05-22 07:52:39 +08:00
parent bdeb7e8263
commit 1f3eb3c992
23 changed files with 3112 additions and 16 deletions

6
.gitignore vendored
View File

@ -73,5 +73,7 @@ temp/
# ========================= # =========================
# Go workspace本機多模組開發用 # Go workspace本機多模組開發用
# ========================= # =========================
go.work # E2E 產物make e2e-full / e2e-up 生成)
go.work.sum test/e2e/fixtures/state.json
test/e2e/fixtures/gateway.pid
.cache/

View File

@ -15,7 +15,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help .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 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: ## 顯示可用指令 help: ## 顯示可用指令
@ -58,6 +58,24 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
test: ## 執行測試 test: ## 執行測試
$(GO) 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 fmt: ## gofmt + goimports不含 lint
$(GOFMT) -s -w $(GOFILES) $(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1) @command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)

245
cmd/e2e-seed/main.go Normal file
View File

@ -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
}

265
docs/e2e-testing.md Normal file
View File

@ -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 / JWTgitignore執行後生成 |
---
## 測試覆蓋矩陣(一目瞭然)
圖例:**✅ 自動 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-01A-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 | 同步 Sendmock 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-04mock provider |
---
## 統計摘要
| 類別 | 自動 E2E | 待擴充 / 手動 |
|------|:--------:|:-------------:|
| Normal | 2 | 0 |
| Auth | 4 | 9ZITADEL |
| 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
```

View File

@ -2,6 +2,8 @@ package member
import ( import (
"context" "context"
"fmt"
"os"
"time" "time"
memberdom "gateway/internal/model/member/domain" memberdom "gateway/internal/model/member/domain"
@ -72,6 +74,10 @@ func startVerification(
} }
return nil, sendErr 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{ return &types.VerificationStartData{
ChallengeID: dto.ChallengeID, ChallengeID: dto.ChallengeID,
ExpiresIn: dto.ExpiresIn, ExpiresIn: dto.ExpiresIn,

309
internal/model/auth/SDD.md Normal file
View File

@ -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 社交註冊/登入暫存 SessionRedis
- 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 TokenCloudEP 自簽 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 JWTmiddleware `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<br/>(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/` | RegistrationChannelemail / 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 採 HS256access / 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 lock30s→ 原子 `$inc used_count` |
**消費時機:**
- Email 註冊:在 `/register` 起始即 Consume
- 社交註冊Start 僅 ValidateCallback 才 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_codesMongoDB
| 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 | 過期時間 ms0=永不過期) | — |
| new_users_only | Bool | 僅限新用戶(社交註冊) | — |
| create_at | Int64 | 建立時間 ms | — |
| update_at | Int64 | 更新時間 ms | — |
#### registration_metadataMongoDB
| 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` |

View File

@ -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
- 外部身份 ProvisionOIDC / LDAP / SCIM
- Profile 讀寫與業務 contact 驗證標記
- OTP challengebcrypt + 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** | 外部 IDLDAP/SCIM external_id 或 zitadel_sub→ UID 對映 |
| **UID** | 可讀主鍵,格式 `{UIDPrefix}-{Sequence}`(如 `ACME-10000003` |
| **OTP** | 一次性數字驗證碼Email/Phone 業務驗證) |
| **TOTP** | RFC 6238 時間型 OTPGoogle Authenticator |
| **Origin** | 會員來源platform_native / oidc / ldap / scim |
### 1.4 Technologies to be used
| 項目 | 技術 |
|------|------|
| Application Language | Go 1.22+ |
| Framework | go-zero |
| Cache | RedisOTP、TOTP enroll、UID seq、verify rate |
| Database | MongoDB |
| Crypto | bcryptOTP、AES-GCMTOTP 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<br/>(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<br/>(platform 註冊)
[*] --> active: Provisioning.Ensure*<br/>(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 challengeVerify 成功後立即刪除(一次性)。
**TOTP** secret 以 AES-GCM cipher 存 Mongoenroll 階段 staged secret 存 RedisTTL 600sVerifyCode 含 replay 保護timestep 去重)。
---
## 4. Data Design
### 4.1 Data Dictionary
#### tenantsMongoDB
| 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 | — |
#### membersMongoDB
| 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 | — |
#### identitiesMongoDB
| 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 challengebcrypt 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 VerificationOTP
| 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` |

View File

@ -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 的同步寄送、異步佇列、冪等、租戶配額、指數退避重試與 DLQDead Letter Queue管理。
### 1.2 Scope
**範圍內:**
- 同步 `Send`:渲染模板後立即送達
- 異步 `Enqueue`Mongo 寫入 pending + Redis ZSET 排程 + RetryWorker 背景投遞
- 冪等Redis cache + Mongo unique index
- 租戶每日 Email/SMS 配額
- 模板渲染embed HTML / SMS / Subject多語系
- Provider chainSMTP、SES、Mitake、Mock
- Admin DLQ 查詢與手動重試
**範圍外:**
- Push / Webhookenum 已定義,尚未實作)
- 專用 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 | MongoDBnotifications、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 → 回傳 pendingRetryWorker 背景 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<br/>SMTP / SES / Mock]
SMSChain[sms.Chain<br/>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
#### notificationsMongoDB
| 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_dlqMongoDB
| 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 | 重試 metadatalocale、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 ZSETscore=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 |

View File

@ -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 授權,並支援外部 IdPZITADEL / 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 | RedisCasbin 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<br/>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 policyReplace 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
#### permissionsMongoDB — 平台全局)
| 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 | — |
#### rolesMongoDB — 租戶範圍)
| 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_permissionsMongoDB
| 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_rolesMongoDB
| 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_mappingsMongoDB
| 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 Catalogtree/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 |

View File

@ -7,6 +7,7 @@ import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"time" "time"
"gateway/internal/model/permission/domain/entity" "gateway/internal/model/permission/domain/entity"
@ -129,12 +130,12 @@ func Apply(
return report, nil return report, nil
} }
idByName, err := loadCatalogIDIndex(ctx, perms) allPerms, permByName, err := loadCatalogIndex(ctx, perms)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, tenantID := range opts.TenantIDs { 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 return nil, err
} }
} }
@ -225,23 +226,42 @@ func loadCatalogIDIndex(
ctx context.Context, ctx context.Context,
perms domrepo.PermissionRepository, perms domrepo.PermissionRepository,
) (map[string]string, error) { ) (map[string]string, error) {
all, err := perms.GetAll(ctx, nil) all, byName, err := loadCatalogIndex(ctx, perms)
if err != nil { if err != nil {
return nil, err return nil, err
} }
idByName := make(map[string]string, len(all)) idByName := make(map[string]string, len(all))
for _, p := range all { for name, perm := range byName {
idByName[p.Name] = p.ID.Hex() idByName[name] = perm.ID.Hex()
} }
return idByName, nil 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( func seedTenantRoles(
ctx context.Context, ctx context.Context,
roles domrepo.RoleRepository, roles domrepo.RoleRepository,
rolePerms domrepo.RolePermissionRepository, rolePerms domrepo.RolePermissionRepository,
tenantID string, tenantID string,
idByName map[string]string, allPerms []*entity.Permission,
permByName map[string]*entity.Permission,
report *Report, report *Report,
) error { ) error {
for _, def := range DefaultSystemRoles { for _, def := range DefaultSystemRoles {
@ -260,13 +280,9 @@ func seedTenantRoles(
} }
report.RolesUpserted++ report.RolesUpserted++
} }
permissionIDs := make([]string, 0, len(def.PermissionNames)) permissionIDs, err := resolveRolePermissionIDs(def, allPerms, permByName)
for _, name := range def.PermissionNames { if err != nil {
id, ok := idByName[name] return err
if !ok {
return fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
}
permissionIDs = append(permissionIDs, id)
} }
if err := rolePerms.SetForRole(ctx, tenantID, role.ID.Hex(), permissionIDs); err != nil { 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) return fmt.Errorf("permission seed: tenant=%s set role perms %s: %w", tenantID, def.Key, err)
@ -275,3 +291,63 @@ func seedTenantRoles(
} }
return nil 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]
}
}

21
scripts/e2e-down.sh Executable file
View File

@ -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)"

91
scripts/e2e-lib.sh Normal file
View File

@ -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
}

65
scripts/e2e-run.sh Executable file
View File

@ -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"

36
scripts/e2e-up.sh Executable file
View File

@ -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"

91
test/e2e/auth_test.go Normal file
View File

@ -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)
})
}
}

144
test/e2e/client.go Normal file
View File

@ -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
}

17
test/e2e/exec.go Normal file
View File

@ -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
}

View File

@ -0,0 +1,93 @@
# E2E 專用設定Casbin enabledmake 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

View File

@ -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

25
test/e2e/health_test.go Normal file
View File

@ -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)
}

194
test/e2e/member_test.go Normal file
View File

@ -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
}

249
test/e2e/permission_test.go Normal file
View File

@ -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
}

132
test/e2e/setup_test.go Normal file
View File

@ -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,
}
}