diff --git a/.gitignore b/.gitignore index ef56624..3c2843f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ coverage.html # 專案編譯產物(根目錄 binary 名稱與 module 相同時) /gateway +# k6 / dev binary 產物 +/bin/ + # ========================= # go-doc / 工具 binary # ========================= diff --git a/AGENTS.md b/AGENTS.md index 6a85302..d6953ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,97 +1,39 @@ # AGENTS.md -給 AI coding agent(Claude / 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//`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。 +## 一分鐘快速理解 -跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic//`,**usecase 不可呼叫其他 usecase**。 +- **業務模組** 一律放 `internal/model//`,內部分 `domain` / `repository` / `usecase` / `config`。 +- **跨模組編排**(多 usecase 串接)放 `internal/logic//`;**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 列舉前 | -| [`generate/doc-generate/README.md`](generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 | -| `internal/model//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//_logic.go` 的業務邏輯 -5. `go build ./...` 確保編譯通過 -6. `make lint` / `make test` 視改動範圍跑 - -### 2. 新增 / 修改業務模組 - -- 領域介面與型別放 `internal/model//domain/` -- Mongo / Redis 實作放 `internal/model//repository/` -- 原子 usecase 放 `internal/model//usecase/`(**不可**互相呼叫) -- 多步驟流程編排放 `internal/logic//` -- 模組的對外裝配入口統一在 `internal/model//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` 加 ` rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc. = middleware.NewMiddleware(...).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//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()` 裡呼叫 `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 用繁中描述「為什麼」改,不是「改了什麼」 +| [docs/AGENTS.md](docs/AGENTS.md) | Agent 工作準則總覽(必讀) | +| [README.md](README.md) | 專案總覽、開發約定、HTTP / 錯誤格式 | +| [docs/model.md](docs/model.md) | `internal/model/{module}` 分層規範 | +| [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) | 統一註冊/登入完整時序 | +| [docs/e2e-testing.md](docs/e2e-testing.md) | E2E 測試流程 | ## 指令速查 -| 指令 | 用途 | -|---|---| -| `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 索引 | +```bash +make gen-api # .api → handler / logic(skip exists) / types +make gen-doc # .api → docs/openapi/gateway.yaml +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 help`。 +完整列表:`make help`。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f44c07a --- /dev/null +++ b/CLAUDE.md @@ -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` 文件,除非使用者明確要求。 +- 不主動 commit;commit 前先 `git status` / `git diff` 確認;commit message 描述「為什麼」。 +- `rm -rf` / 強制 push / 改 git config 等破壞性操作前必須先取得使用者同意。 +- 程式碼註解只寫「為什麼」與邊界條件,不寫「import the module」這種顯而易見的描述。 diff --git a/Makefile b/Makefile index b3dcfc1..5b6866c 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,6 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help -.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test test-e2e 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: ## 顯示可用指令 @echo "Gateway Makefile" @echo "" @@ -57,38 +54,6 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML 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 journeys(k6 風格多步流程;需 state.json) - GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestJourney_' - GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZJourney_' - -e2e-full: ## 全新 Docker + index + seed + E2E contract tests + 關閉 - bash scripts/e2e-run.sh - -e2e-journey: ## 全新 Docker + index + seed + E2E user journeys(k6 風格)+ 關閉 - E2E_MODE=journey \ - E2E_TEST_PATTERN='TestJourney_' \ - E2E_TEST_PATTERN_ZZZ='TestZZZJourney_' \ - bash scripts/e2e-run.sh - -e2e-casbin: ## 全新 Docker + Casbin enabled + E2E + 關閉(含 RBAC 403 / reload) - E2E_CASBIN=1 E2E_CONFIG=test/e2e/fixtures/e2e.casbin.yaml \ - E2E_TEST_PATTERN='Test(Auth_|Health|Member|Permission_(Catalog|Me|CasbinRBAC))' \ - 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) $(GOFMT) -s -w $(GOFILES) @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) -run: ## 啟動 Gateway(etc/gateway.yaml,無需 Docker) - $(GO) run gateway.go -f etc/gateway.yaml +# ============================================================ +# Docker compose(本機依賴) +# ============================================================ -setup-dev: ## 建立本機 gateway.dev.yaml(自 example,不會被 git 追蹤) - @test -f etc/gateway.dev.yaml || cp etc/gateway.dev.example.yaml etc/gateway.dev.yaml - @echo ">> etc/gateway.dev.yaml ready (edit locally; not committed)" +DOCKER_COMPOSE ?= docker compose +COMPOSE_FILE := deploy/docker-compose.yml +COMPOSE := $(DOCKER_COMPOSE) -f $(COMPOSE_FILE) -run-dev: setup-dev ## 啟動 Gateway(etc/gateway.dev.yaml,需 make deps-up) - $(GO) run gateway.go -f etc/gateway.dev.yaml +deps-up: ## 起 Mongo + Redis(最小本機依賴) + $(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 + Redis(docker compose) - docker compose up -d mongo redis +deps-down: ## 停服務(保留 volume) + $(COMPOSE) --profile smtp --profile k6 down -deps-up-smtp: ## 啟動 Mongo + Redis + MailHog(本機 SMTP 測試) - docker compose --profile smtp up -d mongo redis mailhog +deps-down-v: ## 停服務並刪 volume(會清資料) + $(COMPOSE) --profile smtp --profile k6 down -v -deps-down: ## 停止 docker compose 容器(保留 volume) - docker compose --profile smtp down +deps-logs: ## 看 compose log + $(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 - docker compose --profile smtp logs -f +K6 ?= k6 +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: ## 查看依賴服務狀態 - docker compose --profile smtp ps +# k6 安裝指引(macOS / Linux) +define K6_INSTALL_HINT +k6 not found in PATH. -mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動) - $(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml +Install: + 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) - @test -n "$(METHOD)" || (echo "usage: make notify-test METHOD=email-send TO=you@example.com" && \ - echo " make notify-test METHOD=sms-send PHONE=0912345678" && \ - 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,) +Or use docker (no install): K6='docker run --rm -i --network host -v $$PWD:/app -w /app grafana/k6:latest' make k6-smoke +endef +export K6_INSTALL_HINT -totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis) - $(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \ - $(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \ - $(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \ - $(if $(CODE),-code "$(CODE)",) +k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引) + @command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1) + @echo "k6: $$($(K6) version 2>&1 | head -1)" -member-seed: setup-dev ## 建立 dev tenant + member(需 Mongo+Redis) - $(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \ - $(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",) +k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + zitadel) + $(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel + @echo "ZITADEL bootstrapping (this can take 30–90s the first time)…" + @echo "→ run 'make k6-wait' to block until it is ready" -config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入 - $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v +k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env + @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 role(rbac 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" \ No newline at end of file diff --git a/cmd/e2e-seed/main.go b/cmd/e2e-seed/main.go deleted file mode 100644 index 6508df8..0000000 --- a/cmd/e2e-seed/main.go +++ /dev/null @@ -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 -} diff --git a/cmd/k6-seed-admin/main.go b/cmd/k6-seed-admin/main.go new file mode 100644 index 0000000..04766e6 --- /dev/null +++ b/cmd/k6-seed-admin/main.go @@ -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) +} diff --git a/cmd/member-seed/main.go b/cmd/member-seed/main.go deleted file mode 100644 index fbd7f7b..0000000 --- a/cmd/member-seed/main.go +++ /dev/null @@ -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 -} diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go deleted file mode 100644 index f9df59a..0000000 --- a/cmd/mongo-index/main.go +++ /dev/null @@ -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 -} diff --git a/cmd/notify-test/main.go b/cmd/notify-test/main.go deleted file mode 100644 index ddcb0d6..0000000 --- a/cmd/notify-test/main.go +++ /dev/null @@ -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 [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"} -} diff --git a/cmd/permission-seed/main.go b/cmd/permission-seed/main.go deleted file mode 100644 index 88946eb..0000000 --- a/cmd/permission-seed/main.go +++ /dev/null @@ -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 -} diff --git a/cmd/totp-test/main.go b/cmd/totp-test/main.go deleted file mode 100644 index 4aceb95..0000000 --- a/cmd/totp-test/main.go +++ /dev/null @@ -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 -} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ddc0cf6 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,100 @@ +# 本機開發 / k6 測試依賴:MongoDB、Redis、MailHog、Postgres、ZITADEL +# +# 啟動: +# make deps-up → mongo + redis(最小,吃 etc/gateway.dev.yaml) +# make deps-up-smtp → + mailhog(profile 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 stack(profile 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: diff --git a/deploy/zitadel/.gitignore b/deploy/zitadel/.gitignore new file mode 100644 index 0000000..f9eecbe --- /dev/null +++ b/deploy/zitadel/.gitignore @@ -0,0 +1,3 @@ +# ZITADEL bootstrap outputs(PAT / machine key)— 不入 git +machinekey/zitadel-admin-sa.token +machinekey/zitadel-admin-sa.json diff --git a/deploy/zitadel/README.md b/deploy/zitadel/README.md new file mode 100644 index 0000000..32a78a8 --- /dev/null +++ b/deploy/zitadel/README.md @@ -0,0 +1,52 @@ +# ZITADEL(dev / k6) + +本機跑 k6 測試用的 ZITADEL stack(docker-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 UI:http://localhost:8080/ui/console +- OIDC issuer:http://localhost:8080 +- Management API:http://localhost:8080/management/v1 +- Health:http://localhost:8080/debug/healthz + +## 不可帶上 prod + +`MasterkeyNeedsToHave32Characters` 與 [steps.yaml](steps.yaml) 內的密碼都是固定 dev 值,**只能**本機用。 diff --git a/deploy/zitadel/machinekey/.gitkeep b/deploy/zitadel/machinekey/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/zitadel/machinekey/k6.env b/deploy/zitadel/machinekey/k6.env new file mode 100644 index 0000000..1561991 --- /dev/null +++ b/deploy/zitadel/machinekey/k6.env @@ -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 diff --git a/deploy/zitadel/machinekey/k6.env.admin b/deploy/zitadel/machinekey/k6.env.admin new file mode 100644 index 0000000..e69de29 diff --git a/deploy/zitadel/steps.yaml b/deploy/zitadel/steps.yaml new file mode 100644 index 0000000..05d559c --- /dev/null +++ b/deploy/zitadel/steps.yaml @@ -0,0 +1,40 @@ +# ZITADEL FirstInstance bootstrap(dev / k6 用,固定憑證) +# 啟動完成後: +# deploy/zitadel/machinekey/zitadel-admin-sa.token ← 給 Gateway 當 ServiceUserToken +# deploy/zitadel/machinekey/zitadel-admin-sa.json ← 給 SDK 用的 JWT key(k6 不會用到) + +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" diff --git a/deploy/zitadel/zitadel.yaml b/deploy/zitadel/zitadel.yaml new file mode 100644 index 0000000..79e4e12 --- /dev/null +++ b/deploy/zitadel/zitadel.yaml @@ -0,0 +1,41 @@ +# ZITADEL runtime config(dev / 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 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 3bb4db5..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,52 +0,0 @@ -# 本機開發依賴:MongoDB(notification 持久化)、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: diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..5991a35 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md + +給 AI coding agent(Claude / Cursor / Codex / 其他)的專案工作準則。請在開始任務前讀過一遍,並在需要時翻閱對應子文件。 + +## 專案簡介 + +`template-monorepo` 是基於 [go-zero](https://github.com/zeromicro/go-zero) 的 API Gateway,採用「**模組化 Clean Architecture**」:每個業務模組(auth / member / notification / permission ...)放在 `internal/model//`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。 + +跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic//`,**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//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//_logic.go` 的業務邏輯 +5. `go build ./...` 確保編譯通過 +6. `make lint` / `make test` 視改動範圍跑 + +### 2. 新增 / 修改業務模組 + +- 領域介面與型別放 `internal/model//domain/` +- Mongo / Redis 實作放 `internal/model//repository/` +- 原子 usecase 放 `internal/model//usecase/`(**不可**互相呼叫) +- 多步驟流程編排放 `internal/logic//` +- 模組的對外裝配入口統一在 `internal/model//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` 加 ` rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc. = middleware.NewMiddleware(...).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//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()` 裡呼叫 `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`。 diff --git a/etc/gateway.k6.yaml b/etc/gateway.k6.yaml new file mode 100644 index 0000000..1a310b3 --- /dev/null +++ b/etc/gateway.k6.yaml @@ -0,0 +1,116 @@ +# k6 測試專用設定(搭配 make k6-up + make k6-gateway) +# +# 與 dev 差異: +# - Email: SMTP → MailHog(localhost:1025);OTP 由 k6 透過 MailHog HTTP API 撈 +# - SMS : provider=mock,並由 mock_sender 寫到 Redis(key: dev:notification:last:sms:) +# - Permission.Casbin.Enabled: true(rbac 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 diff --git a/internal/library/zitadel/client.go b/internal/library/zitadel/client.go index ecb968b..d60c113 100644 --- a/internal/library/zitadel/client.go +++ b/internal/library/zitadel/client.go @@ -228,7 +228,11 @@ func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body if resp.StatusCode == http.StatusConflict { 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)) } if out != nil && len(raw) > 0 { diff --git a/internal/model/notification/provider/email/mock_sender.go b/internal/model/notification/provider/email/mock_sender.go index 764a1da..78b9cbd 100644 --- a/internal/model/notification/provider/email/mock_sender.go +++ b/internal/model/notification/provider/email/mock_sender.go @@ -9,6 +9,13 @@ import ( "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). type MockSender struct { name string @@ -19,6 +26,11 @@ type MockSender struct { Err error MessageID string 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:" for each To address. + redis MockRedisHook + redisKeyTTL int } type MockSenderOption func(*MockSender) @@ -39,11 +51,26 @@ func WithMockMessageID(id string) MockSenderOption { return func(m *MockSender) { m.MessageID = id } } +// WithMockRedis mirrors every outbound mock email body into Redis at key +// "dev:notification:last:email:" (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 { m := &MockSender{ - name: "mock", - sort: 0, - MessageID: "mock-email-id", + name: "mock", + sort: 0, + MessageID: "mock-email-id", + redisKeyTTL: 600, } for _, opt := range opts { opt(m) @@ -54,6 +81,9 @@ func NewMockSender(opts ...MockSenderOption) *MockSender { func (m *MockSender) Name() string { return m.name } 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) { m.mu.Lock() m.calls = append(m.calls, msg) @@ -68,6 +98,17 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { if msg != nil { 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) + 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 } diff --git a/internal/model/notification/provider/email/mock_sender_test.go b/internal/model/notification/provider/email/mock_sender_test.go new file mode 100644 index 0000000..bbe5db8 --- /dev/null +++ b/internal/model/notification/provider/email/mock_sender_test.go @@ -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) +} diff --git a/internal/model/notification/provider/sms/mock_sender.go b/internal/model/notification/provider/sms/mock_sender.go index 3b078df..44846ba 100644 --- a/internal/model/notification/provider/sms/mock_sender.go +++ b/internal/model/notification/provider/sms/mock_sender.go @@ -8,6 +8,16 @@ import ( "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). type MockSender struct { name string @@ -18,6 +28,12 @@ type MockSender struct { Err error MessageID string 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:" 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) @@ -38,11 +54,25 @@ func WithMockMessageID(id string) MockSenderOption { 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:". +// 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 { m := &MockSender{ - name: "mock", - sort: 0, - MessageID: "mock-sms-id", + name: "mock", + sort: 0, + MessageID: "mock-sms-id", + redisKeyTTL: 600, } for _, opt := range opts { opt(m) @@ -53,6 +83,10 @@ func NewMockSender(opts ...MockSenderOption) *MockSender { func (m *MockSender) Name() string { return m.name } 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) { m.mu.Lock() m.calls = append(m.calls, msg) @@ -67,6 +101,12 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { if msg != nil { logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s", 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 } diff --git a/internal/model/notification/provider/sms/mock_sender_test.go b/internal/model/notification/provider/sms/mock_sender_test.go new file mode 100644 index 0000000..5803383 --- /dev/null +++ b/internal/model/notification/provider/sms/mock_sender_test.go @@ -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) +} diff --git a/internal/model/notification/usecase/factory.go b/internal/model/notification/usecase/factory.go index d47e80d..caa31e2 100644 --- a/internal/model/notification/usecase/factory.go +++ b/internal/model/notification/usecase/factory.go @@ -29,7 +29,7 @@ func NewNotifierUseCaseFromParam(param FactoryParam) (domusecase.NotifierUseCase 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) if err != nil { return nil, err @@ -37,7 +37,11 @@ func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) { if len(senders) == 0 { switch cfg.Email.Provider { 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: 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 } -func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) { +func buildSMSChain(cfg notifconfig.Config, rc *redislib.Client) (*sms.Chain, error) { senders, err := collectSMSSenders(cfg) if err != nil { return nil, err @@ -95,7 +99,11 @@ func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) { if len(senders) == 0 { switch cfg.SMS.Provider { 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: return nil, fmt.Errorf("notification: no sms senders enabled and provider %q is not mock", cfg.SMS.Provider) } diff --git a/internal/model/notification/usecase/factory_test.go b/internal/model/notification/usecase/factory_test.go index 81db839..f3466b8 100644 --- a/internal/model/notification/usecase/factory_test.go +++ b/internal/model/notification/usecase/factory_test.go @@ -12,7 +12,7 @@ import ( func TestBuildEmailChain_MockByDefault(t *testing.T) { chain, err := buildEmailChain(notifconfig.Config{ Email: notifconfig.EmailConfig{Provider: notifconfig.ProviderMock}, - }) + }, nil) require.NoError(t, err) require.NotNil(t, chain) } @@ -34,7 +34,7 @@ func TestBuildEmailChain_SMTPAndSES(t *testing.T) { SecretKey: "secret", }, }, - }) + }, nil) require.NoError(t, err) require.NotNil(t, chain) } @@ -44,7 +44,7 @@ func TestBuildEmailChain_SESRequiresCredentials(t *testing.T) { Email: notifconfig.EmailConfig{ SES: notifconfig.SESProviderSettings{Enable: true, Region: "ap-northeast-1"}, }, - }) + }, nil) assert.Error(t, err) } @@ -58,7 +58,7 @@ func TestBuildSMSChain_Mitake(t *testing.T) { Password: "pass", }, }, - }) + }, nil) require.NoError(t, err) require.NotNil(t, chain) } diff --git a/internal/model/notification/usecase/module.go b/internal/model/notification/usecase/module.go index f966040..8044652 100644 --- a/internal/model/notification/usecase/module.go +++ b/internal/model/notification/usecase/module.go @@ -47,11 +47,11 @@ func NewModuleFromParam(param FactoryParam) (*Module, error) { queue = repository.NewRedisRetryQueue(param.Redis, retryQueueKey(param.Config)) } - emailChain, err := buildEmailChain(param.Config) + emailChain, err := buildEmailChain(param.Config, param.Redis) if err != nil { return nil, err } - smsChain, err := buildSMSChain(param.Config) + smsChain, err := buildSMSChain(param.Config, param.Redis) if err != nil { return nil, err } diff --git a/internal/model/permission/seed/catalog.go b/internal/model/permission/seed/catalog.go index 46116ff..7562f45 100644 --- a/internal/model/permission/seed/catalog.go +++ b/internal/model/permission/seed/catalog.go @@ -67,7 +67,8 @@ var DefaultSystemRoles = []SystemRoleDefinition{ DisplayName: "Tenant Admin", PermissionNames: []string{ "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", }, }, diff --git a/internal/model/permission/seed/catalog.json b/internal/model/permission/seed/catalog.json index d27983c..b13adc4 100644 --- a/internal/model/permission/seed/catalog.json +++ b/internal/model/permission/seed/catalog.json @@ -83,26 +83,42 @@ { "name": "permission.role.write", "parent": "permission.role.management", - "http_methods": "POST|PUT|DELETE", - "http_path": "/api/v1/permissions/roles*", + "http_methods": "POST", + "http_path": "/api/v1/permissions/roles", "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", "parent": "permission.role.management", - "http_methods": "POST|DELETE", - "http_path": "/api/v1/permissions/users/*/roles*", + "http_methods": "GET|POST", + "http_path": "/api/v1/permissions/users/*/roles", "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", "parent": "permission.role.management", - "http_methods": "PUT|DELETE", - "http_path": "/api/v1/permissions/role-mappings*", + "http_methods": "GET|PUT|DELETE", + "http_path": "/api/v1/permissions/role-mappings", "type": "backend_user", - "description": "管理外部角色映射" + "description": "讀取 / 管理外部角色映射" }, { "name": "permission.policy.reload", diff --git a/k6-seed-admin b/k6-seed-admin new file mode 100755 index 0000000..d3bc928 Binary files /dev/null and b/k6-seed-admin differ diff --git a/scripts/e2e-down.sh b/scripts/e2e-down.sh deleted file mode 100755 index 97d7fd6..0000000 --- a/scripts/e2e-down.sh +++ /dev/null @@ -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 涵蓋一般 + mailhog;docker compose 對未掛起的 profile 是 no-op,安全。 -docker compose --profile smtp down -v -e2e_ok "e2e-down OK(gateway stopped, docker cleaned)" diff --git a/scripts/e2e-lib.sh b/scripts/e2e-lib.sh deleted file mode 100644 index 1f64421..0000000 --- a/scripts/e2e-lib.sh +++ /dev/null @@ -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 "啟動 mailhog(http://localhost:8025)" -e2e_info() { printf "${E2E_DIM}>> %s${E2E_RESET}\n" "$*"; } -e2e_ok() { printf "${E2E_GREEN}✔ %s${E2E_RESET}\n" "$*"; } -e2e_warn() { printf "${E2E_YELLOW}! %s${E2E_RESET}\n" "$*"; } - -e2e_root_dir() { - cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -} - -# 把所有 e2e services 列出來(給使用者知道環境準備好了什麼) -e2e_print_services() { - local with_smtp="${1:-}" - echo - printf "${E2E_BOLD}E2E 環境服務${E2E_RESET}\n" - printf " %-12s %-32s %s\n" "MongoDB" "127.0.0.1:27017" "${E2E_DIM}database=gateway_e2e${E2E_RESET}" - printf " %-12s %-32s %s\n" "Redis" "127.0.0.1:6379" "${E2E_DIM}OTP / Casbin policy / blacklist${E2E_RESET}" - printf " %-12s %-32s %s\n" "Gateway" "http://127.0.0.1:18888" "${E2E_DIM}health: /api/v1/health${E2E_RESET}" - if [[ "${with_smtp}" == "1" ]]; then - printf " %-12s %-32s %s\n" "MailHog" "http://127.0.0.1:8025" "${E2E_DIM}SMTP=1025(E2E_WITH_SMTP=1)${E2E_RESET}" - fi - echo -} - -# Stop gateway started for E2E: pid file → port listeners → stale go run orphans. -e2e_stop_gateway() { - local port="${1:-18888}" - 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 -} diff --git a/scripts/e2e-list.sh b/scripts/e2e-list.sh deleted file mode 100755 index 0d9bed3..0000000 --- a/scripts/e2e-list.sh +++ /dev/null @@ -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 "需要 ripgrep(brew install ripgrep)" >&2 - exit 1 -fi - -# Colors(非 TTY 時關掉) -if [[ -t 1 ]]; then - BOLD=$'\033[1m'; DIM=$'\033[2m'; CYAN=$'\033[36m'; YELLOW=$'\033[33m'; RESET=$'\033[0m' -else - BOLD=""; DIM=""; CYAN=""; YELLOW=""; RESET="" -fi - -echo "${BOLD}Gateway E2E — 自動測試清單${RESET}" -echo "${DIM}(執行:make e2e-full / make e2e-journey / make test-e2e;單測:go test -tags=e2e -run TestXxx)${RESET}" - -# ───────────────────────────────────────────────────────────── -# Section A: Contract tests(單 endpoint,由 e2eStep banner 撈) -# ───────────────────────────────────────────────────────────── -echo -echo "${BOLD}${CYAN}═══ Contract tests(make e2e-full)═══${RESET}" -echo "${DIM}單一 endpoint 驗 HTTP contract;可平行;每個 func 一個測試。${RESET}" - -# 模組分組 -contract_module() { - case "$(basename "$1")" in - health_test.go) echo "Health" ;; - auth_test.go) echo "Auth" ;; - member_test.go) echo "Member" ;; - permission_test.go) echo "Permission" ;; - *) echo "Other" ;; - esac -} - -contract_count=0 -current_module="" -while IFS= read -r line; do - file="${line%%:*}"; rest="${line#*:}" - lineno="${rest%%:*}" - sig="${rest#*:}" - fname="$(printf '%s' "$sig" | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/')" - - # 只看 contract test 檔(不含 journey_*) - case "$(basename "$file")" in - journey_*.go|journey.go) continue ;; - esac - - mod="$(contract_module "$file")" - if [[ "$mod" != "$current_module" ]]; then - echo - echo " ${BOLD}── $mod ──${RESET}" - current_module="$mod" - fi - - step=$(awk -v start="$lineno" 'NR>=start && NR<=start+5 && /e2eStep\(t,/ { print; exit }' "$file") - if [[ -z "$step" ]]; then - printf " ${YELLOW}? %-40s${RESET} ${DIM}(no e2eStep banner)${RESET}\n" "$fname" - continue - fi - id="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\1/p')" - method="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\2/p')" - path="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\3/p')" - desc="$(printf '%s' "$step" | sed -nE 's/.*e2eStep\(t, "([^"]*)", *"([^"]*)", *"([^"]*)", *"([^"]*)"\).*/\4/p')" - - printf " ${BOLD}[%-9s]${RESET} %-7s %-50s %s\n" "$id" "$method" "$path" "$desc" - printf " ${DIM}└─ %s${RESET}\n" "$fname" - contract_count=$((contract_count+1)) -done < <(rg -n --no-heading '^func Test[A-Za-z0-9_]+\(t \*testing\.T\)' "${TESTS_DIR}" -t go) - -# ───────────────────────────────────────────────────────────── -# Section B: Journeys(k6 風格多步驟) -# ───────────────────────────────────────────────────────────── -echo -echo "${BOLD}${CYAN}═══ Journeys(make e2e-journey)═══${RESET}" -echo "${DIM}多步驟 user flow,共享狀態;任一步 fail 自動 skip 後續;用 NewJourney() + j.Step()。${RESET}" - -journey_count=0 -journey_step_total=0 -while IFS= read -r line; do - file="${line%%:*}"; rest="${line#*:}" - lineno="${rest%%:*}" - sig="${rest#*:}" - fname="$(printf '%s' "$sig" | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/')" - - case "$(basename "$file")" in - journey_*.go) : ;; - *) continue ;; - esac - - # 抓 NewJourney(t, "J-1", "title") - jline=$(awk -v start="$lineno" 'NR>=start && NR<=start+3 && /NewJourney\(t,/ { print; exit }' "$file") - jid="$(printf '%s' "$jline" | sed -nE 's/.*NewJourney\(t, "([^"]*)", *"([^"]*)"\).*/\1/p')" - jtitle="$(printf '%s' "$jline" | sed -nE 's/.*NewJourney\(t, "([^"]*)", *"([^"]*)"\).*/\2/p')" - if [[ -z "$jid" ]]; then - jid="?"; jtitle="(no NewJourney call)" - fi - steps="$(rg -n '\s+j\.(Step|SkipStep)\(' "$file" 2>/dev/null | wc -l | tr -d ' ')" - echo - printf " ${BOLD}[%s] %s${RESET} ${DIM}(%d steps · %s)${RESET}\n" "$jid" "$jtitle" "$steps" "$fname" - - # 列出每個 step 的 id + desc - rg -oN 'j\.(Step|SkipStep)\("[^"]+",\s*"[^"]+"' "$file" 2>/dev/null \ - | sed -nE 's/.*j\.(Step|SkipStep)\("([^"]+)", *"([^"]+)".*/\1|\2|\3/p' \ - | awk -F'|' -v jid="$jid" -v reset="$RESET" -v yellow="$YELLOW" ' - { - kind=$1; sid=$2; desc=$3 - marker = (kind == "SkipStep") ? "⊘" : "▶" - color = (kind == "SkipStep") ? yellow : "" - printf " %s%s [%s.%s]%s %s\n", color, marker, jid, sid, reset, desc - } - ' - - journey_count=$((journey_count+1)) - journey_step_total=$((journey_step_total+steps)) -done < <(rg -n --no-heading '^func Test[A-Za-z0-9_]+\(t \*testing\.T\)' "${TESTS_DIR}" -t go) - -# ───────────────────────────────────────────────────────────── -echo -echo "${DIM}合計:${contract_count} 個 contract tests · ${journey_count} 個 journeys (${journey_step_total} steps)${RESET}" diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh deleted file mode 100755 index 811196d..0000000 --- a/scripts/e2e-run.sh +++ /dev/null @@ -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 額外起 MailHog(http://localhost:8025)方便肉眼看寄信 -# E2E_CONFIG=... 預設 test/e2e/fixtures/e2e.yaml -# E2E_TEST_PATTERN 第一輪 go test -run pattern -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# 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 compose(mongo + redis$( [[ "$E2E_WITH_SMTP" == "1" ]] && echo " + mailhog" ))" -docker compose down -v >/dev/null 2>&1 || true -if [[ "$E2E_WITH_SMTP" == "1" ]]; then - docker compose --profile smtp up -d mongo redis mailhog -else - docker compose up -d mongo redis -fi - -e2e_step "2/${TOTAL_STEPS}" "wait for healthcheck" -for i in $(seq 1 60); do - if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then - e2e_ok "mongo / redis healthy(${i}s)" - break - fi - sleep 1 - 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 + JWT(cmd/e2e-seed)" -rm -f "${E2E_STATE}" -seed_args=(-f "${E2E_CONFIG}" -out "${E2E_STATE}") -if [[ -n "${E2E_ROLE:-}" ]]; then - seed_args+=(-role "${E2E_ROLE}") -fi -go run ./cmd/e2e-seed "${seed_args[@]}" -e2e_ok "state.json written → ${E2E_STATE}" - -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" diff --git a/scripts/e2e-up.sh b/scripts/e2e-up.sh deleted file mode 100755 index b5a8285..0000000 --- a/scripts/e2e-up.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# 啟動 E2E 環境但不跑測試(方便本機除錯) -# -# E2E_WITH_SMTP=1 多起一個 MailHog(http://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" diff --git a/test/e2e/auth_test.go b/test/e2e/auth_test.go deleted file mode 100644 index 7e8896f..0000000 --- a/test/e2e/auth_test.go +++ /dev/null @@ -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 → 401(AuthJWT middleware)") - c := NewClient(t) - resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, false) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - 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 = InputInvalidFormat(gateway parse / validate 進入點) - require.Equal(t, int64(10), env.Code/1_000_000, "expected Facade scope, got code=%d", env.Code) - }) - } -} diff --git a/test/e2e/client.go b/test/e2e/client.go deleted file mode 100644 index 0fd22cc..0000000 --- a/test/e2e/client.go +++ /dev/null @@ -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 -} diff --git a/test/e2e/exec.go b/test/e2e/exec.go deleted file mode 100644 index 00e06f8..0000000 --- a/test/e2e/exec.go +++ /dev/null @@ -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 -} diff --git a/test/e2e/fixtures/e2e.casbin.yaml b/test/e2e/fixtures/e2e.casbin.yaml deleted file mode 100644 index 6489c36..0000000 --- a/test/e2e/fixtures/e2e.casbin.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# E2E 專用設定(Casbin enabled;make 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 diff --git a/test/e2e/fixtures/e2e.yaml b/test/e2e/fixtures/e2e.yaml deleted file mode 100644 index 3062d2b..0000000 --- a/test/e2e/fixtures/e2e.yaml +++ /dev/null @@ -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 diff --git a/test/e2e/health_test.go b/test/e2e/health_test.go deleted file mode 100644 index 3e66497..0000000 --- a/test/e2e/health_test.go +++ /dev/null @@ -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) -} diff --git a/test/e2e/journey.go b/test/e2e/journey.go deleted file mode 100644 index cb3b14f..0000000 --- a/test/e2e/journey.go +++ /dev/null @@ -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) -} diff --git a/test/e2e/journey_owner_test.go b/test/e2e/journey_owner_test.go deleted file mode 100644 index 8821cd5..0000000 --- a/test/e2e/journey_owner_test.go +++ /dev/null @@ -1,162 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestJourney_OwnerOnboarding 模擬 Tenant Owner 入職第一天會走的完整流程: -// 已 login(seed 提供 JWT)→ 看自己 → 更新 profile → 驗證業務 email → 驗證 phone -// → 綁定 TOTP → step-up 驗碼 → 解除 TOTP → logout。 -// -// 是「狀態流」測試:每步都用上一步的結果(challenge_id / display_name / OTP code), -// 任一步 fail 後續自動 skip,便於一眼定位斷點。 -func TestJourney_OwnerOnboarding(t *testing.T) { - j := NewJourney(t, "J-1", "Tenant Owner 入職第一天(已登入後完整 onboarding)") - defer j.Summary() - - c := NewClient(t) - - var ( - emailTarget = "owner-journey@example.com" - phoneTarget = "+886900111222" - emailChallenge string - phoneChallenge string - otpauthURL string - totpDigits int - totpPeriod int - totpCode string - ) - - j.Step("1", "GET /me — 用 seed JWT 確認自己是 tenant_owner 且 status=active", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - TenantID string `json:"tenant_id"` - UID string `json:"uid"` - Status string `json:"status"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, c.Fixture.UID, me.UID) - require.Equal(t, "active", me.Status) - }) - - j.Step("2", "PATCH /me — 更新 display_name", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{ - "display_name": "Journey Owner", - }, true) - var me struct { - DisplayName string `json:"display_name"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, "Journey Owner", me.DisplayName) - }) - - j.Step("3", "POST /me/verifications/email/start — 申請業務 email OTP", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{ - "target": emailTarget, - }, true) - var start struct { - ChallengeID string `json:"challenge_id"` - } - require.NoError(t, json.Unmarshal(env.Data, &start)) - require.NotEmpty(t, start.ChallengeID) - emailChallenge = start.ChallengeID - }) - - j.Step("4", "POST /me/verifications/email/confirm — 從 Redis 取碼後驗證", func(t *testing.T) { - require.NotEmpty(t, emailChallenge, "missing email challenge from step 3") - code := FetchE2EOTP(t, emailChallenge) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{ - "challenge_id": emailChallenge, - "code": code, - }, true) - }) - - j.Step("5", "GET /me — 確認 business_email_verified=true", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true) - var me struct { - BusinessEmail string `json:"business_email"` - BusinessEmailVerified bool `json:"business_email_verified"` - } - require.NoError(t, json.Unmarshal(env.Data, &me)) - require.Equal(t, emailTarget, me.BusinessEmail) - require.True(t, me.BusinessEmailVerified, "expected email_verified=true after confirm") - }) - - j.Step("6", "POST /me/verifications/phone/start — 申請業務 phone OTP", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{ - "target": phoneTarget, - }, true) - var start struct { - ChallengeID string `json:"challenge_id"` - } - require.NoError(t, json.Unmarshal(env.Data, &start)) - phoneChallenge = start.ChallengeID - }) - - j.Step("7", "POST /me/verifications/phone/confirm — 取碼後驗證", func(t *testing.T) { - require.NotEmpty(t, phoneChallenge) - code := FetchE2EOTP(t, phoneChallenge) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{ - "challenge_id": phoneChallenge, - "code": code, - }, true) - }) - - j.Step("8", "POST /me/totp/enroll-start — 開始綁定 TOTP", func(t *testing.T) { - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true) - var start struct { - OtpauthURL string `json:"otpauth_url"` - Digits int `json:"digits"` - PeriodSec int `json:"period_seconds"` - } - require.NoError(t, json.Unmarshal(env.Data, &start)) - require.NotEmpty(t, start.OtpauthURL) - otpauthURL = start.OtpauthURL - totpDigits = start.Digits - totpPeriod = start.PeriodSec - }) - - j.Step("9", "POST /me/totp/enroll-confirm — 用 otpauth_url 算當下 TOTP code 確認綁定", func(t *testing.T) { - require.NotEmpty(t, otpauthURL) - totpCode = codeFromOtpauthURL(t, otpauthURL, totpDigits, totpPeriod) - env := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{ - "code": totpCode, - }, true) - var confirmed struct { - BackupCodes []string `json:"backup_codes"` - } - require.NoError(t, json.Unmarshal(env.Data, &confirmed)) - require.NotEmpty(t, confirmed.BackupCodes, "enroll-confirm should return backup codes exactly once") - }) - - j.Step("10", "POST /me/totp/verify — step-up 驗碼成功", func(t *testing.T) { - require.NotEmpty(t, totpCode) - c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ - "code": totpCode, - }, true) - }) - - j.Step("11", "POST /me/totp/verify (replay) — 同 code 再打應該 403(重放保護)", func(t *testing.T) { - require.NotEmpty(t, totpCode) - env := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{ - "code": totpCode, - }, true, http.StatusForbidden) - require.NotEqual(t, int64(successCode), env.Code) - }) - - j.Step("12", "DELETE /me/totp — 解除綁定", func(t *testing.T) { - c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true) - env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true) - var st struct { - Enrolled bool `json:"enrolled"` - } - require.NoError(t, json.Unmarshal(env.Data, &st)) - require.False(t, st.Enrolled) - }) -} diff --git a/test/e2e/journey_rbac_test.go b/test/e2e/journey_rbac_test.go deleted file mode 100644 index d9ac1a6..0000000 --- a/test/e2e/journey_rbac_test.go +++ /dev/null @@ -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 token;no-role user 用來驗證「被指派後拿到權限」。 -func TestJourney_TenantAdminCustomRole(t *testing.T) { - j := NewJourney(t, "J-2", "Tenant Admin 從零建立 qa_engineer 角色 → 指派 → 驗證 → 撤銷") - defer j.Summary() - - owner := NewClient(t) - noRole := NewNoRoleClient(t) - - var ( - roleKey = "journey_qa_engineer" - externalKey = fmt.Sprintf("journey-qa-group-%s", noRole.Fixture.UID) - roleID string - permissionID string - ) - - // 清掉殘留(前一輪可能 fail 沒走到清理 step) - t.Cleanup(func() { - _, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - }, true) - if roleID != "" { - _, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles/"+roleID, nil, true) - _, _ = owner.Do(t, http.MethodDelete, "/api/v1/permissions/roles/"+roleID, nil, true) - } - }) - - j.Step("1", "POST /permissions/roles — 建立 qa_engineer 角色", func(t *testing.T) { - env := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{ - "key": roleKey, - "display_name": "QA Engineer", - "status": "open", - }, true) - var role struct { - ID string `json:"id"` - Key string `json:"key"` - } - require.NoError(t, json.Unmarshal(env.Data, &role)) - require.Equal(t, roleKey, role.Key) - require.NotEmpty(t, role.ID) - roleID = role.ID - }) - - j.Step("2", "GET /permissions/catalog — 從平台 catalog 撈第一個 leaf permission id", func(t *testing.T) { - permissionID = firstCatalogPermissionID(t, owner) - require.NotEmpty(t, permissionID) - }) - - j.Step("3", "PUT /permissions/roles/:id/permissions — 把選到的 permission 灌進 role", func(t *testing.T) { - require.NotEmpty(t, roleID) - require.NotEmpty(t, permissionID) - owner.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+roleID+"/permissions", map[string][]string{ - "permission_ids": {permissionID}, - }, true) - - // 讀回比對:parent closure 會把祖先一起加進去,所以集合 >= 1 + 必含 permissionID - env := owner.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+roleID+"/permissions", nil, true) - var list struct { - Permissions []struct { - ID string `json:"id"` - } `json:"permissions"` - } - require.NoError(t, json.Unmarshal(env.Data, &list)) - found := false - for _, p := range list.Permissions { - if p.ID == permissionID { - found = true - break - } - } - require.True(t, found) - }) - - j.Step("4", "PUT /permissions/role-mappings — 設定 zitadel:qa-group → qa_engineer 對映", func(t *testing.T) { - env := owner.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - "internal_role_key": roleKey, - }, true) - var mapping struct { - ExternalKey string `json:"external_key"` - InternalRoleKey string `json:"internal_role_key"` - } - require.NoError(t, json.Unmarshal(env.Data, &mapping)) - require.Equal(t, externalKey, mapping.ExternalKey) - require.Equal(t, roleKey, mapping.InternalRoleKey) - }) - - j.Step("5", "POST /permissions/users/:uid/roles — 把 qa_engineer 手動指派給 no-role user", func(t *testing.T) { - require.NotEmpty(t, roleID) - owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles", map[string]string{ - "role_id": roleID, - }, true) - }) - - j.Step("6", "GET /permissions/me (no-role user 視角) — 確認新角色 + 權限都拿到了", func(t *testing.T) { - env := noRole.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=false", nil, true) - var data struct { - Roles []string `json:"roles"` - Permissions map[string]string `json:"permissions"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.Contains(t, data.Roles, roleKey, "no-role user should now have qa_engineer") - require.NotEmpty(t, data.Permissions, "expected non-empty permissions after role assignment") - }) - - j.Step("7", "DELETE /permissions/users/:uid/roles/:id — 撤銷指派", func(t *testing.T) { - require.NotEmpty(t, roleID) - owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+noRole.Fixture.UID+"/roles/"+roleID, nil, true) - - env := noRole.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=false", nil, true) - var data struct { - Roles []string `json:"roles"` - } - require.NoError(t, json.Unmarshal(env.Data, &data)) - require.NotContains(t, data.Roles, roleKey) - }) - - j.Step("8", "DELETE /permissions/role-mappings + /roles — 收尾刪 mapping + role", func(t *testing.T) { - owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{ - "external_source": "zitadel", - "external_key": externalKey, - }, true) - require.NotEmpty(t, roleID) - owner.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+roleID, nil, true) - // 標記已清,t.Cleanup 那邊就不會再重複打 - roleID = "" - }) -} diff --git a/test/e2e/journey_registration_test.go b/test/e2e/journey_registration_test.go deleted file mode 100644 index fd10c59..0000000 --- a/test/e2e/journey_registration_test.go +++ /dev/null @@ -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 # 驗 OTP,member 從 unverified 轉 active -// → POST /login # ZITADEL ROPG 拿 id_token,gateway 簽 CloudEP JWT -// → GET /members/me # 用新 JWT 看自己 -// -// 這條 journey 需要真實 ZITADEL;目前 e2e env 只有 mock,故整段標記 skip 並 -// 列出步驟。等 docker-compose 接上 ZITADEL container(或指向 staging)後,把 -// SkipStep 換成 Step + 真的 HTTP call 即可。 -func TestJourney_FullRegistration(t *testing.T) { - j := NewJourney(t, "J-4", "完整註冊 → 登入 → 看自己(需 ZITADEL,目前 stub)") - defer j.Summary() - - const reason = "目前 e2e env 未接 ZITADEL;接上後改成 Step() 即可" - - j.SkipStep("1", "POST /auth/register — 建 ZITADEL human user + member draft + 寄 OTP", reason) - j.SkipStep("2", "GET MailHog API 取 OTP 信件內容(或 mock 直接抓 Redis)", reason) - j.SkipStep("3", "POST /auth/register/confirm — 驗 OTP,member status 由 unverified → active", reason) - j.SkipStep("4", "POST /auth/login — ZITADEL ROPG 拿 id_token,gateway 簽 CloudEP JWT", reason) - j.SkipStep("5", "GET /members/me — 用新 JWT 看自己", reason) -} diff --git a/test/e2e/journey_session_test.go b/test/e2e/journey_session_test.go deleted file mode 100644 index 1d4b7d3..0000000 --- a/test/e2e/journey_session_test.go +++ /dev/null @@ -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 再打應該 401(jti blacklisted)", func(t *testing.T) { - resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, true) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - require.NotEqual(t, int64(successCode), env.Code) - }) -} - diff --git a/test/e2e/member_test.go b/test/e2e/member_test.go deleted file mode 100644 index ea91e92..0000000 --- a/test/e2e/member_test.go +++ /dev/null @@ -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", "讀 profile(tenant/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 -} diff --git a/test/e2e/permission_test.go b/test/e2e/permission_test.go deleted file mode 100644 index b1ba685..0000000 --- a/test/e2e/permission_test.go +++ /dev/null @@ -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 enforcement:owner reload policy → no-role user GET /roles=403") - if os.Getenv("E2E_CASBIN") != "1" { - t.Skip("set E2E_CASBIN=1 and use e2e.casbin.yaml to enable Casbin enforcement") - } - - 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 -} diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go deleted file mode 100644 index c2d92e6..0000000 --- a/test/e2e/setup_test.go +++ /dev/null @@ -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, - } -} diff --git a/test/k6/README.md b/test/k6/README.md new file mode 100644 index 0000000..167beb2 --- /dev/null +++ b/test/k6/README.md @@ -0,0 +1,141 @@ +# k6 API tests + +完整的 Gateway API smoke + journey 測試套件。**所有 36 個對外端點**都至少在 `smoke/` 或 `journeys/` 裡有一發。 + +## TL;DR + +```bash +make k6-up # docker:mongo + 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 TOTP(P4) +│ ├── auth.js # register / confirm / login / refresh helper(P2) +│ └── seed.js # tenant + invite + admin role bootstrap(P5) +├── 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 寄到 MailHog;k6 透過 `/api/v2/search?kind=to&query=` 撈到信件,從 body 抓 6 位數。 +- **SMS** → mock provider 把 body 寫到 `dev:notification:last:sms:`(見 [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 0(admin 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 路徑 TODO,smoke 只跑 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。 diff --git a/test/k6/journeys/email_register_full.js b/test/k6/journeys/email_register_full.js new file mode 100644 index 0000000..a636bde --- /dev/null +++ b/test/k6/journeys/email_register_full.js @@ -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 }); +} diff --git a/test/k6/journeys/email_verify.js b/test/k6/journeys/email_verify.js new file mode 100644 index 0000000..5a43584 --- /dev/null +++ b/test/k6/journeys/email_verify.js @@ -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}`); + } +} diff --git a/test/k6/journeys/login_refresh.js b/test/k6/journeys/login_refresh.js new file mode 100644 index 0000000..3c6e3c4 --- /dev/null +++ b/test/k6/journeys/login_refresh.js @@ -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 }); +} diff --git a/test/k6/journeys/phone_verify.js b/test/k6/journeys/phone_verify.js new file mode 100644 index 0000000..416975a --- /dev/null +++ b/test/k6/journeys/phone_verify.js @@ -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:". 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}`); + } +} diff --git a/test/k6/journeys/rbac_admin.js b/test/k6/journeys/rbac_admin.js new file mode 100644 index 0000000..9173642 --- /dev/null +++ b/test/k6/journeys/rbac_admin.js @@ -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'); +} diff --git a/test/k6/journeys/token_exchange.js b/test/k6/journeys/token_exchange.js new file mode 100644 index 0000000..6f9924e --- /dev/null +++ b/test/k6/journeys/token_exchange.js @@ -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)'); +} diff --git a/test/k6/journeys/totp_full.js b/test/k6/journeys/totp_full.js new file mode 100644 index 0000000..7793f12 --- /dev/null +++ b/test/k6/journeys/totp_full.js @@ -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'); +} diff --git a/test/k6/lib/auth.js b/test/k6/lib/auth.js new file mode 100644 index 0000000..55fab2d --- /dev/null +++ b/test/k6/lib/auth.js @@ -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 }; +} diff --git a/test/k6/lib/config.js b/test/k6/lib/config.js new file mode 100644 index 0000000..391d1d6 --- /dev/null +++ b/test/k6/lib/config.js @@ -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; diff --git a/test/k6/lib/http.js b/test/k6/lib/http.js new file mode 100644 index 0000000..e20015f --- /dev/null +++ b/test/k6/lib/http.js @@ -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; +} diff --git a/test/k6/lib/otp.js b/test/k6/lib/otp.js new file mode 100644 index 0000000..347cfbb --- /dev/null +++ b/test/k6/lib/otp.js @@ -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:" 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= +// 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:" 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`); +} diff --git a/test/k6/lib/totp.js b/test/k6/lib/totp.js new file mode 100644 index 0000000..70a4bf7 --- /dev/null +++ b/test/k6/lib/totp.js @@ -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); +} diff --git a/test/k6/smoke/auth_bearer.js b/test/k6/smoke/auth_bearer.js new file mode 100644 index 0000000..c6863c9 --- /dev/null +++ b/test/k6/smoke/auth_bearer.js @@ -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}`); + } +} diff --git a/test/k6/smoke/auth_public.js b/test/k6/smoke/auth_public.js new file mode 100644 index 0000000..1a3722e --- /dev/null +++ b/test/k6/smoke/auth_public.js @@ -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); +} diff --git a/test/k6/smoke/health.js b/test/k6/smoke/health.js new file mode 100644 index 0000000..64b686e --- /dev/null +++ b/test/k6/smoke/health.js @@ -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}`); + } +} diff --git a/test/k6/smoke/member.js b/test/k6/smoke/member.js new file mode 100644 index 0000000..0ef88e6 --- /dev/null +++ b/test/k6/smoke/member.js @@ -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}`); + } +} diff --git a/test/k6/smoke/permission_admin.js b/test/k6/smoke/permission_admin.js new file mode 100644 index 0000000..9736305 --- /dev/null +++ b/test/k6/smoke/permission_admin.js @@ -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'); +} diff --git a/test/k6/smoke/permission_read.js b/test/k6/smoke/permission_read.js new file mode 100644 index 0000000..22cb086 --- /dev/null +++ b/test/k6/smoke/permission_read.js @@ -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'); +}