diff --git a/Makefile b/Makefile index 537d579..b3dcfc1 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help -.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test 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,13 +58,23 @@ 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 journeys(k6 風格多步流程;需 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 journeys(k6 風格)+ 關閉 + 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) E2E_CASBIN=1 E2E_CONFIG=test/e2e/fixtures/e2e.casbin.yaml \ E2E_TEST_PATTERN='Test(Auth_|Health|Member|Permission_(Catalog|Me|CasbinRBAC))' \ @@ -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) diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index e2c756b..d65b469 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -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 tests(make e2e-full)═══ + + ── Member ── + [M-01 ] GET /api/v1/members/me 讀 profile(tenant/uid/status) + [M-03/M-04] POST /me/verifications/email/{start,...} 業務 email OTP 申請 → 驗證 + ── Permission ── + [P-03~P-06] * /api/v1/permissions/roles 租戶角色 CRUD + +═══ Journeys(make 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 compose(mongo + redis)== +== [2/6] wait for healthcheck == +✔ mongo / redis healthy(4s) +== [3/6] 建立 Mongo 索引(cmd/mongo-index)== +== [4/6] seed tenant + member + permission + JWT(cmd/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 — 讀 profile(tenant/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 → 跑 journeys(k6 風格) → 關閉 | | `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` | 額外啟動 MailHog(http://localhost:8025) | ### E2E 專用設定 @@ -53,7 +124,88 @@ flowchart LR --- -## 測試覆蓋矩陣(一目瞭然) +## Journeys(k6 風格 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) + }) + + // 需要外部服務的步驟用 SkipStep(journey 不算 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` = 額外啟動 MailHog(profile=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 +│ │ ├── e2e.yaml # E2E 設定 +│ │ ├── e2e.casbin.yaml # Casbin enabled E2E 設定 +│ │ └── state.json # 生成(gitignore) +│ ├── client.go # HTTP helper +│ ├── 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)。 --- diff --git a/scripts/e2e-down.sh b/scripts/e2e-down.sh index 02e02c8..97d7fd6 100755 --- a/scripts/e2e-down.sh +++ b/scripts/e2e-down.sh @@ -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 涵蓋一般 + mailhog;docker compose 對未掛起的 profile 是 no-op,安全。 +docker compose --profile smtp down -v +e2e_ok "e2e-down OK(gateway stopped, docker cleaned)" diff --git a/scripts/e2e-lib.sh b/scripts/e2e-lib.sh index 8ff00a9..1f64421 100644 --- a/scripts/e2e-lib.sh +++ b/scripts/e2e-lib.sh @@ -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 "啟動 mailhog(http://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=1025(E2E_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}" diff --git a/scripts/e2e-list.sh b/scripts/e2e-list.sh new file mode 100755 index 0000000..0d9bed3 --- /dev/null +++ b/scripts/e2e-list.sh @@ -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 "需要 ripgrep(brew 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 tests(make 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: Journeys(k6 風格多步驟) +# ───────────────────────────────────────────────────────────── +echo +echo "${BOLD}${CYAN}═══ Journeys(make 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}" diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index d275f6d..811196d 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -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 額外起 MailHog(http://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 compose(mongo + 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 + JWT(cmd/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" diff --git a/scripts/e2e-up.sh b/scripts/e2e-up.sh index 4f28b58..b5a8285 100755 --- a/scripts/e2e-up.sh +++ b/scripts/e2e-up.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash # 啟動 E2E 環境但不跑測試(方便本機除錯) +# +# E2E_WITH_SMTP=1 多起一個 MailHog(http://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 -docker compose up -d mongo redis +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" diff --git a/test/e2e/auth_test.go b/test/e2e/auth_test.go index e099de7..7e8896f 100644 --- a/test/e2e/auth_test.go +++ b/test/e2e/auth_test.go @@ -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 → 401(AuthJWT 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 = InputInvalidFormat(gateway parse / validate 進入點) + require.Equal(t, int64(10), env.Code/1_000_000, "expected Facade scope, got code=%d", env.Code) }) } } diff --git a/test/e2e/health_test.go b/test/e2e/health_test.go index da75406..3e66497 100644 --- a/test/e2e/health_test.go +++ b/test/e2e/health_test.go @@ -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) diff --git a/test/e2e/journey.go b/test/e2e/journey.go new file mode 100644 index 0000000..cb3b14f --- /dev/null +++ b/test/e2e/journey.go @@ -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) +} diff --git a/test/e2e/journey_owner_test.go b/test/e2e/journey_owner_test.go new file mode 100644 index 0000000..8821cd5 --- /dev/null +++ b/test/e2e/journey_owner_test.go @@ -0,0 +1,162 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestJourney_OwnerOnboarding 模擬 Tenant Owner 入職第一天會走的完整流程: +// 已 login(seed 提供 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) + }) +} diff --git a/test/e2e/journey_rbac_test.go b/test/e2e/journey_rbac_test.go new file mode 100644 index 0000000..d9ac1a6 --- /dev/null +++ b/test/e2e/journey_rbac_test.go @@ -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 token;no-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 = "" + }) +} diff --git a/test/e2e/journey_registration_test.go b/test/e2e/journey_registration_test.go new file mode 100644 index 0000000..fd10c59 --- /dev/null +++ b/test/e2e/journey_registration_test.go @@ -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 # 驗 OTP,member 從 unverified 轉 active +// → POST /login # ZITADEL ROPG 拿 id_token,gateway 簽 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 — 驗 OTP,member status 由 unverified → active", reason) + j.SkipStep("4", "POST /auth/login — ZITADEL ROPG 拿 id_token,gateway 簽 CloudEP JWT", reason) + j.SkipStep("5", "GET /members/me — 用新 JWT 看自己", reason) +} diff --git a/test/e2e/journey_session_test.go b/test/e2e/journey_session_test.go new file mode 100644 index 0000000..1d4b7d3 --- /dev/null +++ b/test/e2e/journey_session_test.go @@ -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 再打應該 401(jti 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) + }) +} + diff --git a/test/e2e/member_test.go b/test/e2e/member_test.go index 5f40fdb..ea91e92 100644 --- a/test/e2e/member_test.go +++ b/test/e2e/member_test.go @@ -16,6 +16,7 @@ import ( ) func TestMember_GetMe(t *testing.T) { + e2eStep(t, "M-01", "GET", "/api/v1/members/me", "讀 profile(tenant/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) diff --git a/test/e2e/permission_test.go b/test/e2e/permission_test.go index f28249d..b1ba685 100644 --- a/test/e2e/permission_test.go +++ b/test/e2e/permission_test.go @@ -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 enforcement:owner 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") } diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go index 21e8697..c2d92e6 100644 --- a/test/e2e/setup_test.go +++ b/test/e2e/setup_test.go @@ -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 == "" {