test(e2e): 加 banner / e2e-list / k6 風格 user journey

讓「我有哪些測試、現在在測什麼」一眼看得到,並補上跨 endpoint 的狀態流測試:

每個測試開頭印中文 banner
- 新增 e2eStep(t, id, method, path, desc) helper(test/e2e/setup_test.go)
- 17 個 contract test 開頭加 banner,go test -v 會逐個顯示
    ▶ [M-01] GET /api/v1/members/me — 讀 profile(tenant/uid/status)
- 對外 ID 與 docs/e2e-testing.md 的測試覆蓋矩陣對齊

新增 make e2e-list
- scripts/e2e-list.sh 掃 _test.go,分兩節印 contract tests + journeys;
  每個 journey 列出所有 step ID + 描述(Step 用 ▶、SkipStep 用 ⊘)

scripts 彩色 step banner + optional MailHog
- scripts/e2e-lib.sh 抽共用 helpers(e2e_step/info/ok/warn、e2e_print_services)
- e2e-run.sh / e2e-up.sh 改用 step banner + 服務面板(執行完印出 Mongo/Redis/
  Gateway/MailHog 的 URL)
- E2E_WITH_SMTP=1 會額外起 MailHog(http://localhost:8025),方便肉眼確認流程

k6 風格 user journey
- 新增 test/e2e/journey.go:NewJourney + Step + SkipStep + Summary,
  任一步 fail 自動 skip 後續,輸出 ▶ [J-x.y] 階層 banner
- J-1 Tenant Owner 入職第一天(12 steps):/me → PATCH → email verify
  → phone verify → TOTP enroll/verify/replay/disable
- J-2 Tenant Admin 建 qa_engineer 角色 → 指派 → 二人視角驗證 → 撤銷(8 steps)
- J-3 Session 生命週期 refresh → /me → logout → 舊 token 401(4 steps,ZZZ 排最後)
- J-4 完整註冊 → 登入(5 steps stub,標 SkipStep;接 ZITADEL container 後改 Step 即可)
- make e2e-journey / make test-e2e-journey 拆獨立 target;e2e-run.sh 透過
  E2E_MODE=journey + E2E_TEST_PATTERN_ZZZ 切換

docs/e2e-testing.md
- 首節改為「我現在有哪些測試?make e2e-list」並附 banner 範例輸出
- 加 Journeys 章節:journey 列表、執行範例、失敗時的輸出、寫新 journey 範本
- 補 e2e-journey / test-e2e-journey / E2E_WITH_SMTP 環境變數

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-05-22 17:18:36 +08:00
parent 55446b9060
commit 9616969fd0
17 changed files with 963 additions and 42 deletions

View File

@ -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 test-e2e e2e-full e2e-casbin e2e-up e2e-down fmt lint lint-fix fix check run \
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test test-e2e test-e2e-journey e2e-full e2e-casbin e2e-up e2e-down e2e-list 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,11 +58,21 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
test: ## 執行測試
$(GO) test ./...
test-e2e: ## 對已啟動的 Gateway 跑 E2E(需 state.json見 docs/e2e-testing.md
test-e2e: ## 對已啟動的 Gateway 跑 E2E contract tests單一 endpoint 驗證;需 state.json
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 + 關閉(一鍵完整測試)
test-e2e-journey: ## 對已啟動的 Gateway 跑 E2E user journeysk6 風格多步流程;需 state.json
GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestJourney_'
GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZJourney_'
e2e-full: ## 全新 Docker + index + seed + E2E contract tests + 關閉
bash scripts/e2e-run.sh
e2e-journey: ## 全新 Docker + index + seed + E2E user journeysk6 風格)+ 關閉
E2E_MODE=journey \
E2E_TEST_PATTERN='TestJourney_' \
E2E_TEST_PATTERN_ZZZ='TestZZZJourney_' \
bash scripts/e2e-run.sh
e2e-casbin: ## 全新 Docker + Casbin enabled + E2E + 關閉(含 RBAC 403 / reload
@ -76,6 +86,9 @@ e2e-up: ## 起 Docker + index + seed + Gateway不跑測試、不 teardown
e2e-down: ## 停止 E2E Gateway 並 docker compose down -v
bash scripts/e2e-down.sh
e2e-list: ## 列出所有 E2E 測試ID + HTTP path + 中文描述)
@bash scripts/e2e-list.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)

View File

@ -1,6 +1,46 @@
# Gateway E2E 測試指南
本文件列出 **所有 HTTP API 測試情境****一鍵跑完整 E2E** 的方式。自動化測試程式在 `test/e2e/`build tag: `e2e`)。
兩種測試風格並行build tag: `e2e`code 在 `test/e2e/`
| 風格 | 用途 | 對應指令 |
|------|------|----------|
| **Contract tests** | 單一 endpoint 驗 HTTP contract請求 → 回應),可平行 | `make e2e-full` |
| **Journeys**k6 風格) | 多步驟 user flow**共享狀態**、**任一步 fail 自動 skip 後續** | `make e2e-journey` |
---
## 我現在有哪些測試?
```bash
make e2e-list
```
會分兩節印出來:
```
═══ Contract testsmake e2e-full═══
── Member ──
[M-01 ] GET /api/v1/members/me 讀 profiletenant/uid/status
[M-03/M-04] POST /me/verifications/email/{start,...} 業務 email OTP 申請 → 驗證
── Permission ──
[P-03~P-06] * /api/v1/permissions/roles 租戶角色 CRUD
═══ Journeysmake e2e-journey═══
[J-1] Tenant Owner 入職第一天(已登入後完整 onboarding (12 steps)
▶ [J-1.1] GET /me — 用 seed JWT 確認自己是 tenant_owner 且 status=active
▶ [J-1.2] PATCH /me — 更新 display_name
▶ [J-1.3] POST /me/verifications/email/start — 申請業務 email OTP
...
[J-2] Tenant Admin 從零建立 qa_engineer 角色 → 指派 → 驗證 → 撤銷 (8 steps)
[J-3] Session 生命週期refresh → /me → logout → 舊 token 401 (4 steps)
[J-4] 完整註冊 → 登入 → 看自己(需 ZITADEL目前 stub (5 steps)
⊘ [J-4.1] ...
```
> 新增 contract test → 在 `_test.go` func 開頭加 `e2eStep(t, "ID", "METHOD", "path", "中文")`
> 新增 journey → 在 `journey_xxx_test.go``NewJourney(t, "J-x", "title")` + `j.Step("id", "desc", fn)``make e2e-list` 都會自動撈到
---
@ -19,7 +59,33 @@ make e2e-full
bash scripts/e2e-run.sh
```
成功時最後一行:`>> E2E OK`
執行時看到的順序(每個 step 都會印 banner
```
== [1/6] fresh docker composemongo + redis==
== [2/6] wait for healthcheck ==
✔ mongo / redis healthy4s
== [3/6] 建立 Mongo 索引cmd/mongo-index==
== [4/6] seed tenant + member + permission + JWTcmd/e2e-seed==
== [5/6] 啟動 Gateway:18888==
E2E 環境服務
MongoDB 127.0.0.1:27017 database=gateway_e2e
Redis 127.0.0.1:6379 OTP / Casbin policy / blacklist
Gateway http://127.0.0.1:18888 health: /api/v1/health
== [6/6] 跑 E2E每個測試會印 ▶ [ID] METHOD path — 中文情境)==
=== RUN TestMember_GetMe
member_test.go:19: ▶ [M-01] GET /api/v1/members/me — 讀 profiletenant/uid/status
--- PASS: TestMember_GetMe (0.05s)
=== RUN TestMember_EmailVerification_FullFlow
member_test.go:46: ▶ [M-03/M-04] POST /me/verifications/email/{start,confirm} — 業務 email OTP 申請 → 從 Redis 取碼 → 驗證 → email_verified=true
--- PASS: TestMember_EmailVerification_FullFlow (0.21s)
...
✔ E2E OK
```
成功時最後一行:`✔ E2E OK`。失敗會在 banner 之後直接顯示 testify 的 diff能立刻對應到「哪個 ID 哪個情境壞掉」。
### 流程圖
@ -37,11 +103,16 @@ flowchart LR
| 指令 | 用途 |
|------|------|
| `make e2e-list` | 列出所有 contract tests + journeys |
| `make e2e-full` | 全新 docker → seed → 跑 contract tests → 關閉 |
| `make e2e-journey` | 全新 docker → seed → 跑 journeysk6 風格) → 關閉 |
| `make e2e-up` | 起環境 + seed + Gateway**不跑測試**(本機除錯) |
| `make test-e2e` | 對**已啟動**的 Gateway 跑 E2E需先有 `state.json` |
| `make test-e2e` | 對已啟動的 Gateway 只跑 contract tests |
| `make test-e2e-journey` | 對已啟動的 Gateway 只跑 journeys |
| `make e2e-casbin` | 以 `Permission.Casbin.Enabled: true` 跑 RBAC reload / deny E2E |
| `make e2e-down` | 停 Gateway + `docker compose down -v` |
| `make e2e-down` | 停 Gateway + `docker compose down -v`(含 mailhog |
| `E2E_KEEP_DOCKER=1 make e2e-full` | 測完**保留** Docker方便查 Mongo/Redis |
| `E2E_WITH_SMTP=1 make e2e-full` | 額外啟動 MailHoghttp://localhost:8025 |
### E2E 專用設定
@ -53,7 +124,88 @@ flowchart LR
---
## 測試覆蓋矩陣(一目瞭然)
## Journeysk6 風格 user flow
> 「我查看 /me 要先登入;要登入要先註冊」這種**狀態依賴**的測試。每個 journey
> 是一條時間線上一步的結果token / challenge_id / role_id餵下一步**任何
> 一步 fail 都會自動 skip 後續**,避免被噪音 fail 蓋掉真正斷點。
### 目前有的 journeys
| ID | Journey | Steps | Test func | 備註 |
|----|---------|------:|-----------|------|
| **J-1** | Tenant Owner 入職第一天 | 12 | `TestJourney_OwnerOnboarding` | /me → PATCH → email verify → phone verify → TOTP 全鏈路 |
| **J-2** | Tenant Admin 從零建立 qa_engineer 角色 → 指派 → 驗證 → 撤銷 | 8 | `TestJourney_TenantAdminCustomRole` | 含「no-role user 拿到新角色」二人視角驗證 |
| **J-3** | Session 生命週期refresh → /me → logout → 舊 token 401 | 4 | `TestZZZJourney_SessionLifecycle` | 會撤銷 JWT故拆第二輪跑 |
| **J-4** | 完整註冊 → 登入 → 看自己 | 5 (skip) | `TestJourney_FullRegistration` | **需 ZITADEL**,目前 stub接 container 後改 `j.Step()` 就能跑 |
### 執行範例
```
=== RUN TestJourney_OwnerOnboarding
journey_owner_test.go:20: ▶ [J-1] Tenant Owner 入職第一天(已登入後完整 onboarding
=== RUN TestJourney_OwnerOnboarding/J-1.1_GET_/me_...
journey.go:54: ▶ [J-1.1] GET /me — 用 seed JWT 確認自己是 tenant_owner 且 status=active
--- PASS: TestJourney_OwnerOnboarding/J-1.1_... (0.00s)
...
journey.go:85: ✔ [J-1] Tenant Owner 入職第一天(已登入後完整 onboarding — 12/12 steps executed
--- PASS: TestJourney_OwnerOnboarding (0.57s)
```
失敗時:
```
=== RUN TestJourney_OwnerOnboarding/J-1.4_POST_..._confirm_...
journey.go:54: ▶ [J-1.4] POST /me/verifications/email/confirm — 從 Redis 取碼後驗證
require.go:223:
Error Trace: ...
Error: Not equal: expected 102000, got 29202005
journey.go:65: ✗ [J-1.4] FAIL — aborting remaining steps
--- FAIL: TestJourney_OwnerOnboarding/J-1.4_... (0.10s)
=== RUN TestJourney_OwnerOnboarding/J-1.5_GET_/me_...
journey.go:50: ⊘ [J-1.5] skipped — journey aborted at an earlier step
--- SKIP: TestJourney_OwnerOnboarding/J-1.5_... (0.00s)
[J-1.6 ... J-1.12 全 SKIP]
journey.go:85: ✗ [J-1] ... — 4/12 steps executed
```
### 寫新 journey
```go
func TestJourney_PaymentFlow(t *testing.T) {
j := NewJourney(t, "J-5", "下單 → 付款 → 收據")
defer j.Summary()
c := NewClient(t)
var orderID, paymentID string
j.Step("1", "POST /orders — 建立訂單", func(t *testing.T) {
env := c.DoExpectOK(t, "POST", "/api/v1/orders", body, true)
orderID = parseOrderID(t, env.Data)
require.NotEmpty(t, orderID)
})
j.Step("2", "POST /orders/:id/pay — 付款", func(t *testing.T) {
require.NotEmpty(t, orderID)
env := c.DoExpectOK(t, "POST", "/api/v1/orders/"+orderID+"/pay", nil, true)
paymentID = parsePaymentID(t, env.Data)
})
j.Step("3", "GET /payments/:id/receipt — 確認收據", func(t *testing.T) {
require.NotEmpty(t, paymentID)
c.DoExpectOK(t, "GET", "/api/v1/payments/"+paymentID+"/receipt", nil, true)
})
// 需要外部服務的步驟用 SkipStepjourney 不算 fail
j.SkipStep("4", "POST 對接金流 webhook", "需 mock 金流 sandbox")
}
```
`NewJourney` / `j.Step``test/e2e/journey.go`;不要呼叫 `t.Parallel()`journey 需要時間序。
---
## 測試覆蓋矩陣contract tests一目瞭然
圖例:**✅ 自動 E2E** · **⏭ 需 ZITADEL / 手動** · **🔧 基礎設施**
@ -161,7 +313,7 @@ flowchart LR
1. 設定 `etc/gateway.dev.yaml``Zitadel.*`
2. `make deps-up && make run-dev`
3. 依 `docs/auth-unified-registration.md` 跑 register → confirm → login
3. 依 [`docs/auth-unified-registration.md`](./auth-unified-registration.md) 跑 register → confirm → login
### TOTP 互動測試
@ -184,6 +336,7 @@ make totp-test STEP=flow
| `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 |
| `E2E_WITH_SMTP` | — | `1` = 額外啟動 MailHogprofile=smtp |
| `GATEWAY_PORT` | `18888` | health check 用 |
| `E2E_ROLE` | `tenant_owner` | 覆寫 seed 指派給主測試使用者的 system role |
| `E2E_TEST_PATTERN` | `Test(Auth_\|Health\|Member\|Permission)` | 覆寫第一輪 `go test -run` pattern |
@ -197,19 +350,21 @@ make totp-test STEP=flow
gateway/
├── cmd/e2e-seed/ # E2E 資料 + JWT seed
├── scripts/
│ ├── e2e-lib.sh # 共用 bash helper顏色 / step banner / gateway lifecycle
│ ├── e2e-run.sh # 一鍵完整流程
│ ├── e2e-up.sh # 只起環境
│ └── e2e-down.sh # 關閉
│ ├── e2e-down.sh # 關閉
│ └── e2e-list.sh # 列出所有測試make e2e-list
├── 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
│ ├── setup_test.go # TestMain + e2eStep banner helper
│ ├── journey.go # Journey 框架NewJourney + Step + SkipStep
│ ├── {health,auth,member,permission}_test.go # contract tests
│ └── journey_{owner,rbac,session,registration}_test.go # journeys
└── docs/e2e-testing.md # 本文件
```
@ -219,16 +374,20 @@ gateway/
```yaml
# 範例 GitHub Actions job
- name: E2E
- name: E2E contract
run: make e2e-full
working-directory: gateway
- name: E2E journeys
run: make e2e-journey
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
PR 門檻:`make check`unit+ `make e2e-full`contract+ `make e2e-journey`user flow+ `make e2e-casbin`RBAC enforcement
---

View File

@ -17,5 +17,6 @@ if command -v lsof >/dev/null 2>&1 && lsof -ti tcp:"${GATEWAY_PORT}" >/dev/null
exit 1
fi
docker compose down -v
echo "e2e-down OK (gateway stopped, docker cleaned)"
# --profile smtp 涵蓋一般 + mailhogdocker compose 對未掛起的 profile 是 no-op安全。
docker compose --profile smtp down -v
e2e_ok "e2e-down OKgateway stopped, docker cleaned"

View File

@ -2,10 +2,43 @@
# Shared helpers for e2e-run / e2e-up / e2e-down.
# shellcheck disable=SC2034
# Colors非 TTY 自動關掉CI log 才不會有 ANSI 噪音)
if [[ -t 1 ]]; then
E2E_BOLD=$'\033[1m'; E2E_DIM=$'\033[2m'; E2E_GREEN=$'\033[32m'
E2E_CYAN=$'\033[36m'; E2E_YELLOW=$'\033[33m'; E2E_RESET=$'\033[0m'
else
E2E_BOLD=""; E2E_DIM=""; E2E_GREEN=""; E2E_CYAN=""; E2E_YELLOW=""; E2E_RESET=""
fi
# e2e_step "1/6" "fresh docker compose"
e2e_step() {
local idx="$1"; shift
printf "\n${E2E_BOLD}${E2E_CYAN}== [%s] %s ==${E2E_RESET}\n" "$idx" "$*"
}
# e2e_info "啟動 mailhoghttp://localhost:8025"
e2e_info() { printf "${E2E_DIM}>> %s${E2E_RESET}\n" "$*"; }
e2e_ok() { printf "${E2E_GREEN}✔ %s${E2E_RESET}\n" "$*"; }
e2e_warn() { printf "${E2E_YELLOW}! %s${E2E_RESET}\n" "$*"; }
e2e_root_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
}
# 把所有 e2e services 列出來(給使用者知道環境準備好了什麼)
e2e_print_services() {
local with_smtp="${1:-}"
echo
printf "${E2E_BOLD}E2E 環境服務${E2E_RESET}\n"
printf " %-12s %-32s %s\n" "MongoDB" "127.0.0.1:27017" "${E2E_DIM}database=gateway_e2e${E2E_RESET}"
printf " %-12s %-32s %s\n" "Redis" "127.0.0.1:6379" "${E2E_DIM}OTP / Casbin policy / blacklist${E2E_RESET}"
printf " %-12s %-32s %s\n" "Gateway" "http://127.0.0.1:18888" "${E2E_DIM}health: /api/v1/health${E2E_RESET}"
if [[ "${with_smtp}" == "1" ]]; then
printf " %-12s %-32s %s\n" "MailHog" "http://127.0.0.1:8025" "${E2E_DIM}SMTP=1025E2E_WITH_SMTP=1${E2E_RESET}"
fi
echo
}
# Stop gateway started for E2E: pid file → port listeners → stale go run orphans.
e2e_stop_gateway() {
local port="${1:-18888}"

126
scripts/e2e-list.sh Executable file
View File

@ -0,0 +1,126 @@
#!/usr/bin/env bash
# 列出所有 E2E 測試(從 _test.go 的 e2eStep(...) 呼叫撈)。
# 對齊 docs/e2e-testing.md 的「測試覆蓋矩陣」;新增 / 修改 e2eStep 後重跑即可。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TESTS_DIR="${ROOT}/test/e2e"
if ! command -v rg >/dev/null 2>&1; then
echo "需要 ripgrepbrew install ripgrep" >&2
exit 1
fi
# Colors非 TTY 時關掉)
if [[ -t 1 ]]; then
BOLD=$'\033[1m'; DIM=$'\033[2m'; CYAN=$'\033[36m'; YELLOW=$'\033[33m'; RESET=$'\033[0m'
else
BOLD=""; DIM=""; CYAN=""; YELLOW=""; RESET=""
fi
echo "${BOLD}Gateway E2E — 自動測試清單${RESET}"
echo "${DIM}執行make e2e-full / make e2e-journey / make test-e2e單測go test -tags=e2e -run TestXxx${RESET}"
# ─────────────────────────────────────────────────────────────
# Section A: Contract tests單 endpoint由 e2eStep banner 撈)
# ─────────────────────────────────────────────────────────────
echo
echo "${BOLD}${CYAN}═══ Contract testsmake e2e-full═══${RESET}"
echo "${DIM}單一 endpoint 驗 HTTP contract可平行每個 func 一個測試。${RESET}"
# 模組分組
contract_module() {
case "$(basename "$1")" in
health_test.go) echo "Health" ;;
auth_test.go) echo "Auth" ;;
member_test.go) echo "Member" ;;
permission_test.go) echo "Permission" ;;
*) echo "Other" ;;
esac
}
contract_count=0
current_module=""
while IFS= read -r line; do
file="${line%%:*}"; rest="${line#*:}"
lineno="${rest%%:*}"
sig="${rest#*:}"
fname="$(printf '%s' "$sig" | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/')"
# 只看 contract test 檔(不含 journey_*
case "$(basename "$file")" in
journey_*.go|journey.go) continue ;;
esac
mod="$(contract_module "$file")"
if [[ "$mod" != "$current_module" ]]; then
echo
echo " ${BOLD}── $mod ──${RESET}"
current_module="$mod"
fi
step=$(awk -v start="$lineno" 'NR>=start && NR<=start+5 && /e2eStep\(t,/ { print; exit }' "$file")
if [[ -z "$step" ]]; then
printf " ${YELLOW}? %-40s${RESET} ${DIM}(no e2eStep banner)${RESET}\n" "$fname"
continue
fi
id="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\1/p')"
method="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\2/p')"
path="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\3/p')"
desc="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\4/p')"
printf " ${BOLD}[%-9s]${RESET} %-7s %-50s %s\n" "$id" "$method" "$path" "$desc"
printf " ${DIM}└─ %s${RESET}\n" "$fname"
contract_count=$((contract_count+1))
done < <(rg -n --no-heading '^func Test[A-Za-z0-9_]+\(t \*testing\.T\)' "${TESTS_DIR}" -t go)
# ─────────────────────────────────────────────────────────────
# Section B: Journeysk6 風格多步驟)
# ─────────────────────────────────────────────────────────────
echo
echo "${BOLD}${CYAN}═══ Journeysmake e2e-journey═══${RESET}"
echo "${DIM}多步驟 user flow共享狀態任一步 fail 自動 skip 後續;用 NewJourney() + j.Step()。${RESET}"
journey_count=0
journey_step_total=0
while IFS= read -r line; do
file="${line%%:*}"; rest="${line#*:}"
lineno="${rest%%:*}"
sig="${rest#*:}"
fname="$(printf '%s' "$sig" | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/')"
case "$(basename "$file")" in
journey_*.go) : ;;
*) continue ;;
esac
# 抓 NewJourney(t, "J-1", "title")
jline=$(awk -v start="$lineno" 'NR>=start && NR<=start+3 && /NewJourney\(t,/ { print; exit }' "$file")
jid="$(printf '%s' "$jline" | sed -nE 's/.*NewJourney\(t, "([^"]*)", *"([^"]*)"\).*/\1/p')"
jtitle="$(printf '%s' "$jline" | sed -nE 's/.*NewJourney\(t, "([^"]*)", *"([^"]*)"\).*/\2/p')"
if [[ -z "$jid" ]]; then
jid="?"; jtitle="(no NewJourney call)"
fi
steps="$(rg -n '\s+j\.(Step|SkipStep)\(' "$file" 2>/dev/null | wc -l | tr -d ' ')"
echo
printf " ${BOLD}[%s] %s${RESET} ${DIM}(%d steps · %s)${RESET}\n" "$jid" "$jtitle" "$steps" "$fname"
# 列出每個 step 的 id + desc
rg -oN 'j\.(Step|SkipStep)\("[^"]+",\s*"[^"]+"' "$file" 2>/dev/null \
| sed -nE 's/.*j\.(Step|SkipStep)\("([^"]+)", *"([^"]+)".*/\1|\2|\3/p' \
| awk -F'|' -v jid="$jid" -v reset="$RESET" -v yellow="$YELLOW" '
{
kind=$1; sid=$2; desc=$3
marker = (kind == "SkipStep") ? "⊘" : "▶"
color = (kind == "SkipStep") ? yellow : ""
printf " %s%s [%s.%s]%s %s\n", color, marker, jid, sid, reset, desc
}
'
journey_count=$((journey_count+1))
journey_step_total=$((journey_step_total+steps))
done < <(rg -n --no-heading '^func Test[A-Za-z0-9_]+\(t \*testing\.T\)' "${TESTS_DIR}" -t go)
# ─────────────────────────────────────────────────────────────
echo
echo "${DIM}合計:${contract_count} 個 contract tests · ${journey_count} 個 journeys (${journey_step_total} steps)${RESET}"

View File

@ -1,5 +1,11 @@
#!/usr/bin/env bash
# 一鍵 E2E全新 Docker → index → seed → 起 Gateway → 跑測試 → 關閉並清 volume
#
# 環境變數:
# E2E_KEEP_DOCKER=1 跑完不 docker compose down -v方便查 Mongo/Redis
# E2E_WITH_SMTP=1 額外起 MailHoghttp://localhost:8025方便肉眼看寄信
# E2E_CONFIG=... 預設 test/e2e/fixtures/e2e.yaml
# E2E_TEST_PATTERN 第一輪 go test -run pattern
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -9,28 +15,38 @@ cd "$ROOT"
E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}"
E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}"
E2E_MODE="${E2E_MODE:-contract}" # contract / journey
E2E_TEST_PATTERN="${E2E_TEST_PATTERN:-Test(Auth_|Health|Member|Permission)}"
E2E_TEST_PATTERN_ZZZ="${E2E_TEST_PATTERN_ZZZ:-TestZZZ_AuthTokenRefreshAndLogout}"
GATEWAY_PORT="${GATEWAY_PORT:-18888}"
PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}"
E2E_WITH_SMTP="${E2E_WITH_SMTP:-}"
cleanup() {
e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}"
if [[ "${E2E_KEEP_DOCKER:-}" != "1" ]]; then
echo ">> docker compose down -v"
docker compose down -v
e2e_info "docker compose down -v如要保留資料E2E_KEEP_DOCKER=1"
docker compose down -v >/dev/null 2>&1 || docker compose --profile smtp down -v >/dev/null 2>&1 || true
else
echo ">> E2E_KEEP_DOCKER=1: leaving containers running"
e2e_warn "E2E_KEEP_DOCKER=1容器繼續運行"
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
TOTAL_STEPS=6
echo ">> [2/6] wait for mongo/redis healthy"
e2e_step "1/${TOTAL_STEPS}" "fresh docker composemongo + redis$( [[ "$E2E_WITH_SMTP" == "1" ]] && echo " + mailhog" )"
docker compose down -v >/dev/null 2>&1 || true
if [[ "$E2E_WITH_SMTP" == "1" ]]; then
docker compose --profile smtp up -d mongo redis mailhog
else
docker compose up -d mongo redis
fi
e2e_step "2/${TOTAL_STEPS}" "wait for healthcheck"
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
e2e_ok "mongo / redis healthy${i}s"
break
fi
sleep 1
@ -41,25 +57,41 @@ for i in $(seq 1 60); do
fi
done
echo ">> [3/6] mongo indexes"
e2e_step "3/${TOTAL_STEPS}" "建立 Mongo 索引cmd/mongo-index"
go run ./cmd/mongo-index -f "${E2E_CONFIG}"
e2e_ok "indexes ready"
echo ">> [4/6] e2e seed (tenant + member + permission + JWT)"
e2e_step "4/${TOTAL_STEPS}" "seed tenant + member + permission + JWTcmd/e2e-seed"
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[@]}"
e2e_ok "state.json written → ${E2E_STATE}"
echo ">> [5/6] start gateway on :${GATEWAY_PORT}"
e2e_step "5/${TOTAL_STEPS}" "啟動 Gateway:${GATEWAY_PORT}"
e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}" >/dev/null
e2e_wait_gateway "${GATEWAY_PORT}"
e2e_ok "gateway up"
echo ">> [6/6] run e2e tests (main suite, then auth teardown)"
e2e_print_services "$E2E_WITH_SMTP"
case "${E2E_MODE}" in
journey)
step6_title="跑 E2E user journeys每步驟印 ▶ [J-x.y] 中文情境,斷一步停整 journey" ;;
*)
step6_title="跑 E2E contract tests每個測試印 ▶ [ID] METHOD path — 中文情境)" ;;
esac
e2e_step "6/${TOTAL_STEPS}" "${step6_title}"
e2e_info "第一輪pattern=${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 "${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"
e2e_info "第二輪pattern=${E2E_TEST_PATTERN_ZZZ}(會撤銷 JWT故最後跑"
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_ZZZ}"
echo
e2e_ok "E2E OK"

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash
# 啟動 E2E 環境但不跑測試(方便本機除錯)
#
# E2E_WITH_SMTP=1 多起一個 MailHoghttp://localhost:8025
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -11,26 +13,50 @@ 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}"
E2E_WITH_SMTP="${E2E_WITH_SMTP:-}"
e2e_step "1/5" "fresh docker compose"
docker compose down -v >/dev/null 2>&1 || true
if [[ "$E2E_WITH_SMTP" == "1" ]]; then
docker compose --profile smtp up -d mongo redis mailhog
else
docker compose up -d mongo redis
fi
e2e_step "2/5" "wait for healthcheck"
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
e2e_ok "mongo / redis healthy${i}s"
break
fi
sleep 1
done
e2e_step "3/5" "建立 Mongo 索引"
go run ./cmd/mongo-index -f "${E2E_CONFIG}"
go run ./cmd/e2e-seed -f "${E2E_CONFIG}" -out "${E2E_STATE}"
e2e_ok "indexes ready"
e2e_step "4/5" "seed E2E 資料 + JWT"
go run ./cmd/e2e-seed -f "${E2E_CONFIG}" -out "${E2E_STATE}"
e2e_ok "state.json written"
e2e_step "5/5" "啟動 Gateway:${GATEWAY_PORT}"
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}"
e2e_warn "gateway already running pid=$(cat "${PID_FILE}")"
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}"
e2e_ok "gateway started pid=${pid}"
fi
e2e_wait_gateway "${GATEWAY_PORT}"
echo "e2e-up OK — run: make test-e2e"
e2e_print_services "$E2E_WITH_SMTP"
echo "${E2E_BOLD}下一步${E2E_RESET}"
echo " ${E2E_DIM}# 列出有哪些 E2E 測試${E2E_RESET}"
echo " make e2e-list"
echo " ${E2E_DIM}# 全部測試(每個會顯示 ▶ [ID] METHOD path — 中文情境)${E2E_RESET}"
echo " make test-e2e"
echo " ${E2E_DIM}# 單一測試${E2E_RESET}"
echo " GATEWAY_E2E=1 go test -tags=e2e -v -count=1 ./test/e2e/ -run TestMember_GetMe"
echo " ${E2E_DIM}# 結束${E2E_RESET}"
echo " make e2e-down"

View File

@ -13,6 +13,7 @@ import (
// 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) {
e2eStep(t, "A-10/A-11", "POST", "/api/v1/auth/{token/refresh,logout}", "刷新 token → 用新 access 打 /me → logout → 黑名單後再打 /me=401")
c := isolatedAuthClient(t)
refreshEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/token/refresh", map[string]string{
@ -38,6 +39,7 @@ func TestZZZ_AuthTokenRefreshAndLogout(t *testing.T) {
}
func TestAuth_MissingBearer_401(t *testing.T) {
e2eStep(t, "A-12", "GET", "/api/v1/members/me", "未帶 Bearer → 401AuthJWT middleware")
c := NewClient(t)
resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, false)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
@ -45,6 +47,7 @@ func TestAuth_MissingBearer_401(t *testing.T) {
}
func TestAuth_PublicValidationErrors(t *testing.T) {
e2eStep(t, "A-13", "POST", "/api/v1/auth/*", "公開 Auth 端點輸入驗證錯誤 → 400 + Facade scope")
c := NewClient(t)
cases := []struct {
@ -86,6 +89,8 @@ func TestAuth_PublicValidationErrors(t *testing.T) {
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)
// Facade scope 10101000 = InputInvalidFormatgateway parse / validate 進入點)
require.Equal(t, int64(10), env.Code/1_000_000, "expected Facade scope, got code=%d", env.Code)
})
}
}

View File

@ -11,6 +11,7 @@ import (
)
func TestHealth_Ping(t *testing.T) {
e2eStep(t, "N-01", "GET", "/api/v1/health", "Ping 回 200 + envelope code=102000")
c := NewClient(t)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/health", nil, false)
var data map[string]any
@ -18,6 +19,7 @@ func TestHealth_Ping(t *testing.T) {
}
func TestHealth_NoAuthRequired(t *testing.T) {
e2eStep(t, "N-02", "GET", "/api/v1/health", "未帶 Bearer 也能通過")
c := NewClient(t)
resp, env := c.Do(t, http.MethodGet, "/api/v1/health", nil, false)
require.Equal(t, http.StatusOK, resp.StatusCode)

86
test/e2e/journey.go Normal file
View File

@ -0,0 +1,86 @@
//go:build e2e
package e2e
import (
"sync/atomic"
"testing"
)
// Journey is a k6-style user journey: an ordered sequence of HTTP steps that
// share local state (closures over the parent test). If any step fails the
// remaining steps are auto-skipped, mirroring k6 scenario "abort on fail"
// behaviour so logs are easy to read — you stop at the first broken step.
//
// Use j.Step(id, desc, fn) to add steps; the framework prints:
//
// ▶ [J-1] Tenant Owner 入職第一天
// ▶ [J-1.1] GET /me 看自己是誰
// ▶ [J-1.2] PATCH /me 更新 display_name
// ⊘ [J-1.3] skipped (journey aborted)
type Journey struct {
t *testing.T
id string
title string
aborted atomic.Bool
total int
ran int
failed bool
}
// NewJourney prints the journey banner and returns a builder.
// Call j.Run() at the end to print the final summary.
func NewJourney(t *testing.T, id, title string) *Journey {
t.Helper()
t.Logf("▶ [%s] %s", id, title)
return &Journey{t: t, id: id, title: title}
}
// Step adds a step. fn receives a sub-test t scoped to this step so testify
// require.* abort just this step (not the whole Test function), letting the
// framework print a clean "aborted" line for subsequent steps.
//
// Pre-existing failures in earlier steps short-circuit the step and emit a
// "⊘ skipped" line.
func (j *Journey) Step(id, desc string, fn func(t *testing.T)) {
j.total++
stepID := j.id + "." + id
name := stepID + " " + desc
j.t.Run(name, func(t *testing.T) {
if j.aborted.Load() {
t.Skipf("⊘ [%s] skipped — journey aborted at an earlier step", stepID)
return
}
t.Logf(" ▶ [%s] %s", stepID, desc)
j.ran++
// fn may call require.* which marks t failed and FailNow's. After it
// returns we inspect t.Failed() to decide whether to abort siblings.
fn(t)
if t.Failed() {
j.aborted.Store(true)
j.failed = true
t.Logf("✗ [%s] FAIL — aborting remaining steps", stepID)
}
})
}
// SkipStep marks a step as intentionally skipped (e.g. requires ZITADEL).
// The journey is NOT aborted; the next Step still runs.
func (j *Journey) SkipStep(id, desc, reason string) {
j.total++
stepID := j.id + "." + id
name := stepID + " " + desc
j.t.Run(name, func(t *testing.T) {
t.Skipf("⊘ [%s] %s — %s", stepID, desc, reason)
})
}
// Summary prints the final journey result. Always defer this right after
// NewJourney so it runs even on require.* abort.
func (j *Journey) Summary() {
status := "✔"
if j.failed {
status = "✗"
}
j.t.Logf("%s [%s] %s — %d/%d steps executed", status, j.id, j.title, j.ran, j.total)
}

View File

@ -0,0 +1,162 @@
//go:build e2e
package e2e
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
// TestJourney_OwnerOnboarding 模擬 Tenant Owner 入職第一天會走的完整流程:
// 已 loginseed 提供 JWT→ 看自己 → 更新 profile → 驗證業務 email → 驗證 phone
// → 綁定 TOTP → step-up 驗碼 → 解除 TOTP → logout。
//
// 是「狀態流」測試每步都用上一步的結果challenge_id / display_name / OTP code
// 任一步 fail 後續自動 skip便於一眼定位斷點。
func TestJourney_OwnerOnboarding(t *testing.T) {
j := NewJourney(t, "J-1", "Tenant Owner 入職第一天(已登入後完整 onboarding")
defer j.Summary()
c := NewClient(t)
var (
emailTarget = "owner-journey@example.com"
phoneTarget = "+886900111222"
emailChallenge string
phoneChallenge string
otpauthURL string
totpDigits int
totpPeriod int
totpCode string
)
j.Step("1", "GET /me — 用 seed JWT 確認自己是 tenant_owner 且 status=active", func(t *testing.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.UID, me.UID)
require.Equal(t, "active", me.Status)
})
j.Step("2", "PATCH /me — 更新 display_name", func(t *testing.T) {
env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{
"display_name": "Journey Owner",
}, true)
var me struct {
DisplayName string `json:"display_name"`
}
require.NoError(t, json.Unmarshal(env.Data, &me))
require.Equal(t, "Journey Owner", me.DisplayName)
})
j.Step("3", "POST /me/verifications/email/start — 申請業務 email OTP", func(t *testing.T) {
env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{
"target": emailTarget,
}, true)
var start struct {
ChallengeID string `json:"challenge_id"`
}
require.NoError(t, json.Unmarshal(env.Data, &start))
require.NotEmpty(t, start.ChallengeID)
emailChallenge = start.ChallengeID
})
j.Step("4", "POST /me/verifications/email/confirm — 從 Redis 取碼後驗證", func(t *testing.T) {
require.NotEmpty(t, emailChallenge, "missing email challenge from step 3")
code := FetchE2EOTP(t, emailChallenge)
c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{
"challenge_id": emailChallenge,
"code": code,
}, true)
})
j.Step("5", "GET /me — 確認 business_email_verified=true", func(t *testing.T) {
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, emailTarget, me.BusinessEmail)
require.True(t, me.BusinessEmailVerified, "expected email_verified=true after confirm")
})
j.Step("6", "POST /me/verifications/phone/start — 申請業務 phone OTP", func(t *testing.T) {
env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{
"target": phoneTarget,
}, true)
var start struct {
ChallengeID string `json:"challenge_id"`
}
require.NoError(t, json.Unmarshal(env.Data, &start))
phoneChallenge = start.ChallengeID
})
j.Step("7", "POST /me/verifications/phone/confirm — 取碼後驗證", func(t *testing.T) {
require.NotEmpty(t, phoneChallenge)
code := FetchE2EOTP(t, phoneChallenge)
c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{
"challenge_id": phoneChallenge,
"code": code,
}, true)
})
j.Step("8", "POST /me/totp/enroll-start — 開始綁定 TOTP", func(t *testing.T) {
env := 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(env.Data, &start))
require.NotEmpty(t, start.OtpauthURL)
otpauthURL = start.OtpauthURL
totpDigits = start.Digits
totpPeriod = start.PeriodSec
})
j.Step("9", "POST /me/totp/enroll-confirm — 用 otpauth_url 算當下 TOTP code 確認綁定", func(t *testing.T) {
require.NotEmpty(t, otpauthURL)
totpCode = codeFromOtpauthURL(t, otpauthURL, totpDigits, totpPeriod)
env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{
"code": totpCode,
}, true)
var confirmed struct {
BackupCodes []string `json:"backup_codes"`
}
require.NoError(t, json.Unmarshal(env.Data, &confirmed))
require.NotEmpty(t, confirmed.BackupCodes, "enroll-confirm should return backup codes exactly once")
})
j.Step("10", "POST /me/totp/verify — step-up 驗碼成功", func(t *testing.T) {
require.NotEmpty(t, totpCode)
c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{
"code": totpCode,
}, true)
})
j.Step("11", "POST /me/totp/verify (replay) — 同 code 再打應該 403重放保護", func(t *testing.T) {
require.NotEmpty(t, totpCode)
env := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{
"code": totpCode,
}, true, http.StatusForbidden)
require.NotEqual(t, int64(successCode), env.Code)
})
j.Step("12", "DELETE /me/totp — 解除綁定", func(t *testing.T) {
c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true)
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)
})
}

View File

@ -0,0 +1,147 @@
//go:build e2e
package e2e
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
// TestJourney_TenantAdminCustomRole 模擬 Tenant Admin 為一個新工種「qa_engineer」
// 從零建出可用角色的流程:
// 建 Role → 從 catalog 取 perm id → 全量 PUT 權限 → 設 IdP Mapping → 指派給 user
// → 用該 user 視角看 /permissions/me 確認權限生效 → 撤銷 → 刪 mapping → 刪 role
//
// 整段都用 seed owner tokenno-role user 用來驗證「被指派後拿到權限」。
func TestJourney_TenantAdminCustomRole(t *testing.T) {
j := NewJourney(t, "J-2", "Tenant Admin 從零建立 qa_engineer 角色 → 指派 → 驗證 → 撤銷")
defer j.Summary()
owner := NewClient(t)
noRole := NewNoRoleClient(t)
var (
roleKey = "journey_qa_engineer"
externalKey = fmt.Sprintf("journey-qa-group-%s", noRole.Fixture.UID)
roleID string
permissionID string
)
// 清掉殘留(前一輪可能 fail 沒走到清理 step
t.Cleanup(func() {
_, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{
"external_source": "zitadel",
"external_key": externalKey,
}, true)
if roleID != "" {
_, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles/"+roleID, nil, true)
_, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+roleID, nil, true)
}
})
j.Step("1", "POST /permissions/roles — 建立 qa_engineer 角色", func(t *testing.T) {
env := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
"key": roleKey,
"display_name": "QA Engineer",
"status": "open",
}, true)
var role struct {
ID string `json:"id"`
Key string `json:"key"`
}
require.NoError(t, json.Unmarshal(env.Data, &role))
require.Equal(t, roleKey, role.Key)
require.NotEmpty(t, role.ID)
roleID = role.ID
})
j.Step("2", "GET /permissions/catalog — 從平台 catalog 撈第一個 leaf permission id", func(t *testing.T) {
permissionID = firstCatalogPermissionID(t, owner)
require.NotEmpty(t, permissionID)
})
j.Step("3", "PUT /permissions/roles/:id/permissions — 把選到的 permission 灌進 role", func(t *testing.T) {
require.NotEmpty(t, roleID)
require.NotEmpty(t, permissionID)
owner.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+roleID+"/permissions", map[string][]string{
"permission_ids": {permissionID},
}, true)
// 讀回比對parent closure 會把祖先一起加進去,所以集合 >= 1 + 必含 permissionID
env := owner.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+roleID+"/permissions", nil, true)
var list struct {
Permissions []struct {
ID string `json:"id"`
} `json:"permissions"`
}
require.NoError(t, json.Unmarshal(env.Data, &list))
found := false
for _, p := range list.Permissions {
if p.ID == permissionID {
found = true
break
}
}
require.True(t, found)
})
j.Step("4", "PUT /permissions/role-mappings — 設定 zitadel:qa-group → qa_engineer 對映", func(t *testing.T) {
env := owner.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 {
ExternalKey string `json:"external_key"`
InternalRoleKey string `json:"internal_role_key"`
}
require.NoError(t, json.Unmarshal(env.Data, &mapping))
require.Equal(t, externalKey, mapping.ExternalKey)
require.Equal(t, roleKey, mapping.InternalRoleKey)
})
j.Step("5", "POST /permissions/users/:uid/roles — 把 qa_engineer 手動指派給 no-role user", func(t *testing.T) {
require.NotEmpty(t, roleID)
owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles", map[string]string{
"role_id": roleID,
}, true)
})
j.Step("6", "GET /permissions/me (no-role user 視角) — 確認新角色 + 權限都拿到了", func(t *testing.T) {
env := noRole.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=false", nil, true)
var data struct {
Roles []string `json:"roles"`
Permissions map[string]string `json:"permissions"`
}
require.NoError(t, json.Unmarshal(env.Data, &data))
require.Contains(t, data.Roles, roleKey, "no-role user should now have qa_engineer")
require.NotEmpty(t, data.Permissions, "expected non-empty permissions after role assignment")
})
j.Step("7", "DELETE /permissions/users/:uid/roles/:id — 撤銷指派", func(t *testing.T) {
require.NotEmpty(t, roleID)
owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles/"+roleID, nil, true)
env := noRole.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=false", nil, true)
var data struct {
Roles []string `json:"roles"`
}
require.NoError(t, json.Unmarshal(env.Data, &data))
require.NotContains(t, data.Roles, roleKey)
})
j.Step("8", "DELETE /permissions/role-mappings + /roles — 收尾刪 mapping + role", func(t *testing.T) {
owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{
"external_source": "zitadel",
"external_key": externalKey,
}, true)
require.NotEmpty(t, roleID)
owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+roleID, nil, true)
// 標記已清t.Cleanup 那邊就不會再重複打
roleID = ""
})
}

View File

@ -0,0 +1,28 @@
//go:build e2e
package e2e
import "testing"
// TestJourney_FullRegistration 模擬使用者第一次接觸系統的完整 onboarding
//
// POST /register # 建 ZITADEL human user + member draft + 發 OTP 信
// → POST /register/confirm # 驗 OTPmember 從 unverified 轉 active
// → POST /login # ZITADEL ROPG 拿 id_tokengateway 簽 CloudEP JWT
// → GET /members/me # 用新 JWT 看自己
//
// 這條 journey 需要真實 ZITADEL目前 e2e env 只有 mock故整段標記 skip 並
// 列出步驟。等 docker-compose 接上 ZITADEL container或指向 staging
// SkipStep 換成 Step + 真的 HTTP call 即可。
func TestJourney_FullRegistration(t *testing.T) {
j := NewJourney(t, "J-4", "完整註冊 → 登入 → 看自己(需 ZITADEL目前 stub")
defer j.Summary()
const reason = "目前 e2e env 未接 ZITADEL接上後改成 Step() 即可"
j.SkipStep("1", "POST /auth/register — 建 ZITADEL human user + member draft + 寄 OTP", reason)
j.SkipStep("2", "GET MailHog API 取 OTP 信件內容(或 mock 直接抓 Redis", reason)
j.SkipStep("3", "POST /auth/register/confirm — 驗 OTPmember status 由 unverified → active", reason)
j.SkipStep("4", "POST /auth/login — ZITADEL ROPG 拿 id_tokengateway 簽 CloudEP JWT", reason)
j.SkipStep("5", "GET /members/me — 用新 JWT 看自己", reason)
}

View File

@ -0,0 +1,55 @@
//go:build e2e
package e2e
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
// TestZZZJourney_SessionLifecycle 走 JWT 的完整生命週期:
// refresh 取得新 pair → 用新 access 打 /me 成功 → logout → 同一 access 再打 = 401
//
// 與 TestZZZ_AuthTokenRefreshAndLogout 同樣放在 ZZZ 區段最後跑(會撤銷 JWT
// 用 isolatedAuthClient 確保不汙染 member / permission journey 使用的 seed token。
func TestZZZJourney_SessionLifecycle(t *testing.T) {
j := NewJourney(t, "J-3", "Session 生命週期refresh → /me → logout → 舊 token 401")
defer j.Summary()
c := isolatedAuthClient(t)
j.Step("1", "POST /auth/token/refresh — 用 isolated refresh token 取得新 access/refresh pair", func(t *testing.T) {
env := 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(env.Data, &pair))
require.NotEmpty(t, pair.AccessToken)
require.NotEmpty(t, pair.RefreshToken)
require.Equal(t, c.Fixture.UID, pair.UID)
c.Fixture.AccessToken = pair.AccessToken
c.Fixture.RefreshToken = pair.RefreshToken
})
j.Step("2", "GET /members/me — 用 refresh 完拿到的新 access 打 /me 應該成功", func(t *testing.T) {
c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
})
j.Step("3", "POST /auth/logout — 把目前 jti 加入黑名單", func(t *testing.T) {
c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/logout", nil, true)
})
j.Step("4", "GET /members/me — 同一 access token 再打應該 401jti blacklisted", func(t *testing.T) {
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)
})
}

View File

@ -16,6 +16,7 @@ import (
)
func TestMember_GetMe(t *testing.T) {
e2eStep(t, "M-01", "GET", "/api/v1/members/me", "讀 profiletenant/uid/status")
c := NewClient(t)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
var me struct {
@ -30,6 +31,7 @@ func TestMember_GetMe(t *testing.T) {
}
func TestMember_UpdateMe(t *testing.T) {
e2eStep(t, "M-02", "PATCH", "/api/v1/members/me", "更新 display_name")
c := NewClient(t)
name := "E2E Updated Name"
env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{
@ -43,6 +45,7 @@ func TestMember_UpdateMe(t *testing.T) {
}
func TestMember_EmailVerification_FullFlow(t *testing.T) {
e2eStep(t, "M-03/M-04", "POST", "/me/verifications/email/{start,confirm}", "業務 email OTP 申請 → 從 Redis 取碼 → 驗證 → email_verified=true")
c := NewClient(t)
target := "verified-e2e@example.com"
@ -74,6 +77,7 @@ func TestMember_EmailVerification_FullFlow(t *testing.T) {
}
func TestMember_PhoneVerification_FullFlow(t *testing.T) {
e2eStep(t, "M-05/M-06", "POST", "/me/verifications/phone/{start,confirm}", "業務 phone OTP 申請 → 從 Redis 取碼 → 驗證 → phone_verified=true")
c := NewClient(t)
target := "+886912345678"
@ -105,6 +109,7 @@ func TestMember_PhoneVerification_FullFlow(t *testing.T) {
}
func TestMember_TOTP_Status(t *testing.T) {
e2eStep(t, "M-07", "GET", "/api/v1/members/me/totp", "查 TOTP 狀態(初始 enrolled=false")
c := NewClient(t)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true)
var st struct {
@ -115,6 +120,7 @@ func TestMember_TOTP_Status(t *testing.T) {
}
func TestMember_TOTP_FullFlow(t *testing.T) {
e2eStep(t, "M-08~M-12", "POST", "/me/totp/*", "TOTP 全鏈路enroll-start → confirm → verify → replay 403 → backup-codes → DELETE")
c := NewClient(t)
startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true)

View File

@ -13,6 +13,7 @@ import (
)
func TestPermission_Catalog(t *testing.T) {
e2eStep(t, "P-01", "GET", "/api/v1/permissions/catalog", "讀全平台 Permission Catalog 樹狀結構")
c := NewClient(t)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=true", nil, true)
var data struct {
@ -23,6 +24,7 @@ func TestPermission_Catalog(t *testing.T) {
}
func TestPermission_Me(t *testing.T) {
e2eStep(t, "P-02", "GET", "/api/v1/permissions/me", "讀當前 user 的角色 + 權限樹")
c := NewClient(t)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=true", nil, true)
var data struct {
@ -40,6 +42,7 @@ func TestPermission_Me(t *testing.T) {
}
func TestPermission_RoleCRUD(t *testing.T) {
e2eStep(t, "P-03~P-06", "*", "/api/v1/permissions/roles", "租戶角色 CRUD建立 → 列表 → 更新 display_name → 刪除")
c := NewClient(t)
createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
@ -54,6 +57,8 @@ func TestPermission_RoleCRUD(t *testing.T) {
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
require.Equal(t, "e2e_custom_role", role.Key)
require.NotEmpty(t, role.ID)
// 避免 e2e-up 反覆跑時 role 殘留 → 後續 Create 撞 unique key
t.Cleanup(func() { _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) })
listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles", nil, true)
var list struct {
@ -84,6 +89,7 @@ func TestPermission_RoleCRUD(t *testing.T) {
}
func TestPermission_RolePermissions(t *testing.T) {
e2eStep(t, "P-07/P-08", "PUT/GET", "/api/v1/permissions/roles/:id/permissions", "Role 全量替換 Permission含 parent closure再讀回比對")
c := NewClient(t)
permissionID := firstCatalogPermissionID(t, c)
@ -96,6 +102,7 @@ func TestPermission_RolePermissions(t *testing.T) {
}
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
require.NotEmpty(t, role.ID)
t.Cleanup(func() { _, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true) })
c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+role.ID+"/permissions", map[string][]string{
"permission_ids": {permissionID},
@ -122,6 +129,7 @@ func TestPermission_RolePermissions(t *testing.T) {
}
func TestPermission_AssignUserRole(t *testing.T) {
e2eStep(t, "P-09~P-11", "*", "/api/v1/permissions/users/:uid/roles", "User ↔ Role 指派 / 列表 / 撤銷")
c := NewClient(t)
createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
@ -132,6 +140,10 @@ func TestPermission_AssignUserRole(t *testing.T) {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
t.Cleanup(func() {
_, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles/"+role.ID, nil, true)
_, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
})
c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", map[string]string{
"role_id": role.ID,
@ -158,6 +170,7 @@ func TestPermission_AssignUserRole(t *testing.T) {
}
func TestPermission_RoleMappingCRUD(t *testing.T) {
e2eStep(t, "P-12", "*", "/api/v1/permissions/role-mappings", "外部 IdP group → 內部 Role.Key 對映 CRUD")
c := NewClient(t)
roleKey := "e2e_mapping_role"
externalKey := fmt.Sprintf("e2e-group-%s", c.Fixture.UID)
@ -171,6 +184,13 @@ func TestPermission_RoleMappingCRUD(t *testing.T) {
}
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
require.NotEmpty(t, role.ID)
t.Cleanup(func() {
_, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{
"external_source": "zitadel",
"external_key": externalKey,
}, true)
_, _ = c.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
})
upsertEnv := c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/role-mappings", map[string]string{
"external_source": "zitadel",
@ -215,6 +235,7 @@ func TestPermission_RoleMappingCRUD(t *testing.T) {
}
func TestPermission_CasbinRBAC(t *testing.T) {
e2eStep(t, "P-13/P-14", "*", "/api/v1/permissions/{policy/reload,roles}", "Casbin enforcementowner reload policy → no-role user GET /roles=403")
if os.Getenv("E2E_CASBIN") != "1" {
t.Skip("set E2E_CASBIN=1 and use e2e.casbin.yaml to enable Casbin enforcement")
}

View File

@ -24,6 +24,25 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
// e2eStep prints a one-line banner at the start of every E2E test so `go test -v`
// shows the user-facing test ID, HTTP method/path, and a Chinese summary instead
// of just the Go function name. Format:
//
// ▶ [M-01] GET /api/v1/members/me — 讀 profile
//
// Keep IDs in sync with docs/e2e-testing.md (測試覆蓋矩陣).
func e2eStep(t *testing.T, id, method, path, desc string) {
t.Helper()
switch {
case method == "" && path == "":
t.Logf("▶ [%s] %s", id, desc)
case method == "":
t.Logf("▶ [%s] %s — %s", id, path, desc)
default:
t.Logf("▶ [%s] %s %s — %s", id, method, path, desc)
}
}
func loadSharedFixture() error {
path := os.Getenv("E2E_STATE_FILE")
if path == "" {