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:
parent
55446b9060
commit
9616969fd0
19
Makefile
19
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,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 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
│ ├── 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)。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 = ""
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
Loading…
Reference in New Issue