feat/env #1

Merged
daniel.w merged 4 commits from feat/env into main 2026-05-26 17:11:16 +00:00
71 changed files with 2812 additions and 3535 deletions
Showing only changes of commit 1f660b547a - Show all commits

3
.gitignore vendored
View File

@ -15,6 +15,9 @@ coverage.html
# 專案編譯產物(根目錄 binary 名稱與 module 相同時) # 專案編譯產物(根目錄 binary 名稱與 module 相同時)
/gateway /gateway
# k6 / dev binary 產物
/bin/
# ========================= # =========================
# go-doc / 工具 binary # go-doc / 工具 binary
# ========================= # =========================

112
AGENTS.md
View File

@ -1,97 +1,39 @@
# AGENTS.md # AGENTS.md
給 AI coding agentClaude / Cursor / Codex / 其他)的專案工作準則。請在開始任務前讀過一遍,並在需要時翻閱對應子文件 `template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway採用「模組化 Clean Architecture」
## 專案簡介 完整的 AI agent 工作準則請見 **[docs/AGENTS.md](docs/AGENTS.md)**Cursor / Claude / Codex / 其他 agent 皆適用)。
`template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,採用「**模組化 Clean Architecture**」:每個業務模組auth / member / notification / permission ...)放在 `internal/model/<module>/`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config` ## 一分鐘快速理解
跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」一律放在 `internal/logic/<module>/`,**usecase 不可呼叫其他 usecase**。 - **業務模組** 一律放 `internal/model/<module>/`,內部分 `domain` / `repository` / `usecase` / `config`
- **跨模組編排**(多 usecase 串接)放 `internal/logic/<module>/`**usecase 不可呼叫其他 usecase**。
- **API**`generate/api/*.api` 定義,`make gen-api` 產生 handler / types`make gen-doc` 產生 OpenAPI。
- **錯誤碼** 8 碼 `SSCCCDDD`,全專案唯一在 `internal/library/errors`
- **回應永遠用繁體中文。**
## 必讀文件 ## 常用入口
| 文件 | 何時讀 | | 文件 | 用途 |
|---|---| |---|---|
| [`generate/api/README.md`](generate/api/README.md) | 新增 / 修改 API 端點、type、文件分組、欄位描述、enum 列舉前 | | [docs/AGENTS.md](docs/AGENTS.md) | Agent 工作準則總覽(必讀) |
| [`generate/doc-generate/README.md`](generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 | | [README.md](README.md) | 專案總覽、開發約定、HTTP / 錯誤格式 |
| `internal/model/<module>/README.md` | 動到該模組的領域邏輯時 | | [docs/model.md](docs/model.md) | `internal/model/{module}` 分層規範 |
| `docs/model.md`(若存在) | 全局架構規範 | | [generate/api/README.md](generate/api/README.md) | `.api` 寫法、`@respdoc`、middleware 宣告 |
| [internal/library/errors/README.md](internal/library/errors/README.md) | 8 碼錯誤碼設計與 HTTP 對照 |
## 標準工作流程 | [docs/identity-member-design.md](docs/identity-member-design.md) | Identity / Member / Permission 跨模組架構 |
| [docs/auth-unified-registration.md](docs/auth-unified-registration.md) | 統一註冊/登入完整時序 |
### 1. 修改 API`.api` → handler / types / docs | [docs/e2e-testing.md](docs/e2e-testing.md) | E2E 測試流程 |
1. 編輯 `generate/api/*.api`(遵守 `generate/api/README.md` 的三條規則tags 分組 / backtick 行末 `//` 中文 description / `options=A|B|C` enum)
2. `make gen-api` — 重新產生 `internal/handler/`、`internal/logic/`(已存在則不覆蓋)、`internal/types/types.go`
3. `make gen-doc` — 重新產生 `docs/openapi/gateway.yaml`(gitignore,本地驗證用)
4. 實作 / 修改 `internal/logic/<module>/<handler>_logic.go` 的業務邏輯
5. `go build ./...` 確保編譯通過
6. `make lint` / `make test` 視改動範圍跑
### 2. 新增 / 修改業務模組
- 領域介面與型別放 `internal/model/<module>/domain/`
- Mongo / Redis 實作放 `internal/model/<module>/repository/`
- 原子 usecase 放 `internal/model/<module>/usecase/`(**不可**互相呼叫)
- 多步驟流程編排放 `internal/logic/<module>/`
- 模組的對外裝配入口統一在 `internal/model/<module>/usecase/module.go`,並從 `internal/svc/service_context.go` 注入
### 3. 錯誤碼
- 業務碼格式 `SSCCCDDD`(scope * 1_000_000 + category * 1_000 + detail)
- Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31)
- 新增 scope 時:同步更新 `gateway.api``bizCodeEnumDescription`
### 4. Middleware(go-zero 正規手段)
**禁止**在 `gateway.go``server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api``middleware:` 宣告:
```go
@server (
group: auth
prefix: /api/v1/auth
middleware: AuthJWT // 一個
// middleware: AuthJWT,CasbinRBAC // 多個用逗號
)
```
`make gen-api``routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`
撰寫新 middleware 時:
- 用 **struct + `Handle()` method** 模式(不是 factory function)
- 檔名 = goctl stringx 規則(例 `AuthJWT``authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`)
- 在 `ServiceContext``<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).Handle`
- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來)
詳細範例與分組原則見 [`generate/api/README.md`](generate/api/README.md) "Middleware" 章節。
### 4. Redis / Mongo / 設定
- 每個模組的設定型別放 `internal/model/<module>/config/config.go`,再合入 `internal/config/config.go``Config` struct
- Redis client 共用 `internal/library/redis/`,需 Pub/Sub 用 `client.PubSubClient()`
- Mongo index 註冊到 `cmd/mongo-index/main.go`(在 `run()` 裡呼叫 `<module>repo.EnsureMongoIndexes`)
## 通用準則
- **回應 traditional Chinese**(繁體中文)
- 程式碼註解只寫「為什麼」、邊界條件、trade-off,**不寫**「import the module / increment counter」這類顯而易見的描述
- 不主動建立 `*.md` 文件,除非使用者明確要求
- 改 git config / 強制 push / `rm -rf` 等破壞性操作 **必須**先取得使用者同意
- 不要在沒被要求時直接 commit;commit 前先 `git status` / `git diff` 確認
- commit message 用繁中描述「為什麼」改,不是「改了什麼」
## 指令速查 ## 指令速查
| 指令 | 用途 | ```bash
|---|---| make gen-api # .api → handler / logic(skip exists) / types
| `make gen-api` | `.api` → handler / logic(skip exists)/ types | make gen-doc # .api → docs/openapi/gateway.yaml
| `make gen-doc` | `.api``docs/openapi/gateway.yaml` | make fix # gofmt + goimports + lint --fix + lint
| `make gen-mock` | 模組 mock(gomock) | make check # fix + test提交前必跑
| `make tools` | 安裝 goctl / goimports / golangci-lint | make run-dev # 本機啟動(需 make deps-up
| `make fix` | gofmt + goimports + lint --fix + lint | make deps-up # docker compose Mongo + Redis
| `make check` | fix + test(提交前) | ```
| `make run-dev` | 本機啟動(需 `make deps-up`) |
| `make deps-up` | docker compose Mongo + Redis |
| `make mongo-index` | 建立 / 更新 Mongo 索引 |
完整列表`make help` 完整列表:`make help`。

42
CLAUDE.md Normal file
View File

@ -0,0 +1,42 @@
# CLAUDE.md
Claude Code project memory. 本檔以 `@import` 把專案規範帶入 context所有規則的單一真實來源SSOT在 [docs/AGENTS.md](docs/AGENTS.md)。
## 核心規範(必讀)
@docs/AGENTS.md
@docs/model.md
## 專案總覽
@README.md
## 子系統設計
@docs/identity-member-design.md
@docs/auth-unified-registration.md
## 子目錄 README按需查閱@import;需要時用 Read 工具開啟)
- [`generate/api/README.md`](generate/api/README.md) — `.api` 寫法、middleware 宣告、`@respdoc`
- [`generate/doc-generate/README.md`](generate/doc-generate/README.md) — go-doc tag / `@respdoc` 參考
- [`internal/library/errors/README.md`](internal/library/errors/README.md) — 8 碼錯誤碼設計
- [`internal/library/mongo/README.md`](internal/library/mongo/README.md) — Mongo + Redis cache 流程
- [`internal/library/redis/README.md`](internal/library/redis/README.md) — Redis client 共用
- [`internal/library/validate/custom/README.md`](internal/library/validate/custom/README.md) — 自訂 validator tag
- [`internal/response/README.md`](internal/response/README.md) — Handler / Logic 分工
- [`internal/model/auth/README.md`](internal/model/auth/README.md)
- [`internal/model/member/README.md`](internal/model/member/README.md)
- [`internal/model/permission/README.md`](internal/model/permission/README.md)
- [`internal/model/notification/README.md`](internal/model/notification/README.md)
- [`deploy/README.md`](deploy/README.md) — Docker compose / 部署
- [`etc/README.md`](etc/README.md) — 設定檔說明
## 工作原則摘要
- **回應一律繁體中文。**
- 改動 Go 程式碼後執行 `make check`= `make fix` + `make test`)才算完成。
- 不主動建立 `*.md` 文件,除非使用者明確要求。
- 不主動 commitcommit 前先 `git status` / `git diff` 確認commit message 描述「為什麼」。
- `rm -rf` / 強制 push / 改 git config 等破壞性操作前必須先取得使用者同意。
- 程式碼註解只寫「為什麼」與邊界條件不寫「import the module」這種顯而易見的描述。

206
Makefile
View File

@ -15,9 +15,6 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test 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: ## 顯示可用指令 help: ## 顯示可用指令
@echo "Gateway Makefile" @echo "Gateway Makefile"
@echo "" @echo ""
@ -57,38 +54,6 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
test: ## 執行測試 test: ## 執行測試
$(GO) test ./... $(GO) test ./...
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'
test-e2e-journey: ## 對已啟動的 Gateway 跑 E2E user journeysk6 風格多步流程;需 state.json
GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestJourney_'
GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZJourney_'
e2e-full: ## 全新 Docker + index + seed + E2E contract tests + 關閉
bash scripts/e2e-run.sh
e2e-journey: ## 全新 Docker + index + seed + E2E user journeysk6 風格)+ 關閉
E2E_MODE=journey \
E2E_TEST_PATTERN='TestJourney_' \
E2E_TEST_PATTERN_ZZZ='TestZZZJourney_' \
bash scripts/e2e-run.sh
e2e-casbin: ## 全新 Docker + Casbin enabled + E2E + 關閉(含 RBAC 403 / reload
E2E_CASBIN=1 E2E_CONFIG=test/e2e/fixtures/e2e.casbin.yaml \
E2E_TEST_PATTERN='Test(Auth_|Health|Member|Permission_(Catalog|Me|CasbinRBAC))' \
bash scripts/e2e-run.sh
e2e-up: ## 起 Docker + index + seed + Gateway不跑測試、不 teardown
bash scripts/e2e-up.sh
e2e-down: ## 停止 E2E Gateway 並 docker compose down -v
bash scripts/e2e-down.sh
e2e-list: ## 列出所有 E2E 測試ID + HTTP path + 中文描述)
@bash scripts/e2e-list.sh
fmt: ## gofmt + goimports不含 lint fmt: ## gofmt + goimports不含 lint
$(GOFMT) -s -w $(GOFILES) $(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1) @command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
@ -103,55 +68,150 @@ fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建
check: fix test ## 提交 / PR 前完整檢查fmt、lint、test check: fix test ## 提交 / PR 前完整檢查fmt、lint、test
run: ## 啟動 Gatewayetc/gateway.yaml無需 Docker # ============================================================
$(GO) run gateway.go -f etc/gateway.yaml # Docker compose本機依賴
# ============================================================
setup-dev: ## 建立本機 gateway.dev.yaml自 example不會被 git 追蹤) DOCKER_COMPOSE ?= docker compose
@test -f etc/gateway.dev.yaml || cp etc/gateway.dev.example.yaml etc/gateway.dev.yaml COMPOSE_FILE := deploy/docker-compose.yml
@echo ">> etc/gateway.dev.yaml ready (edit locally; not committed)" COMPOSE := $(DOCKER_COMPOSE) -f $(COMPOSE_FILE)
run-dev: setup-dev ## 啟動 Gatewayetc/gateway.dev.yaml需 make deps-up deps-up: ## 起 Mongo + Redis最小本機依賴
$(GO) run gateway.go -f etc/gateway.dev.yaml $(COMPOSE) up -d mongo redis
run-local: run-dev ## 別名:同 run-dev deps-up-smtp: ## 起 Mongo + Redis + MailHog
$(COMPOSE) --profile smtp up -d mongo redis mailhog
deps-up: ## 啟動本機 Mongo + Redisdocker compose deps-down: ## 停服務(保留 volume
docker compose up -d mongo redis $(COMPOSE) --profile smtp --profile k6 down
deps-up-smtp: ## 啟動 Mongo + Redis + MailHog本機 SMTP 測試 deps-down-v: ## 停服務並刪 volume會清資料
docker compose --profile smtp up -d mongo redis mailhog $(COMPOSE) --profile smtp --profile k6 down -v
deps-down: ## 停止 docker compose 容器(保留 volume deps-logs: ## 看 compose log
docker compose --profile smtp down $(COMPOSE) --profile smtp --profile k6 logs -f
deps-down-v: ## 停止並刪除 volume清空 Mongo/Redis 資料) # ============================================================
docker compose --profile smtp down -v # k6 測試test/k6/
# ============================================================
deps-logs: ## 查看依賴服務 log K6 ?= k6
docker compose --profile smtp logs -f K6_GATEWAY_CONFIG := etc/gateway.k6.yaml
K6_GATEWAY_BIN := bin/gateway-k6
K6_DIR := test/k6
K6_PAT_FILE := deploy/zitadel/machinekey/zitadel-admin-sa.token
K6_ENV_FILE := deploy/zitadel/machinekey/k6.env
ZITADEL_HEALTH_URL := http://localhost:8080/debug/healthz
deps-ps: ## 查看依賴服務狀態 # k6 安裝指引macOS / Linux
docker compose --profile smtp ps define K6_INSTALL_HINT
k6 not found in PATH.
mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動) Install:
$(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml macOS (Homebrew): brew install k6
Linux (apt): sudo gpg -k && sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 \
&& echo 'deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main' | sudo tee /etc/apt/sources.list.d/k6.list \
&& sudo apt update && sudo apt install k6
Other: https://grafana.com/docs/k6/latest/set-up/install-k6/
notify-test: setup-dev ## 通知測試METHOD 必填;例: make notify-test METHOD=email-send TO=a@b.com Or use docker (no install): K6='docker run --rm -i --network host -v $$PWD:/app -w /app grafana/k6:latest' make k6-smoke
@test -n "$(METHOD)" || (echo "usage: make notify-test METHOD=email-send TO=you@example.com" && \ endef
echo " make notify-test METHOD=sms-send PHONE=0912345678" && \ export K6_INSTALL_HINT
echo " make notify-test METHOD=email-send TO=t@e.com MOCK=1" && exit 1)
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證Google Authenticator需 Redis k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引)
$(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \ @command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1)
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \ @echo "k6: $$($(K6) version 2>&1 | head -1)"
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
$(if $(CODE),-code "$(CODE)",)
member-seed: setup-dev ## 建立 dev tenant + member需 Mongo+Redis k6-up: ## 起 k6 全棧mongo + redis + mailhog + postgres + zitadel
$(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \ $(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",) @echo "ZITADEL bootstrapping (this can take 3090s the first time)…"
@echo "→ run 'make k6-wait' to block until it is ready"
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入 k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v @echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)"
@for i in $$(seq 1 120); do \
if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \
echo "zitadel ready ($$i s)"; break; \
fi; \
sleep 1; \
if [ $$i -eq 120 ]; then echo "zitadel did not become ready in 120s"; exit 1; fi; \
done
@for i in $$(seq 1 30); do \
if [ -s "$(K6_PAT_FILE)" ]; then break; fi; \
sleep 1; \
done
@if [ ! -s "$(K6_PAT_FILE)" ]; then \
echo "PAT file $(K6_PAT_FILE) missing — check 'docker logs gateway-zitadel'"; \
exit 1; \
fi
@PAT=$$(tr -d '\n' < $(K6_PAT_FILE)); \
printf 'export ZITADEL_SERVICE_TOKEN=%s\nexport BASE_URL=http://localhost:8888\nexport MAILHOG_URL=http://localhost:8025\nexport REDIS_ADDR=localhost:6379\n' "$$PAT" > $(K6_ENV_FILE); \
echo "wrote $(K6_ENV_FILE)"
@$(MAKE) -s k6-seed-fixtures
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell"
# k6-seed-fixtures idempotently upserts the k6-tenant + K6INVITE invite code
# into gateway_k6 so /auth/register resolves successfully. The invite hash is
# sha256("K6INVITE") — see internal/model/auth/domain/const.go HashInviteCode.
# Uses mongosh inside the container; no extra Go binary needed.
K6_TENANT_ID := k6-tenant
K6_INVITE_HASH := e62a291f0dcf88c50c91fdc78e3539c55e1e20ef645c640b1ec778fe4fabb4cb
k6-seed-fixtures: ## upsert k6-tenant + K6INVITE 到 mongo
@docker exec gateway-mongo mongosh --quiet --eval '\
db = db.getSiblingDB("gateway_k6"); \
var now = NumberLong(Date.now()); \
var t = db.tenants.updateOne( \
{tenant_id: "$(K6_TENANT_ID)"}, \
{$$set: {tenant_id: "$(K6_TENANT_ID)", slug: "$(K6_TENANT_ID)", name: "k6 Tenant", uid_prefix: "K6", status: "active", update_at: now}, $$setOnInsert: {create_at: now}}, \
{upsert: true} \
); \
var i = db.invite_codes.updateOne( \
{tenant_id: "$(K6_TENANT_ID)", code_hash: "$(K6_INVITE_HASH)"}, \
{$$set: {tenant_id: "$(K6_TENANT_ID)", code_hash: "$(K6_INVITE_HASH)", max_uses: NumberLong(1000000), expires_at: NumberLong(0), new_users_only: false, update_at: now}, $$setOnInsert: {used_count: NumberLong(0), create_at: now}}, \
{upsert: true} \
); \
print("tenant matched=" + t.matchedCount + " upserted=" + (t.upsertedId?1:0) + ", invite matched=" + i.matchedCount + " upserted=" + (i.upsertedId?1:0));' && \
echo "seeded fixtures (tenant=$(K6_TENANT_ID) invite=K6INVITE) into gateway_k6"
# Back-compat alias
k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures)
k6-build: ## 建 gateway binary 給 k6 使用
@mkdir -p $(dir $(K6_GATEWAY_BIN))
$(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go
k6-gateway: k6-build ## 前景啟 gateway吃 etc/gateway.k6.yaml + ZITADEL env
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
@set -a; . $(K6_ENV_FILE); set +a; \
$(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG)
k6-smoke: k6-check ## 跑 smoke 測試(每個端點一發)
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
@set -a; . $(K6_ENV_FILE); set +a; \
for f in $(K6_DIR)/smoke/*.js; do \
echo "==> $$f"; $(K6) run "$$f" || exit 1; \
done
k6-journey: k6-check ## 跑 journey 測試(多步驟流程)
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
@set -a; . $(K6_ENV_FILE); set +a; \
for f in $(K6_DIR)/journeys/*.js; do \
echo "==> $$f"; $(K6) run "$$f" || exit 1; \
done
k6-all: k6-smoke k6-journey ## smoke + journey
k6-seed-admin: k6-build ## 註冊 k6-admin 並 seed tenant_admin rolerbac journey 用)
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
@mkdir -p bin
$(GO) build -o bin/k6-seed-admin ./cmd/k6-seed-admin
@set -a; . $(K6_ENV_FILE); set +a; \
./bin/k6-seed-admin > $(K6_ENV_FILE).admin || (echo "k6-seed-admin failed"; exit 1); \
cat $(K6_ENV_FILE).admin >> $(K6_ENV_FILE); \
echo "admin credentials appended to $(K6_ENV_FILE):"; \
cat $(K6_ENV_FILE).admin
k6-down: ## 停 k6 stack 並清 volume會清 Postgres / Mongo / Redis 資料)
$(COMPOSE) --profile k6 down -v
@rm -f $(K6_PAT_FILE) $(K6_ENV_FILE) $(K6_ENV_FILE).admin $(K6_ENV_FILE).tmp deploy/zitadel/machinekey/zitadel-admin-sa.json
@echo "k6 stack stopped, volumes & PAT removed"

View File

@ -1,245 +0,0 @@
// Command e2e-seed prepares a fresh E2E tenant, member, permission roles, and JWT tokens.
//
// Usage (usually invoked by scripts/e2e-run.sh):
//
// go run ./cmd/e2e-seed -f test/e2e/fixtures/e2e.yaml -out test/e2e/fixtures/state.json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"time"
"gateway/internal/config"
redislib "gateway/internal/library/redis"
domauth "gateway/internal/model/auth/domain/usecase"
authrepo "gateway/internal/model/auth/repository"
authusecase "gateway/internal/model/auth/usecase"
dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
"gateway/internal/model/permission/domain/enum"
domperm "gateway/internal/model/permission/domain/usecase"
permrepo "gateway/internal/model/permission/repository"
permseed "gateway/internal/model/permission/seed"
permusecase "gateway/internal/model/permission/usecase"
"github.com/zeromicro/go-zero/core/conf"
)
const (
defaultTenantID = "e2e-tenant"
defaultSlug = "e2e"
defaultPrefix = "E2E"
defaultEmail = "e2e-owner@example.com"
defaultNoRoleEmail = "e2e-no-role@example.com"
defaultRoleKey = "tenant_owner"
)
var (
configFile = flag.String("f", "test/e2e/fixtures/e2e.yaml", "config file")
outFile = flag.String("out", "test/e2e/fixtures/state.json", "output fixture JSON")
tenantID = flag.String("tenant", defaultTenantID, "tenant_id")
slug = flag.String("slug", defaultSlug, "tenant slug")
uidPrefix = flag.String("prefix", defaultPrefix, "uid prefix")
email = flag.String("email", defaultEmail, "member email")
roleKey = flag.String("role", defaultRoleKey, "system role key to assign")
)
// State is consumed by test/e2e HTTP tests.
type State struct {
BaseURL string `json:"base_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
UID string `json:"uid"`
Email string `json:"email"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
RoleKey string `json:"role_key"`
NoRoleUID string `json:"no_role_uid"`
NoRoleEmail string `json:"no_role_email"`
NoRoleAccessToken string `json:"no_role_access_token"`
NoRoleRefreshToken string `json:"no_role_refresh_token"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" || c.Redis.Host == "" {
return fmt.Errorf("e2e-seed: Mongo and Redis are required")
}
if !c.Auth.Defaults().Enabled() {
return fmt.Errorf("e2e-seed: Auth JWT secrets are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
rds, err := redislib.NewClient(c.Redis)
if err != nil {
return fmt.Errorf("e2e-seed: redis: %w", err)
}
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
MongoConf: &c.Mongo,
Config: c.Member,
})
if err != nil {
return fmt.Errorf("e2e-seed: member module: %w", err)
}
if memberMod.Tenant == nil || memberMod.Lifecycle == nil || memberMod.Profile == nil {
return fmt.Errorf("e2e-seed: member module incomplete (need Mongo)")
}
if _, err := memberMod.Tenant.Create(ctx, &dommember.CreateTenantRequest{
TenantID: *tenantID,
Slug: *slug,
Name: "E2E Tenant",
UIDPrefix: *uidPrefix,
}); err != nil {
fmt.Printf("e2e-seed: tenant create skipped (may exist): %v\n", err)
}
uid, err := ensureMember(ctx, memberMod, *tenantID, *email)
if err != nil {
return err
}
if err := seedPermissionAndAssignRole(ctx, c, *tenantID, uid, *roleKey); err != nil {
return err
}
noRoleUID, err := ensureMemberWithZitadel(ctx, memberMod, *tenantID, defaultNoRoleEmail, "e2e-no-role-sub", "E2E No Role")
if err != nil {
return err
}
tokens := authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{
Config: c.Auth,
Revoke: authrepo.NewRedisTokenRevokeStore(rds),
})
pair, err := issueTokenPair(ctx, tokens, *tenantID, uid)
if err != nil {
return fmt.Errorf("e2e-seed: issue token: %w", err)
}
noRolePair, err := issueTokenPair(ctx, tokens, *tenantID, noRoleUID)
if err != nil {
return fmt.Errorf("e2e-seed: issue no-role token: %w", err)
}
state := State{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", c.Port),
TenantID: *tenantID,
TenantSlug: *slug,
UID: uid,
Email: *email,
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
RoleKey: *roleKey,
NoRoleUID: noRoleUID,
NoRoleEmail: defaultNoRoleEmail,
NoRoleAccessToken: noRolePair.AccessToken,
NoRoleRefreshToken: noRolePair.RefreshToken,
}
raw, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("e2e-seed: marshal state: %w", err)
}
if err := os.WriteFile(*outFile, raw, 0o600); err != nil {
return fmt.Errorf("e2e-seed: write %s: %w", *outFile, err)
}
fmt.Printf("e2e-seed: tenant=%s uid=%s role=%s\n", state.TenantID, state.UID, state.RoleKey)
fmt.Printf("e2e-seed: wrote %s (base_url=%s)\n", *outFile, state.BaseURL)
return nil
}
func issueTokenPair(ctx context.Context, tokens domauth.TokenUseCase, tenantID, uid string) (*domauth.TokenPair, error) {
return tokens.IssuePair(ctx, &domauth.IssuePairRequest{
TenantID: tenantID,
UID: uid,
})
}
func ensureMember(ctx context.Context, mod *memberusecase.Module, tenantID, email string) (string, error) {
return ensureMemberWithZitadel(ctx, mod, tenantID, email, "", "E2E Owner")
}
func ensureMemberWithZitadel(ctx context.Context, mod *memberusecase.Module, tenantID, email, zitadelUserID, displayName string) (string, error) {
m, err := mod.Lifecycle.CreateUnverified(ctx, &dommember.CreatePlatformMemberRequest{
TenantID: tenantID,
Email: email,
ZitadelUserID: zitadelUserID,
DisplayName: displayName,
Language: "zh-tw",
})
if err == nil {
if actErr := mod.Lifecycle.Activate(ctx, tenantID, m.UID); actErr != nil {
return "", fmt.Errorf("e2e-seed: activate member: %w", actErr)
}
return m.UID, nil
}
// Idempotent re-run: find existing member by listing (dev tenant has one owner).
list, listErr := mod.Profile.List(ctx, &dommember.ListMembersRequest{
TenantID: tenantID,
Limit: 50,
})
if listErr != nil {
return "", fmt.Errorf("e2e-seed: create member: %w (list fallback: %v)", err, listErr)
}
for _, item := range list.Items {
if item.ZitadelEmail == email || item.BusinessEmail == email {
return item.UID, nil
}
}
return "", fmt.Errorf("e2e-seed: create member: %w", err)
}
func seedPermissionAndAssignRole(ctx context.Context, c config.Config, tenantID, uid, roleKey string) error {
perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo})
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo})
rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo})
if _, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
TenantIDs: []string{tenantID},
}); err != nil {
return fmt.Errorf("e2e-seed: permission seed: %w", err)
}
permMod, err := permusecase.NewModuleFromParam(permusecase.FactoryParam{
MongoConf: &c.Mongo,
Redis: nil,
Config: c.Permission,
})
if err != nil {
return fmt.Errorf("e2e-seed: permission module: %w", err)
}
role, err := permMod.Role.GetByKey(ctx, tenantID, roleKey)
if err != nil {
return fmt.Errorf("e2e-seed: get role %q: %w", roleKey, err)
}
if _, err := permMod.UserRole.Assign(ctx, &domperm.AssignParam{
TenantID: tenantID,
UID: uid,
RoleID: role.ID.Hex(),
Source: enum.RoleSourceManual,
}); err != nil {
// Idempotent re-run when role already assigned.
fmt.Printf("e2e-seed: assign role skipped: %v\n", err)
}
return nil
}

400
cmd/k6-seed-admin/main.go Normal file
View File

@ -0,0 +1,400 @@
// k6-seed-admin bootstraps an admin user for the k6 rbac journey.
//
// Workflow (no external deps beyond gateway + MailHog + Mongo):
// 1. POST /api/v1/auth/register against the local gateway with a fixed
// admin email/password.
// 2. Poll MailHog HTTP API for the 6-digit OTP.
// 3. POST /api/v1/auth/register/confirm to receive a JWT (we don't keep it).
// 4. Connect to Mongo, seed the permission catalog + default system roles for
// the tenant via internal/model/permission/seed.Apply.
// 5. Insert a UserRole linking the new admin UID to the tenant_admin role.
// 6. Print ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID env exports to stdout so
// callers can `eval "$(make k6-seed-admin ...)"` or redirect into a file.
//
// Re-running is safe: register is idempotent at the OTP-confirm step (the
// challenge is fresh per call), and seed.Apply / UserRole insert are
// idempotent-by-key.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
libmongo "gateway/internal/library/mongo"
memberrepo "gateway/internal/model/member/repository"
permdomain "gateway/internal/model/permission/domain"
permentity "gateway/internal/model/permission/domain/entity"
permrepo "gateway/internal/model/permission/repository"
permseed "gateway/internal/model/permission/seed"
"github.com/redis/go-redis/v9"
"github.com/zeromicro/go-zero/core/logx"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
flagBase = flag.String("base", envOr("BASE_URL", "http://localhost:8888"), "Gateway base URL")
flagMailhog = flag.String("mailhog", envOr("MAILHOG_URL", "http://localhost:8025"), "MailHog HTTP API URL")
flagTenant = flag.String("tenant", envOr("TENANT_SLUG", "k6-tenant"), "Tenant slug")
flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code")
// Default email is rotated per-invocation. Re-running seed-admin against
// a stable email would collide with the existing ZITADEL user (28303000
// email already registered) since ZITADEL state lives outside docker
// volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL.
flagEmail = flag.String("email", envOr("ADMIN_EMAIL", fmt.Sprintf("k6-admin-%d@k6.local", time.Now().Unix())), "Admin email")
flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password")
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host")
flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port")
flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database")
flagTenantID = flag.String("tenant-id", envOr("ADMIN_TENANT_ID", ""), "Override resolved tenant_id (skip lookup)")
flagPollSecs = flag.Int("otp-timeout", 10, "MailHog OTP poll timeout (seconds)")
flagDryRun = flag.Bool("dry-run", false, "Skip Mongo writes; only test register flow")
flagRedisAddr = flag.String("redis-addr", envOr("REDIS_ADDR", "localhost:6379"), "Redis addr (host:port) for casbin reload broadcast")
flagReloadChannel = flag.String("reload-channel", envOr("CASBIN_RELOAD_CHANNEL", "casbin:reload:k6"), "Casbin reload Pub/Sub channel (must match gateway Permission.Reload.Channel)")
)
func main() {
flag.Parse()
// go-zero's mongo helper logs every query via logx; in a CLI that pipes
// stdout to k6.env that pollutes the env file with JSON log lines.
// Disable logx entirely — we keep our own [k6-seed-admin] stderr logs.
logx.Disable()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
logf("registering admin %s @ %s", *flagEmail, *flagBase)
regResp, err := register(ctx)
if err != nil {
exitf("register: %v", err)
}
logf("challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
if err != nil {
exitf("poll OTP: %v", err)
}
logf("OTP=%s", code)
tokens, err := confirm(ctx, regResp.ChallengeID, code)
if err != nil {
exitf("register/confirm: %v", err)
}
logf("registration confirmed; admin uid=%s access_token=%d chars", regResp.UID, len(tokens.AccessToken))
if *flagDryRun {
writeOutput(*flagEmail, *flagPassword, regResp.UID, "", tokens)
return
}
mongoConf := &libmongo.Conf{
Schema: "mongodb",
Host: *flagMongoHost,
Port: *flagMongoPort,
Database: *flagMongoDB,
}
tenantID := *flagTenantID
if tenantID == "" {
t, err := resolveTenantID(ctx, mongoConf, *flagTenant)
if err != nil {
exitf("resolve tenant_id for slug=%s: %v", *flagTenant, err)
}
tenantID = t
}
logf("tenant_id=%s", tenantID)
if err := seedRoles(ctx, mongoConf, tenantID); err != nil {
exitf("seed roles: %v", err)
}
roleID, err := assignAdmin(ctx, mongoConf, tenantID, regResp.UID)
if err != nil {
exitf("assign tenant_admin: %v", err)
}
logf("tenant_admin role_id=%s assigned", roleID)
// Casbin lives in process memory inside the gateway and only reloads
// from Mongo when it boots or when something publishes on the reload
// channel. seed-admin runs AFTER the gateway started, so without this
// broadcast the admin's tenant_admin assignment is invisible until a
// restart and the rbac journey 403s on the very first /roles call.
if err := broadcastReload(ctx, *flagRedisAddr, *flagReloadChannel, tenantID); err != nil {
logf("warn: casbin reload broadcast failed (rbac journey may 403 until gateway restart): %v", err)
} else {
logf("casbin policy reload broadcast on %s channel=%s", *flagRedisAddr, *flagReloadChannel)
}
// Pub/Sub is best-effort; give the subscriber a beat to LoadPolicy
// before callers (e.g. make k6-journey) hit /roles.
time.Sleep(500 * time.Millisecond)
writeOutput(*flagEmail, *flagPassword, regResp.UID, tenantID, tokens)
}
// broadcastReload publishes a casbin reload event on the same Redis channel
// the gateway subscribes to (see internal/model/permission/usecase
// /rbac_usecase.go::BroadcastReload). Payload shape mirrors that function.
func broadcastReload(ctx context.Context, addr, channel, tenantID string) error {
if addr == "" {
return fmt.Errorf("redis addr empty")
}
if channel == "" {
channel = permdomain.PolicyReloadChannel
}
if tenantID == "" {
tenantID = permdomain.PolicyReloadAllToken
}
rdb := redis.NewClient(&redis.Options{Addr: addr})
defer func() { _ = rdb.Close() }()
payload, _ := json.Marshal(map[string]any{
"tenant_id": tenantID,
"ts": time.Now().UnixMilli(),
})
pubCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return rdb.Publish(pubCtx, channel, payload).Err()
}
// ---------- HTTP / API helpers ----------
type registerResp struct {
ChallengeID string `json:"challenge_id"`
ExpiresIn int `json:"expires_in"`
UID string `json:"uid"`
}
type confirmResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
UID string `json:"uid"`
TokenType string `json:"token_type"`
}
type envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func register(ctx context.Context) (*registerResp, error) {
body, _ := json.Marshal(map[string]any{
"tenant_slug": *flagTenant,
"invite_code": *flagInvite,
"email": *flagEmail,
"password": *flagPassword,
"display_name": "k6 admin",
"language": "zh-TW",
"accept_terms_version": "2025-01-01",
"marketing_opt_in": false,
})
env, err := doJSON(ctx, "POST", *flagBase+"/api/v1/auth/register", body)
if err != nil {
return nil, err
}
var r registerResp
if err := json.Unmarshal(env.Data, &r); err != nil {
return nil, fmt.Errorf("decode register data: %w", err)
}
return &r, nil
}
func confirm(ctx context.Context, challengeID, code string) (*confirmResp, error) {
body, _ := json.Marshal(map[string]any{
"tenant_slug": *flagTenant,
"challenge_id": challengeID,
"code": code,
})
env, err := doJSON(ctx, "POST", *flagBase+"/api/v1/auth/register/confirm", body)
if err != nil {
return nil, err
}
var r confirmResp
if err := json.Unmarshal(env.Data, &r); err != nil {
return nil, fmt.Errorf("decode confirm data: %w", err)
}
return &r, nil
}
func doJSON(ctx context.Context, method, url string, body []byte) (*envelope, error) {
req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("decode envelope: %w (body=%s)", err, raw)
}
if env.Code != 102000 {
return nil, fmt.Errorf("non-success code=%d message=%s", env.Code, env.Message)
}
return &env, nil
}
var (
otpRegex = regexp.MustCompile(`\b(\d{6})\b`)
cssHexRe = regexp.MustCompile(`#[0-9a-fA-F]{6}\b`)
qpSoftLine = regexp.MustCompile(`=\r?\n`)
)
// extractOTP returns the LAST 6-digit number in the body after stripping
// CSS hex colors (e.g. #059669) and quoted-printable soft line breaks.
// Email bodies render the OTP in a styled span near the bottom; the naive
// "first 6-digit" approach picks up brand colors.
func extractOTP(body string) string {
cleaned := cssHexRe.ReplaceAllString(qpSoftLine.ReplaceAllString(body, ""), "")
matches := otpRegex.FindAllStringSubmatch(cleaned, -1)
if len(matches) == 0 {
return ""
}
return matches[len(matches)-1][1]
}
type mailhogItem struct {
Created string `json:"Created"`
Content struct {
Body string `json:"Body"`
} `json:"Content"`
}
type mailhogList struct {
Items []mailhogItem `json:"items"`
}
func pollOTP(ctx context.Context, email string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
url := fmt.Sprintf("%s/api/v2/search?kind=to&query=%s&start=0&limit=5", *flagMailhog, email)
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err == nil && resp.StatusCode == 200 {
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var list mailhogList
if json.Unmarshal(raw, &list) == nil {
for _, it := range list.Items {
if code := extractOTP(it.Content.Body); code != "" {
return code, nil
}
}
}
} else if resp != nil {
_ = resp.Body.Close()
}
time.Sleep(300 * time.Millisecond)
}
return "", fmt.Errorf("OTP not seen within %s", timeout)
}
// ---------- Mongo helpers ----------
func resolveTenantID(ctx context.Context, conf *libmongo.Conf, slug string) (string, error) {
repo := memberrepo.NewTenantRepository(memberrepo.TenantRepositoryParam{Conf: conf})
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
t, err := repo.GetBySlug(ctx, slug)
if err == nil && t != nil && t.TenantID != "" {
return t.TenantID, nil
}
time.Sleep(200 * time.Millisecond)
}
// Fallback: treat the slug itself as the tenant id (works for gateways
// that use slug == tenant_id, e.g. dev seed).
return slug, nil
}
func seedRoles(ctx context.Context, conf *libmongo.Conf, tenantID string) error {
perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: conf})
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf})
rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: conf})
rpt, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
TenantIDs: []string{tenantID},
})
if err != nil {
return err
}
logf("seed report: catalog=%d roles=%d role_perms=%d", rpt.CatalogUpserted, rpt.RolesUpserted, rpt.RolePermissionSet)
return nil
}
func assignAdmin(ctx context.Context, conf *libmongo.Conf, tenantID, uid string) (string, error) {
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf})
role, err := roles.GetByKey(ctx, tenantID, "tenant_admin")
if err != nil || role == nil {
return "", fmt.Errorf("tenant_admin role not found for tenant=%s: %v", tenantID, err)
}
urRepo := permrepo.NewUserRoleRepository(permrepo.UserRoleRepositoryParam{Conf: conf})
if err := urRepo.Insert(ctx, &permentity.UserRole{
ID: bson.NewObjectID(),
TenantID: tenantID,
UID: uid,
RoleID: role.ID.Hex(),
}); err != nil {
// duplicate-key is OK (idempotent re-run)
if !strings.Contains(err.Error(), "duplicate") {
return "", err
}
}
return role.ID.Hex(), nil
}
// ---------- util ----------
func writeOutput(email, password, uid, tenantID string, tokens *confirmResp) {
fmt.Printf("export ADMIN_EMAIL=%s\n", email)
fmt.Printf("export ADMIN_PASSWORD=%s\n", password)
fmt.Printf("export ADMIN_UID=%s\n", uid)
if tenantID != "" {
fmt.Printf("export ADMIN_TENANT_ID=%s\n", tenantID)
}
if tokens != nil && tokens.AccessToken != "" {
// k6 journeys (rbac_admin.js) prefer these over POST /auth/login,
// since ZITADEL v2 disables the OAuth password grant by default and
// the gateway's /auth/login → VerifyPassword path then 502s.
fmt.Printf("export ADMIN_ACCESS_TOKEN=%s\n", tokens.AccessToken)
fmt.Printf("export ADMIN_REFRESH_TOKEN=%s\n", tokens.RefreshToken)
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func envOrInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
return n
}
}
return def
}
func logf(format string, a ...any) {
fmt.Fprintf(os.Stderr, "[k6-seed-admin] "+format+"\n", a...)
}
func exitf(format string, a ...any) {
logf(format, a...)
os.Exit(1)
}

View File

@ -1,85 +0,0 @@
// Command member-seed creates a dev tenant and member for local API testing.
//
// make deps-up && make mongo-index && make member-seed
package main
import (
"context"
"flag"
"fmt"
"os"
"gateway/internal/config"
redislib "gateway/internal/library/redis"
domusecase "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
"github.com/zeromicro/go-zero/core/conf"
)
var (
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
tenantID = flag.String("tenant", "dev-tenant", "tenant_id")
slug = flag.String("slug", "dev", "tenant slug")
uidPrefix = flag.String("prefix", "DEV", "uid prefix")
email = flag.String("email", "dev@example.com", "member email")
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" || c.Redis.Host == "" {
return fmt.Errorf("member-seed: Mongo and Redis are required")
}
ctx := context.Background()
rds, err := redislib.NewClient(c.Redis)
if err != nil {
return fmt.Errorf("member-seed: redis: %w", err)
}
mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
MongoConf: &c.Mongo,
Config: c.Member,
})
if err != nil {
return fmt.Errorf("member-seed: module: %w", err)
}
if mod.Tenant == nil || mod.Lifecycle == nil {
return fmt.Errorf("member-seed: tenant/lifecycle not wired (need Mongo)")
}
if _, err := mod.Tenant.Create(ctx, &domusecase.CreateTenantRequest{
TenantID: *tenantID,
Slug: *slug,
Name: "Dev Tenant",
UIDPrefix: *uidPrefix,
}); err != nil {
fmt.Printf("tenant create skipped (may exist): %v\n", err)
}
m, err := mod.Lifecycle.CreateUnverified(ctx, &domusecase.CreatePlatformMemberRequest{
TenantID: *tenantID,
Email: *email,
DisplayName: "Dev User",
Language: "zh-tw",
})
if err != nil {
return fmt.Errorf("member-seed: create member: %w", err)
}
if err := mod.Lifecycle.Activate(ctx, *tenantID, m.UID); err != nil {
return fmt.Errorf("member-seed: activate: %w", err)
}
fmt.Printf("tenant_id=%s uid=%s\n", *tenantID, m.UID)
fmt.Printf("Use headers: X-Tenant-ID=%s X-UID=%s\n", *tenantID, m.UID)
return nil
}

View File

@ -1,62 +0,0 @@
// Command mongo-index ensures Gateway MongoDB indexes exist.
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"gateway/internal/config"
authrepo "gateway/internal/model/auth/repository"
memberrepo "gateway/internal/model/member/repository"
notifrepo "gateway/internal/model/notification/repository"
permrepo "gateway/internal/model/permission/repository"
"github.com/zeromicro/go-zero/core/conf"
)
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" {
return fmt.Errorf("mongo-index: Mongo.Host is empty in config")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
notifRepo := notifrepo.NewNotificationRepository(notifrepo.NotificationRepositoryParam{Conf: &c.Mongo})
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
if err := notifRepo.Index20260520001UP(ctx); err != nil {
return fmt.Errorf("mongo-index: notifications: %w", err)
}
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
return fmt.Errorf("mongo-index: notification_dlq: %w", err)
}
if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: member: %w", err)
}
if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: auth: %w", err)
}
if err := permrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: permission: %w", err)
}
fmt.Println("mongo-index: notifications + notification_dlq + member + auth + permission indexes OK")
return nil
}

View File

@ -1,487 +0,0 @@
// Command notify-test runs one notification test by -method (point-and-shoot).
//
// make deps-up && make mongo-index
// make notify-test METHOD=email-send TO=you@example.com
// make notify-test METHOD=sms-send PHONE=0912345678 MOCK=1
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
"gateway/internal/config"
redislib "gateway/internal/library/redis"
memberenum "gateway/internal/model/member/domain/enum"
dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
notifconfig "gateway/internal/model/notification/config"
"gateway/internal/model/notification/domain/enum"
domtpl "gateway/internal/model/notification/domain/template"
domusecase "gateway/internal/model/notification/domain/usecase"
notifusecase "gateway/internal/model/notification/usecase"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/conf"
)
const (
methodEmailSend = "email-send"
methodEmailEnqueue = "email-enqueue"
methodEmailIdempotency = "email-idempotency"
methodSMSSend = "sms-send"
methodSMSEnqueue = "sms-enqueue"
methodMemberEmail = "member-email"
methodMemberPhone = "member-phone"
methodAdminDLQ = "admin-dlq"
)
var validMethods = []string{
methodEmailSend,
methodEmailEnqueue,
methodEmailIdempotency,
methodSMSSend,
methodSMSEnqueue,
methodMemberEmail,
methodMemberPhone,
methodAdminDLQ,
}
var (
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
method = flag.String("method", "", "test method (required): "+strings.Join(validMethods, ", "))
toEmail = flag.String("to", "", "recipient email")
phone = flag.String("phone", "", "recipient phone")
tenantID = flag.String("tenant", "notify-test", "tenant_id")
uid = flag.String("uid", "notify-test-uid", "uid")
mockOnly = flag.Bool("mock", false, "force mock email/SMS providers")
pollSec = flag.Int("poll", 45, "max seconds to wait for async delivery (enqueue methods)")
)
type env struct {
ctx context.Context
tenant string
uid string
to string
phone string
locale string
notifier domusecase.NotifierUseCase
// otp is the atomic primitive; this CLI plays the role of the future
// logic layer and orchestrates OTP.Generate + Notifier.Send inline.
otp dommember.OTPUseCase
admin domusecase.AdminNotifierUseCase
}
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: notify-test -method <name> [options]\n\n")
fmt.Fprintf(os.Stderr, "Methods:\n")
for _, m := range validMethods {
fmt.Fprintf(os.Stderr, " %s\n", m)
}
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " notify-test -method email-send -to you@example.com\n")
fmt.Fprintf(os.Stderr, " notify-test -method email-enqueue -to you@example.com\n")
fmt.Fprintf(os.Stderr, " notify-test -method sms-send -phone 0912345678\n")
fmt.Fprintf(os.Stderr, " notify-test -method member-email -to you@example.com\n")
fmt.Fprintf(os.Stderr, " notify-test -method admin-dlq\n")
fmt.Fprintf(os.Stderr, " notify-test -method email-send -to t@e.com -mock\n")
flag.PrintDefaults()
}
flag.Parse()
code, err := run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
if code != 0 {
os.Exit(code)
}
}
// run wires the requested method and returns (exitCode, error). Deferred
// cleanups inside run always execute before main calls os.Exit.
func run() (int, error) {
m := strings.TrimSpace(*method)
if m == "" {
flag.Usage()
return 2, fmt.Errorf("notify-test: -method is required")
}
if !isValidMethod(m) {
flag.Usage()
return 2, fmt.Errorf("notify-test: unknown method %q", m)
}
if err := validateArgs(m); err != nil {
return 2, fmt.Errorf("notify-test: %w", err)
}
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" {
return 1, fmt.Errorf("notify-test: Mongo.Host is empty")
}
if c.Redis.Host == "" {
return 1, fmt.Errorf("notify-test: Redis.Host is empty")
}
if c.Notification.Email.From == "" && needsEmailFrom(m) {
return 1, fmt.Errorf("notify-test: Notification.Email.From is empty")
}
if *mockOnly {
forceMock(&c.Notification)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*pollSec+60)*time.Second)
defer cancel()
rds, err := redislib.NewClient(c.Redis)
if err != nil {
return 1, fmt.Errorf("notify-test: redis: %w", err)
}
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
MongoConf: &c.Mongo,
Redis: rds,
Config: c.Notification,
})
if err != nil {
return 1, fmt.Errorf("notify-test: notification: %w", err)
}
var otpUC dommember.OTPUseCase
if m == methodMemberEmail || m == methodMemberPhone {
memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
Config: c.Member,
})
if memErr != nil {
return 1, fmt.Errorf("notify-test: member: %w", memErr)
}
otpUC = memberMod.OTP
}
e := &env{
ctx: ctx,
tenant: *tenantID,
uid: *uid,
to: *toEmail,
phone: *phone,
locale: c.Notification.DefaultLocale,
notifier: mod.Notifier,
otp: otpUC,
admin: mod.Admin,
}
if m == methodEmailEnqueue || m == methodSMSEnqueue {
if mod.RetryWorker == nil {
return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)")
}
workerCtx, stop := context.WithCancel(context.Background())
go mod.RetryWorker.Run(workerCtx)
defer stop()
}
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
if runErr := runMethod(e, m); runErr != nil {
return 1, fmt.Errorf("FAIL: %w", runErr)
}
fmt.Println("OK")
return 0, nil
}
func runMethod(e *env, m string) error {
switch m {
case methodEmailSend:
return e.emailSend()
case methodEmailEnqueue:
return e.emailEnqueue()
case methodEmailIdempotency:
return e.emailIdempotency()
case methodSMSSend:
return e.smsSend()
case methodSMSEnqueue:
return e.smsEnqueue()
case methodMemberEmail:
return e.memberEmail()
case methodMemberPhone:
return e.memberPhone()
case methodAdminDLQ:
return e.adminDLQ()
default:
return fmt.Errorf("unhandled method %q", m)
}
}
func (e *env) emailSend() error {
dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: enum.ChannelEmail,
Kind: enum.NotifyVerifyEmail,
Target: e.to,
Locale: e.locale,
Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300},
IdempotencyKey: uuid.NewString(),
DoNotPersistBody: true,
Severity: enum.SeverityInfo,
})
return reportSent(dto, err)
}
func (e *env) emailEnqueue() error {
pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: enum.ChannelEmail,
Kind: enum.NotifyTenantWelcome,
Target: e.to,
Locale: e.locale,
Data: map[string]any{"tenant_name": "Test Corp"},
IdempotencyKey: uuid.NewString(),
DoNotPersistBody: false,
Severity: enum.SeverityInfo,
})
if err != nil {
return err
}
final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second)
if err != nil {
return err
}
fmt.Printf("notification_id=%s provider=%s status=%s\n", final.ID, final.Provider, final.Status)
return nil
}
func (e *env) emailIdempotency() error {
key := uuid.NewString()
first, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: enum.ChannelEmail,
Kind: enum.NotifyVerifyEmail,
Target: e.to,
Locale: e.locale,
Data: map[string]any{domtpl.VarCode: "111111", domtpl.VarExpiresIn: 300},
IdempotencyKey: key,
Severity: enum.SeverityInfo,
})
if err != nil {
return err
}
second, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: enum.ChannelEmail,
Kind: enum.NotifyVerifyEmail,
Target: e.to,
Locale: e.locale,
Data: map[string]any{domtpl.VarCode: "222222", domtpl.VarExpiresIn: 300},
IdempotencyKey: key,
Severity: enum.SeverityInfo,
})
if err != nil {
return err
}
if first.ID != second.ID {
return fmt.Errorf("idempotency: expected same id, got %s vs %s", first.ID, second.ID)
}
fmt.Printf("notification_id=%s (replay ok)\n", first.ID)
return nil
}
func (e *env) smsSend() error {
dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: enum.ChannelSMS,
Kind: enum.NotifyVerifyPhone,
Target: e.phone,
Locale: e.locale,
Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300},
IdempotencyKey: uuid.NewString(),
DoNotPersistBody: true,
Severity: enum.SeverityInfo,
})
return reportSent(dto, err)
}
func (e *env) smsEnqueue() error {
pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: enum.ChannelSMS,
Kind: enum.NotifyVerifyPhone,
Target: e.phone,
Locale: e.locale,
Data: map[string]any{domtpl.VarCode: "654321", domtpl.VarExpiresIn: 300},
IdempotencyKey: uuid.NewString(),
Severity: enum.SeverityInfo,
})
if err != nil {
return err
}
final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second)
if err != nil {
return err
}
fmt.Printf("notification_id=%s provider=%s\n", final.ID, final.Provider)
return nil
}
// memberEmail demonstrates the logic-layer orchestration: generate an OTP
// challenge (atomic) and dispatch the verification email through Notifier
// (atomic). usecases never call each other — this driver is what the real
// logic handler will look like.
func (e *env) memberEmail() error {
return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to)
}
func (e *env) memberPhone() error {
return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone)
}
func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error {
if e.otp == nil {
return fmt.Errorf("member OTP usecase not configured")
}
if target == "" {
return fmt.Errorf("target is empty")
}
dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{
TenantID: e.tenant,
UID: e.uid,
Purpose: purpose,
Target: target,
})
if err != nil {
return err
}
if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: channel,
Kind: kind,
Target: target,
Locale: e.locale,
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
IdempotencyKey: dto.ChallengeID,
DoNotPersistBody: true,
Severity: enum.SeverityInfo,
}); err != nil {
if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil {
fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr)
}
return err
}
fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn)
return nil
}
func (e *env) adminDLQ() error {
if e.admin == nil {
return fmt.Errorf("admin notifier not configured")
}
entries, err := e.admin.ListDLQ(e.ctx, e.tenant, 10)
if err != nil {
return err
}
fmt.Printf("dlq_count=%d\n", len(entries))
return nil
}
func reportSent(dto *domusecase.NotificationDTO, err error) error {
if err != nil {
return err
}
if dto.Status != enum.NotifyStatusSent {
return fmt.Errorf("status=%s last_error=%s", dto.Status, dto.LastError)
}
fmt.Printf("notification_id=%s provider=%s message_id=%s\n", dto.ID, dto.Provider, dto.ProviderMessageID)
return nil
}
func waitSent(ctx context.Context, notifier domusecase.NotifierUseCase, tenantID, notificationID string, timeout time.Duration) (*domusecase.NotificationDTO, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
dto, err := notifier.Get(ctx, tenantID, notificationID)
if err != nil {
return nil, err
}
switch dto.Status {
case enum.NotifyStatusSent:
return dto, nil
case enum.NotifyStatusFailed, enum.NotifyStatusDropped:
return dto, fmt.Errorf("status=%s: %s", dto.Status, dto.LastError)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
return nil, fmt.Errorf("timeout after %s", timeout)
}
func validateArgs(m string) error {
switch m {
case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail:
if *toEmail == "" {
return fmt.Errorf("%s requires -to", m)
}
case methodSMSSend, methodSMSEnqueue, methodMemberPhone:
if *phone == "" {
return fmt.Errorf("%s requires -phone", m)
}
}
return nil
}
func needsEmailFrom(m string) bool {
switch m {
case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail:
return true
default:
return false
}
}
func isValidMethod(m string) bool {
for _, v := range validMethods {
if v == m {
return true
}
}
return false
}
func forceMock(cfg *notifconfig.Config) {
cfg.Email.SMTP.Enable = false
cfg.Email.SES.Enable = false
cfg.Email.Provider = notifconfig.ProviderMock
cfg.SMS.Mitake.Enable = false
cfg.SMS.Provider = notifconfig.ProviderMock
}
func emailProviders(cfg *notifconfig.Config) []string {
var out []string
if cfg.Email.SMTP.Enable {
out = append(out, "smtp")
}
if cfg.Email.SES.Enable {
out = append(out, "ses")
}
if len(out) == 0 {
out = append(out, "mock")
}
return out
}
func smsProviders(cfg *notifconfig.Config) []string {
if cfg.SMS.Mitake.Enable {
return []string{"mitake"}
}
return []string{"mock"}
}

View File

@ -1,89 +0,0 @@
// Command permission-seed upserts the platform-wide permission catalog
// and (optionally) seeds default system roles for one or more tenants.
//
// Usage:
//
// permission-seed -f etc/gateway.dev.yaml # catalog only
// permission-seed -f etc/gateway.dev.yaml -tenant TEN-001 # catalog + tenant roles
// permission-seed -f etc/gateway.dev.yaml -tenant t1,t2 -skip-catalog
//
// The seeder is idempotent: re-running only updates fields that changed
// in the embedded catalog. Default system roles (tenant_owner, etc.)
// always have is_system=true; their permission set is rewritten on each
// run so renaming a catalog entry propagates automatically.
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
"gateway/internal/config"
permrepo "gateway/internal/model/permission/repository"
permseed "gateway/internal/model/permission/seed"
"github.com/zeromicro/go-zero/core/conf"
)
var (
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
tenantList = flag.String("tenant", "", "comma-separated tenant IDs to seed default system roles into")
skipCatalog = flag.Bool("skip-catalog", false, "skip platform-wide catalog upsert (only seed tenant roles)")
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" {
return fmt.Errorf("permission-seed: Mongo.Host is empty in config")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := permrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("permission-seed: ensure indexes: %w", err)
}
perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo})
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo})
rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo})
tenantIDs := splitTenantIDs(*tenantList)
report, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
TenantIDs: tenantIDs,
SkipCatalog: *skipCatalog,
})
if err != nil {
return fmt.Errorf("permission-seed: apply: %w", err)
}
fmt.Printf("permission-seed: catalog upserted=%d roles upserted=%d role-permission rows=%d tenants=%v\n",
report.CatalogUpserted, report.RolesUpserted, report.RolePermissionSet, tenantIDs)
return nil
}
func splitTenantIDs(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

View File

@ -1,330 +0,0 @@
// Command totp-test runs an interactive TOTP enrollment and verification flow
// against local Redis + in-memory profile (single process).
//
// Prerequisites:
//
// make deps-up
// make setup-dev # ensure Member.TOTP.SecretKEK is set
//
// Usage:
//
// make totp-test
// go run ./cmd/totp-test -f etc/gateway.dev.yaml
package main
import (
"bufio"
"context"
"flag"
"fmt"
"net/url"
"os"
"strings"
"gateway/internal/config"
redislib "gateway/internal/library/redis"
domusecase "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
"github.com/skip2/go-qrcode"
"github.com/zeromicro/go-zero/core/conf"
)
const (
stepFlow = "flow"
stepEnroll = "enroll"
stepConfirm = "confirm"
stepVerify = "verify"
stepStatus = "status"
stepDisable = "disable"
)
var (
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
stepFlag = flag.String("step", stepFlow, "step: flow, enroll, confirm, verify, status, disable")
tenantID = flag.String("tenant", "totp-test", "tenant_id")
uidFlag = flag.String("uid", "totp-test-uid", "uid")
account = flag.String("account", "totp-test@example.com", "account label shown in Authenticator")
codeFlag = flag.String("code", "", "TOTP code (non-interactive confirm/verify)")
kekFlag = flag.String("kek", "", "override Member.TOTP.SecretKEK (hex or base64)")
)
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: totp-test [options]\n\n")
fmt.Fprintf(os.Stderr, "Interactive TOTP test for Google Authenticator / Authy.\n")
fmt.Fprintf(os.Stderr, "Default step=flow guides enroll → confirm → verify in one process.\n\n")
fmt.Fprintf(os.Stderr, "Examples:\n")
fmt.Fprintf(os.Stderr, " totp-test\n")
fmt.Fprintf(os.Stderr, " totp-test -step status\n")
fmt.Fprintf(os.Stderr, " totp-test -step confirm -code 482913\n")
flag.PrintDefaults()
}
flag.Parse()
code, err := run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
if code != 0 {
os.Exit(code)
}
}
func run() (int, error) {
step := strings.TrimSpace(*stepFlag)
if step == "" {
step = stepFlow
}
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Redis.Host == "" {
return 1, fmt.Errorf("totp-test: Redis.Host is empty (run: make deps-up)")
}
kek := strings.TrimSpace(*kekFlag)
if kek == "" {
kek = strings.TrimSpace(c.Member.TOTP.SecretKEK)
}
if kek == "" {
return 1, fmt.Errorf("totp-test: Member.TOTP.SecretKEK is empty; set it in %s or pass -kek", *configFile)
}
c.Member.TOTP.SecretKEK = kek
ctx := context.Background()
rds, err := redislib.NewClient(c.Redis)
if err != nil {
return 1, fmt.Errorf("totp-test: redis: %w", err)
}
mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
Config: c.Member,
})
if err != nil {
return 1, fmt.Errorf("totp-test: member: %w", err)
}
if mod.TOTP == nil {
return 1, fmt.Errorf("totp-test: TOTP usecase not wired (invalid SecretKEK?)")
}
env := &session{
ctx: ctx,
tenant: *tenantID,
uid: *uidFlag,
totp: mod.TOTP,
in: bufio.NewReader(os.Stdin),
}
switch step {
case stepFlow:
if err := env.runFlow(); err != nil {
return 1, fmt.Errorf("FAIL: %w", err)
}
case stepEnroll:
if err := env.doEnroll(); err != nil {
return 1, fmt.Errorf("FAIL: %w", err)
}
case stepConfirm:
if err := env.doConfirm(strings.TrimSpace(*codeFlag)); err != nil {
return 1, fmt.Errorf("FAIL: %w", err)
}
case stepVerify:
if err := env.doVerify(strings.TrimSpace(*codeFlag)); err != nil {
return 1, fmt.Errorf("FAIL: %w", err)
}
case stepStatus:
if err := env.doStatus(); err != nil {
return 1, fmt.Errorf("FAIL: %w", err)
}
case stepDisable:
if err := env.doDisable(); err != nil {
return 1, fmt.Errorf("FAIL: %w", err)
}
default:
flag.Usage()
return 2, fmt.Errorf("totp-test: unknown step %q", step)
}
fmt.Println("OK")
return 0, nil
}
type session struct {
ctx context.Context
tenant string
uid string
totp domusecase.TOTPUseCase
in *bufio.Reader
}
func (s *session) runFlow() error {
status, err := s.totp.Status(s.ctx, s.tenant, s.uid)
if err != nil {
return err
}
if status.Enrolled {
fmt.Println("Already enrolled for this tenant/uid.")
if err := s.doStatus(); err != nil {
return err
}
fmt.Println()
fmt.Println("Proceeding to verify-only (skip enroll/confirm).")
} else {
if err := s.doEnroll(); err != nil {
return err
}
fmt.Println()
confirmCode, err := s.readCode("Enter the 6-digit code from Google Authenticator to confirm enrollment: ")
if err != nil {
return err
}
if err := s.doConfirm(confirmCode); err != nil {
return err
}
fmt.Println()
fmt.Println("Wait for the Authenticator code to refresh (up to 30s), then verify step-up.")
}
verifyCode, err := s.readCode("Enter a fresh 6-digit code to verify (step-up): ")
if err != nil {
return err
}
if err := s.doVerify(verifyCode); err != nil {
return err
}
fmt.Println()
fmt.Println("Testing replay protection with the same code (should fail)...")
if err := s.doVerify(verifyCode); err == nil {
return fmt.Errorf("expected replay failure but verify succeeded")
}
fmt.Println("Replay correctly rejected.")
return nil
}
func (s *session) doEnroll() error {
start, err := s.totp.StartEnroll(s.ctx, s.tenant, s.uid, *account)
if err != nil {
return err
}
secret, err := secretFromOtpauthURL(start.OtpauthURL)
if err != nil {
return err
}
fmt.Println("=== TOTP Enrollment ===")
fmt.Printf("tenant=%s uid=%s\n", s.tenant, s.uid)
fmt.Printf("issuer=%s account=%s digits=%d period=%ds expires_in=%ds\n",
start.Issuer, start.Account, start.Digits, start.PeriodSec, start.ExpiresIn)
fmt.Println()
fmt.Println("Option A — scan QR code with Google Authenticator:")
fmt.Println()
if err := printTerminalQR(start.OtpauthURL); err != nil {
fmt.Fprintf(os.Stderr, "warn: QR render failed: %v\n", err)
}
fmt.Println()
fmt.Println("Option B — enter setup key manually in Google Authenticator:")
fmt.Printf(" Type: Time based\n")
fmt.Printf(" Account: %s\n", start.Account)
fmt.Printf(" Issuer: %s\n", start.Issuer)
fmt.Printf(" Secret key: %s\n", secret)
fmt.Println()
fmt.Println("otpauth URL (for debugging):")
fmt.Println(start.OtpauthURL)
fmt.Println()
fmt.Printf("Complete enrollment within %d seconds.\n", start.ExpiresIn)
return nil
}
func (s *session) doConfirm(code string) error {
if code == "" {
var err error
code, err = s.readCode("Enter the 6-digit code from Google Authenticator: ")
if err != nil {
return err
}
}
backup, err := s.totp.ConfirmEnroll(s.ctx, s.tenant, s.uid, code)
if err != nil {
return err
}
fmt.Println("Enrollment confirmed.")
fmt.Printf("backup_codes (%d, save these — shown once):\n", len(backup))
for i, c := range backup {
fmt.Printf(" [%02d] %s\n", i+1, c)
}
return s.doStatus()
}
func (s *session) doVerify(code string) error {
if code == "" {
var err error
code, err = s.readCode("Enter a 6-digit TOTP code (or backup code): ")
if err != nil {
return err
}
}
if err := s.totp.VerifyCode(s.ctx, s.tenant, s.uid, code); err != nil {
return err
}
fmt.Println("VerifyCode: success")
return nil
}
func (s *session) doStatus() error {
status, err := s.totp.Status(s.ctx, s.tenant, s.uid)
if err != nil {
return err
}
fmt.Printf("status enrolled=%t backup_codes_remaining=%d", status.Enrolled, status.BackupCodesRemaining)
if status.Enrolled {
fmt.Printf(" enrolled_at=%d", status.EnrolledAt)
}
fmt.Println()
return nil
}
func (s *session) doDisable() error {
if err := s.totp.Disable(s.ctx, s.tenant, s.uid); err != nil {
return err
}
fmt.Println("TOTP disabled.")
return s.doStatus()
}
func (s *session) readCode(prompt string) (string, error) {
fmt.Print(prompt)
line, err := s.in.ReadString('\n')
if err != nil {
return "", fmt.Errorf("read code: %w", err)
}
code := strings.TrimSpace(line)
if code == "" {
return "", fmt.Errorf("code is empty")
}
return code, nil
}
func secretFromOtpauthURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("parse otpauth url: %w", err)
}
secret := strings.TrimSpace(u.Query().Get("secret"))
if secret == "" {
return "", fmt.Errorf("otpauth url missing secret parameter")
}
return secret, nil
}
func printTerminalQR(content string) error {
qr, err := qrcode.New(content, qrcode.Medium)
if err != nil {
return err
}
fmt.Print(qr.ToSmallString(false))
return nil
}

100
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,100 @@
# 本機開發 / k6 測試依賴MongoDB、Redis、MailHog、Postgres、ZITADEL
#
# 啟動:
# make deps-up → mongo + redis最小吃 etc/gateway.dev.yaml
# make deps-up-smtp → + mailhogprofile smtp
# make k6-up → mongo + redis + mailhog + postgres + zitadel吃 etc/gateway.k6.yaml
#
# ZITADEL admin PAT 會寫到 deploy/zitadel/machinekey/zitadel-admin-sa.token
services:
mongo:
image: mongo:7
container_name: gateway-mongo
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: gateway
volumes:
- mongo_data:/data/db
- ./mongo/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
container_name: gateway-redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
mailhog:
profiles: ["smtp", "k6"]
image: mailhog/mailhog:v1.0.1
container_name: gateway-mailhog
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI / HTTP API
# ===== ZITADEL stackprofile k6=====
postgres:
profiles: ["k6"]
image: postgres:17-alpine
container_name: gateway-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: zitadel
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "zitadel"]
interval: 5s
timeout: 3s
retries: 20
zitadel:
profiles: ["k6"]
image: ghcr.io/zitadel/zitadel:v2.65.0
container_name: gateway-zitadel
restart: unless-stopped
command: ["start-from-init", "--masterkey", "MasterkeyNeedsToHave32Characters", "--tlsMode", "disabled", "--config", "/etc/zitadel/zitadel.yaml", "--steps", "/etc/zitadel/steps.yaml"]
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
volumes:
- ./zitadel/zitadel.yaml:/etc/zitadel/zitadel.yaml:ro
- ./zitadel/steps.yaml:/etc/zitadel/steps.yaml:ro
- ./zitadel/machinekey:/machinekey
healthcheck:
# zitadel image lacks wget/curl; use /proc/net/tcp to verify the port
# is in LISTEN state (10 = LISTEN). Cheap, no binary deps.
test: ["CMD-SHELL", "grep -q ':1F90 .* 0A' /proc/net/tcp || exit 1"]
interval: 5s
timeout: 3s
retries: 60
start_period: 60s
volumes:
mongo_data:
redis_data:
postgres_data:

3
deploy/zitadel/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# ZITADEL bootstrap outputsPAT / machine key— 不入 git
machinekey/zitadel-admin-sa.token
machinekey/zitadel-admin-sa.json

52
deploy/zitadel/README.md Normal file
View File

@ -0,0 +1,52 @@
# ZITADELdev / k6
本機跑 k6 測試用的 ZITADEL stackdocker-compose `profile: k6`)。
## 啟動
```bash
make k6-up
```
會啟動 mongo / redis / mailhog / postgres / zitadel。
ZITADEL 首次啟動會 init Postgres schema 並執行 [steps.yaml](steps.yaml) 預載:
- Instance 名稱:`ZITADEL`
- Org`GatewayDev`
- Admin 使用者:`zitadel-admin@zitadel.localhost` / `Password1!`
- Service Account`zitadel-admin-sa`(產生 PAT 寫到 `machinekey/zitadel-admin-sa.token`
完成需 30~90 秒,可用 `make k6-wait` 等到 `/debug/healthz` 200。
## PAT 取用
```bash
cat deploy/zitadel/machinekey/zitadel-admin-sa.token
```
把這個值塞進 `etc/gateway.k6.yaml``Zitadel.ServiceUserToken`,或用環境變數:
```bash
export ZITADEL_SERVICE_TOKEN=$(cat deploy/zitadel/machinekey/zitadel-admin-sa.token)
```
`make k6-gateway` 會自動做這件事。
## 重設
```bash
make k6-down # 停容器(保留 volume
docker volume rm template-monorepo_postgres_data # 清 ZITADEL 資料
rm deploy/zitadel/machinekey/zitadel-admin-sa.* # 清 PAT
```
## 端點
- Console UIhttp://localhost:8080/ui/console
- OIDC issuerhttp://localhost:8080
- Management APIhttp://localhost:8080/management/v1
- Healthhttp://localhost:8080/debug/healthz
## 不可帶上 prod
`MasterkeyNeedsToHave32Characters` 與 [steps.yaml](steps.yaml) 內的密碼都是固定 dev 值,**只能**本機用。

View File

View File

@ -0,0 +1,10 @@
export ZITADEL_SERVICE_TOKEN=lvYZ4FNjNap4H2Lx7h9s02gr1tB4VxdWFk2yl4Fj3T3lSHhSn1Mv4lS6dGygiuP2cQ6j1D4
export BASE_URL=http://localhost:8888
export MAILHOG_URL=http://localhost:8025
export REDIS_ADDR=localhost:6379
export ADMIN_EMAIL=k6-admin-1779775084@k6.local
export ADMIN_PASSWORD=K6-Admin-Pass-1!
export ADMIN_UID=K6-10000075
export ADMIN_TENANT_ID=k6-tenant
export ADMIN_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnRfaWQiOiJrNi10ZW5hbnQiLCJ1aWQiOiJLNi0xMDAwMDA3NSIsInR5cCI6ImFjY2VzcyIsImF1dGhfZ2VuIjowLCJleHAiOjE3Nzk3NzU5ODUsImlhdCI6MTc3OTc3NTA4NSwianRpIjoiMzJlYjYyMGMtNmU5Ny00YWM5LTgzYTItMGQyMjRhODcwOWVjIn0.TQiRHCk-QVKShBNIR4F9TGQrSCc9YatmxCgE2oxnV6I
export ADMIN_REFRESH_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJ0ZW5hbnRfaWQiOiJrNi10ZW5hbnQiLCJ1aWQiOiJLNi0xMDAwMDA3NSIsInR5cCI6InJlZnJlc2giLCJhdXRoX2dlbiI6MCwiZXhwIjoxNzgwMzc5ODg1LCJpYXQiOjE3Nzk3NzUwODUsImp0aSI6IjA5ODczZTQyLTg2NGUtNDQ4ZS04OTBiLTBlMjhlZTZkOTA2YyJ9.jIIZykM2L9kmNeUzSU-fHtUsIUS6eyvGb06eT7DWPUk

View File

40
deploy/zitadel/steps.yaml Normal file
View File

@ -0,0 +1,40 @@
# ZITADEL FirstInstance bootstrapdev / k6 用,固定憑證)
# 啟動完成後:
# deploy/zitadel/machinekey/zitadel-admin-sa.token ← 給 Gateway 當 ServiceUserToken
# deploy/zitadel/machinekey/zitadel-admin-sa.json ← 給 SDK 用的 JWT keyk6 不會用到)
FirstInstance:
MachineKeyPath: /machinekey/zitadel-admin-sa.json
PatPath: /machinekey/zitadel-admin-sa.token
InstanceName: ZITADEL
DefaultLanguage: en
Org:
Name: GatewayDev
Human:
UserName: zitadel-admin@zitadel.localhost
FirstName: ZITADEL
LastName: Admin
NickName: admin
DisplayName: Admin
PreferredLanguage: en
Email:
Address: admin@zitadel.localhost
Verified: true
Password: Password1!
PasswordChangeRequired: false
Machine:
Machine:
Username: zitadel-admin-sa
Name: ServiceAccount
Description: Backend service account for Gateway (dev / k6)
Pat:
ExpirationDate: "2099-01-01T00:00:00Z"
Scopes:
- openid
MachineKey:
Type: 1
ExpirationDate: "2099-01-01T00:00:00Z"

View File

@ -0,0 +1,41 @@
# ZITADEL runtime configdev / k6 用TLS 關閉)
# 完整選項https://zitadel.com/docs/self-hosting/manage/configure
ExternalDomain: localhost
ExternalPort: 8080
ExternalSecure: false
TLS:
Enabled: false
Port: 8080
Database:
postgres:
Host: postgres
Port: 5432
Database: zitadel
MaxOpenConns: 20
MaxIdleConns: 5
MaxConnLifetime: 30m
MaxConnIdleTime: 5m
User:
Username: postgres
Password: postgres
SSL:
Mode: disable
Admin:
Username: postgres
Password: postgres
SSL:
Mode: disable
Log:
Level: info
DefaultInstance:
LoginPolicy:
AllowRegister: true
AllowUsernamePassword: true
AllowExternalIDP: false
ForceMFA: false
HidePasswordReset: false
PasswordlessType: 1

View File

@ -1,52 +0,0 @@
# 本機開發依賴MongoDBnotification 持久化、Redis冪等配額異步重試member OTP
#
# 啟動make deps-up
# 設定etc/gateway.dev.yaml搭配 make run-dev
# 索引:首次啟動由 deploy/mongo/init 建立;既有 volume 可執行 make mongo-index
services:
mongo:
image: mongo:7
container_name: gateway-mongo
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: gateway
volumes:
- mongo_data:/data/db
- ./deploy/mongo/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
container_name: gateway-redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
mailhog:
profiles: ["smtp"]
image: mailhog/mailhog:v1.0.1
container_name: gateway-mailhog
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
volumes:
mongo_data:
redis_data:

97
docs/AGENTS.md Normal file
View File

@ -0,0 +1,97 @@
# AGENTS.md
給 AI coding agentClaude / Cursor / Codex / 其他)的專案工作準則。請在開始任務前讀過一遍,並在需要時翻閱對應子文件。
## 專案簡介
`template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,採用「**模組化 Clean Architecture**」:每個業務模組auth / member / notification / permission ...)放在 `internal/model/<module>/`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`
跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」一律放在 `internal/logic/<module>/`,**usecase 不可呼叫其他 usecase**。
## 必讀文件
| 文件 | 何時讀 |
|---|---|
| [`generate/api/README.md`](../generate/api/README.md) | 新增 / 修改 API 端點、type、文件分組、欄位描述、enum 列舉前 |
| [`generate/doc-generate/README.md`](../generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 |
| `internal/model/<module>/README.md` | 動到該模組的領域邏輯時 |
| `docs/model.md`(若存在) | 全局架構規範 |
## 標準工作流程
### 1. 修改 API`.api` → handler / types / docs
1. 編輯 `generate/api/*.api`(遵守 `generate/api/README.md` 的三條規則tags 分組 / backtick 行末 `//` 中文 description / `options=A|B|C` enum)
2. `make gen-api` — 重新產生 `internal/handler/`、`internal/logic/`(已存在則不覆蓋)、`internal/types/types.go`
3. `make gen-doc` — 重新產生 `docs/openapi/gateway.yaml`(gitignore,本地驗證用)
4. 實作 / 修改 `internal/logic/<module>/<handler>_logic.go` 的業務邏輯
5. `go build ./...` 確保編譯通過
6. `make lint` / `make test` 視改動範圍跑
### 2. 新增 / 修改業務模組
- 領域介面與型別放 `internal/model/<module>/domain/`
- Mongo / Redis 實作放 `internal/model/<module>/repository/`
- 原子 usecase 放 `internal/model/<module>/usecase/`(**不可**互相呼叫)
- 多步驟流程編排放 `internal/logic/<module>/`
- 模組的對外裝配入口統一在 `internal/model/<module>/usecase/module.go`,並從 `internal/svc/service_context.go` 注入
### 3. 錯誤碼
- 業務碼格式 `SSCCCDDD`(scope * 1_000_000 + category * 1_000 + detail)
- Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31)
- 新增 scope 時:同步更新 `gateway.api``bizCodeEnumDescription`
### 4. Middleware(go-zero 正規手段)
**禁止**在 `gateway.go``server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api``middleware:` 宣告:
```go
@server (
group: auth
prefix: /api/v1/auth
middleware: AuthJWT // 一個
// middleware: AuthJWT,CasbinRBAC // 多個用逗號
)
```
`make gen-api``routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`
撰寫新 middleware 時:
- 用 **struct + `Handle()` method** 模式(不是 factory function)
- 檔名 = goctl stringx 規則(例 `AuthJWT``authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`)
- 在 `ServiceContext``<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).Handle`
- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來)
詳細範例與分組原則見 [`generate/api/README.md`](../generate/api/README.md) "Middleware" 章節。
### 4. Redis / Mongo / 設定
- 每個模組的設定型別放 `internal/model/<module>/config/config.go`,再合入 `internal/config/config.go``Config` struct
- Redis client 共用 `internal/library/redis/`,需 Pub/Sub 用 `client.PubSubClient()`
- Mongo index 註冊到 `cmd/mongo-index/main.go`(在 `run()` 裡呼叫 `<module>repo.EnsureMongoIndexes`)
## 通用準則
- **回應 traditional Chinese**(繁體中文)
- 程式碼註解只寫「為什麼」、邊界條件、trade-off,**不寫**「import the module / increment counter」這類顯而易見的描述
- 不主動建立 `*.md` 文件,除非使用者明確要求
- 改 git config / 強制 push / `rm -rf` 等破壞性操作 **必須**先取得使用者同意
- 不要在沒被要求時直接 commit;commit 前先 `git status` / `git diff` 確認
- commit message 用繁中描述「為什麼」改,不是「改了什麼」
## 指令速查
| 指令 | 用途 |
|---|---|
| `make gen-api` | `.api` → handler / logic(skip exists)/ types |
| `make gen-doc` | `.api``docs/openapi/gateway.yaml` |
| `make gen-mock` | 模組 mock(gomock) |
| `make tools` | 安裝 goctl / goimports / golangci-lint |
| `make fix` | gofmt + goimports + lint --fix + lint |
| `make check` | fix + test(提交前) |
| `make run-dev` | 本機啟動(需 `make deps-up`) |
| `make deps-up` | docker compose Mongo + Redis |
| `make mongo-index` | 建立 / 更新 Mongo 索引 |
完整列表跑 `make help`

116
etc/gateway.k6.yaml Normal file
View File

@ -0,0 +1,116 @@
# k6 測試專用設定(搭配 make k6-up + make k6-gateway
#
# 與 dev 差異:
# - Email: SMTP → MailHoglocalhost:1025OTP 由 k6 透過 MailHog HTTP API 撈
# - SMS : provider=mock並由 mock_sender 寫到 Rediskey: dev:notification:last:sms:<phone>
# - Permission.Casbin.Enabled: truerbac journey
# - Zitadel: 用環境變數帶 PAT / OAuth secret
#
# 啟動:
# export ZITADEL_SERVICE_TOKEN=$(cat deploy/zitadel/machinekey/zitadel-admin-sa.token)
# ./gateway -f etc/gateway.k6.yaml
# (或直接 make k6-gateway
Name: gateway
Host: 0.0.0.0
Port: 8888
Mongo:
Schema: mongodb
Host: 127.0.0.1
Port: 27017
Database: gateway_k6
TLS: false
MaxPoolSize: 30
MinPoolSize: 5
MaxConnIdleTime: 30m
Redis:
Host: localhost:6379
Type: node
Notification:
DefaultLocale: zh-tw
Email:
Provider: smtp
From: noreply@k6.local
SMTP:
Enable: true
Sort: 1
Host: localhost
Port: 1025
Username: ""
Password: ""
SES:
Enable: false
SMS:
Provider: mock
Mitake:
Enable: false
Async:
QueueRedisKey: notification:queue:k6
Worker: 1
MaxRetry: 2
BackoffSeconds: [1, 3]
RatePerTenant:
Email: 1000
SMS: 1000
Member:
OTP:
Length: 6
TTLSeconds: 300
MaxAttempts: 10
ResendCooldownSeconds: 1
DailyVerifyLimit: 200
TOTP:
Issuer: CloudEP-k6
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1
BackupCodeCount: 10
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
Registration:
RequireInviteCode: true
TrustSocialEmailVerified: true
Auth:
AccessExpire: 900
RefreshExpire: 604800
ActiveKID: v1
AccessSecret: "k6-access-secret-32-bytes-min!!!"
RefreshSecret: "k6-refresh-secret-32-bytes-min!!"
RegistrationSessionTTLSeconds: 600
Permission:
Casbin:
Enabled: true
ModelPath: etc/rbac.conf
PolicyAdapter: auto
Cache:
UserRolesTTLSeconds: 10
RolePermsTTLSeconds: 10
CatalogTTLSeconds: 60
Reload:
Channel: casbin:reload:k6
DebounceMilliseconds: 50
HeartbeatSeconds: 60
# ServiceUserToken / OAuthClientSecret 由環境變數注入:
# ZITADEL_SERVICE_TOKEN, ZITADEL_OAUTH_CLIENT_SECRET, ZITADEL_GOOGLE_CLIENT_SECRET
Zitadel:
Issuer: http://localhost:8080
APIBase: http://localhost:8080
ServiceUserToken: ""
DefaultOrgID: ""
OAuthClientID: ""
OAuthClientSecret: ""
GoogleClientID: ""
GoogleClientSecret: ""
GoogleIdPID: ""
JWKSUrl: ""
TimeoutSeconds: 15

View File

@ -228,7 +228,11 @@ func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body
if resp.StatusCode == http.StatusConflict { if resp.StatusCode == http.StatusConflict {
return ErrUserAlreadyExists return ErrUserAlreadyExists
} }
if resp.StatusCode != wantStatus { // Accept any 2xx as success. ZITADEL v2 returns 201 for create endpoints
// (e.g. POST /v2/users/human) and 200 for most others; wantStatus is kept
// for caller intent but we don't reject other 2xx responses.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = wantStatus
return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw)) return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw))
} }
if out != nil && len(raw) > 0 { if out != nil && len(raw) > 0 {

View File

@ -9,6 +9,13 @@ import (
"github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/logx"
) )
// MockRedisHook is the minimal Redis surface needed to persist the last mock
// email body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis
// satisfies it (SetexCtx).
type MockRedisHook interface {
SetexCtx(ctx context.Context, key, value string, seconds int) error
}
// MockSender records calls and returns configurable results (for tests and local dev). // MockSender records calls and returns configurable results (for tests and local dev).
type MockSender struct { type MockSender struct {
name string name string
@ -19,6 +26,11 @@ type MockSender struct {
Err error Err error
MessageID string MessageID string
SendHook func(ctx context.Context, msg *Message) (string, error) SendHook func(ctx context.Context, msg *Message) (string, error)
// Optional Redis hook for dev/k6: every successful Send writes the body
// to "dev:notification:last:email:<recipient>" for each To address.
redis MockRedisHook
redisKeyTTL int
} }
type MockSenderOption func(*MockSender) type MockSenderOption func(*MockSender)
@ -39,11 +51,26 @@ func WithMockMessageID(id string) MockSenderOption {
return func(m *MockSender) { m.MessageID = id } return func(m *MockSender) { m.MessageID = id }
} }
// WithMockRedis mirrors every outbound mock email body into Redis at key
// "dev:notification:last:email:<recipient>" (one key per To address).
// Primary OTP transport for k6 is still MailHog HTTP API; this hook is a
// fallback so the SMTP-disabled mock mode is also k6-friendly.
func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption {
return func(m *MockSender) {
m.redis = r
if ttlSeconds <= 0 {
ttlSeconds = 600
}
m.redisKeyTTL = ttlSeconds
}
}
func NewMockSender(opts ...MockSenderOption) *MockSender { func NewMockSender(opts ...MockSenderOption) *MockSender {
m := &MockSender{ m := &MockSender{
name: "mock", name: "mock",
sort: 0, sort: 0,
MessageID: "mock-email-id", MessageID: "mock-email-id",
redisKeyTTL: 600,
} }
for _, opt := range opts { for _, opt := range opts {
opt(m) opt(m)
@ -54,6 +81,9 @@ func NewMockSender(opts ...MockSenderOption) *MockSender {
func (m *MockSender) Name() string { return m.name } func (m *MockSender) Name() string { return m.name }
func (m *MockSender) Sort() int { return m.sort } func (m *MockSender) Sort() int { return m.sort }
// MockEmailRedisKeyPrefix is the key prefix written by the Redis hook.
const MockEmailRedisKeyPrefix = "dev:notification:last:email:"
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
m.mu.Lock() m.mu.Lock()
m.calls = append(m.calls, msg) m.calls = append(m.calls, msg)
@ -68,6 +98,17 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
if msg != nil { if msg != nil {
logx.Infof("[notification mock email] from=%s to=%s subject=%q body=%s message_id=%s", logx.Infof("[notification mock email] from=%s to=%s subject=%q body=%s message_id=%s",
msg.From, strings.Join(msg.To, ","), msg.Subject, truncateForLog(msg.Body, 500), m.MessageID) msg.From, strings.Join(msg.To, ","), msg.Subject, truncateForLog(msg.Body, 500), m.MessageID)
if m.redis != nil {
for _, to := range msg.To {
if to == "" {
continue
}
key := MockEmailRedisKeyPrefix + to
if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil {
logx.Errorf("[notification mock email] redis hook setex %s: %v", key, err)
}
}
}
} }
return m.MessageID, nil return m.MessageID, nil
} }

View File

@ -0,0 +1,57 @@
package email
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type recordingHook struct {
mu sync.Mutex
calls []hookCall
}
type hookCall struct {
key string
value string
seconds int
}
func (h *recordingHook) SetexCtx(_ context.Context, key, value string, seconds int) error {
h.mu.Lock()
defer h.mu.Unlock()
h.calls = append(h.calls, hookCall{key: key, value: value, seconds: seconds})
return nil
}
func TestMockSender_RedisHookWritesBodyPerRecipient(t *testing.T) {
hook := &recordingHook{}
s := NewMockSender(WithMockRedis(hook, 30))
_, err := s.Send(context.Background(), &Message{
From: "noreply@k6.local",
To: []string{"alice@example.com", "bob@example.com"},
Subject: "code",
Body: "code is 123456",
})
require.NoError(t, err)
hook.mu.Lock()
defer hook.mu.Unlock()
require.Len(t, hook.calls, 2)
assert.Equal(t, MockEmailRedisKeyPrefix+"alice@example.com", hook.calls[0].key)
assert.Equal(t, MockEmailRedisKeyPrefix+"bob@example.com", hook.calls[1].key)
for _, c := range hook.calls {
assert.Equal(t, "code is 123456", c.value)
assert.Equal(t, 30, c.seconds)
}
}
func TestMockSender_NoHookSkipsRedis(t *testing.T) {
s := NewMockSender()
_, err := s.Send(context.Background(), &Message{To: []string{"x@y"}, Body: "z"})
require.NoError(t, err)
}

View File

@ -8,6 +8,16 @@ import (
"github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/logx"
) )
// MockRedisHook is the minimal Redis surface needed to persist the last mock
// SMS body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis
// already satisfies it (SetexCtx).
//
// Production SMS senders never receive this hook; it is only wired when
// SMSConfig.Provider=="mock" and a Redis client is available.
type MockRedisHook interface {
SetexCtx(ctx context.Context, key, value string, seconds int) error
}
// MockSender records calls and returns configurable results (for tests and local dev). // MockSender records calls and returns configurable results (for tests and local dev).
type MockSender struct { type MockSender struct {
name string name string
@ -18,6 +28,12 @@ type MockSender struct {
Err error Err error
MessageID string MessageID string
SendHook func(ctx context.Context, msg *Message) (string, error) SendHook func(ctx context.Context, msg *Message) (string, error)
// Optional Redis hook for dev/k6: on every successful Send, the message
// body is written to "dev:notification:last:sms:<phone>" with the given TTL.
// nil hook → behaviour identical to original implementation.
redis MockRedisHook
redisKeyTTL int // seconds, defaults to 600 when redis hook set
} }
type MockSenderOption func(*MockSender) type MockSenderOption func(*MockSender)
@ -38,11 +54,25 @@ func WithMockMessageID(id string) MockSenderOption {
return func(m *MockSender) { m.MessageID = id } return func(m *MockSender) { m.MessageID = id }
} }
// WithMockRedis enables dev/k6 OTP inspection by mirroring every outbound
// mock SMS body into Redis at key "dev:notification:last:sms:<phone>".
// ttlSeconds <= 0 → defaults to 600 (10m).
func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption {
return func(m *MockSender) {
m.redis = r
if ttlSeconds <= 0 {
ttlSeconds = 600
}
m.redisKeyTTL = ttlSeconds
}
}
func NewMockSender(opts ...MockSenderOption) *MockSender { func NewMockSender(opts ...MockSenderOption) *MockSender {
m := &MockSender{ m := &MockSender{
name: "mock", name: "mock",
sort: 0, sort: 0,
MessageID: "mock-sms-id", MessageID: "mock-sms-id",
redisKeyTTL: 600,
} }
for _, opt := range opts { for _, opt := range opts {
opt(m) opt(m)
@ -53,6 +83,10 @@ func NewMockSender(opts ...MockSenderOption) *MockSender {
func (m *MockSender) Name() string { return m.name } func (m *MockSender) Name() string { return m.name }
func (m *MockSender) Sort() int { return m.sort } func (m *MockSender) Sort() int { return m.sort }
// MockSMSRedisKeyPrefix is the key prefix written by the Redis hook.
// Exposed so k6 / dev tooling can resolve the key for a given phone.
const MockSMSRedisKeyPrefix = "dev:notification:last:sms:"
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
m.mu.Lock() m.mu.Lock()
m.calls = append(m.calls, msg) m.calls = append(m.calls, msg)
@ -67,6 +101,12 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
if msg != nil { if msg != nil {
logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s", logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s",
msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID) msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID)
if m.redis != nil && msg.PhoneNumber != "" {
key := MockSMSRedisKeyPrefix + msg.PhoneNumber
if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil {
logx.Errorf("[notification mock sms] redis hook setex %s: %v", key, err)
}
}
} }
return m.MessageID, nil return m.MessageID, nil
} }

View File

@ -0,0 +1,62 @@
package sms
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingHook is a stub MockRedisHook for verifying mock SMS Redis writes.
type recordingHook struct {
mu sync.Mutex
calls []hookCall
}
type hookCall struct {
key string
value string
seconds int
}
func (h *recordingHook) SetexCtx(_ context.Context, key, value string, seconds int) error {
h.mu.Lock()
defer h.mu.Unlock()
h.calls = append(h.calls, hookCall{key: key, value: value, seconds: seconds})
return nil
}
func TestMockSender_RedisHookWritesBody(t *testing.T) {
hook := &recordingHook{}
s := NewMockSender(WithMockRedis(hook, 60))
_, err := s.Send(context.Background(), &Message{PhoneNumber: "+886912345678", Body: "your code is 123456"})
require.NoError(t, err)
hook.mu.Lock()
defer hook.mu.Unlock()
require.Len(t, hook.calls, 1)
assert.Equal(t, MockSMSRedisKeyPrefix+"+886912345678", hook.calls[0].key)
assert.Equal(t, "your code is 123456", hook.calls[0].value)
assert.Equal(t, 60, hook.calls[0].seconds)
}
func TestMockSender_RedisHookSkippedWithoutPhone(t *testing.T) {
hook := &recordingHook{}
s := NewMockSender(WithMockRedis(hook, 0))
_, err := s.Send(context.Background(), &Message{PhoneNumber: "", Body: "x"})
require.NoError(t, err)
hook.mu.Lock()
defer hook.mu.Unlock()
assert.Empty(t, hook.calls)
}
func TestMockSender_NoHookByDefault(t *testing.T) {
s := NewMockSender()
_, err := s.Send(context.Background(), &Message{PhoneNumber: "+886900000000", Body: "x"})
require.NoError(t, err)
}

View File

@ -29,7 +29,7 @@ func NewNotifierUseCaseFromParam(param FactoryParam) (domusecase.NotifierUseCase
return mod.Notifier, nil return mod.Notifier, nil
} }
func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) { func buildEmailChain(cfg notifconfig.Config, rc *redislib.Client) (*email.Chain, error) {
senders, err := collectEmailSenders(cfg) senders, err := collectEmailSenders(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
@ -37,7 +37,11 @@ func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) {
if len(senders) == 0 { if len(senders) == 0 {
switch cfg.Email.Provider { switch cfg.Email.Provider {
case "", notifconfig.ProviderMock: case "", notifconfig.ProviderMock:
return email.NewChain(email.NewMockSender(email.WithMockName(notifconfig.ProviderMock))), nil opts := []email.MockSenderOption{email.WithMockName(notifconfig.ProviderMock)}
if r := rc.Zero(); r != nil {
opts = append(opts, email.WithMockRedis(r, 0))
}
return email.NewChain(email.NewMockSender(opts...)), nil
default: default:
return nil, fmt.Errorf("notification: no email senders enabled and provider %q is not mock", cfg.Email.Provider) return nil, fmt.Errorf("notification: no email senders enabled and provider %q is not mock", cfg.Email.Provider)
} }
@ -87,7 +91,7 @@ func collectEmailSenders(cfg notifconfig.Config) ([]email.Sender, error) {
return senders, nil return senders, nil
} }
func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) { func buildSMSChain(cfg notifconfig.Config, rc *redislib.Client) (*sms.Chain, error) {
senders, err := collectSMSSenders(cfg) senders, err := collectSMSSenders(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
@ -95,7 +99,11 @@ func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) {
if len(senders) == 0 { if len(senders) == 0 {
switch cfg.SMS.Provider { switch cfg.SMS.Provider {
case "", notifconfig.ProviderMock: case "", notifconfig.ProviderMock:
return sms.NewChain(sms.NewMockSender(sms.WithMockName(notifconfig.ProviderMock))), nil opts := []sms.MockSenderOption{sms.WithMockName(notifconfig.ProviderMock)}
if r := rc.Zero(); r != nil {
opts = append(opts, sms.WithMockRedis(r, 0))
}
return sms.NewChain(sms.NewMockSender(opts...)), nil
default: default:
return nil, fmt.Errorf("notification: no sms senders enabled and provider %q is not mock", cfg.SMS.Provider) return nil, fmt.Errorf("notification: no sms senders enabled and provider %q is not mock", cfg.SMS.Provider)
} }

View File

@ -12,7 +12,7 @@ import (
func TestBuildEmailChain_MockByDefault(t *testing.T) { func TestBuildEmailChain_MockByDefault(t *testing.T) {
chain, err := buildEmailChain(notifconfig.Config{ chain, err := buildEmailChain(notifconfig.Config{
Email: notifconfig.EmailConfig{Provider: notifconfig.ProviderMock}, Email: notifconfig.EmailConfig{Provider: notifconfig.ProviderMock},
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, chain) require.NotNil(t, chain)
} }
@ -34,7 +34,7 @@ func TestBuildEmailChain_SMTPAndSES(t *testing.T) {
SecretKey: "secret", SecretKey: "secret",
}, },
}, },
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, chain) require.NotNil(t, chain)
} }
@ -44,7 +44,7 @@ func TestBuildEmailChain_SESRequiresCredentials(t *testing.T) {
Email: notifconfig.EmailConfig{ Email: notifconfig.EmailConfig{
SES: notifconfig.SESProviderSettings{Enable: true, Region: "ap-northeast-1"}, SES: notifconfig.SESProviderSettings{Enable: true, Region: "ap-northeast-1"},
}, },
}) }, nil)
assert.Error(t, err) assert.Error(t, err)
} }
@ -58,7 +58,7 @@ func TestBuildSMSChain_Mitake(t *testing.T) {
Password: "pass", Password: "pass",
}, },
}, },
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, chain) require.NotNil(t, chain)
} }

View File

@ -47,11 +47,11 @@ func NewModuleFromParam(param FactoryParam) (*Module, error) {
queue = repository.NewRedisRetryQueue(param.Redis, retryQueueKey(param.Config)) queue = repository.NewRedisRetryQueue(param.Redis, retryQueueKey(param.Config))
} }
emailChain, err := buildEmailChain(param.Config) emailChain, err := buildEmailChain(param.Config, param.Redis)
if err != nil { if err != nil {
return nil, err return nil, err
} }
smsChain, err := buildSMSChain(param.Config) smsChain, err := buildSMSChain(param.Config, param.Redis)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -67,7 +67,8 @@ var DefaultSystemRoles = []SystemRoleDefinition{
DisplayName: "Tenant Admin", DisplayName: "Tenant Admin",
PermissionNames: []string{ PermissionNames: []string{
"member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status", "member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
"permission.role.read", "permission.role.write", "permission.assign.write", "permission.role.read", "permission.role.write", "permission.role.modify",
"permission.assign.write", "permission.assign.revoke",
"permission.mapping.write", "permission.policy.reload", "permission.mapping.write", "permission.policy.reload",
}, },
}, },

View File

@ -83,26 +83,42 @@
{ {
"name": "permission.role.write", "name": "permission.role.write",
"parent": "permission.role.management", "parent": "permission.role.management",
"http_methods": "POST|PUT|DELETE", "http_methods": "POST",
"http_path": "/api/v1/permissions/roles*", "http_path": "/api/v1/permissions/roles",
"type": "backend_user", "type": "backend_user",
"description": "管理角色(建立/修改/刪除)" "description": "建立角色"
},
{
"name": "permission.role.modify",
"parent": "permission.role.management",
"http_methods": "GET|PUT|PATCH|DELETE",
"http_path": "/api/v1/permissions/roles/*",
"type": "backend_user",
"description": "修改 / 刪除 / 讀取角色 permission 細節"
}, },
{ {
"name": "permission.assign.write", "name": "permission.assign.write",
"parent": "permission.role.management", "parent": "permission.role.management",
"http_methods": "POST|DELETE", "http_methods": "GET|POST",
"http_path": "/api/v1/permissions/users/*/roles*", "http_path": "/api/v1/permissions/users/*/roles",
"type": "backend_user", "type": "backend_user",
"description": "指派 / 撤銷使用者角色" "description": "查詢 / 指派使用者角色"
},
{
"name": "permission.assign.revoke",
"parent": "permission.role.management",
"http_methods": "DELETE",
"http_path": "/api/v1/permissions/users/*/roles/*",
"type": "backend_user",
"description": "撤銷使用者單一角色"
}, },
{ {
"name": "permission.mapping.write", "name": "permission.mapping.write",
"parent": "permission.role.management", "parent": "permission.role.management",
"http_methods": "PUT|DELETE", "http_methods": "GET|PUT|DELETE",
"http_path": "/api/v1/permissions/role-mappings*", "http_path": "/api/v1/permissions/role-mappings",
"type": "backend_user", "type": "backend_user",
"description": "管理外部角色映射" "description": "讀取 / 管理外部角色映射"
}, },
{ {
"name": "permission.policy.reload", "name": "permission.policy.reload",

BIN
k6-seed-admin Executable file

Binary file not shown.

View File

@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/e2e-lib.sh
source "${ROOT}/scripts/e2e-lib.sh"
cd "$ROOT"
GATEWAY_PORT="${GATEWAY_PORT:-18888}"
PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}"
e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}"
if command -v lsof >/dev/null 2>&1 && lsof -ti tcp:"${GATEWAY_PORT}" >/dev/null 2>&1; then
echo "e2e-down: warning — port ${GATEWAY_PORT} still in use" >&2
lsof -i tcp:"${GATEWAY_PORT}" >&2 || true
exit 1
fi
# --profile smtp 涵蓋一般 + mailhogdocker compose 對未掛起的 profile 是 no-op安全。
docker compose --profile smtp down -v
e2e_ok "e2e-down OKgateway stopped, docker cleaned"

View File

@ -1,124 +0,0 @@
#!/usr/bin/env bash
# Shared helpers for e2e-run / e2e-up / e2e-down.
# shellcheck disable=SC2034
# Colors非 TTY 自動關掉CI log 才不會有 ANSI 噪音)
if [[ -t 1 ]]; then
E2E_BOLD=$'\033[1m'; E2E_DIM=$'\033[2m'; E2E_GREEN=$'\033[32m'
E2E_CYAN=$'\033[36m'; E2E_YELLOW=$'\033[33m'; E2E_RESET=$'\033[0m'
else
E2E_BOLD=""; E2E_DIM=""; E2E_GREEN=""; E2E_CYAN=""; E2E_YELLOW=""; E2E_RESET=""
fi
# e2e_step "1/6" "fresh docker compose"
e2e_step() {
local idx="$1"; shift
printf "\n${E2E_BOLD}${E2E_CYAN}== [%s] %s ==${E2E_RESET}\n" "$idx" "$*"
}
# e2e_info "啟動 mailhoghttp://localhost:8025"
e2e_info() { printf "${E2E_DIM}>> %s${E2E_RESET}\n" "$*"; }
e2e_ok() { printf "${E2E_GREEN}✔ %s${E2E_RESET}\n" "$*"; }
e2e_warn() { printf "${E2E_YELLOW}! %s${E2E_RESET}\n" "$*"; }
e2e_root_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
}
# 把所有 e2e services 列出來(給使用者知道環境準備好了什麼)
e2e_print_services() {
local with_smtp="${1:-}"
echo
printf "${E2E_BOLD}E2E 環境服務${E2E_RESET}\n"
printf " %-12s %-32s %s\n" "MongoDB" "127.0.0.1:27017" "${E2E_DIM}database=gateway_e2e${E2E_RESET}"
printf " %-12s %-32s %s\n" "Redis" "127.0.0.1:6379" "${E2E_DIM}OTP / Casbin policy / blacklist${E2E_RESET}"
printf " %-12s %-32s %s\n" "Gateway" "http://127.0.0.1:18888" "${E2E_DIM}health: /api/v1/health${E2E_RESET}"
if [[ "${with_smtp}" == "1" ]]; then
printf " %-12s %-32s %s\n" "MailHog" "http://127.0.0.1:8025" "${E2E_DIM}SMTP=1025E2E_WITH_SMTP=1${E2E_RESET}"
fi
echo
}
# Stop gateway started for E2E: pid file → port listeners → stale go run orphans.
e2e_stop_gateway() {
local port="${1:-18888}"
local pid_file="${2:-}"
if [[ -n "${pid_file}" && -f "${pid_file}" ]]; then
local pid
pid="$(cat "${pid_file}")"
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
echo ">> stopping gateway pid=${pid}"
kill "${pid}" 2>/dev/null || true
for _ in $(seq 1 10); do
kill -0 "${pid}" 2>/dev/null || break
sleep 0.2
done
if kill -0 "${pid}" 2>/dev/null; then
kill -9 "${pid}" 2>/dev/null || true
fi
wait "${pid}" 2>/dev/null || true
fi
rm -f "${pid_file}"
fi
if command -v lsof >/dev/null 2>&1; then
local pids
pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)"
if [[ -n "${pids// /}" ]]; then
echo ">> stopping listener(s) on :${port} (${pids})"
# shellcheck disable=SC2086
kill ${pids} 2>/dev/null || true
sleep 0.5
pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)"
if [[ -n "${pids// /}" ]]; then
# shellcheck disable=SC2086
kill -9 ${pids} 2>/dev/null || true
fi
fi
fi
# go run leaves a compiled binary under $TMPDIR; kill by e2e config path if still up.
if command -v pgrep >/dev/null 2>&1; then
while IFS= read -r orphan; do
[[ -z "${orphan}" ]] && continue
echo ">> stopping orphan gateway pid=${orphan}"
kill "${orphan}" 2>/dev/null || true
done < <(pgrep -f "gateway(-e2e)? .*${port}|gateway.go -f .*e2e.yaml" 2>/dev/null || true)
fi
}
# Build a real binary so $! is the server PID (go run only tracks the wrapper).
e2e_start_gateway() {
local root="$1"
local config="$2"
local port="$3"
local pid_file="$4"
local bin="${root}/.cache/e2e-gateway"
mkdir -p "${root}/.cache"
e2e_stop_gateway "${port}" "${pid_file}"
echo ">> building e2e gateway binary"
(cd "${root}" && go build -o "${bin}" gateway.go)
echo ">> starting gateway on :${port}"
GATEWAY_E2E=1 "${bin}" -f "${config}" &
local pid=$!
echo "${pid}" > "${pid_file}"
echo "${pid}"
}
e2e_wait_gateway() {
local port="$1"
local url="http://127.0.0.1:${port}/api/v1/health"
for i in $(seq 1 60); do
if curl -sf "${url}" >/dev/null; then
return 0
fi
sleep 1
done
echo "timeout waiting for gateway ${url}" >&2
return 1
}

View File

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

View File

@ -1,97 +0,0 @@
#!/usr/bin/env bash
# 一鍵 E2E全新 Docker → index → seed → 起 Gateway → 跑測試 → 關閉並清 volume
#
# 環境變數:
# E2E_KEEP_DOCKER=1 跑完不 docker compose down -v方便查 Mongo/Redis
# E2E_WITH_SMTP=1 額外起 MailHoghttp://localhost:8025方便肉眼看寄信
# E2E_CONFIG=... 預設 test/e2e/fixtures/e2e.yaml
# E2E_TEST_PATTERN 第一輪 go test -run pattern
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/e2e-lib.sh
source "${ROOT}/scripts/e2e-lib.sh"
cd "$ROOT"
E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}"
E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}"
E2E_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
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
e2e_warn "E2E_KEEP_DOCKER=1容器繼續運行"
fi
}
trap cleanup EXIT
TOTAL_STEPS=6
e2e_step "1/${TOTAL_STEPS}" "fresh docker composemongo + redis$( [[ "$E2E_WITH_SMTP" == "1" ]] && echo " + mailhog" )"
docker compose down -v >/dev/null 2>&1 || true
if [[ "$E2E_WITH_SMTP" == "1" ]]; then
docker compose --profile smtp up -d mongo redis mailhog
else
docker compose up -d mongo redis
fi
e2e_step "2/${TOTAL_STEPS}" "wait for healthcheck"
for i in $(seq 1 60); do
if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then
e2e_ok "mongo / redis healthy${i}s"
break
fi
sleep 1
if [[ "$i" -eq 60 ]]; then
echo "timeout waiting for docker health" >&2
docker compose ps >&2
exit 1
fi
done
e2e_step "3/${TOTAL_STEPS}" "建立 Mongo 索引cmd/mongo-index"
go run ./cmd/mongo-index -f "${E2E_CONFIG}"
e2e_ok "indexes ready"
e2e_step "4/${TOTAL_STEPS}" "seed tenant + member + permission + JWTcmd/e2e-seed"
rm -f "${E2E_STATE}"
seed_args=(-f "${E2E_CONFIG}" -out "${E2E_STATE}")
if [[ -n "${E2E_ROLE:-}" ]]; then
seed_args+=(-role "${E2E_ROLE}")
fi
go run ./cmd/e2e-seed "${seed_args[@]}"
e2e_ok "state.json written → ${E2E_STATE}"
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"
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}"
e2e_info "第二輪pattern=${E2E_TEST_PATTERN_ZZZ}(會撤銷 JWT故最後跑"
GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \
go test -tags=e2e -v -count=1 ./test/e2e/... -run "${E2E_TEST_PATTERN_ZZZ}"
echo
e2e_ok "E2E OK"

View File

@ -1,62 +0,0 @@
#!/usr/bin/env bash
# 啟動 E2E 環境但不跑測試(方便本機除錯)
#
# E2E_WITH_SMTP=1 多起一個 MailHoghttp://localhost:8025
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/e2e-lib.sh
source "${ROOT}/scripts/e2e-lib.sh"
cd "$ROOT"
E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}"
E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}"
GATEWAY_PORT="${GATEWAY_PORT:-18888}"
PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}"
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}"
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
e2e_warn "gateway already running pid=$(cat "${PID_FILE}")"
else
pid="$(e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}")"
e2e_ok "gateway started pid=${pid}"
fi
e2e_wait_gateway "${GATEWAY_PORT}"
e2e_print_services "$E2E_WITH_SMTP"
echo "${E2E_BOLD}下一步${E2E_RESET}"
echo " ${E2E_DIM}# 列出有哪些 E2E 測試${E2E_RESET}"
echo " make e2e-list"
echo " ${E2E_DIM}# 全部測試(每個會顯示 ▶ [ID] METHOD path — 中文情境)${E2E_RESET}"
echo " make test-e2e"
echo " ${E2E_DIM}# 單一測試${E2E_RESET}"
echo " GATEWAY_E2E=1 go test -tags=e2e -v -count=1 ./test/e2e/ -run TestMember_GetMe"
echo " ${E2E_DIM}# 結束${E2E_RESET}"
echo " make e2e-down"

View File

@ -1,96 +0,0 @@
//go:build e2e
package e2e
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
// TestZZZ_AuthTokenRefreshAndLogout runs last (separate go test invocation).
// It uses an isolated refresh so seed tokens used by member/permission stay valid.
func TestZZZ_AuthTokenRefreshAndLogout(t *testing.T) {
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{
"refresh_token": c.Fixture.RefreshToken,
}, false)
var pair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
UID string `json:"uid"`
}
require.NoError(t, json.Unmarshal(refreshEnv.Data, &pair))
require.Equal(t, c.Fixture.UID, pair.UID)
c.Fixture.AccessToken = pair.AccessToken
c.Fixture.RefreshToken = pair.RefreshToken
c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/logout", nil, true)
resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, true)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
require.NotEqual(t, int64(successCode), env.Code)
}
func TestAuth_MissingBearer_401(t *testing.T) {
e2eStep(t, "A-12", "GET", "/api/v1/members/me", "未帶 Bearer → 401AuthJWT middleware")
c := NewClient(t)
resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, false)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
require.NotEqual(t, int64(successCode), env.Code)
}
func TestAuth_PublicValidationErrors(t *testing.T) {
e2eStep(t, "A-13", "POST", "/api/v1/auth/*", "公開 Auth 端點輸入驗證錯誤 → 400 + Facade scope")
c := NewClient(t)
cases := []struct {
name string
path string
body any
}{
{
name: "register missing required fields",
path: "/api/v1/auth/register",
body: map[string]any{},
},
{
name: "login invalid email and password",
path: "/api/v1/auth/login",
body: map[string]any{
"tenant_slug": c.Fixture.TenantSlug,
"email": "not-an-email",
"password": "short",
},
},
{
name: "token refresh missing token",
path: "/api/v1/auth/token/refresh",
body: map[string]any{},
},
{
name: "social login invalid provider",
path: "/api/v1/auth/login/social/start",
body: map[string]any{
"tenant_slug": c.Fixture.TenantSlug,
"provider": "github",
"redirect_uri": "http://127.0.0.1/callback",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env := c.DoExpectHTTP(t, http.MethodPost, tc.path, tc.body, false, http.StatusBadRequest)
require.NotEqual(t, int64(successCode), env.Code)
// Facade scope 10101000 = InputInvalidFormatgateway parse / validate 進入點)
require.Equal(t, int64(10), env.Code/1_000_000, "expected Facade scope, got code=%d", env.Code)
})
}
}

View File

@ -1,144 +0,0 @@
//go:build e2e
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/require"
)
const successCode = 102000
// Fixture holds seed output from cmd/e2e-seed.
type Fixture struct {
BaseURL string `json:"base_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
UID string `json:"uid"`
Email string `json:"email"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
RoleKey string `json:"role_key"`
NoRoleUID string `json:"no_role_uid"`
NoRoleEmail string `json:"no_role_email"`
NoRoleAccessToken string `json:"no_role_access_token"`
NoRoleRefreshToken string `json:"no_role_refresh_token"`
}
// Client is a thin HTTP helper for Gateway E2E tests.
type Client struct {
BaseURL string
HTTP *http.Client
Fixture Fixture
}
// Envelope matches gateway/types.Status JSON.
type Envelope struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Error json.RawMessage `json:"error,omitempty"`
}
// LoadFixture returns the shared bootstrap fixture (refreshed once in TestMain).
func LoadFixture(t *testing.T) Fixture {
t.Helper()
fx := loadFixture()
require.NotEmpty(t, fx.BaseURL)
require.NotEmpty(t, fx.AccessToken)
return fx
}
// NewClient builds a client from the shared fixture.
func NewClient(t *testing.T) *Client {
t.Helper()
return freshClient(t)
}
// NewNoRoleClient builds a client for the seeded member that intentionally
// has no role assignment. It is used by Casbin-enabled authorization tests.
func NewNoRoleClient(t *testing.T) *Client {
t.Helper()
c := freshClient(t)
require.NotEmpty(t, c.Fixture.NoRoleUID)
require.NotEmpty(t, c.Fixture.NoRoleAccessToken)
c.Fixture.UID = c.Fixture.NoRoleUID
c.Fixture.Email = c.Fixture.NoRoleEmail
c.Fixture.AccessToken = c.Fixture.NoRoleAccessToken
c.Fixture.RefreshToken = c.Fixture.NoRoleRefreshToken
return c
}
func (c *Client) URL(path string) string {
return c.BaseURL + path
}
func (c *Client) Do(t *testing.T, method, path string, body any, auth bool) (*http.Response, Envelope) {
t.Helper()
var r io.Reader
if body != nil {
raw, err := json.Marshal(body)
require.NoError(t, err)
r = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, c.URL(path), r)
require.NoError(t, err)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if auth {
req.Header.Set("Authorization", "Bearer "+c.Fixture.AccessToken)
}
resp, err := c.HTTP.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var env Envelope
require.NoError(t, json.Unmarshal(respBody, &env), "body=%s", string(respBody))
return resp, env
}
func (c *Client) DoExpectOK(t *testing.T, method, path string, body any, auth bool) Envelope {
t.Helper()
resp, env := c.Do(t, method, path, body, auth)
require.Equal(t, http.StatusOK, resp.StatusCode, "path=%s code=%d body=%s", path, env.Code, string(mustRaw(env)))
require.Equal(t, int64(successCode), env.Code, "path=%s message=%s", path, env.Message)
return env
}
func (c *Client) DoExpectHTTP(t *testing.T, method, path string, body any, auth bool, httpStatus int) Envelope {
t.Helper()
resp, env := c.Do(t, method, path, body, auth)
require.Equal(t, httpStatus, resp.StatusCode, "path=%s env=%+v", path, env)
return env
}
func mustRaw(env Envelope) []byte {
if len(env.Data) == 0 {
return []byte(env.Message)
}
return env.Data
}
// FetchE2EOTP reads OTP plain code stashed by verify_helper when GATEWAY_E2E=1.
func FetchE2EOTP(t *testing.T, challengeID string) string {
t.Helper()
if cli := os.Getenv("REDISCLI"); cli != "" {
out, err := runCmd(cli, "-c", fmt.Sprintf("GET e2e:otp:%s", challengeID))
require.NoError(t, err)
require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID)
return out
}
out, err := runCmd("docker", "exec", "gateway-redis", "redis-cli", "GET", "e2e:otp:"+challengeID)
require.NoError(t, err, "fetch otp via docker exec (is gateway-redis running?)")
require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID)
return out
}

View File

@ -1,17 +0,0 @@
//go:build e2e
package e2e
import (
"os/exec"
"strings"
)
func runCmd(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}

View File

@ -1,93 +0,0 @@
# E2E 專用設定Casbin enabledmake e2e-casbin 使用)
# 固定 Port 18888避免與本機 dev server (8888) 衝突
Name: gateway-e2e-casbin
Host: 0.0.0.0
Port: 18888
Mongo:
Schema: mongodb
Host: 127.0.0.1
Port: 27017
Database: gateway_e2e
AuthSource: ""
ReplicaName: ""
TLS: false
MaxPoolSize: 30
MinPoolSize: 5
MaxConnIdleTime: 30m
Redis:
Host: localhost:6379
Type: node
Notification:
DefaultLocale: zh-tw
Email:
Provider: mock
From: e2e-noreply@example.com
SMTP:
Enable: false
SMS:
Provider: mock
Mitake:
Enable: false
Async:
Worker: 1
MaxRetry: 3
BackoffSeconds: [1, 2, 5]
RatePerTenant:
Email: 1000
SMS: 500
Member:
OTP:
Length: 6
TTLSeconds: 300
MaxAttempts: 5
ResendCooldownSeconds: 1
DailyVerifyLimit: 100
TOTP:
Issuer: CloudEP-E2E
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1
BackupCodeCount: 5
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
Registration:
RequireInviteCode: false
TrustSocialEmailVerified: true
Auth:
AccessExpire: 900
RefreshExpire: 604800
ActiveKID: v1
AccessSecret: "e2e-access-secret-32-bytes-min!!"
RefreshSecret: "e2e-refresh-secret-32-bytes-min!"
RegistrationSessionTTLSeconds: 600
Permission:
Casbin:
Enabled: true
ModelPath: etc/rbac.conf
PolicyAdapter: redis
Cache:
UserRolesTTLSeconds: 60
RolePermsTTLSeconds: 60
CatalogTTLSeconds: 120
Reload:
Channel: casbin:reload:e2e
DebounceMilliseconds: 100
HeartbeatSeconds: 30
Zitadel:
Issuer: ""
ServiceUserToken: ""
DefaultOrgID: ""
OAuthClientID: ""
OAuthClientSecret: ""
TimeoutSeconds: 5

View File

@ -1,93 +0,0 @@
# E2E 專用設定make e2e-full 使用;勿與 gateway.dev.yaml 混用)
# 固定 Port 18888避免與本機 dev server (8888) 衝突
Name: gateway-e2e
Host: 0.0.0.0
Port: 18888
Mongo:
Schema: mongodb
Host: 127.0.0.1
Port: 27017
Database: gateway_e2e
AuthSource: ""
ReplicaName: ""
TLS: false
MaxPoolSize: 30
MinPoolSize: 5
MaxConnIdleTime: 30m
Redis:
Host: localhost:6379
Type: node
Notification:
DefaultLocale: zh-tw
Email:
Provider: mock
From: e2e-noreply@example.com
SMTP:
Enable: false
SMS:
Provider: mock
Mitake:
Enable: false
Async:
Worker: 1
MaxRetry: 3
BackoffSeconds: [1, 2, 5]
RatePerTenant:
Email: 1000
SMS: 500
Member:
OTP:
Length: 6
TTLSeconds: 300
MaxAttempts: 5
ResendCooldownSeconds: 1
DailyVerifyLimit: 100
TOTP:
Issuer: CloudEP-E2E
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1
BackupCodeCount: 5
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
Registration:
RequireInviteCode: false
TrustSocialEmailVerified: true
Auth:
AccessExpire: 900
RefreshExpire: 604800
ActiveKID: v1
AccessSecret: "e2e-access-secret-32-bytes-min!!"
RefreshSecret: "e2e-refresh-secret-32-bytes-min!"
RegistrationSessionTTLSeconds: 600
Permission:
Casbin:
Enabled: false
ModelPath: etc/rbac.conf
PolicyAdapter: auto
Cache:
UserRolesTTLSeconds: 60
RolePermsTTLSeconds: 60
CatalogTTLSeconds: 120
Reload:
Channel: casbin:reload:e2e
DebounceMilliseconds: 100
HeartbeatSeconds: 30
Zitadel:
Issuer: ""
ServiceUserToken: ""
DefaultOrgID: ""
OAuthClientID: ""
OAuthClientSecret: ""
TimeoutSeconds: 5

View File

@ -1,27 +0,0 @@
//go:build e2e
package e2e
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
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
require.NoError(t, json.Unmarshal(env.Data, &data))
}
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)
require.Equal(t, int64(successCode), env.Code)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,200 +0,0 @@
//go:build e2e
package e2e
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"testing"
"time"
membertotp "gateway/internal/model/member/totp"
"github.com/stretchr/testify/require"
)
func TestMember_GetMe(t *testing.T) {
e2eStep(t, "M-01", "GET", "/api/v1/members/me", "讀 profiletenant/uid/status")
c := NewClient(t)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
var me struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(env.Data, &me))
require.Equal(t, c.Fixture.TenantID, me.TenantID)
require.Equal(t, c.Fixture.UID, me.UID)
require.Equal(t, "active", me.Status)
}
func TestMember_UpdateMe(t *testing.T) {
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{
"display_name": name,
}, true)
var me struct {
DisplayName string `json:"display_name"`
}
require.NoError(t, json.Unmarshal(env.Data, &me))
require.Equal(t, name, me.DisplayName)
}
func TestMember_EmailVerification_FullFlow(t *testing.T) {
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"
startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{
"target": target,
}, true)
var start struct {
ChallengeID string `json:"challenge_id"`
ExpiresIn int `json:"expires_in"`
}
require.NoError(t, json.Unmarshal(startEnv.Data, &start))
require.NotEmpty(t, start.ChallengeID)
require.Positive(t, start.ExpiresIn)
code := FetchE2EOTP(t, start.ChallengeID)
c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{
"challenge_id": start.ChallengeID,
"code": code,
}, true)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
var me struct {
BusinessEmail string `json:"business_email"`
BusinessEmailVerified bool `json:"business_email_verified"`
}
require.NoError(t, json.Unmarshal(env.Data, &me))
require.Equal(t, target, me.BusinessEmail)
require.True(t, me.BusinessEmailVerified)
}
func TestMember_PhoneVerification_FullFlow(t *testing.T) {
e2eStep(t, "M-05/M-06", "POST", "/me/verifications/phone/{start,confirm}", "業務 phone OTP 申請 → 從 Redis 取碼 → 驗證 → phone_verified=true")
c := NewClient(t)
target := "+886912345678"
startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{
"target": target,
}, true)
var start struct {
ChallengeID string `json:"challenge_id"`
ExpiresIn int `json:"expires_in"`
}
require.NoError(t, json.Unmarshal(startEnv.Data, &start))
require.NotEmpty(t, start.ChallengeID)
require.Positive(t, start.ExpiresIn)
code := FetchE2EOTP(t, start.ChallengeID)
c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{
"challenge_id": start.ChallengeID,
"code": code,
}, true)
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
var me struct {
BusinessPhone string `json:"business_phone"`
BusinessPhoneVerified bool `json:"business_phone_verified"`
}
require.NoError(t, json.Unmarshal(env.Data, &me))
require.Equal(t, target, me.BusinessPhone)
require.True(t, me.BusinessPhoneVerified)
}
func TestMember_TOTP_Status(t *testing.T) {
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 {
Enrolled bool `json:"enrolled"`
}
require.NoError(t, json.Unmarshal(env.Data, &st))
require.False(t, st.Enrolled)
}
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)
var start struct {
OtpauthURL string `json:"otpauth_url"`
Digits int `json:"digits"`
PeriodSec int `json:"period_seconds"`
}
require.NoError(t, json.Unmarshal(startEnv.Data, &start))
require.NotEmpty(t, start.OtpauthURL)
require.Positive(t, start.Digits)
require.Positive(t, start.PeriodSec)
code := codeFromOtpauthURL(t, start.OtpauthURL, start.Digits, start.PeriodSec)
confirmEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{
"code": code,
}, true)
var confirmed struct {
BackupCodes []string `json:"backup_codes"`
}
require.NoError(t, json.Unmarshal(confirmEnv.Data, &confirmed))
require.NotEmpty(t, confirmed.BackupCodes)
statusEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true)
var status struct {
Enrolled bool `json:"enrolled"`
BackupCodesRemaining int `json:"backup_codes_remaining"`
}
require.NoError(t, json.Unmarshal(statusEnv.Data, &status))
require.True(t, status.Enrolled)
require.Equal(t, len(confirmed.BackupCodes), status.BackupCodesRemaining)
c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{
"code": code,
}, true)
replayEnv := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{
"code": code,
}, true, http.StatusForbidden)
require.NotEqual(t, int64(successCode), replayEnv.Code)
backupEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/backup-codes", nil, true)
var backup struct {
BackupCodes []string `json:"backup_codes"`
}
require.NoError(t, json.Unmarshal(backupEnv.Data, &backup))
require.NotEmpty(t, backup.BackupCodes)
c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true)
finalEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true)
var finalStatus struct {
Enrolled bool `json:"enrolled"`
}
require.NoError(t, json.Unmarshal(finalEnv.Data, &finalStatus))
require.False(t, finalStatus.Enrolled)
}
func codeFromOtpauthURL(t *testing.T, rawURL string, digits, periodSec int) string {
t.Helper()
u, err := url.Parse(rawURL)
require.NoError(t, err)
require.Equal(t, "otpauth", u.Scheme)
require.Equal(t, "totp", u.Host)
q := u.Query()
secret, err := membertotp.DecodeSecret(q.Get("secret"))
require.NoError(t, err)
if digits <= 0 {
digits, _ = strconv.Atoi(q.Get("digits"))
}
if periodSec <= 0 {
periodSec, _ = strconv.Atoi(q.Get("period"))
}
code, err := membertotp.Generate(secret, time.Now(), time.Duration(periodSec)*time.Second, digits)
require.NoError(t, err)
return code
}

View File

@ -1,270 +0,0 @@
//go:build e2e
package e2e
import (
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/require"
)
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 {
Tree []map[string]any `json:"tree"`
}
require.NoError(t, json.Unmarshal(env.Data, &data))
require.NotEmpty(t, data.Tree)
}
func TestPermission_Me(t *testing.T) {
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 {
UID string `json:"uid"`
TenantID string `json:"tenant_id"`
Roles []string `json:"roles"`
Permissions map[string]string `json:"permissions"`
Tree []map[string]any `json:"tree"`
}
require.NoError(t, json.Unmarshal(env.Data, &data))
require.Equal(t, c.Fixture.UID, data.UID)
require.Equal(t, c.Fixture.TenantID, data.TenantID)
require.Contains(t, data.Roles, c.Fixture.RoleKey)
require.NotEmpty(t, data.Permissions)
}
func TestPermission_RoleCRUD(t *testing.T) {
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{
"key": "e2e_custom_role",
"display_name": "E2E Custom",
"status": "open",
}, true)
var role struct {
ID string `json:"id"`
Key string `json:"key"`
}
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
require.Equal(t, "e2e_custom_role", role.Key)
require.NotEmpty(t, role.ID)
// 避免 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 {
Roles []struct {
Key string `json:"key"`
} `json:"roles"`
}
require.NoError(t, json.Unmarshal(listEnv.Data, &list))
found := false
for _, r := range list.Roles {
if r.Key == "e2e_custom_role" {
found = true
break
}
}
require.True(t, found, "created role should appear in list")
patchEnv := c.DoExpectOK(t, http.MethodPatch, "/api/v1/permissions/roles/"+role.ID, map[string]string{
"display_name": "E2E Custom Renamed",
}, true)
var patched struct {
DisplayName string `json:"display_name"`
}
require.NoError(t, json.Unmarshal(patchEnv.Data, &patched))
require.Equal(t, "E2E Custom Renamed", patched.DisplayName)
c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
}
func TestPermission_RolePermissions(t *testing.T) {
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)
createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
"key": "e2e_role_permissions",
"display_name": "E2E Role Permissions",
}, true)
var role struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
require.NotEmpty(t, role.ID)
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},
}, true)
listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+role.ID+"/permissions", nil, true)
var list struct {
Permissions []struct {
ID string `json:"id"`
} `json:"permissions"`
}
require.NoError(t, json.Unmarshal(listEnv.Data, &list))
require.NotEmpty(t, list.Permissions)
found := false
for _, p := range list.Permissions {
if p.ID == permissionID {
found = true
break
}
}
require.True(t, found, "role should include requested permission")
c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
}
func TestPermission_AssignUserRole(t *testing.T) {
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{
"key": "e2e_assign_role",
"display_name": "E2E Assign",
}, true)
var role struct {
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,
}, true)
listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", nil, true)
var list struct {
UserRoles []struct {
RoleID string `json:"role_id"`
} `json:"user_roles"`
}
require.NoError(t, json.Unmarshal(listEnv.Data, &list))
found := false
for _, r := range list.UserRoles {
if r.RoleID == role.ID {
found = true
break
}
}
require.True(t, found)
c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles/"+role.ID, nil, true)
c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
}
func TestPermission_RoleMappingCRUD(t *testing.T) {
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)
createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
"key": roleKey,
"display_name": "E2E Mapping Role",
}, true)
var role struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(createEnv.Data, &role))
require.NotEmpty(t, role.ID)
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",
"external_key": externalKey,
"internal_role_key": roleKey,
}, true)
var mapping struct {
ID string `json:"id"`
ExternalSource string `json:"external_source"`
ExternalKey string `json:"external_key"`
InternalRoleID string `json:"internal_role_id"`
InternalRoleKey string `json:"internal_role_key"`
}
require.NoError(t, json.Unmarshal(upsertEnv.Data, &mapping))
require.NotEmpty(t, mapping.ID)
require.Equal(t, "zitadel", mapping.ExternalSource)
require.Equal(t, externalKey, mapping.ExternalKey)
require.Equal(t, role.ID, mapping.InternalRoleID)
require.Equal(t, roleKey, mapping.InternalRoleKey)
listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/role-mappings?source=zitadel", nil, true)
var list struct {
Mappings []struct {
ExternalKey string `json:"external_key"`
} `json:"mappings"`
}
require.NoError(t, json.Unmarshal(listEnv.Data, &list))
found := false
for _, item := range list.Mappings {
if item.ExternalKey == externalKey {
found = true
break
}
}
require.True(t, found, "created role mapping should appear in list")
c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{
"external_source": "zitadel",
"external_key": externalKey,
}, true)
c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
}
func TestPermission_CasbinRBAC(t *testing.T) {
e2eStep(t, "P-13/P-14", "*", "/api/v1/permissions/{policy/reload,roles}", "Casbin enforcementowner reload policy → no-role user GET /roles=403")
if os.Getenv("E2E_CASBIN") != "1" {
t.Skip("set E2E_CASBIN=1 and use e2e.casbin.yaml to enable Casbin enforcement")
}
owner := NewClient(t)
reloadEnv := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/policy/reload", nil, true)
var reload struct {
Tenant string `json:"tenant"`
TS int64 `json:"ts"`
}
require.NoError(t, json.Unmarshal(reloadEnv.Data, &reload))
require.Equal(t, owner.Fixture.TenantID, reload.Tenant)
require.Positive(t, reload.TS)
noRole := NewNoRoleClient(t)
denied := noRole.DoExpectHTTP(t, http.MethodGet, "/api/v1/permissions/roles", nil, true, http.StatusForbidden)
require.NotEqual(t, int64(successCode), denied.Code)
}
func firstCatalogPermissionID(t *testing.T, c *Client) string {
t.Helper()
env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=false", nil, true)
var data struct {
List []struct {
ID string `json:"id"`
} `json:"list"`
}
require.NoError(t, json.Unmarshal(env.Data, &data))
require.NotEmpty(t, data.List)
require.NotEmpty(t, data.List[0].ID)
return data.List[0].ID
}

View File

@ -1,151 +0,0 @@
//go:build e2e
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
)
var sharedFixture Fixture
func TestMain(m *testing.M) {
if err := loadSharedFixture(); err != nil {
fmt.Fprintf(os.Stderr, "e2e bootstrap: %v\n", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// 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 == "" {
path = filepath.Join("fixtures", "state.json")
}
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w (run make e2e-full)", path, err)
}
if err := json.Unmarshal(raw, &sharedFixture); err != nil {
return err
}
if sharedFixture.BaseURL == "" || sharedFixture.AccessToken == "" {
return fmt.Errorf("invalid fixture in %s", path)
}
if override := os.Getenv("E2E_BASE_URL"); override != "" {
sharedFixture.BaseURL = override
}
return nil
}
func refreshTokenPair(baseURL, refreshToken string) (Fixture, error) {
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/token/refresh", bytes.NewReader(body))
if err != nil {
return Fixture{}, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return Fixture{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return Fixture{}, err
}
var env Envelope
if err := json.Unmarshal(raw, &env); err != nil {
return Fixture{}, err
}
if resp.StatusCode != http.StatusOK || env.Code != successCode {
return Fixture{}, fmt.Errorf("refresh failed: status=%d code=%d", resp.StatusCode, env.Code)
}
var data struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(env.Data, &data); err != nil {
return Fixture{}, err
}
out := Fixture{AccessToken: data.AccessToken, RefreshToken: data.RefreshToken}
if out.AccessToken == "" || out.RefreshToken == "" {
return Fixture{}, fmt.Errorf("refresh returned empty tokens")
}
return out, nil
}
func loadFixture() Fixture {
return sharedFixture
}
func freshClient(t *testing.T) *Client {
t.Helper()
fx := loadFixture()
base := fx.BaseURL
if override := os.Getenv("E2E_BASE_URL"); override != "" {
base = override
}
return &Client{
BaseURL: base,
HTTP: &http.Client{Timeout: 15 * time.Second},
Fixture: fx,
}
}
func isolatedAuthClient(t *testing.T) *Client {
t.Helper()
path := os.Getenv("E2E_STATE_FILE")
if path == "" {
path = filepath.Join("fixtures", "state.json")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fx Fixture
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("parse fixture: %v", err)
}
if override := os.Getenv("E2E_BASE_URL"); override != "" {
fx.BaseURL = override
}
pair, err := refreshTokenPair(fx.BaseURL, fx.RefreshToken)
if err != nil {
t.Fatalf("isolated refresh: %v", err)
}
fx.AccessToken = pair.AccessToken
fx.RefreshToken = pair.RefreshToken
return &Client{
BaseURL: fx.BaseURL,
HTTP: &http.Client{Timeout: 15 * time.Second},
Fixture: fx,
}
}

141
test/k6/README.md Normal file
View File

@ -0,0 +1,141 @@
# k6 API tests
完整的 Gateway API smoke + journey 測試套件。**所有 36 個對外端點**都至少在 `smoke/``journeys/` 裡有一發。
## TL;DR
```bash
make k6-up # dockermongo + redis + mailhog + postgres + zitadel
make k6-wait # 等 ZITADEL ready + 把 PAT 寫到 env file
make k6-gateway & # 起 gateway背景吃 etc/gateway.k6.yaml
make k6-seed-admin # rbac journey 才需要seed tenant_admin user
make k6-all # 跑 smoke + journey
make k6-down # 清掉
```
單跑一個檔(記得先載入環境變數):
```bash
source deploy/zitadel/machinekey/k6.env
k6 run test/k6/smoke/health.js
k6 run test/k6/journeys/email_register_full.js
```
## 環境變數
| 變數 | 預設 | 說明 |
|---|---|---|
| `BASE_URL` | `http://localhost:8888` | Gateway base URL |
| `MAILHOG_URL` | `http://localhost:8025` | MailHog HTTP API撈 email OTP |
| `REDIS_ADDR` | `localhost:6379` | Redis 位址(撈 SMS OTP |
| `TENANT_SLUG` | `k6-tenant` | register payload tenant |
| `INVITE_CODE` | `K6INVITE` | tenant 開啟 invite 時用 |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | — | rbac journey seeded admin |
| `OTP_POLL_INTERVAL_MS` | `300` | OTP poll 頻率 |
| `OTP_POLL_TIMEOUT_MS` | `5000` | OTP poll 超時 |
## 目錄結構
```
test/k6/
├── README.md
├── lib/ # 共用 helper
│ ├── config.js # 環境變數 + unique() + SUCCESS_CODE
│ ├── http.js # get/post/...、checkEnvelope、withBearer
│ ├── otp.js # fetchEmailOTP (MailHog) / fetchSMSOTP (Redis)
│ ├── totp.js # HMAC-SHA1 TOTPP4
│ ├── auth.js # register / confirm / login / refresh helperP2
│ └── seed.js # tenant + invite + admin role bootstrapP5
├── smoke/ # 每個端點至少一發
│ ├── health.js # GET /api/v1/health
│ ├── auth_public.js # register / login / refresh / social-start (+negative)
│ ├── auth_bearer.js # logout
│ ├── member.js # me / patch / verify start+confirm / TOTP
│ ├── permission_read.js # catalog / me
│ └── permission_admin.js # roles CRUD / role-permissions / user-roles / mappings / policy reload
└── journeys/ # 完整流程
├── email_register_full.js # register → confirm OTP(MailHog) → me → patch → logout
├── login_refresh.js # login → refresh → me → logout
├── email_verify.js # register → confirm → email verify start → confirm
├── phone_verify.js # register → confirm → phone verify (Redis OTP)
├── totp_full.js # enroll → confirm → verify → backup-codes → disable
├── rbac_admin.js # role CRUD → assign → policy reload → me/permissions
└── token_exchange.js # ZITADEL id_token → CloudEP JWT
```
## OTP 怎麼撈
- **Email**`make k6-up` 含 MailHog:1025 SMTP / :8025 HTTP API。Gateway 把 OTP 寄到 MailHogk6 透過 `/api/v2/search?kind=to&query=<email>` 撈到信件,從 body 抓 6 位數。
- **SMS** → mock provider 把 body 寫到 `dev:notification:last:sms:<phone>`(見 [mock_sender.go](../../internal/model/notification/provider/sms/mock_sender.go) `WithMockRedis`k6 用 `k6/experimental/redis` 直接讀。
兩者都有 `OTP_POLL_TIMEOUT_MS`(預設 5 秒)保護,超時直接 fail。
## 覆蓋率39 endpoints
| 模組 | 端點 | 覆蓋 |
|---|---|---|
| Normal | `GET /api/v1/health` | smoke/health |
| Auth 公開 | `POST /register` | journeys/email_register_full + smoke/auth_public |
| Auth 公開 | `POST /register/confirm` | journeys/email_register_full |
| Auth 公開 | `POST /register/resend` | smoke/auth_public |
| Auth 公開 | `POST /register/social/start` | smoke/auth_public (happy) |
| Auth 公開 | `GET /register/social/callback` | smoke/auth_public (negative — TODO happy) |
| Auth 公開 | `POST /login` | journeys/login_refresh + smoke/auth_public (negative) |
| Auth 公開 | `POST /token/refresh` | journeys/login_refresh + smoke/auth_public (negative) |
| Auth 公開 | `POST /token/exchange` | journeys/token_exchange (negative — TODO happy) |
| Auth 公開 | `POST /login/social/start` | smoke/auth_public (happy) |
| Auth 公開 | `GET /login/social/callback` | smoke/auth_public (negative — TODO happy) |
| Auth Bearer | `POST /logout` | smoke/auth_bearer + journeys/email_register_full |
| Member | `GET /me` | smoke/member + all journeys |
| Member | `PATCH /me` | smoke/member + journeys/email_register_full |
| Member | `POST /me/verifications/email/start` | smoke/member + journeys/email_verify |
| Member | `POST /me/verifications/email/confirm` | smoke/member (negative) + journeys/email_verify (happy) |
| Member | `POST /me/verifications/phone/start` | smoke/member + journeys/phone_verify |
| Member | `POST /me/verifications/phone/confirm` | smoke/member (negative) + journeys/phone_verify (happy) |
| Member | `GET /me/totp` | smoke/member + journeys/totp_full |
| Member | `POST /me/totp/enroll-start` | smoke/member + journeys/totp_full |
| Member | `POST /me/totp/enroll-confirm` | smoke/member (negative) + journeys/totp_full (happy) |
| Member | `POST /me/totp/verify` | smoke/member (negative) + journeys/totp_full (happy) |
| Member | `POST /me/totp/backup-codes` | smoke/member (negative) + journeys/totp_full (happy) |
| Member | `DELETE /me/totp` | smoke/member + journeys/totp_full |
| Perm 讀 | `GET /permissions/catalog` | smoke/permission_read + journeys/rbac_admin |
| Perm 讀 | `GET /permissions/me` | smoke/permission_read + journeys/rbac_admin |
| Perm 管理 | `GET /roles` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `POST /roles` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `PATCH /roles/:id` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `DELETE /roles/:id` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `GET /roles/:id/permissions` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `PUT /roles/:id/permissions` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `GET /users/:uid/roles` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `POST /users/:uid/roles` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `DELETE /users/:uid/roles/:role_id` | smoke/permission_admin + journeys/rbac_admin |
| Perm 管理 | `GET /role-mappings` | smoke/permission_admin |
| Perm 管理 | `PUT /role-mappings` | smoke/permission_admin |
| Perm 管理 | `DELETE /role-mappings` | smoke/permission_admin |
| Perm 管理 | `POST /policy/reload` | smoke/permission_admin + journeys/rbac_admin |
## RBAC 系列(需 admin seed
`journeys/rbac_admin.js` 需要 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 環境變數。`make k6-seed-admin`
會跑 [cmd/k6-seed-admin](../../cmd/k6-seed-admin/main.go)
1. 用 API 註冊一個固定的 `k6-admin@k6.local`
2. 從 MailHog 撈 OTP 完成 confirm
3. 寫入 permission catalog + 預設 system roles透過 `internal/model/permission/seed`
4. 指派 `tenant_admin` 給該 UID
5. 把 `ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID` 寫到 `k6.env`
`k6-seed-admin` 是冪等的,重跑沒事。
沒跑 seed 時,`rbac_admin.js` 會印 skip notice 並 exit 0admin endpoint 的路由存在性
已由 `smoke/permission_admin.js` 涵蓋)。
## 已知無法純 k6 跑的
- `GET /api/v1/auth/register/social/callback``GET /api/v1/auth/login/social/callback` happy path 需要 Google OAuth UI 跳轉,純 k6 無法走完。Smoke 只覆蓋 negative無效 state → 400
- `POST /api/v1/auth/token/exchange` happy path 需要一個來自 ZITADEL 的有效 `id_token`;目前 `make k6-up` 的 ZITADEL bootstrap 只建 service account PAT沒有開 OIDC client password grant因此 happy 路徑 TODOsmoke 只跑 negative。要開`deploy/zitadel/steps.yaml` 加 Application + password grant再用 `/oauth/v2/token` 拿 id_token。
## 不要 commit 的東西
- `deploy/zitadel/machinekey/zitadel-admin-sa.token` / `.json` 已在 `.gitignore`
- `etc/gateway.k6.yaml` 內的 secret 都是固定 dev 值,本機 / CI 可用,**勿** 上 prod。

View File

@ -0,0 +1,46 @@
// Journey: full email registration happy path.
//
// Endpoints exercised:
// POST /api/v1/auth/register (RegisterReq)
// POST /api/v1/auth/register/confirm (RegisterConfirmReq, OTP via MailHog)
// GET /api/v1/members/me (Bearer)
// PATCH /api/v1/members/me (UpdateMemberMeReq)
// POST /api/v1/auth/logout (Bearer)
import { get, patch, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm, logout } from '../lib/auth.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: {
checks: ['rate==1.0'],
http_req_failed: ['rate<0.05'],
},
};
export default function () {
const { identity, tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// GET /members/me
const meRes = get('/api/v1/members/me', bearer);
const me = checkEnvelope(meRes, 'GET /members/me');
if (!me.data || me.data.uid !== tokens.uid) {
throw new Error(`me.uid mismatch: ${meRes.body}`);
}
// PATCH /members/me
const newName = `${identity.displayName} updated`;
const patchRes = patch('/api/v1/members/me', {
display_name: newName,
language: 'en',
currency: 'TWD',
}, bearer);
const patched = checkEnvelope(patchRes, 'PATCH /members/me');
if (patched.data.display_name !== newName) {
throw new Error(`display_name did not update: got=${patched.data.display_name}`);
}
// POST /auth/logout
logout({ accessToken: tokens.access_token });
}

View File

@ -0,0 +1,53 @@
// Journey: business email verification end-to-end
//
// Endpoints exercised:
// POST /api/v1/auth/register
// POST /api/v1/auth/register/confirm
// POST /api/v1/members/me/verifications/email/start
// POST /api/v1/members/me/verifications/email/confirm
// GET /api/v1/members/me (verify business_email_verified flag is true)
import { get, post, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
import { fetchEmailOTP } from '../lib/otp.js';
import { unique } from '../lib/config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// Use a fresh business email distinct from the registration one so the
// verify OTP can be distinguished from the registration OTP in MailHog.
const businessEmail = `${unique('biz')}@k6.local`;
const since = Date.now() - 1000; // tolerate slight clock skew
const startRes = post(
'/api/v1/members/me/verifications/email/start',
{ target: businessEmail },
bearer,
);
const start = checkEnvelope(startRes, 'POST /me/verifications/email/start').data;
if (!start.challenge_id) throw new Error('email/start: missing challenge_id');
const code = fetchEmailOTP(businessEmail, { since });
const confirmRes = post(
'/api/v1/members/me/verifications/email/confirm',
{ challenge_id: start.challenge_id, code },
bearer,
);
checkEnvelope(confirmRes, 'POST /me/verifications/email/confirm');
const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me (post-verify)').data;
if (me.business_email !== businessEmail) {
throw new Error(`business_email not set: got=${me.business_email}`);
}
if (me.business_email_verified !== true) {
throw new Error(`business_email_verified should be true: got=${me.business_email_verified}`);
}
}

View File

@ -0,0 +1,65 @@
// Journey: (login →) refresh → me → logout
//
// Endpoints exercised:
// POST /api/v1/auth/login (tried; gracefully skipped if ZITADEL v2
// doesn't support password grant)
// POST /api/v1/auth/token/refresh (always exercised, using register tokens)
// GET /api/v1/members/me (always)
// POST /api/v1/auth/logout (always)
//
// Why the conditional login? Modern ZITADEL v2 disables OAuth resource-owner
// password grant by default ({"error":"unsupported_grant_type"}), so the
// gateway's /auth/login → zitadel.VerifyPassword pipeline returns
// 502 + 28802000 in the default k6 environment. We probe login once; if it
// 5xx's we fall back to the registration tokens so the rest of the journey
// (refresh, me, logout) still gets coverage.
import { get, post, safeJson, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm, refreshToken, logout } from '../lib/auth.js';
import { cfg } from '../lib/config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { identity, tokens: initial } = registerAndConfirm();
// Probe login. If the env can't do password-grant, fall back to the
// tokens we already have from registerAndConfirm.
let tokens = initial;
const loginRes = post('/api/v1/auth/login', {
tenant_slug: cfg.tenantSlug,
email: identity.email,
password: identity.password,
});
if (loginRes.status === 200) {
const body = checkEnvelope(loginRes, 'POST /auth/login (happy)');
tokens = body.data;
if (tokens.uid !== initial.uid) throw new Error('login returned different uid');
} else {
const body = safeJson(loginRes) || {};
// Only the documented zitadel third-party failure is acceptable here.
if (loginRes.status !== 502 || body.code !== 28802000) {
throw new Error(
`login: unexpected failure status=${loginRes.status} body=${loginRes.body}`,
);
}
// proceed with register tokens; covered by /token/refresh below.
}
// refresh access token (works regardless of which token path we took)
const refreshed = refreshToken({ refreshToken: tokens.refresh_token });
if (!refreshed.access_token) throw new Error('refresh: missing access_token');
// GET /me with the refreshed access token
const me = checkEnvelope(
get('/api/v1/members/me', { Authorization: `Bearer ${refreshed.access_token}` }),
'GET /members/me (after refresh)',
).data;
if (me.uid !== tokens.uid) throw new Error('me.uid mismatch');
// logout
logout({ accessToken: refreshed.access_token });
}

View File

@ -0,0 +1,68 @@
// Journey: business phone verification end-to-end (SMS OTP via Redis)
//
// Endpoints exercised:
// POST /api/v1/auth/register
// POST /api/v1/auth/register/confirm
// POST /api/v1/members/me/verifications/phone/start
// POST /api/v1/members/me/verifications/phone/confirm
// GET /api/v1/members/me (verify business_phone_verified flag is true)
//
// SMS OTP source: the mock SMS sender (when WithMockRedis is wired by the
// notification factory in k6 mode) writes the SMS body to Redis at
// "dev:notification:last:sms:<phone>". fetchSMSOTP polls that key.
//
// k6/experimental/redis requires k6 v0.46+.
import { get, post, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
import { fetchSMSOTP } from '../lib/otp.js';
import { unique } from '../lib/config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
// Generate a deterministic-ish unique E.164 number per iteration to avoid
// collisions across concurrent runs. Use +886912 + 7 digits derived from the
// VU+iter+timestamp.
function uniquePhone() {
const ts = Date.now() % 1_000_000_0;
const vu = (typeof __VU !== 'undefined' && __VU) || 0;
const iter = (typeof __ITER !== 'undefined' && __ITER) || 0;
const suffix = String(ts + vu * 100 + iter).padStart(7, '0').slice(-7);
return `+886912${suffix}`;
}
export default async function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
const phone = uniquePhone();
const startRes = post(
'/api/v1/members/me/verifications/phone/start',
{ target: phone },
bearer,
);
const start = checkEnvelope(startRes, 'POST /me/verifications/phone/start').data;
if (!start.challenge_id) throw new Error('phone/start: missing challenge_id');
const { code } = await fetchSMSOTP(phone);
if (!code) throw new Error(`could not extract SMS OTP for ${phone}`);
const confirmRes = post(
'/api/v1/members/me/verifications/phone/confirm',
{ challenge_id: start.challenge_id, code },
bearer,
);
checkEnvelope(confirmRes, 'POST /me/verifications/phone/confirm');
const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me (post-phone-verify)').data;
if (me.business_phone !== phone) {
throw new Error(`business_phone not set: got=${me.business_phone}`);
}
if (me.business_phone_verified !== true) {
throw new Error(`business_phone_verified should be true: got=${me.business_phone_verified}`);
}
}

View File

@ -0,0 +1,131 @@
// Journey: admin RBAC end-to-end (login admin → roles CRUD → assign → reload → verify → cleanup)
//
// Endpoints exercised (happy path, admin role required):
// POST /api/v1/auth/login (admin)
// GET /api/v1/permissions/roles
// POST /api/v1/permissions/roles
// PATCH /api/v1/permissions/roles/:id
// GET /api/v1/permissions/roles/:id/permissions
// PUT /api/v1/permissions/roles/:id/permissions
// GET /api/v1/permissions/catalog (to pick a permission_id)
// POST /api/v1/permissions/users/:uid/roles
// GET /api/v1/permissions/users/:uid/roles
// POST /api/v1/permissions/policy/reload
// GET /api/v1/permissions/me (as the assigned user)
// DELETE /api/v1/permissions/users/:uid/roles/:role_id
// DELETE /api/v1/permissions/roles/:id
//
// PREREQUISITE — admin user seeded by `make k6-seed-admin`. ADMIN_EMAIL,
// ADMIN_PASSWORD env vars must be set (k6.env exports them). If unset the
// journey prints a skip notice and exits 0 (covered by smoke/permission_admin
// for route-existence checks).
import { sleep } from 'k6';
import { get, post, put, patch, del, checkEnvelope } from '../lib/http.js';
import { login } from '../lib/auth.js';
import { registerAndConfirm } from '../lib/auth.js';
import { cfg, unique } from '../lib/config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
function pickAnyPermissionID(bearer) {
const cat = checkEnvelope(get('/api/v1/permissions/catalog', bearer), 'GET /permissions/catalog').data;
const list = (cat && (cat.list || cat.tree)) || [];
// Find a leaf — node with http_methods set.
const stack = [...list];
while (stack.length) {
const n = stack.shift();
if (n.http_methods && n.id) return n.id;
if (n.children) for (const c of n.children) stack.push(c);
}
if (list.length > 0 && list[0].id) return list[0].id;
throw new Error('catalog: no permissions found');
}
export default function () {
if (!cfg.adminEmail || (!cfg.adminPassword && !cfg.adminAccessToken)) {
console.log('[rbac_admin] skipped: ADMIN_EMAIL + (ADMIN_PASSWORD or ADMIN_ACCESS_TOKEN) env not set. Run `make k6-seed-admin` first.');
return;
}
// 1. obtain admin access_token. Prefer ADMIN_ACCESS_TOKEN (issued by
// k6-seed-admin at registration time) because ZITADEL v2 disables OAuth
// password grant by default, so /auth/login → VerifyPassword returns 502.
let adminAccessToken = cfg.adminAccessToken;
if (!adminAccessToken) {
const admin = login({ email: cfg.adminEmail, password: cfg.adminPassword });
adminAccessToken = admin.access_token;
}
const adminBearer = { Authorization: `Bearer ${adminAccessToken}` };
// 2. register a target user (we'll assign a role to them and verify via /me)
const { tokens: target } = registerAndConfirm();
const targetBearer = { Authorization: `Bearer ${target.access_token}` };
// 3. list roles (sanity)
const rolesBefore = checkEnvelope(get('/api/v1/permissions/roles', adminBearer), 'GET /roles').data;
if (!rolesBefore || !Array.isArray(rolesBefore.roles)) throw new Error('roles list bad shape');
// 4. create a role
const roleKey = unique('k6role').replace(/[^a-z0-9_]/g, '_');
const created = checkEnvelope(
post('/api/v1/permissions/roles', { key: roleKey, display_name: 'k6 RBAC role' }, adminBearer),
'POST /roles',
).data;
const roleID = created.id;
// 5. patch role display name
const newDisplay = `${created.display_name} (patched)`;
checkEnvelope(
patch(`/api/v1/permissions/roles/${roleID}`, { display_name: newDisplay }, adminBearer),
'PATCH /roles/:id',
);
// 6. get role permissions (empty initially)
checkEnvelope(get(`/api/v1/permissions/roles/${roleID}/permissions`, adminBearer), 'GET /roles/:id/permissions');
// 7. put role permissions (replace with one permission id picked from catalog)
const permID = pickAnyPermissionID(adminBearer);
checkEnvelope(
put(`/api/v1/permissions/roles/${roleID}/permissions`, { permission_ids: [permID] }, adminBearer),
'PUT /roles/:id/permissions',
);
// 8. assign role to target user
checkEnvelope(
post(`/api/v1/permissions/users/${target.uid}/roles`, { role_id: roleID }, adminBearer),
'POST /users/:uid/roles',
);
// 9. list target user's roles
const targetRoles = checkEnvelope(
get(`/api/v1/permissions/users/${target.uid}/roles`, adminBearer),
'GET /users/:uid/roles',
).data;
const hasRole = targetRoles.user_roles && targetRoles.user_roles.some((ur) => ur.role_id === roleID);
if (!hasRole) throw new Error('assigned role not visible in user_roles');
// 10. policy reload
checkEnvelope(post('/api/v1/permissions/policy/reload', {}, adminBearer), 'POST /policy/reload');
// give the gateway a moment for the pub/sub reload to settle
sleep(0.5);
// 11. target /permissions/me reflects the role
const me = checkEnvelope(get('/api/v1/permissions/me', targetBearer), 'GET /permissions/me').data;
if (!me.roles || !me.roles.includes(roleKey)) {
throw new Error(`/permissions/me did not include role ${roleKey}: got=${JSON.stringify(me.roles)}`);
}
// 12. revoke role
checkEnvelope(
del(`/api/v1/permissions/users/${target.uid}/roles/${roleID}`, null, adminBearer),
'DELETE /users/:uid/roles/:role_id',
);
// 13. delete role (now safe because no user is assigned)
checkEnvelope(del(`/api/v1/permissions/roles/${roleID}`, null, adminBearer), 'DELETE /roles/:id');
}

View File

@ -0,0 +1,51 @@
// Journey: ZITADEL id_token → CloudEP JWT exchange.
//
// Endpoint exercised:
// POST /api/v1/auth/token/exchange (TokenExchangeReq)
//
// Happy path requires a valid ZITADEL id_token issued for the gateway's
// configured OIDC client. The default `make k6-up` ZITADEL bootstrap only
// creates a service-account PAT (no human-user OIDC client + password grant),
// so we cover negative cases only and mark the happy path as TODO.
//
// To enable happy-path: configure a ZITADEL OIDC application with password
// grant in deploy/zitadel/steps.yaml, export OAUTH_CLIENT_ID + secret + user
// credentials, then call ZITADEL's /oauth/v2/token to obtain an id_token.
import { post, checkErrorOneOf } from '../lib/http.js';
import { cfg } from '../lib/config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
// Negative 1: empty id_token → 400 missing/invalid input
const r1 = post('/api/v1/auth/token/exchange', {
tenant_slug: cfg.tenantSlug,
id_token: '',
});
// either 400 (validation) or 401 (id_token invalid)
if (r1.status !== 400 && r1.status !== 401) {
throw new Error(`empty id_token: unexpected status ${r1.status}: ${r1.body}`);
}
// Negative 2: bogus id_token. Two acceptable outcomes depending on whether
// a JWKS URL is wired:
// 401+28501000 → invalid id_token (JWKS wired; gateway verified locally)
// 502+28802000 → zitadel request failed (default k6 env: no JWKS configured,
// gateway falls through to a remote introspection call).
// Either outcome proves the endpoint exists and refuses bad tokens.
const r2 = post('/api/v1/auth/token/exchange', {
tenant_slug: cfg.tenantSlug,
id_token: 'not.a.real.jwt',
});
checkErrorOneOf(r2, 'POST /auth/token/exchange (bogus id_token)', [
[401, 28501000],
[502, 28802000],
]);
// TODO: happy path — needs ZITADEL OAuth client + password grant configured.
console.log('[token_exchange] happy path skipped (needs ZITADEL OIDC client; see file header)');
}

View File

@ -0,0 +1,81 @@
// Journey: TOTP full lifecycle (enroll → verify → backup-codes → disable)
//
// Endpoints exercised:
// GET /api/v1/members/me/totp (before)
// POST /api/v1/members/me/totp/enroll-start
// POST /api/v1/members/me/totp/enroll-confirm
// POST /api/v1/members/me/totp/verify
// POST /api/v1/members/me/totp/backup-codes (regenerate)
// GET /api/v1/members/me/totp (mid; enrolled=true)
// DELETE /api/v1/members/me/totp
// GET /api/v1/members/me/totp (after; enrolled=false)
//
// TOTP code generation is local (lib/totp.js) — no external authenticator.
import { sleep } from 'k6';
import { get, post, del, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
import { generateTOTP, parseOTPAuth } from '../lib/totp.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// 1. status before enroll
const before = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (before)').data;
if (before.enrolled !== false) throw new Error('totp should be disabled initially');
// 2. enroll-start → get otpauth URL
const enroll = checkEnvelope(post('/api/v1/members/me/totp/enroll-start', null, bearer), 'POST /me/totp/enroll-start').data;
parseOTPAuth(enroll.otpauth_url); // validate it parses
const code = generateTOTP(enroll.otpauth_url);
// 3. enroll-confirm
const confirm = checkEnvelope(
post('/api/v1/members/me/totp/enroll-confirm', { code }, bearer),
'POST /me/totp/enroll-confirm',
).data;
if (!Array.isArray(confirm.backup_codes) || confirm.backup_codes.length === 0) {
throw new Error('enroll-confirm: backup_codes missing');
}
// 4. status mid (enrolled=true)
const mid = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (mid)').data;
if (mid.enrolled !== true) throw new Error('totp should be enrolled after confirm');
// 5. verify — wait until current TOTP differs from the one just consumed
// so the replay guard (Member.TOTP.ReplayTTLSeconds) does not reject it.
let verifyCode = generateTOTP(enroll.otpauth_url);
for (let i = 0; verifyCode === code && i < 30; i++) {
sleep(1.1);
verifyCode = generateTOTP(enroll.otpauth_url);
}
if (verifyCode === code) {
throw new Error('TOTP window did not advance; cannot avoid replay');
}
checkEnvelope(
post('/api/v1/members/me/totp/verify', { code: verifyCode }, bearer),
'POST /me/totp/verify',
);
// 6. backup-codes (regenerate)
const regen = checkEnvelope(
post('/api/v1/members/me/totp/backup-codes', null, bearer),
'POST /me/totp/backup-codes',
).data;
if (!Array.isArray(regen.backup_codes) || regen.backup_codes.length === 0) {
throw new Error('backup-codes: missing codes');
}
// 7. disable
checkEnvelope(del('/api/v1/members/me/totp', null, bearer), 'DELETE /me/totp');
// 8. status after disable
const after = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp (after)').data;
if (after.enrolled !== false) throw new Error('totp should be disabled after DELETE');
}

109
test/k6/lib/auth.js Normal file
View File

@ -0,0 +1,109 @@
// Auth flow helpers — register / confirm / login / refresh / logout.
// All requests go through lib/http.js; OTP comes from lib/otp.js.
import { post } from './http.js';
import { checkEnvelope } from './http.js';
import { cfg, unique } from './config.js';
import { fetchEmailOTP } from './otp.js';
// makeIdentity returns a unique (email, password, display_name) tuple for the
// current VU iteration. Use to avoid collisions in concurrent runs.
export function makeIdentity(prefix = 'k6') {
const slug = unique(prefix);
return {
email: `${slug}@k6.local`,
password: 'K6-StrongPass-1!',
displayName: `K6 ${slug}`,
};
}
// registerEmail calls POST /api/v1/auth/register and returns the parsed
// RegisterData (challenge_id, expires_in, uid).
export function registerEmail({ tenantSlug = cfg.tenantSlug, inviteCode = cfg.inviteCode, email, password, displayName, language = 'zh-TW', termsVersion = '2025-01-01', marketingOptIn = false } = {}) {
const res = post('/api/v1/auth/register', {
tenant_slug: tenantSlug,
invite_code: inviteCode,
email,
password,
display_name: displayName,
language,
accept_terms_version: termsVersion,
marketing_opt_in: marketingOptIn,
});
const body = checkEnvelope(res, 'POST /auth/register');
if (!body.data || !body.data.challenge_id) {
throw new Error(`register: missing challenge_id in ${res.body}`);
}
return body.data;
}
// confirmRegister fetches the OTP from MailHog then calls
// POST /api/v1/auth/register/confirm. Returns AuthTokenData.
export function confirmRegister({ tenantSlug = cfg.tenantSlug, email, challengeId }) {
const code = fetchEmailOTP(email);
const res = post('/api/v1/auth/register/confirm', {
tenant_slug: tenantSlug,
challenge_id: challengeId,
code,
});
const body = checkEnvelope(res, 'POST /auth/register/confirm');
if (!body.data || !body.data.access_token) {
throw new Error(`register/confirm: missing access_token in ${res.body}`);
}
return body.data;
}
// resendRegister calls POST /api/v1/auth/register/resend.
// Returns RegisterData (challenge_id, expires_in, uid).
export function resendRegister({ tenantSlug = cfg.tenantSlug, challengeId }) {
const res = post('/api/v1/auth/register/resend', {
tenant_slug: tenantSlug,
challenge_id: challengeId,
});
return checkEnvelope(res, 'POST /auth/register/resend').data;
}
// login calls POST /api/v1/auth/login. Returns AuthTokenData.
export function login({ tenantSlug = cfg.tenantSlug, email, password }) {
const res = post('/api/v1/auth/login', {
tenant_slug: tenantSlug,
email,
password,
});
const body = checkEnvelope(res, 'POST /auth/login');
if (!body.data || !body.data.access_token) {
throw new Error(`login: missing access_token in ${res.body}`);
}
return body.data;
}
// refreshToken calls POST /api/v1/auth/token/refresh.
export function refreshToken({ refreshToken }) {
const res = post('/api/v1/auth/token/refresh', { refresh_token: refreshToken });
const body = checkEnvelope(res, 'POST /auth/token/refresh');
if (!body.data || !body.data.access_token) {
throw new Error(`token/refresh: missing access_token in ${res.body}`);
}
return body.data;
}
// logout calls POST /api/v1/auth/logout (requires Bearer access_token).
export function logout({ accessToken }) {
const res = post('/api/v1/auth/logout', null, { Authorization: `Bearer ${accessToken}` });
const body = checkEnvelope(res, 'POST /auth/logout');
return body.data;
}
// registerAndConfirm is the most common building block: makes an identity,
// runs register → confirm, returns { identity, tokens, registerData }.
export function registerAndConfirm({ tenantSlug = cfg.tenantSlug, inviteCode = cfg.inviteCode } = {}) {
const identity = makeIdentity();
const reg = registerEmail({
tenantSlug,
inviteCode,
email: identity.email,
password: identity.password,
displayName: identity.displayName,
});
const tokens = confirmRegister({ tenantSlug, email: identity.email, challengeId: reg.challenge_id });
return { identity, tokens, registerData: reg };
}

43
test/k6/lib/config.js Normal file
View File

@ -0,0 +1,43 @@
// Test config — read from env so the same scripts run locally and in CI.
//
// Required:
// BASE_URL gateway base URL (default http://localhost:8888)
// MAILHOG_URL MailHog HTTP API (default http://localhost:8025)
// REDIS_ADDR Redis address for SMS OTP read (default localhost:6379)
//
// Optional (test data tuning):
// TENANT_SLUG tenant used for register payloads (default k6-tenant)
// INVITE_CODE invite code for tenants requiring it (default K6INVITE)
// ADMIN_EMAIL seeded admin login for rbac journeys
// ADMIN_PASSWORD
// ADMIN_ACCESS_TOKEN pre-issued admin access_token (from k6-seed-admin);
// used by rbac_admin.js to bypass /auth/login when
// ZITADEL v2 password grant is unavailable.
// ADMIN_REFRESH_TOKEN matching refresh token (optional, future use).
//
// All scripts must import from here; do NOT hard-code URLs/credentials.
export const cfg = {
baseUrl: __ENV.BASE_URL || 'http://localhost:8888',
mailhogUrl: __ENV.MAILHOG_URL || 'http://localhost:8025',
redisAddr: __ENV.REDIS_ADDR || 'localhost:6379',
tenantSlug: __ENV.TENANT_SLUG || 'k6-tenant',
inviteCode: __ENV.INVITE_CODE || 'K6INVITE',
adminEmail: __ENV.ADMIN_EMAIL || '',
adminPassword: __ENV.ADMIN_PASSWORD || '',
adminAccessToken: __ENV.ADMIN_ACCESS_TOKEN || '',
adminRefreshToken: __ENV.ADMIN_REFRESH_TOKEN || '',
// OTP polling parameters (MailHog / Redis)
otpPollIntervalMs: parseInt(__ENV.OTP_POLL_INTERVAL_MS || '300', 10),
otpPollTimeoutMs: parseInt(__ENV.OTP_POLL_TIMEOUT_MS || '5000', 10),
};
// Build a unique-ish identity per VU+iteration so concurrent runs do not collide.
export function unique(prefix) {
const ts = Date.now();
const vu = (typeof __VU !== 'undefined' && __VU) || 0;
const iter = (typeof __ITER !== 'undefined' && __ITER) || 0;
return `${prefix}-${ts}-${vu}-${iter}`;
}
// Common biz code: 102000 = success envelope (see internal/library/errors/code).
export const SUCCESS_CODE = 102000;

120
test/k6/lib/http.js Normal file
View File

@ -0,0 +1,120 @@
// HTTP helpers — every request goes through these so checks/envelope handling
// is consistent across smoke and journey scripts.
import http from 'k6/http';
import { check, fail } from 'k6';
import { cfg, SUCCESS_CODE } from './config.js';
const JSON_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json' };
function url(path) {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return cfg.baseUrl + path;
}
function mergeHeaders(extra) {
return Object.assign({}, JSON_HEADERS, extra || {});
}
export function withBearer(token, extra) {
return mergeHeaders(Object.assign({ Authorization: `Bearer ${token}` }, extra || {}));
}
export function get(path, headers, params) {
return http.get(url(path), Object.assign({ headers: mergeHeaders(headers) }, params || {}));
}
export function post(path, body, headers, params) {
return http.post(
url(path),
body == null ? null : JSON.stringify(body),
Object.assign({ headers: mergeHeaders(headers) }, params || {}),
);
}
export function put(path, body, headers, params) {
return http.put(
url(path),
body == null ? null : JSON.stringify(body),
Object.assign({ headers: mergeHeaders(headers) }, params || {}),
);
}
export function patch(path, body, headers, params) {
return http.patch(
url(path),
body == null ? null : JSON.stringify(body),
Object.assign({ headers: mergeHeaders(headers) }, params || {}),
);
}
export function del(path, body, headers, params) {
return http.del(
url(path),
body == null ? null : JSON.stringify(body),
Object.assign({ headers: mergeHeaders(headers) }, params || {}),
);
}
// safeJson parses res.body; returns null when body is empty/not JSON.
export function safeJson(res) {
if (!res || !res.body || res.body.length === 0) return null;
try {
return JSON.parse(res.body);
} catch (_) {
return null;
}
}
// checkEnvelope verifies the standard CloudEP success envelope.
// { code: 102000, message: "SUCCESS", data: ... }
// Returns the parsed body so callers can keep chaining.
export function checkEnvelope(res, label, expectedStatus = 200, expectedCode = SUCCESS_CODE) {
const body = safeJson(res);
const ok = check(res, {
[`${label}: status ${expectedStatus}`]: (r) => r.status === expectedStatus,
[`${label}: code ${expectedCode}`]: () => body && body.code === expectedCode,
});
if (!ok) {
// surface real payload so failures are actionable in `make k6-*` output
fail(`${label} failed: status=${res.status} body=${res.body}`);
}
return body;
}
// checkError expects a non-2xx response and a business error code.
// expectedBiz is the 8-digit numeric SSCCCDDD code.
export function checkError(res, label, expectedStatus, expectedBiz) {
const body = safeJson(res);
const ok = check(res, {
[`${label}: status ${expectedStatus}`]: (r) => r.status === expectedStatus,
[`${label}: code ${expectedBiz}`]: () => body && body.code === expectedBiz,
});
if (!ok) {
fail(`${label} expected error ${expectedBiz} got status=${res.status} body=${res.body}`);
}
return body;
}
// checkErrorOneOf accepts any of several (status, code) pairs. Use for
// endpoints whose error path depends on environment wiring (e.g.
// /auth/login may legitimately return either:
// 401 + 28501000 (invalid credentials, OAuth wired)
// 502 + 28802000 (zitadel request failed, OAuth not wired or password
// grant not enabled — true of modern ZITADEL v2 by default)
// pairs: array of [status, bizCode] tuples.
export function checkErrorOneOf(res, label, pairs) {
const body = safeJson(res);
const matched = pairs.find(
([s, c]) => res.status === s && body && body.code === c,
);
const labelStr = pairs.map(([s, c]) => `${s}+${c}`).join(' | ');
const ok = check(res, {
[`${label}: one of {${labelStr}}`]: () => Boolean(matched),
});
if (!ok) {
fail(
`${label} expected one of [${labelStr}] got status=${res.status} body=${res.body}`,
);
}
return body;
}

110
test/k6/lib/otp.js Normal file
View File

@ -0,0 +1,110 @@
// OTP retrieval helpers (P1 skeleton).
//
// fetchEmailOTP(email) → reads the latest MailHog mail for `email`, scans body
// for a 6-digit code. Full implementation in P2 lib.
// fetchSMSOTP(phone) → reads "dev:notification:last:sms:<phone>" from Redis
// (k6/experimental/redis). Implementation in P3.
//
// Both poll for up to cfg.otpPollTimeoutMs because Notifier writes happen
// asynchronously (Send goroutine + Redis hook).
import http from 'k6/http';
import { sleep } from 'k6';
// k6 v2.0+ uses 'k6/x/redis' (the experimental module was promoted). The
// default export is a namespace; the constructor is `redis.Client`.
import redis from 'k6/x/redis';
import { cfg } from './config.js';
const SIX_DIGITS = /\b(\d{6})\b/;
// CSS hex colors (#aabbcc) look like 6-digit OTPs to a naive regex; strip
// them before scanning. We also strip quoted-printable soft-line-breaks
// (`=\n`) since MailHog returns bodies QP-encoded.
const CSS_HEX = /#[0-9a-fA-F]{6}\b/g;
const QP_SOFT_BREAK = /=\r?\n/g;
function sleepMs(ms) {
sleep(ms / 1000);
}
// extractOTPFromText returns the LAST 6-digit number found (after stripping
// CSS hex colors). "Last" because the OTP is typically rendered near the
// bottom of the email body, after the header/branding markup.
export function extractOTPFromText(text) {
if (!text) return '';
const cleaned = String(text).replace(QP_SOFT_BREAK, '').replace(CSS_HEX, '');
const all = cleaned.match(/\b\d{6}\b/g);
if (!all || all.length === 0) return '';
return all[all.length - 1];
}
// fetchEmailOTP polls MailHog's /api/v2/search?kind=to&query=<email>
// until a 6-digit OTP can be parsed out of the most recent message body.
//
// opts.since (ms epoch, default 0): ignore mails delivered before this ts —
// needed when the same address has multiple OTPs in MailHog (e.g. register
// then email-verify), so the verify step picks up the new mail rather than
// the register one.
// opts.limit (default 5): number of latest mails to inspect each poll.
export function fetchEmailOTP(email, opts = {}) {
const since = opts.since || 0;
const limit = opts.limit || 5;
const deadline = Date.now() + cfg.otpPollTimeoutMs;
const u = `${cfg.mailhogUrl}/api/v2/search?kind=to&query=${encodeURIComponent(email)}&start=0&limit=${limit}`;
let last = '';
while (Date.now() < deadline) {
const res = http.get(u);
if (res.status === 200) {
try {
const body = JSON.parse(res.body);
const items = (body && body.items) || [];
for (const item of items) {
const created = item.Created ? Date.parse(item.Created) : 0;
if (created && created < since) continue;
const candidates = [
item.Content && item.Content.Body,
item.MIME && item.MIME.Parts && item.MIME.Parts.map((p) => p.Body).join('\n'),
].filter(Boolean);
for (const c of candidates) {
const code = extractOTPFromText(c);
if (code) return code;
}
}
if (items.length > 0) last = `no 6-digit code in ${items.length} items (since=${since})`;
} catch (e) {
last = `parse-error: ${e}`;
}
} else {
last = `mailhog status ${res.status}`;
}
sleepMs(cfg.otpPollIntervalMs);
}
throw new Error(`fetchEmailOTP(${email}) timed out: ${last}`);
}
// fetchSMSOTP polls Redis key "dev:notification:last:sms:<phone>" set by the
// mock SMS sender (see internal/model/notification/provider/sms/mock_sender.go).
//
// opts.prevBody: when supplied, the poll ignores writes whose body equals
// prevBody. This is essential when the same phone has two OTPs in a single
// test (e.g. register-resend or re-verify); the caller passes the body it
// already consumed so the helper waits for the next one.
//
// Requires k6 v2.0+ which exposes the redis client at k6/x/redis (the
// `k6/experimental/redis` module was removed in v2.0). Statically imported
// above so this works in default k6 compatibility mode.
export async function fetchSMSOTP(phone, opts = {}) {
const prev = opts.prevBody || '';
const [host, port] = cfg.redisAddr.split(':');
const client = new redis.Client({ socket: { host: host, port: parseInt(port || '6379', 10) } });
const key = `dev:notification:last:sms:${phone}`;
const deadline = Date.now() + cfg.otpPollTimeoutMs;
while (Date.now() < deadline) {
const body = await client.get(key);
if (body && body !== prev) {
const code = extractOTPFromText(body);
if (code) return { code, body };
}
sleepMs(cfg.otpPollIntervalMs);
}
throw new Error(`fetchSMSOTP(${phone}) timed out`);
}

106
test/k6/lib/totp.js Normal file
View File

@ -0,0 +1,106 @@
// TOTP (RFC 6238) generator for k6.
//
// Used by journeys/totp_full.js: parse the otpauth_url returned by
// /me/totp/enroll-start, then compute a 6-digit code for the current 30s window.
//
// k6 ships HMAC-SHA1 in k6/crypto so no xk6 extension is required.
import crypto from 'k6/crypto';
const BASE32_ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// base32Decode returns a Uint8Array for a (possibly padded) base32 string.
// Accepts mixed case and ignores '=' padding and whitespace.
export function base32Decode(input) {
if (!input) return new Uint8Array(0);
let cleaned = '';
for (let i = 0; i < input.length; i++) {
const c = input[i].toUpperCase();
if (c === '=' || c === ' ' || c === '\n' || c === '\r' || c === '\t') continue;
cleaned += c;
}
const out = [];
let bits = 0;
let value = 0;
for (let i = 0; i < cleaned.length; i++) {
const idx = BASE32_ALPHA.indexOf(cleaned[i]);
if (idx < 0) throw new Error(`base32: invalid char '${cleaned[i]}'`);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
// parseOTPAuth extracts the base32 secret + algo/digits/period from an
// otpauth://totp/... URL. Returns defaults (SHA1, 6 digits, 30s period) for
// fields not present. Throws if `secret` is missing.
export function parseOTPAuth(url) {
if (!url || url.indexOf('otpauth://totp/') !== 0) {
throw new Error(`parseOTPAuth: not an otpauth totp url: ${url}`);
}
const q = url.indexOf('?');
if (q < 0) throw new Error('parseOTPAuth: missing query string');
const query = url.slice(q + 1);
const params = {};
for (const part of query.split('&')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = decodeURIComponent(part.slice(0, eq));
const v = decodeURIComponent(part.slice(eq + 1));
params[k.toLowerCase()] = v;
}
if (!params.secret) throw new Error('parseOTPAuth: missing secret param');
return {
secret: params.secret,
algorithm: (params.algorithm || 'SHA1').toUpperCase(),
digits: parseInt(params.digits || '6', 10),
period: parseInt(params.period || '30', 10),
};
}
// hotp computes an HOTP code as per RFC 4226 given a key (Uint8Array) and a
// 64-bit counter. Returns a digits-long zero-padded string.
function hotp(keyBytes, counter, digits) {
// Build 8-byte big-endian counter buffer
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
// Counter can exceed Number.MAX_SAFE_INTEGER theoretically but for unix-time
// counters until year ~9999 it fits comfortably in a 32-bit low half.
const high = Math.floor(counter / 0x100000000);
const low = counter >>> 0;
view.setUint32(0, high, false);
view.setUint32(4, low, false);
// hmac with binary output gives us an ArrayBuffer of 20 bytes for SHA1
const macBuf = crypto.hmac('sha1', keyBytes.buffer, buf, 'binary');
const mac = new Uint8Array(macBuf);
const offset = mac[mac.length - 1] & 0x0f;
const bin =
((mac[offset] & 0x7f) << 24) |
((mac[offset + 1] & 0xff) << 16) |
((mac[offset + 2] & 0xff) << 8) |
(mac[offset + 3] & 0xff);
const mod = Math.pow(10, digits);
return String(bin % mod).padStart(digits, '0');
}
// generateTOTP returns the current TOTP code for the given otpauth secret.
// Pass timeOverrideMs to test specific windows; defaults to Date.now().
export function generateTOTP(otpauthURLOrSecret, timeOverrideMs) {
let opts;
if (typeof otpauthURLOrSecret === 'string' && otpauthURLOrSecret.indexOf('otpauth://') === 0) {
opts = parseOTPAuth(otpauthURLOrSecret);
} else {
opts = { secret: otpauthURLOrSecret, algorithm: 'SHA1', digits: 6, period: 30 };
}
if (opts.algorithm !== 'SHA1') {
throw new Error(`generateTOTP: only SHA1 supported, got ${opts.algorithm}`);
}
const key = base32Decode(opts.secret);
const t = typeof timeOverrideMs === 'number' ? timeOverrideMs : Date.now();
const counter = Math.floor(t / 1000 / opts.period);
return hotp(key, counter, opts.digits);
}

View File

@ -0,0 +1,29 @@
// smoke: bearer-protected auth endpoints
//
// Covers:
// POST /api/v1/auth/logout (happy: 200)
// POST /api/v1/auth/logout (negative: 401 without Bearer)
import { post, checkEnvelope, checkError } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
// negative: no bearer
const naked = post('/api/v1/auth/logout');
checkError(naked, 'POST /auth/logout (no bearer)', 401, 28501000);
// happy: with bearer
const { tokens } = registerAndConfirm();
const ok = post('/api/v1/auth/logout', null, {
Authorization: `Bearer ${tokens.access_token}`,
});
const body = checkEnvelope(ok, 'POST /auth/logout');
if (!body.data || body.data.ok !== true) {
throw new Error(`logout: data.ok != true: ${ok.body}`);
}
}

View File

@ -0,0 +1,96 @@
// smoke: public auth endpoints (no Bearer)
//
// Covers:
// POST /api/v1/auth/register (happy → challenge_id)
// POST /api/v1/auth/register/resend (happy → new challenge_id)
// POST /api/v1/auth/login (negative → invalid credentials)
// POST /api/v1/auth/token/refresh (negative → invalid refresh token)
// POST /api/v1/auth/register/social/start (happy → oauth_url)
// POST /api/v1/auth/login/social/start (happy → oauth_url)
// GET /api/v1/auth/register/social/callback (negative → invalid state)
// GET /api/v1/auth/login/social/callback (negative → invalid state)
//
// Note: social callback happy path requires browser redirect (skipped — see README).
import { sleep } from 'k6';
import { get, post, checkEnvelope, checkError, checkErrorOneOf } from '../lib/http.js';
import { cfg, unique } from '../lib/config.js';
import { registerEmail, resendRegister } from '../lib/auth.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
// 1. register happy
const email = `${unique('smoke-pub')}@k6.local`;
const reg = registerEmail({ email, password: 'K6-StrongPass-1!', displayName: 'smoke-pub' });
// 2. register resend happy (new challenge issued). Respect the resend
// cooldown (etc/gateway.k6.yaml → Member.OTP.ResendCooldownSeconds=1) plus
// a small safety margin so we don't hit 29604000.
sleep(1.2);
resendRegister({ challengeId: reg.challenge_id });
// 3. login negative — wrong password against a non-existent user.
// Accepted outcomes:
// 401+28501000 → invalid credentials path (full OAuth client wired)
// 502+28802000 → zitadel request failed (default k6 env: no OAuth client,
// and modern ZITADEL v2 disables password grant anyway).
// Either outcome proves the endpoint is reachable + returns an error envelope.
const loginRes = post('/api/v1/auth/login', {
tenant_slug: cfg.tenantSlug,
email: 'no-such-user@k6.local',
password: 'wrongPassword1!',
});
checkErrorOneOf(loginRes, 'POST /auth/login (negative)', [
[401, 28501000],
[502, 28802000],
]);
// 4. token/refresh negative — bogus refresh token. Same dual-outcome reason.
const refreshRes = post('/api/v1/auth/token/refresh', { refresh_token: 'not-a-token' });
checkErrorOneOf(refreshRes, 'POST /auth/token/refresh (negative)', [
[401, 28501000],
[502, 28802000],
]);
// 5. register/social/start — requires Google IdP wired in ZITADEL.
// Accepted outcomes:
// 200+102000 → oauth_url returned (full Google IdP wired)
// 502+28802000 → zitadel request failed (default k6 env: no GoogleIdPID).
// We verify the endpoint accepts the request and returns either path.
const regSocialRes = post('/api/v1/auth/register/social/start', {
tenant_slug: cfg.tenantSlug,
invite_code: cfg.inviteCode,
provider: 'google',
accept_terms_version: '2025-01-01',
language: 'zh-TW',
redirect_uri: 'http://localhost:8888/api/v1/auth/register/social/callback',
});
checkErrorOneOf(regSocialRes, 'POST /auth/register/social/start', [
[200, 102000],
[502, 28802000],
]);
// 6. login/social/start — same dual-outcome reason.
const loginSocialRes = post('/api/v1/auth/login/social/start', {
tenant_slug: cfg.tenantSlug,
provider: 'google',
redirect_uri: 'http://localhost:8888/api/v1/auth/login/social/callback',
});
checkErrorOneOf(loginSocialRes, 'POST /auth/login/social/start', [
[200, 102000],
[502, 28802000],
]);
// 7. register/social/callback negative — invalid state
const cbReg = get('/api/v1/auth/register/social/callback?code=fake&state=invalid');
// 400 with 28101000 (oauth state invalid)
checkError(cbReg, 'GET /auth/register/social/callback (invalid state)', 400, 28101000);
// 8. login/social/callback negative — invalid state
const cbLogin = get('/api/v1/auth/login/social/callback?code=fake&state=invalid');
checkError(cbLogin, 'GET /auth/login/social/callback (invalid state)', 400, 28101000);
}

21
test/k6/smoke/health.js Normal file
View File

@ -0,0 +1,21 @@
// smoke: GET /api/v1/health
// Covers: normal.PingData health endpoint.
import { get, checkEnvelope } from '../lib/http.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: {
checks: ['rate==1.0'],
http_req_failed: ['rate==0.0'],
http_req_duration: ['p(95)<500'],
},
};
export default function () {
const res = get('/api/v1/health');
const body = checkEnvelope(res, 'GET /api/v1/health');
if (!body || !body.data || typeof body.data.pong !== 'string') {
throw new Error(`unexpected payload: ${res.body}`);
}
}

109
test/k6/smoke/member.js Normal file
View File

@ -0,0 +1,109 @@
// smoke: member endpoints (Bearer)
//
// Covers (11 endpoints):
// GET /api/v1/members/me
// PATCH /api/v1/members/me
// POST /api/v1/members/me/verifications/email/start
// POST /api/v1/members/me/verifications/email/confirm (negative: invalid code)
// POST /api/v1/members/me/verifications/phone/start
// POST /api/v1/members/me/verifications/phone/confirm (negative: invalid code)
// GET /api/v1/members/me/totp (status before enroll)
// POST /api/v1/members/me/totp/enroll-start
// POST /api/v1/members/me/totp/enroll-confirm (negative: invalid code)
// POST /api/v1/members/me/totp/verify (negative: not enrolled)
// POST /api/v1/members/me/totp/backup-codes (negative: not enrolled)
// DELETE /api/v1/members/me/totp (negative: not enrolled / 404)
//
// Happy paths for TOTP and verification end-to-end live in journeys/.
import { get, post, patch, del, checkEnvelope, checkError } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
import { unique } from '../lib/config.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// 1. GET /me
const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me').data;
if (me.uid !== tokens.uid) throw new Error('me.uid mismatch');
// 2. PATCH /me
const newName = `smoke-${unique('m')}`;
const patched = checkEnvelope(patch('/api/v1/members/me', { display_name: newName }, bearer), 'PATCH /members/me').data;
if (patched.display_name !== newName) throw new Error('display_name not updated');
// 3. POST /me/verifications/email/start
const eStart = checkEnvelope(
post('/api/v1/members/me/verifications/email/start', { target: `verify-${unique('e')}@k6.local` }, bearer),
'POST /me/verifications/email/start',
).data;
if (!eStart.challenge_id) throw new Error('email start: missing challenge_id');
// 4. POST /me/verifications/email/confirm (negative — wrong code 000000)
checkError(
post('/api/v1/members/me/verifications/email/confirm', { challenge_id: eStart.challenge_id, code: '000000' }, bearer),
'POST /me/verifications/email/confirm (bad code)',
403,
29505000,
);
// 5. POST /me/verifications/phone/start
const pStart = checkEnvelope(
post('/api/v1/members/me/verifications/phone/start', { target: '+886912000001' }, bearer),
'POST /me/verifications/phone/start',
).data;
if (!pStart.challenge_id) throw new Error('phone start: missing challenge_id');
// 6. POST /me/verifications/phone/confirm (negative)
checkError(
post('/api/v1/members/me/verifications/phone/confirm', { challenge_id: pStart.challenge_id, code: '000000' }, bearer),
'POST /me/verifications/phone/confirm (bad code)',
403,
29505000,
);
// 7. GET /me/totp (status — not enrolled)
const totpStatus = checkEnvelope(get('/api/v1/members/me/totp', bearer), 'GET /me/totp').data;
if (totpStatus.enrolled !== false) throw new Error('totp should not be enrolled');
// 8. POST /me/totp/enroll-start (happy)
const enroll = checkEnvelope(post('/api/v1/members/me/totp/enroll-start', null, bearer), 'POST /me/totp/enroll-start').data;
if (!enroll.otpauth_url) throw new Error('enroll-start: missing otpauth_url');
// 9. POST /me/totp/enroll-confirm (negative — bad code)
checkError(
post('/api/v1/members/me/totp/enroll-confirm', { code: '000000' }, bearer),
'POST /me/totp/enroll-confirm (bad code)',
403,
29505000,
);
// 10. POST /me/totp/verify (negative — not enrolled)
checkError(
post('/api/v1/members/me/totp/verify', { code: '000000' }, bearer),
'POST /me/totp/verify (not enrolled)',
409,
29309000,
);
// 11. POST /me/totp/backup-codes (negative — not enrolled)
checkError(
post('/api/v1/members/me/totp/backup-codes', null, bearer),
'POST /me/totp/backup-codes (not enrolled)',
409,
29309000,
);
// 12. DELETE /me/totp — when not enrolled the contract returns 200 (idempotent disable).
// Accept either 200 success envelope or 404 (member-not-found edge case).
const delRes = del('/api/v1/members/me/totp', null, bearer);
if (delRes.status !== 200 && delRes.status !== 404) {
throw new Error(`DELETE /me/totp unexpected status ${delRes.status}: ${delRes.body}`);
}
}

View File

@ -0,0 +1,70 @@
// smoke: permission admin endpoints (Bearer + Casbin RBAC required)
//
// Goal: verify each route is wired and rejects a non-admin caller. Full happy-
// path admin testing lives in journeys/rbac_admin.js (requires seeded admin).
//
// Covers (12 endpoints, mostly negative since the test user has no admin role):
// GET /api/v1/permissions/roles
// POST /api/v1/permissions/roles
// PATCH /api/v1/permissions/roles/:id
// DELETE /api/v1/permissions/roles/:id
// GET /api/v1/permissions/roles/:id/permissions
// PUT /api/v1/permissions/roles/:id/permissions
// GET /api/v1/permissions/users/:uid/roles
// POST /api/v1/permissions/users/:uid/roles
// DELETE /api/v1/permissions/users/:uid/roles/:role_id
// GET /api/v1/permissions/role-mappings
// PUT /api/v1/permissions/role-mappings
// DELETE /api/v1/permissions/role-mappings
// POST /api/v1/permissions/policy/reload
//
// Each call is expected to return 403 (RBAC) — we just need to confirm the
// status is non-2xx and the route exists (no 404).
import { get, post, put, patch, del } from '../lib/http.js';
import { check } from 'k6';
import { registerAndConfirm } from '../lib/auth.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
function assertForbidden(res, label) {
// Casbin reject → 403 (31507000 forbidden). We tolerate 401/403 here since
// the exact code may vary across edge cases (missing role vs RBAC denied).
const ok = check(res, {
[`${label}: route exists (not 404)`]: (r) => r.status !== 404,
[`${label}: non-2xx (RBAC blocks)`]: (r) => r.status >= 400 && r.status < 500,
});
if (!ok) {
throw new Error(`${label}: unexpected status ${res.status} body=${res.body}`);
}
}
export default function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
const fakeID = '000000000000000000000000';
assertForbidden(get('/api/v1/permissions/roles', bearer), 'GET /roles');
assertForbidden(post('/api/v1/permissions/roles', { key: 'smoke_role' }, bearer), 'POST /roles');
assertForbidden(patch(`/api/v1/permissions/roles/${fakeID}`, { display_name: 'x' }, bearer), 'PATCH /roles/:id');
assertForbidden(del(`/api/v1/permissions/roles/${fakeID}`, null, bearer), 'DELETE /roles/:id');
assertForbidden(get(`/api/v1/permissions/roles/${fakeID}/permissions`, bearer), 'GET /roles/:id/permissions');
assertForbidden(put(`/api/v1/permissions/roles/${fakeID}/permissions`, { permission_ids: [] }, bearer), 'PUT /roles/:id/permissions');
assertForbidden(get(`/api/v1/permissions/users/${tokens.uid}/roles`, bearer), 'GET /users/:uid/roles');
assertForbidden(post(`/api/v1/permissions/users/${tokens.uid}/roles`, { role_id: fakeID }, bearer), 'POST /users/:uid/roles');
assertForbidden(del(`/api/v1/permissions/users/${tokens.uid}/roles/${fakeID}`, null, bearer), 'DELETE /users/:uid/roles/:role_id');
assertForbidden(get('/api/v1/permissions/role-mappings', bearer), 'GET /role-mappings');
assertForbidden(put('/api/v1/permissions/role-mappings', {
external_source: 'zitadel',
external_key: 'smoke',
internal_role_key: 'platform_admin',
}, bearer), 'PUT /role-mappings');
assertForbidden(del('/api/v1/permissions/role-mappings', {
external_source: 'zitadel',
external_key: 'smoke',
}, bearer), 'DELETE /role-mappings');
assertForbidden(post('/api/v1/permissions/policy/reload', {}, bearer), 'POST /policy/reload');
}

View File

@ -0,0 +1,43 @@
// smoke: permission read endpoints (Bearer, no RBAC required)
//
// Covers:
// GET /api/v1/permissions/catalog (?tree=true and flat)
// GET /api/v1/permissions/me (regular user → empty roles ok)
// GET /api/v1/permissions/me?include_tree=true
import { get, checkEnvelope } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
// GET /catalog (flat). When the catalog is empty (no perms seeded into
// gateway_k6) the Go struct uses `omitempty`, so both list and tree are
// legitimately stripped from the response; the envelope success is enough
// to prove the endpoint and auth chain work. When perms ARE seeded
// (after k6-seed-admin), .list is a non-empty array.
const flat = checkEnvelope(get('/api/v1/permissions/catalog', bearer), 'GET /permissions/catalog').data;
if (flat && flat.list !== undefined && !Array.isArray(flat.list)) {
throw new Error(`catalog: .list is not an array: ${JSON.stringify(flat)}`);
}
// GET /catalog?tree=true (same envelope-only assertion).
const tree = checkEnvelope(get('/api/v1/permissions/catalog?tree=true', bearer), 'GET /permissions/catalog?tree=true').data;
if (tree && tree.tree !== undefined && !Array.isArray(tree.tree)) {
throw new Error(`catalog tree: .tree is not an array: ${JSON.stringify(tree)}`);
}
// GET /me
const me = checkEnvelope(get('/api/v1/permissions/me', bearer), 'GET /permissions/me').data;
if (me.uid !== tokens.uid) throw new Error('me.uid mismatch');
if (!Array.isArray(me.roles)) throw new Error('me.roles is not array');
// GET /me?include_tree=true
checkEnvelope(get('/api/v1/permissions/me?include_tree=true', bearer), 'GET /permissions/me?include_tree=true');
}