feat/env #1
|
|
@ -15,6 +15,9 @@ coverage.html
|
||||||
# 專案編譯產物(根目錄 binary 名稱與 module 相同時)
|
# 專案編譯產物(根目錄 binary 名稱與 module 相同時)
|
||||||
/gateway
|
/gateway
|
||||||
|
|
||||||
|
# k6 / dev binary 產物
|
||||||
|
/bin/
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# go-doc / 工具 binary
|
# go-doc / 工具 binary
|
||||||
# =========================
|
# =========================
|
||||||
|
|
|
||||||
112
AGENTS.md
112
AGENTS.md
|
|
@ -1,97 +1,39 @@
|
||||||
# AGENTS.md
|
# 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/<module>/`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。
|
## 一分鐘快速理解
|
||||||
|
|
||||||
跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic/<module>/`,**usecase 不可呼叫其他 usecase**。
|
- **業務模組** 一律放 `internal/model/<module>/`,內部分 `domain` / `repository` / `usecase` / `config`。
|
||||||
|
- **跨模組編排**(多 usecase 串接)放 `internal/logic/<module>/`;**usecase 不可呼叫其他 usecase**。
|
||||||
|
- **API** 由 `generate/api/*.api` 定義,`make gen-api` 產生 handler / types,`make gen-doc` 產生 OpenAPI。
|
||||||
|
- **錯誤碼** 8 碼 `SSCCCDDD`,全專案唯一在 `internal/library/errors`。
|
||||||
|
- **回應永遠用繁體中文。**
|
||||||
|
|
||||||
## 必讀文件
|
## 常用入口
|
||||||
|
|
||||||
| 文件 | 何時讀 |
|
| 文件 | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`generate/api/README.md`](generate/api/README.md) | 新增 / 修改 API 端點、type、文件分組、欄位描述、enum 列舉前 |
|
| [docs/AGENTS.md](docs/AGENTS.md) | Agent 工作準則總覽(必讀) |
|
||||||
| [`generate/doc-generate/README.md`](generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 |
|
| [README.md](README.md) | 專案總覽、開發約定、HTTP / 錯誤格式 |
|
||||||
| `internal/model/<module>/README.md` | 動到該模組的領域邏輯時 |
|
| [docs/model.md](docs/model.md) | `internal/model/{module}` 分層規範 |
|
||||||
| `docs/model.md`(若存在) | 全局架構規範 |
|
| [generate/api/README.md](generate/api/README.md) | `.api` 寫法、`@respdoc`、middleware 宣告 |
|
||||||
|
| [internal/library/errors/README.md](internal/library/errors/README.md) | 8 碼錯誤碼設計與 HTTP 對照 |
|
||||||
## 標準工作流程
|
| [docs/identity-member-design.md](docs/identity-member-design.md) | Identity / Member / Permission 跨模組架構 |
|
||||||
|
| [docs/auth-unified-registration.md](docs/auth-unified-registration.md) | 統一註冊/登入完整時序 |
|
||||||
### 1. 修改 API(`.api` → handler / types / docs)
|
| [docs/e2e-testing.md](docs/e2e-testing.md) | E2E 測試流程 |
|
||||||
|
|
||||||
1. 編輯 `generate/api/*.api`(遵守 `generate/api/README.md` 的三條規則:tags 分組 / backtick 行末 `//` 中文 description / `options=A|B|C` enum)
|
|
||||||
2. `make gen-api` — 重新產生 `internal/handler/`、`internal/logic/`(已存在則不覆蓋)、`internal/types/types.go`
|
|
||||||
3. `make gen-doc` — 重新產生 `docs/openapi/gateway.yaml`(gitignore,本地驗證用)
|
|
||||||
4. 實作 / 修改 `internal/logic/<module>/<handler>_logic.go` 的業務邏輯
|
|
||||||
5. `go build ./...` 確保編譯通過
|
|
||||||
6. `make lint` / `make test` 視改動範圍跑
|
|
||||||
|
|
||||||
### 2. 新增 / 修改業務模組
|
|
||||||
|
|
||||||
- 領域介面與型別放 `internal/model/<module>/domain/`
|
|
||||||
- Mongo / Redis 實作放 `internal/model/<module>/repository/`
|
|
||||||
- 原子 usecase 放 `internal/model/<module>/usecase/`(**不可**互相呼叫)
|
|
||||||
- 多步驟流程編排放 `internal/logic/<module>/`
|
|
||||||
- 模組的對外裝配入口統一在 `internal/model/<module>/usecase/module.go`,並從 `internal/svc/service_context.go` 注入
|
|
||||||
|
|
||||||
### 3. 錯誤碼
|
|
||||||
|
|
||||||
- 業務碼格式 `SSCCCDDD`(scope * 1_000_000 + category * 1_000 + detail)
|
|
||||||
- Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31)
|
|
||||||
- 新增 scope 時:同步更新 `gateway.api` 的 `bizCodeEnumDescription`
|
|
||||||
|
|
||||||
### 4. Middleware(go-zero 正規手段)
|
|
||||||
|
|
||||||
**禁止**在 `gateway.go` 用 `server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api` 的 `middleware:` 宣告:
|
|
||||||
|
|
||||||
```go
|
|
||||||
@server (
|
|
||||||
group: auth
|
|
||||||
prefix: /api/v1/auth
|
|
||||||
middleware: AuthJWT // 一個
|
|
||||||
// middleware: AuthJWT,CasbinRBAC // 多個用逗號
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
跑 `make gen-api` 後 `routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`。
|
|
||||||
|
|
||||||
撰寫新 middleware 時:
|
|
||||||
- 用 **struct + `Handle()` method** 模式(不是 factory function)
|
|
||||||
- 檔名 = goctl stringx 規則(例 `AuthJWT` → `authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`)
|
|
||||||
- 在 `ServiceContext` 加 `<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).Handle`
|
|
||||||
- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來)
|
|
||||||
|
|
||||||
詳細範例與分組原則見 [`generate/api/README.md`](generate/api/README.md) "Middleware" 章節。
|
|
||||||
|
|
||||||
### 4. Redis / Mongo / 設定
|
|
||||||
|
|
||||||
- 每個模組的設定型別放 `internal/model/<module>/config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct
|
|
||||||
- Redis client 共用 `internal/library/redis/`,需 Pub/Sub 用 `client.PubSubClient()`
|
|
||||||
- Mongo index 註冊到 `cmd/mongo-index/main.go`(在 `run()` 裡呼叫 `<module>repo.EnsureMongoIndexes`)
|
|
||||||
|
|
||||||
## 通用準則
|
|
||||||
|
|
||||||
- **回應 traditional Chinese**(繁體中文)
|
|
||||||
- 程式碼註解只寫「為什麼」、邊界條件、trade-off,**不寫**「import the module / increment counter」這類顯而易見的描述
|
|
||||||
- 不主動建立 `*.md` 文件,除非使用者明確要求
|
|
||||||
- 改 git config / 強制 push / `rm -rf` 等破壞性操作 **必須**先取得使用者同意
|
|
||||||
- 不要在沒被要求時直接 commit;commit 前先 `git status` / `git diff` 確認
|
|
||||||
- commit message 用繁中描述「為什麼」改,不是「改了什麼」
|
|
||||||
|
|
||||||
## 指令速查
|
## 指令速查
|
||||||
|
|
||||||
| 指令 | 用途 |
|
```bash
|
||||||
|---|---|
|
make gen-api # .api → handler / logic(skip exists) / types
|
||||||
| `make gen-api` | `.api` → handler / logic(skip exists)/ types |
|
make gen-doc # .api → docs/openapi/gateway.yaml
|
||||||
| `make gen-doc` | `.api` → `docs/openapi/gateway.yaml` |
|
make fix # gofmt + goimports + lint --fix + lint
|
||||||
| `make gen-mock` | 模組 mock(gomock) |
|
make check # fix + test(提交前必跑)
|
||||||
| `make tools` | 安裝 goctl / goimports / golangci-lint |
|
make run-dev # 本機啟動(需 make deps-up)
|
||||||
| `make fix` | gofmt + goimports + lint --fix + lint |
|
make deps-up # docker compose Mongo + Redis
|
||||||
| `make check` | fix + test(提交前) |
|
```
|
||||||
| `make run-dev` | 本機啟動(需 `make deps-up`) |
|
|
||||||
| `make deps-up` | docker compose Mongo + Redis |
|
|
||||||
| `make mongo-index` | 建立 / 更新 Mongo 索引 |
|
|
||||||
|
|
||||||
完整列表跑 `make help`。
|
完整列表:`make help`。
|
||||||
|
|
|
||||||
|
|
@ -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」這種顯而易見的描述。
|
||||||
206
Makefile
206
Makefile
|
|
@ -15,9 +15,6 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test test-e2e test-e2e-journey e2e-full e2e-casbin e2e-up e2e-down e2e-list fmt lint lint-fix fix check run \
|
|
||||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local
|
|
||||||
|
|
||||||
help: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
@echo "Gateway Makefile"
|
@echo "Gateway Makefile"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
@ -57,38 +54,6 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
|
||||||
|
|
||||||
test: ## 執行測試
|
test: ## 執行測試
|
||||||
$(GO) test ./...
|
$(GO) test ./...
|
||||||
|
|
||||||
test-e2e: ## 對已啟動的 Gateway 跑 E2E contract tests(單一 endpoint 驗證;需 state.json)
|
|
||||||
GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'Test(Auth_|Health|Member|Permission)'
|
|
||||||
GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZ_AuthTokenRefreshAndLogout'
|
|
||||||
|
|
||||||
test-e2e-journey: ## 對已啟動的 Gateway 跑 E2E user 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)
|
fmt: ## gofmt + goimports(不含 lint)
|
||||||
$(GOFMT) -s -w $(GOFILES)
|
$(GOFMT) -s -w $(GOFILES)
|
||||||
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
||||||
|
|
@ -103,55 +68,150 @@ fix: fmt lint-fix lint ## 格式化 + 自動修 lint + 再檢查(提交前建
|
||||||
|
|
||||||
check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test)
|
check: fix test ## 提交 / PR 前完整檢查(fmt、lint、test)
|
||||||
|
|
||||||
run: ## 啟動 Gateway(etc/gateway.yaml,無需 Docker)
|
# ============================================================
|
||||||
$(GO) run gateway.go -f etc/gateway.yaml
|
# Docker compose(本機依賴)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
setup-dev: ## 建立本機 gateway.dev.yaml(自 example,不會被 git 追蹤)
|
DOCKER_COMPOSE ?= docker compose
|
||||||
@test -f etc/gateway.dev.yaml || cp etc/gateway.dev.example.yaml etc/gateway.dev.yaml
|
COMPOSE_FILE := deploy/docker-compose.yml
|
||||||
@echo ">> etc/gateway.dev.yaml ready (edit locally; not committed)"
|
COMPOSE := $(DOCKER_COMPOSE) -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
run-dev: setup-dev ## 啟動 Gateway(etc/gateway.dev.yaml,需 make deps-up)
|
deps-up: ## 起 Mongo + Redis(最小本機依賴)
|
||||||
$(GO) run gateway.go -f etc/gateway.dev.yaml
|
$(COMPOSE) up -d mongo redis
|
||||||
|
|
||||||
run-local: run-dev ## 別名:同 run-dev
|
deps-up-smtp: ## 起 Mongo + Redis + MailHog
|
||||||
|
$(COMPOSE) --profile smtp up -d mongo redis mailhog
|
||||||
|
|
||||||
deps-up: ## 啟動本機 Mongo + Redis(docker compose)
|
deps-down: ## 停服務(保留 volume)
|
||||||
docker compose up -d mongo redis
|
$(COMPOSE) --profile smtp --profile k6 down
|
||||||
|
|
||||||
deps-up-smtp: ## 啟動 Mongo + Redis + MailHog(本機 SMTP 測試)
|
deps-down-v: ## 停服務並刪 volume(會清資料)
|
||||||
docker compose --profile smtp up -d mongo redis mailhog
|
$(COMPOSE) --profile smtp --profile k6 down -v
|
||||||
|
|
||||||
deps-down: ## 停止 docker compose 容器(保留 volume)
|
deps-logs: ## 看 compose log
|
||||||
docker compose --profile smtp down
|
$(COMPOSE) --profile smtp --profile k6 logs -f
|
||||||
|
|
||||||
deps-down-v: ## 停止並刪除 volume(清空 Mongo/Redis 資料)
|
# ============================================================
|
||||||
docker compose --profile smtp down -v
|
# k6 測試(test/k6/)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
deps-logs: ## 查看依賴服務 log
|
K6 ?= k6
|
||||||
docker compose --profile smtp logs -f
|
K6_GATEWAY_CONFIG := etc/gateway.k6.yaml
|
||||||
|
K6_GATEWAY_BIN := bin/gateway-k6
|
||||||
|
K6_DIR := test/k6
|
||||||
|
K6_PAT_FILE := deploy/zitadel/machinekey/zitadel-admin-sa.token
|
||||||
|
K6_ENV_FILE := deploy/zitadel/machinekey/k6.env
|
||||||
|
ZITADEL_HEALTH_URL := http://localhost:8080/debug/healthz
|
||||||
|
|
||||||
deps-ps: ## 查看依賴服務狀態
|
# k6 安裝指引(macOS / Linux)
|
||||||
docker compose --profile smtp ps
|
define K6_INSTALL_HINT
|
||||||
|
k6 not found in PATH.
|
||||||
|
|
||||||
mongo-index: ## 建立 notification Mongo 索引(需 Mongo 已啟動)
|
Install:
|
||||||
$(GO) run ./cmd/mongo-index -f etc/gateway.dev.yaml
|
macOS (Homebrew): brew install k6
|
||||||
|
Linux (apt): sudo gpg -k && sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 \
|
||||||
|
&& echo 'deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main' | sudo tee /etc/apt/sources.list.d/k6.list \
|
||||||
|
&& sudo apt update && sudo apt install k6
|
||||||
|
Other: https://grafana.com/docs/k6/latest/set-up/install-k6/
|
||||||
|
|
||||||
notify-test: setup-dev ## 通知測試(METHOD 必填;例: make notify-test METHOD=email-send TO=a@b.com)
|
Or use docker (no install): K6='docker run --rm -i --network host -v $$PWD:/app -w /app grafana/k6:latest' make k6-smoke
|
||||||
@test -n "$(METHOD)" || (echo "usage: make notify-test METHOD=email-send TO=you@example.com" && \
|
endef
|
||||||
echo " make notify-test METHOD=sms-send PHONE=0912345678" && \
|
export K6_INSTALL_HINT
|
||||||
echo " make notify-test METHOD=email-send TO=t@e.com MOCK=1" && exit 1)
|
|
||||||
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
|
|
||||||
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
|
|
||||||
|
|
||||||
totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis)
|
k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引)
|
||||||
$(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \
|
@command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1)
|
||||||
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \
|
@echo "k6: $$($(K6) version 2>&1 | head -1)"
|
||||||
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
|
||||||
$(if $(CODE),-code "$(CODE)",)
|
|
||||||
|
|
||||||
member-seed: setup-dev ## 建立 dev tenant + member(需 Mongo+Redis)
|
k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + zitadel)
|
||||||
$(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \
|
$(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel
|
||||||
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",)
|
@echo "ZITADEL bootstrapping (this can take 30–90s the first time)…"
|
||||||
|
@echo "→ run 'make k6-wait' to block until it is ready"
|
||||||
|
|
||||||
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env
|
||||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
@echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)…"
|
||||||
|
@for i in $$(seq 1 120); do \
|
||||||
|
if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \
|
||||||
|
echo "zitadel ready ($$i s)"; break; \
|
||||||
|
fi; \
|
||||||
|
sleep 1; \
|
||||||
|
if [ $$i -eq 120 ]; then echo "zitadel did not become ready in 120s"; exit 1; fi; \
|
||||||
|
done
|
||||||
|
@for i in $$(seq 1 30); do \
|
||||||
|
if [ -s "$(K6_PAT_FILE)" ]; then break; fi; \
|
||||||
|
sleep 1; \
|
||||||
|
done
|
||||||
|
@if [ ! -s "$(K6_PAT_FILE)" ]; then \
|
||||||
|
echo "PAT file $(K6_PAT_FILE) missing — check 'docker logs gateway-zitadel'"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@PAT=$$(tr -d '\n' < $(K6_PAT_FILE)); \
|
||||||
|
printf 'export ZITADEL_SERVICE_TOKEN=%s\nexport BASE_URL=http://localhost:8888\nexport MAILHOG_URL=http://localhost:8025\nexport REDIS_ADDR=localhost:6379\n' "$$PAT" > $(K6_ENV_FILE); \
|
||||||
|
echo "wrote $(K6_ENV_FILE)"
|
||||||
|
@$(MAKE) -s k6-seed-fixtures
|
||||||
|
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell"
|
||||||
|
|
||||||
|
# k6-seed-fixtures idempotently upserts the k6-tenant + K6INVITE invite code
|
||||||
|
# into gateway_k6 so /auth/register resolves successfully. The invite hash is
|
||||||
|
# sha256("K6INVITE") — see internal/model/auth/domain/const.go HashInviteCode.
|
||||||
|
# Uses mongosh inside the container; no extra Go binary needed.
|
||||||
|
K6_TENANT_ID := k6-tenant
|
||||||
|
K6_INVITE_HASH := e62a291f0dcf88c50c91fdc78e3539c55e1e20ef645c640b1ec778fe4fabb4cb
|
||||||
|
k6-seed-fixtures: ## upsert k6-tenant + K6INVITE 到 mongo
|
||||||
|
@docker exec gateway-mongo mongosh --quiet --eval '\
|
||||||
|
db = db.getSiblingDB("gateway_k6"); \
|
||||||
|
var now = NumberLong(Date.now()); \
|
||||||
|
var t = db.tenants.updateOne( \
|
||||||
|
{tenant_id: "$(K6_TENANT_ID)"}, \
|
||||||
|
{$$set: {tenant_id: "$(K6_TENANT_ID)", slug: "$(K6_TENANT_ID)", name: "k6 Tenant", uid_prefix: "K6", status: "active", update_at: now}, $$setOnInsert: {create_at: now}}, \
|
||||||
|
{upsert: true} \
|
||||||
|
); \
|
||||||
|
var i = db.invite_codes.updateOne( \
|
||||||
|
{tenant_id: "$(K6_TENANT_ID)", code_hash: "$(K6_INVITE_HASH)"}, \
|
||||||
|
{$$set: {tenant_id: "$(K6_TENANT_ID)", code_hash: "$(K6_INVITE_HASH)", max_uses: NumberLong(1000000), expires_at: NumberLong(0), new_users_only: false, update_at: now}, $$setOnInsert: {used_count: NumberLong(0), create_at: now}}, \
|
||||||
|
{upsert: true} \
|
||||||
|
); \
|
||||||
|
print("tenant matched=" + t.matchedCount + " upserted=" + (t.upsertedId?1:0) + ", invite matched=" + i.matchedCount + " upserted=" + (i.upsertedId?1:0));' && \
|
||||||
|
echo "seeded fixtures (tenant=$(K6_TENANT_ID) invite=K6INVITE) into gateway_k6"
|
||||||
|
|
||||||
|
# Back-compat alias
|
||||||
|
k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures)
|
||||||
|
|
||||||
|
k6-build: ## 建 gateway binary 給 k6 使用
|
||||||
|
@mkdir -p $(dir $(K6_GATEWAY_BIN))
|
||||||
|
$(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go
|
||||||
|
|
||||||
|
k6-gateway: k6-build ## 前景啟 gateway(吃 etc/gateway.k6.yaml + ZITADEL env)
|
||||||
|
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
|
||||||
|
@set -a; . $(K6_ENV_FILE); set +a; \
|
||||||
|
$(K6_GATEWAY_BIN) -f $(K6_GATEWAY_CONFIG)
|
||||||
|
|
||||||
|
k6-smoke: k6-check ## 跑 smoke 測試(每個端點一發)
|
||||||
|
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
|
||||||
|
@set -a; . $(K6_ENV_FILE); set +a; \
|
||||||
|
for f in $(K6_DIR)/smoke/*.js; do \
|
||||||
|
echo "==> $$f"; $(K6) run "$$f" || exit 1; \
|
||||||
|
done
|
||||||
|
|
||||||
|
k6-journey: k6-check ## 跑 journey 測試(多步驟流程)
|
||||||
|
@if [ ! -s "$(K6_ENV_FILE)" ]; then echo "run 'make k6-wait' first"; exit 1; fi
|
||||||
|
@set -a; . $(K6_ENV_FILE); set +a; \
|
||||||
|
for f in $(K6_DIR)/journeys/*.js; do \
|
||||||
|
echo "==> $$f"; $(K6) run "$$f" || exit 1; \
|
||||||
|
done
|
||||||
|
|
||||||
|
k6-all: k6-smoke k6-journey ## smoke + journey
|
||||||
|
|
||||||
|
k6-seed-admin: k6-build ## 註冊 k6-admin 並 seed tenant_admin 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"
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,487 +0,0 @@
|
||||||
// Command notify-test runs one notification test by -method (point-and-shoot).
|
|
||||||
//
|
|
||||||
// make deps-up && make mongo-index
|
|
||||||
// make notify-test METHOD=email-send TO=you@example.com
|
|
||||||
// make notify-test METHOD=sms-send PHONE=0912345678 MOCK=1
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gateway/internal/config"
|
|
||||||
redislib "gateway/internal/library/redis"
|
|
||||||
memberenum "gateway/internal/model/member/domain/enum"
|
|
||||||
dommember "gateway/internal/model/member/domain/usecase"
|
|
||||||
memberusecase "gateway/internal/model/member/usecase"
|
|
||||||
notifconfig "gateway/internal/model/notification/config"
|
|
||||||
"gateway/internal/model/notification/domain/enum"
|
|
||||||
domtpl "gateway/internal/model/notification/domain/template"
|
|
||||||
domusecase "gateway/internal/model/notification/domain/usecase"
|
|
||||||
notifusecase "gateway/internal/model/notification/usecase"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/zeromicro/go-zero/core/conf"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
methodEmailSend = "email-send"
|
|
||||||
methodEmailEnqueue = "email-enqueue"
|
|
||||||
methodEmailIdempotency = "email-idempotency"
|
|
||||||
methodSMSSend = "sms-send"
|
|
||||||
methodSMSEnqueue = "sms-enqueue"
|
|
||||||
methodMemberEmail = "member-email"
|
|
||||||
methodMemberPhone = "member-phone"
|
|
||||||
methodAdminDLQ = "admin-dlq"
|
|
||||||
)
|
|
||||||
|
|
||||||
var validMethods = []string{
|
|
||||||
methodEmailSend,
|
|
||||||
methodEmailEnqueue,
|
|
||||||
methodEmailIdempotency,
|
|
||||||
methodSMSSend,
|
|
||||||
methodSMSEnqueue,
|
|
||||||
methodMemberEmail,
|
|
||||||
methodMemberPhone,
|
|
||||||
methodAdminDLQ,
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
|
|
||||||
method = flag.String("method", "", "test method (required): "+strings.Join(validMethods, ", "))
|
|
||||||
toEmail = flag.String("to", "", "recipient email")
|
|
||||||
phone = flag.String("phone", "", "recipient phone")
|
|
||||||
tenantID = flag.String("tenant", "notify-test", "tenant_id")
|
|
||||||
uid = flag.String("uid", "notify-test-uid", "uid")
|
|
||||||
mockOnly = flag.Bool("mock", false, "force mock email/SMS providers")
|
|
||||||
pollSec = flag.Int("poll", 45, "max seconds to wait for async delivery (enqueue methods)")
|
|
||||||
)
|
|
||||||
|
|
||||||
type env struct {
|
|
||||||
ctx context.Context
|
|
||||||
tenant string
|
|
||||||
uid string
|
|
||||||
to string
|
|
||||||
phone string
|
|
||||||
locale string
|
|
||||||
notifier domusecase.NotifierUseCase
|
|
||||||
// otp is the atomic primitive; this CLI plays the role of the future
|
|
||||||
// logic layer and orchestrates OTP.Generate + Notifier.Send inline.
|
|
||||||
otp dommember.OTPUseCase
|
|
||||||
admin domusecase.AdminNotifierUseCase
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: notify-test -method <name> [options]\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Methods:\n")
|
|
||||||
for _, m := range validMethods {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s\n", m)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " notify-test -method email-send -to you@example.com\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " notify-test -method email-enqueue -to you@example.com\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " notify-test -method sms-send -phone 0912345678\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " notify-test -method member-email -to you@example.com\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " notify-test -method admin-dlq\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " notify-test -method email-send -to t@e.com -mock\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
code, err := run()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
}
|
|
||||||
if code != 0 {
|
|
||||||
os.Exit(code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// run wires the requested method and returns (exitCode, error). Deferred
|
|
||||||
// cleanups inside run always execute before main calls os.Exit.
|
|
||||||
func run() (int, error) {
|
|
||||||
m := strings.TrimSpace(*method)
|
|
||||||
if m == "" {
|
|
||||||
flag.Usage()
|
|
||||||
return 2, fmt.Errorf("notify-test: -method is required")
|
|
||||||
}
|
|
||||||
if !isValidMethod(m) {
|
|
||||||
flag.Usage()
|
|
||||||
return 2, fmt.Errorf("notify-test: unknown method %q", m)
|
|
||||||
}
|
|
||||||
if err := validateArgs(m); err != nil {
|
|
||||||
return 2, fmt.Errorf("notify-test: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var c config.Config
|
|
||||||
conf.MustLoad(*configFile, &c)
|
|
||||||
if c.Mongo.Host == "" {
|
|
||||||
return 1, fmt.Errorf("notify-test: Mongo.Host is empty")
|
|
||||||
}
|
|
||||||
if c.Redis.Host == "" {
|
|
||||||
return 1, fmt.Errorf("notify-test: Redis.Host is empty")
|
|
||||||
}
|
|
||||||
if c.Notification.Email.From == "" && needsEmailFrom(m) {
|
|
||||||
return 1, fmt.Errorf("notify-test: Notification.Email.From is empty")
|
|
||||||
}
|
|
||||||
if *mockOnly {
|
|
||||||
forceMock(&c.Notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*pollSec+60)*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
rds, err := redislib.NewClient(c.Redis)
|
|
||||||
if err != nil {
|
|
||||||
return 1, fmt.Errorf("notify-test: redis: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
|
|
||||||
MongoConf: &c.Mongo,
|
|
||||||
Redis: rds,
|
|
||||||
Config: c.Notification,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 1, fmt.Errorf("notify-test: notification: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var otpUC dommember.OTPUseCase
|
|
||||||
if m == methodMemberEmail || m == methodMemberPhone {
|
|
||||||
memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
|
||||||
Redis: rds,
|
|
||||||
Config: c.Member,
|
|
||||||
})
|
|
||||||
if memErr != nil {
|
|
||||||
return 1, fmt.Errorf("notify-test: member: %w", memErr)
|
|
||||||
}
|
|
||||||
otpUC = memberMod.OTP
|
|
||||||
}
|
|
||||||
|
|
||||||
e := &env{
|
|
||||||
ctx: ctx,
|
|
||||||
tenant: *tenantID,
|
|
||||||
uid: *uid,
|
|
||||||
to: *toEmail,
|
|
||||||
phone: *phone,
|
|
||||||
locale: c.Notification.DefaultLocale,
|
|
||||||
notifier: mod.Notifier,
|
|
||||||
otp: otpUC,
|
|
||||||
admin: mod.Admin,
|
|
||||||
}
|
|
||||||
|
|
||||||
if m == methodEmailEnqueue || m == methodSMSEnqueue {
|
|
||||||
if mod.RetryWorker == nil {
|
|
||||||
return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)")
|
|
||||||
}
|
|
||||||
workerCtx, stop := context.WithCancel(context.Background())
|
|
||||||
go mod.RetryWorker.Run(workerCtx)
|
|
||||||
defer stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
|
|
||||||
|
|
||||||
if runErr := runMethod(e, m); runErr != nil {
|
|
||||||
return 1, fmt.Errorf("FAIL: %w", runErr)
|
|
||||||
}
|
|
||||||
fmt.Println("OK")
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMethod(e *env, m string) error {
|
|
||||||
switch m {
|
|
||||||
case methodEmailSend:
|
|
||||||
return e.emailSend()
|
|
||||||
case methodEmailEnqueue:
|
|
||||||
return e.emailEnqueue()
|
|
||||||
case methodEmailIdempotency:
|
|
||||||
return e.emailIdempotency()
|
|
||||||
case methodSMSSend:
|
|
||||||
return e.smsSend()
|
|
||||||
case methodSMSEnqueue:
|
|
||||||
return e.smsEnqueue()
|
|
||||||
case methodMemberEmail:
|
|
||||||
return e.memberEmail()
|
|
||||||
case methodMemberPhone:
|
|
||||||
return e.memberPhone()
|
|
||||||
case methodAdminDLQ:
|
|
||||||
return e.adminDLQ()
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unhandled method %q", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) emailSend() error {
|
|
||||||
dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: enum.ChannelEmail,
|
|
||||||
Kind: enum.NotifyVerifyEmail,
|
|
||||||
Target: e.to,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300},
|
|
||||||
IdempotencyKey: uuid.NewString(),
|
|
||||||
DoNotPersistBody: true,
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
})
|
|
||||||
return reportSent(dto, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) emailEnqueue() error {
|
|
||||||
pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: enum.ChannelEmail,
|
|
||||||
Kind: enum.NotifyTenantWelcome,
|
|
||||||
Target: e.to,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{"tenant_name": "Test Corp"},
|
|
||||||
IdempotencyKey: uuid.NewString(),
|
|
||||||
DoNotPersistBody: false,
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("notification_id=%s provider=%s status=%s\n", final.ID, final.Provider, final.Status)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) emailIdempotency() error {
|
|
||||||
key := uuid.NewString()
|
|
||||||
first, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: enum.ChannelEmail,
|
|
||||||
Kind: enum.NotifyVerifyEmail,
|
|
||||||
Target: e.to,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{domtpl.VarCode: "111111", domtpl.VarExpiresIn: 300},
|
|
||||||
IdempotencyKey: key,
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
second, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: enum.ChannelEmail,
|
|
||||||
Kind: enum.NotifyVerifyEmail,
|
|
||||||
Target: e.to,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{domtpl.VarCode: "222222", domtpl.VarExpiresIn: 300},
|
|
||||||
IdempotencyKey: key,
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if first.ID != second.ID {
|
|
||||||
return fmt.Errorf("idempotency: expected same id, got %s vs %s", first.ID, second.ID)
|
|
||||||
}
|
|
||||||
fmt.Printf("notification_id=%s (replay ok)\n", first.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) smsSend() error {
|
|
||||||
dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: enum.ChannelSMS,
|
|
||||||
Kind: enum.NotifyVerifyPhone,
|
|
||||||
Target: e.phone,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300},
|
|
||||||
IdempotencyKey: uuid.NewString(),
|
|
||||||
DoNotPersistBody: true,
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
})
|
|
||||||
return reportSent(dto, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) smsEnqueue() error {
|
|
||||||
pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: enum.ChannelSMS,
|
|
||||||
Kind: enum.NotifyVerifyPhone,
|
|
||||||
Target: e.phone,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{domtpl.VarCode: "654321", domtpl.VarExpiresIn: 300},
|
|
||||||
IdempotencyKey: uuid.NewString(),
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("notification_id=%s provider=%s\n", final.ID, final.Provider)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// memberEmail demonstrates the logic-layer orchestration: generate an OTP
|
|
||||||
// challenge (atomic) and dispatch the verification email through Notifier
|
|
||||||
// (atomic). usecases never call each other — this driver is what the real
|
|
||||||
// logic handler will look like.
|
|
||||||
func (e *env) memberEmail() error {
|
|
||||||
return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) memberPhone() error {
|
|
||||||
return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error {
|
|
||||||
if e.otp == nil {
|
|
||||||
return fmt.Errorf("member OTP usecase not configured")
|
|
||||||
}
|
|
||||||
if target == "" {
|
|
||||||
return fmt.Errorf("target is empty")
|
|
||||||
}
|
|
||||||
dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Purpose: purpose,
|
|
||||||
Target: target,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
|
||||||
TenantID: e.tenant,
|
|
||||||
UID: e.uid,
|
|
||||||
Channel: channel,
|
|
||||||
Kind: kind,
|
|
||||||
Target: target,
|
|
||||||
Locale: e.locale,
|
|
||||||
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
|
|
||||||
IdempotencyKey: dto.ChallengeID,
|
|
||||||
DoNotPersistBody: true,
|
|
||||||
Severity: enum.SeverityInfo,
|
|
||||||
}); err != nil {
|
|
||||||
if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *env) adminDLQ() error {
|
|
||||||
if e.admin == nil {
|
|
||||||
return fmt.Errorf("admin notifier not configured")
|
|
||||||
}
|
|
||||||
entries, err := e.admin.ListDLQ(e.ctx, e.tenant, 10)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("dlq_count=%d\n", len(entries))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportSent(dto *domusecase.NotificationDTO, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if dto.Status != enum.NotifyStatusSent {
|
|
||||||
return fmt.Errorf("status=%s last_error=%s", dto.Status, dto.LastError)
|
|
||||||
}
|
|
||||||
fmt.Printf("notification_id=%s provider=%s message_id=%s\n", dto.ID, dto.Provider, dto.ProviderMessageID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitSent(ctx context.Context, notifier domusecase.NotifierUseCase, tenantID, notificationID string, timeout time.Duration) (*domusecase.NotificationDTO, error) {
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
dto, err := notifier.Get(ctx, tenantID, notificationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch dto.Status {
|
|
||||||
case enum.NotifyStatusSent:
|
|
||||||
return dto, nil
|
|
||||||
case enum.NotifyStatusFailed, enum.NotifyStatusDropped:
|
|
||||||
return dto, fmt.Errorf("status=%s: %s", dto.Status, dto.LastError)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("timeout after %s", timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateArgs(m string) error {
|
|
||||||
switch m {
|
|
||||||
case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail:
|
|
||||||
if *toEmail == "" {
|
|
||||||
return fmt.Errorf("%s requires -to", m)
|
|
||||||
}
|
|
||||||
case methodSMSSend, methodSMSEnqueue, methodMemberPhone:
|
|
||||||
if *phone == "" {
|
|
||||||
return fmt.Errorf("%s requires -phone", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsEmailFrom(m string) bool {
|
|
||||||
switch m {
|
|
||||||
case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidMethod(m string) bool {
|
|
||||||
for _, v := range validMethods {
|
|
||||||
if v == m {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func forceMock(cfg *notifconfig.Config) {
|
|
||||||
cfg.Email.SMTP.Enable = false
|
|
||||||
cfg.Email.SES.Enable = false
|
|
||||||
cfg.Email.Provider = notifconfig.ProviderMock
|
|
||||||
cfg.SMS.Mitake.Enable = false
|
|
||||||
cfg.SMS.Provider = notifconfig.ProviderMock
|
|
||||||
}
|
|
||||||
|
|
||||||
func emailProviders(cfg *notifconfig.Config) []string {
|
|
||||||
var out []string
|
|
||||||
if cfg.Email.SMTP.Enable {
|
|
||||||
out = append(out, "smtp")
|
|
||||||
}
|
|
||||||
if cfg.Email.SES.Enable {
|
|
||||||
out = append(out, "ses")
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
out = append(out, "mock")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func smsProviders(cfg *notifconfig.Config) []string {
|
|
||||||
if cfg.SMS.Mitake.Enable {
|
|
||||||
return []string{"mitake"}
|
|
||||||
}
|
|
||||||
return []string{"mock"}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# ZITADEL bootstrap outputs(PAT / machine key)— 不入 git
|
||||||
|
machinekey/zitadel-admin-sa.token
|
||||||
|
machinekey/zitadel-admin-sa.json
|
||||||
|
|
@ -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 值,**只能**本機用。
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -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/<module>/`,內部分 `domain`(介面 + enum + errors) / `repository`(Mongo / Redis 實作) / `usecase`(原子業務邏輯) / `config`。
|
||||||
|
|
||||||
|
跨模組編排(例如「發 OTP → 寄信 → 驗碼 → 更新 profile」)一律放在 `internal/logic/<module>/`,**usecase 不可呼叫其他 usecase**。
|
||||||
|
|
||||||
|
## 必讀文件
|
||||||
|
|
||||||
|
| 文件 | 何時讀 |
|
||||||
|
|---|---|
|
||||||
|
| [`generate/api/README.md`](../generate/api/README.md) | 新增 / 修改 API 端點、type、文件分組、欄位描述、enum 列舉前 |
|
||||||
|
| [`generate/doc-generate/README.md`](../generate/doc-generate/README.md) | 需要查 go-doc 支援的 tag / `@respdoc` 寫法時 |
|
||||||
|
| `internal/model/<module>/README.md` | 動到該模組的領域邏輯時 |
|
||||||
|
| `docs/model.md`(若存在) | 全局架構規範 |
|
||||||
|
|
||||||
|
## 標準工作流程
|
||||||
|
|
||||||
|
### 1. 修改 API(`.api` → handler / types / docs)
|
||||||
|
|
||||||
|
1. 編輯 `generate/api/*.api`(遵守 `generate/api/README.md` 的三條規則:tags 分組 / backtick 行末 `//` 中文 description / `options=A|B|C` enum)
|
||||||
|
2. `make gen-api` — 重新產生 `internal/handler/`、`internal/logic/`(已存在則不覆蓋)、`internal/types/types.go`
|
||||||
|
3. `make gen-doc` — 重新產生 `docs/openapi/gateway.yaml`(gitignore,本地驗證用)
|
||||||
|
4. 實作 / 修改 `internal/logic/<module>/<handler>_logic.go` 的業務邏輯
|
||||||
|
5. `go build ./...` 確保編譯通過
|
||||||
|
6. `make lint` / `make test` 視改動範圍跑
|
||||||
|
|
||||||
|
### 2. 新增 / 修改業務模組
|
||||||
|
|
||||||
|
- 領域介面與型別放 `internal/model/<module>/domain/`
|
||||||
|
- Mongo / Redis 實作放 `internal/model/<module>/repository/`
|
||||||
|
- 原子 usecase 放 `internal/model/<module>/usecase/`(**不可**互相呼叫)
|
||||||
|
- 多步驟流程編排放 `internal/logic/<module>/`
|
||||||
|
- 模組的對外裝配入口統一在 `internal/model/<module>/usecase/module.go`,並從 `internal/svc/service_context.go` 注入
|
||||||
|
|
||||||
|
### 3. 錯誤碼
|
||||||
|
|
||||||
|
- 業務碼格式 `SSCCCDDD`(scope * 1_000_000 + category * 1_000 + detail)
|
||||||
|
- Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31)
|
||||||
|
- 新增 scope 時:同步更新 `gateway.api` 的 `bizCodeEnumDescription`
|
||||||
|
|
||||||
|
### 4. Middleware(go-zero 正規手段)
|
||||||
|
|
||||||
|
**禁止**在 `gateway.go` 用 `server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api` 的 `middleware:` 宣告:
|
||||||
|
|
||||||
|
```go
|
||||||
|
@server (
|
||||||
|
group: auth
|
||||||
|
prefix: /api/v1/auth
|
||||||
|
middleware: AuthJWT // 一個
|
||||||
|
// middleware: AuthJWT,CasbinRBAC // 多個用逗號
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
跑 `make gen-api` 後 `routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`。
|
||||||
|
|
||||||
|
撰寫新 middleware 時:
|
||||||
|
- 用 **struct + `Handle()` method** 模式(不是 factory function)
|
||||||
|
- 檔名 = goctl stringx 規則(例 `AuthJWT` → `authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`)
|
||||||
|
- 在 `ServiceContext` 加 `<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).Handle`
|
||||||
|
- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來)
|
||||||
|
|
||||||
|
詳細範例與分組原則見 [`generate/api/README.md`](../generate/api/README.md) "Middleware" 章節。
|
||||||
|
|
||||||
|
### 4. Redis / Mongo / 設定
|
||||||
|
|
||||||
|
- 每個模組的設定型別放 `internal/model/<module>/config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct
|
||||||
|
- Redis client 共用 `internal/library/redis/`,需 Pub/Sub 用 `client.PubSubClient()`
|
||||||
|
- Mongo index 註冊到 `cmd/mongo-index/main.go`(在 `run()` 裡呼叫 `<module>repo.EnsureMongoIndexes`)
|
||||||
|
|
||||||
|
## 通用準則
|
||||||
|
|
||||||
|
- **回應 traditional Chinese**(繁體中文)
|
||||||
|
- 程式碼註解只寫「為什麼」、邊界條件、trade-off,**不寫**「import the module / increment counter」這類顯而易見的描述
|
||||||
|
- 不主動建立 `*.md` 文件,除非使用者明確要求
|
||||||
|
- 改 git config / 強制 push / `rm -rf` 等破壞性操作 **必須**先取得使用者同意
|
||||||
|
- 不要在沒被要求時直接 commit;commit 前先 `git status` / `git diff` 確認
|
||||||
|
- commit message 用繁中描述「為什麼」改,不是「改了什麼」
|
||||||
|
|
||||||
|
## 指令速查
|
||||||
|
|
||||||
|
| 指令 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `make gen-api` | `.api` → handler / logic(skip exists)/ types |
|
||||||
|
| `make gen-doc` | `.api` → `docs/openapi/gateway.yaml` |
|
||||||
|
| `make gen-mock` | 模組 mock(gomock) |
|
||||||
|
| `make tools` | 安裝 goctl / goimports / golangci-lint |
|
||||||
|
| `make fix` | gofmt + goimports + lint --fix + lint |
|
||||||
|
| `make check` | fix + test(提交前) |
|
||||||
|
| `make run-dev` | 本機啟動(需 `make deps-up`) |
|
||||||
|
| `make deps-up` | docker compose Mongo + Redis |
|
||||||
|
| `make mongo-index` | 建立 / 更新 Mongo 索引 |
|
||||||
|
|
||||||
|
完整列表跑 `make help`。
|
||||||
|
|
@ -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:<phone>)
|
||||||
|
# - 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
|
||||||
|
|
@ -228,7 +228,11 @@ func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body
|
||||||
if resp.StatusCode == http.StatusConflict {
|
if resp.StatusCode == http.StatusConflict {
|
||||||
return ErrUserAlreadyExists
|
return ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
if resp.StatusCode != wantStatus {
|
// Accept any 2xx as success. ZITADEL v2 returns 201 for create endpoints
|
||||||
|
// (e.g. POST /v2/users/human) and 200 for most others; wantStatus is kept
|
||||||
|
// for caller intent but we don't reject other 2xx responses.
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
_ = wantStatus
|
||||||
return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw))
|
return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw))
|
||||||
}
|
}
|
||||||
if out != nil && len(raw) > 0 {
|
if out != nil && len(raw) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ import (
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MockRedisHook is the minimal Redis surface needed to persist the last mock
|
||||||
|
// email body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis
|
||||||
|
// satisfies it (SetexCtx).
|
||||||
|
type MockRedisHook interface {
|
||||||
|
SetexCtx(ctx context.Context, key, value string, seconds int) error
|
||||||
|
}
|
||||||
|
|
||||||
// MockSender records calls and returns configurable results (for tests and local dev).
|
// MockSender records calls and returns configurable results (for tests and local dev).
|
||||||
type MockSender struct {
|
type MockSender struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -19,6 +26,11 @@ type MockSender struct {
|
||||||
Err error
|
Err error
|
||||||
MessageID string
|
MessageID string
|
||||||
SendHook func(ctx context.Context, msg *Message) (string, error)
|
SendHook func(ctx context.Context, msg *Message) (string, error)
|
||||||
|
|
||||||
|
// Optional Redis hook for dev/k6: every successful Send writes the body
|
||||||
|
// to "dev:notification:last:email:<recipient>" for each To address.
|
||||||
|
redis MockRedisHook
|
||||||
|
redisKeyTTL int
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockSenderOption func(*MockSender)
|
type MockSenderOption func(*MockSender)
|
||||||
|
|
@ -39,11 +51,26 @@ func WithMockMessageID(id string) MockSenderOption {
|
||||||
return func(m *MockSender) { m.MessageID = id }
|
return func(m *MockSender) { m.MessageID = id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithMockRedis mirrors every outbound mock email body into Redis at key
|
||||||
|
// "dev:notification:last:email:<recipient>" (one key per To address).
|
||||||
|
// Primary OTP transport for k6 is still MailHog HTTP API; this hook is a
|
||||||
|
// fallback so the SMTP-disabled mock mode is also k6-friendly.
|
||||||
|
func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption {
|
||||||
|
return func(m *MockSender) {
|
||||||
|
m.redis = r
|
||||||
|
if ttlSeconds <= 0 {
|
||||||
|
ttlSeconds = 600
|
||||||
|
}
|
||||||
|
m.redisKeyTTL = ttlSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewMockSender(opts ...MockSenderOption) *MockSender {
|
func NewMockSender(opts ...MockSenderOption) *MockSender {
|
||||||
m := &MockSender{
|
m := &MockSender{
|
||||||
name: "mock",
|
name: "mock",
|
||||||
sort: 0,
|
sort: 0,
|
||||||
MessageID: "mock-email-id",
|
MessageID: "mock-email-id",
|
||||||
|
redisKeyTTL: 600,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(m)
|
opt(m)
|
||||||
|
|
@ -54,6 +81,9 @@ func NewMockSender(opts ...MockSenderOption) *MockSender {
|
||||||
func (m *MockSender) Name() string { return m.name }
|
func (m *MockSender) Name() string { return m.name }
|
||||||
func (m *MockSender) Sort() int { return m.sort }
|
func (m *MockSender) Sort() int { return m.sort }
|
||||||
|
|
||||||
|
// MockEmailRedisKeyPrefix is the key prefix written by the Redis hook.
|
||||||
|
const MockEmailRedisKeyPrefix = "dev:notification:last:email:"
|
||||||
|
|
||||||
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.calls = append(m.calls, msg)
|
m.calls = append(m.calls, msg)
|
||||||
|
|
@ -68,6 +98,17 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
if msg != nil {
|
if msg != nil {
|
||||||
logx.Infof("[notification mock email] from=%s to=%s subject=%q body=%s message_id=%s",
|
logx.Infof("[notification mock email] from=%s to=%s subject=%q body=%s message_id=%s",
|
||||||
msg.From, strings.Join(msg.To, ","), msg.Subject, truncateForLog(msg.Body, 500), m.MessageID)
|
msg.From, strings.Join(msg.To, ","), msg.Subject, truncateForLog(msg.Body, 500), m.MessageID)
|
||||||
|
if m.redis != nil {
|
||||||
|
for _, to := range msg.To {
|
||||||
|
if to == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := MockEmailRedisKeyPrefix + to
|
||||||
|
if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil {
|
||||||
|
logx.Errorf("[notification mock email] redis hook setex %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m.MessageID, nil
|
return m.MessageID, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,16 @@ import (
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MockRedisHook is the minimal Redis surface needed to persist the last mock
|
||||||
|
// SMS body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis
|
||||||
|
// already satisfies it (SetexCtx).
|
||||||
|
//
|
||||||
|
// Production SMS senders never receive this hook; it is only wired when
|
||||||
|
// SMSConfig.Provider=="mock" and a Redis client is available.
|
||||||
|
type MockRedisHook interface {
|
||||||
|
SetexCtx(ctx context.Context, key, value string, seconds int) error
|
||||||
|
}
|
||||||
|
|
||||||
// MockSender records calls and returns configurable results (for tests and local dev).
|
// MockSender records calls and returns configurable results (for tests and local dev).
|
||||||
type MockSender struct {
|
type MockSender struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -18,6 +28,12 @@ type MockSender struct {
|
||||||
Err error
|
Err error
|
||||||
MessageID string
|
MessageID string
|
||||||
SendHook func(ctx context.Context, msg *Message) (string, error)
|
SendHook func(ctx context.Context, msg *Message) (string, error)
|
||||||
|
|
||||||
|
// Optional Redis hook for dev/k6: on every successful Send, the message
|
||||||
|
// body is written to "dev:notification:last:sms:<phone>" with the given TTL.
|
||||||
|
// nil hook → behaviour identical to original implementation.
|
||||||
|
redis MockRedisHook
|
||||||
|
redisKeyTTL int // seconds, defaults to 600 when redis hook set
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockSenderOption func(*MockSender)
|
type MockSenderOption func(*MockSender)
|
||||||
|
|
@ -38,11 +54,25 @@ func WithMockMessageID(id string) MockSenderOption {
|
||||||
return func(m *MockSender) { m.MessageID = id }
|
return func(m *MockSender) { m.MessageID = id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithMockRedis enables dev/k6 OTP inspection by mirroring every outbound
|
||||||
|
// mock SMS body into Redis at key "dev:notification:last:sms:<phone>".
|
||||||
|
// ttlSeconds <= 0 → defaults to 600 (10m).
|
||||||
|
func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption {
|
||||||
|
return func(m *MockSender) {
|
||||||
|
m.redis = r
|
||||||
|
if ttlSeconds <= 0 {
|
||||||
|
ttlSeconds = 600
|
||||||
|
}
|
||||||
|
m.redisKeyTTL = ttlSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewMockSender(opts ...MockSenderOption) *MockSender {
|
func NewMockSender(opts ...MockSenderOption) *MockSender {
|
||||||
m := &MockSender{
|
m := &MockSender{
|
||||||
name: "mock",
|
name: "mock",
|
||||||
sort: 0,
|
sort: 0,
|
||||||
MessageID: "mock-sms-id",
|
MessageID: "mock-sms-id",
|
||||||
|
redisKeyTTL: 600,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(m)
|
opt(m)
|
||||||
|
|
@ -53,6 +83,10 @@ func NewMockSender(opts ...MockSenderOption) *MockSender {
|
||||||
func (m *MockSender) Name() string { return m.name }
|
func (m *MockSender) Name() string { return m.name }
|
||||||
func (m *MockSender) Sort() int { return m.sort }
|
func (m *MockSender) Sort() int { return m.sort }
|
||||||
|
|
||||||
|
// MockSMSRedisKeyPrefix is the key prefix written by the Redis hook.
|
||||||
|
// Exposed so k6 / dev tooling can resolve the key for a given phone.
|
||||||
|
const MockSMSRedisKeyPrefix = "dev:notification:last:sms:"
|
||||||
|
|
||||||
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.calls = append(m.calls, msg)
|
m.calls = append(m.calls, msg)
|
||||||
|
|
@ -67,6 +101,12 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
if msg != nil {
|
if msg != nil {
|
||||||
logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s",
|
logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s",
|
||||||
msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID)
|
msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID)
|
||||||
|
if m.redis != nil && msg.PhoneNumber != "" {
|
||||||
|
key := MockSMSRedisKeyPrefix + msg.PhoneNumber
|
||||||
|
if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil {
|
||||||
|
logx.Errorf("[notification mock sms] redis hook setex %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m.MessageID, nil
|
return m.MessageID, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ func NewNotifierUseCaseFromParam(param FactoryParam) (domusecase.NotifierUseCase
|
||||||
return mod.Notifier, nil
|
return mod.Notifier, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) {
|
func buildEmailChain(cfg notifconfig.Config, rc *redislib.Client) (*email.Chain, error) {
|
||||||
senders, err := collectEmailSenders(cfg)
|
senders, err := collectEmailSenders(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -37,7 +37,11 @@ func buildEmailChain(cfg notifconfig.Config) (*email.Chain, error) {
|
||||||
if len(senders) == 0 {
|
if len(senders) == 0 {
|
||||||
switch cfg.Email.Provider {
|
switch cfg.Email.Provider {
|
||||||
case "", notifconfig.ProviderMock:
|
case "", notifconfig.ProviderMock:
|
||||||
return email.NewChain(email.NewMockSender(email.WithMockName(notifconfig.ProviderMock))), nil
|
opts := []email.MockSenderOption{email.WithMockName(notifconfig.ProviderMock)}
|
||||||
|
if r := rc.Zero(); r != nil {
|
||||||
|
opts = append(opts, email.WithMockRedis(r, 0))
|
||||||
|
}
|
||||||
|
return email.NewChain(email.NewMockSender(opts...)), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("notification: no email senders enabled and provider %q is not mock", cfg.Email.Provider)
|
return nil, fmt.Errorf("notification: no email senders enabled and provider %q is not mock", cfg.Email.Provider)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +91,7 @@ func collectEmailSenders(cfg notifconfig.Config) ([]email.Sender, error) {
|
||||||
return senders, nil
|
return senders, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) {
|
func buildSMSChain(cfg notifconfig.Config, rc *redislib.Client) (*sms.Chain, error) {
|
||||||
senders, err := collectSMSSenders(cfg)
|
senders, err := collectSMSSenders(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -95,7 +99,11 @@ func buildSMSChain(cfg notifconfig.Config) (*sms.Chain, error) {
|
||||||
if len(senders) == 0 {
|
if len(senders) == 0 {
|
||||||
switch cfg.SMS.Provider {
|
switch cfg.SMS.Provider {
|
||||||
case "", notifconfig.ProviderMock:
|
case "", notifconfig.ProviderMock:
|
||||||
return sms.NewChain(sms.NewMockSender(sms.WithMockName(notifconfig.ProviderMock))), nil
|
opts := []sms.MockSenderOption{sms.WithMockName(notifconfig.ProviderMock)}
|
||||||
|
if r := rc.Zero(); r != nil {
|
||||||
|
opts = append(opts, sms.WithMockRedis(r, 0))
|
||||||
|
}
|
||||||
|
return sms.NewChain(sms.NewMockSender(opts...)), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("notification: no sms senders enabled and provider %q is not mock", cfg.SMS.Provider)
|
return nil, fmt.Errorf("notification: no sms senders enabled and provider %q is not mock", cfg.SMS.Provider)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
func TestBuildEmailChain_MockByDefault(t *testing.T) {
|
func TestBuildEmailChain_MockByDefault(t *testing.T) {
|
||||||
chain, err := buildEmailChain(notifconfig.Config{
|
chain, err := buildEmailChain(notifconfig.Config{
|
||||||
Email: notifconfig.EmailConfig{Provider: notifconfig.ProviderMock},
|
Email: notifconfig.EmailConfig{Provider: notifconfig.ProviderMock},
|
||||||
})
|
}, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, chain)
|
require.NotNil(t, chain)
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ func TestBuildEmailChain_SMTPAndSES(t *testing.T) {
|
||||||
SecretKey: "secret",
|
SecretKey: "secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, chain)
|
require.NotNil(t, chain)
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func TestBuildEmailChain_SESRequiresCredentials(t *testing.T) {
|
||||||
Email: notifconfig.EmailConfig{
|
Email: notifconfig.EmailConfig{
|
||||||
SES: notifconfig.SESProviderSettings{Enable: true, Region: "ap-northeast-1"},
|
SES: notifconfig.SESProviderSettings{Enable: true, Region: "ap-northeast-1"},
|
||||||
},
|
},
|
||||||
})
|
}, nil)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ func TestBuildSMSChain_Mitake(t *testing.T) {
|
||||||
Password: "pass",
|
Password: "pass",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, chain)
|
require.NotNil(t, chain)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,11 @@ func NewModuleFromParam(param FactoryParam) (*Module, error) {
|
||||||
queue = repository.NewRedisRetryQueue(param.Redis, retryQueueKey(param.Config))
|
queue = repository.NewRedisRetryQueue(param.Redis, retryQueueKey(param.Config))
|
||||||
}
|
}
|
||||||
|
|
||||||
emailChain, err := buildEmailChain(param.Config)
|
emailChain, err := buildEmailChain(param.Config, param.Redis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
smsChain, err := buildSMSChain(param.Config)
|
smsChain, err := buildSMSChain(param.Config, param.Redis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@ var DefaultSystemRoles = []SystemRoleDefinition{
|
||||||
DisplayName: "Tenant Admin",
|
DisplayName: "Tenant Admin",
|
||||||
PermissionNames: []string{
|
PermissionNames: []string{
|
||||||
"member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
|
"member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
|
||||||
"permission.role.read", "permission.role.write", "permission.assign.write",
|
"permission.role.read", "permission.role.write", "permission.role.modify",
|
||||||
|
"permission.assign.write", "permission.assign.revoke",
|
||||||
"permission.mapping.write", "permission.policy.reload",
|
"permission.mapping.write", "permission.policy.reload",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -83,26 +83,42 @@
|
||||||
{
|
{
|
||||||
"name": "permission.role.write",
|
"name": "permission.role.write",
|
||||||
"parent": "permission.role.management",
|
"parent": "permission.role.management",
|
||||||
"http_methods": "POST|PUT|DELETE",
|
"http_methods": "POST",
|
||||||
"http_path": "/api/v1/permissions/roles*",
|
"http_path": "/api/v1/permissions/roles",
|
||||||
"type": "backend_user",
|
"type": "backend_user",
|
||||||
"description": "管理角色(建立/修改/刪除)"
|
"description": "建立角色"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "permission.role.modify",
|
||||||
|
"parent": "permission.role.management",
|
||||||
|
"http_methods": "GET|PUT|PATCH|DELETE",
|
||||||
|
"http_path": "/api/v1/permissions/roles/*",
|
||||||
|
"type": "backend_user",
|
||||||
|
"description": "修改 / 刪除 / 讀取角色 permission 細節"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "permission.assign.write",
|
"name": "permission.assign.write",
|
||||||
"parent": "permission.role.management",
|
"parent": "permission.role.management",
|
||||||
"http_methods": "POST|DELETE",
|
"http_methods": "GET|POST",
|
||||||
"http_path": "/api/v1/permissions/users/*/roles*",
|
"http_path": "/api/v1/permissions/users/*/roles",
|
||||||
"type": "backend_user",
|
"type": "backend_user",
|
||||||
"description": "指派 / 撤銷使用者角色"
|
"description": "查詢 / 指派使用者角色"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "permission.assign.revoke",
|
||||||
|
"parent": "permission.role.management",
|
||||||
|
"http_methods": "DELETE",
|
||||||
|
"http_path": "/api/v1/permissions/users/*/roles/*",
|
||||||
|
"type": "backend_user",
|
||||||
|
"description": "撤銷使用者單一角色"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "permission.mapping.write",
|
"name": "permission.mapping.write",
|
||||||
"parent": "permission.role.management",
|
"parent": "permission.role.management",
|
||||||
"http_methods": "PUT|DELETE",
|
"http_methods": "GET|PUT|DELETE",
|
||||||
"http_path": "/api/v1/permissions/role-mappings*",
|
"http_path": "/api/v1/permissions/role-mappings",
|
||||||
"type": "backend_user",
|
"type": "backend_user",
|
||||||
"description": "管理外部角色映射"
|
"description": "讀取 / 管理外部角色映射"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "permission.policy.reload",
|
"name": "permission.policy.reload",
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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)"
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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}"
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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 = ""
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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=<email>` 撈到信件,從 body 抓 6 位數。
|
||||||
|
- **SMS** → mock provider 把 body 寫到 `dev:notification:last:sms:<phone>`(見 [mock_sender.go](../../internal/model/notification/provider/sms/mock_sender.go) `WithMockRedis`),k6 用 `k6/experimental/redis` 直接讀。
|
||||||
|
|
||||||
|
兩者都有 `OTP_POLL_TIMEOUT_MS`(預設 5 秒)保護,超時直接 fail。
|
||||||
|
|
||||||
|
## 覆蓋率(39 endpoints)
|
||||||
|
|
||||||
|
| 模組 | 端點 | 覆蓋 |
|
||||||
|
|---|---|---|
|
||||||
|
| Normal | `GET /api/v1/health` | smoke/health |
|
||||||
|
| Auth 公開 | `POST /register` | journeys/email_register_full + smoke/auth_public |
|
||||||
|
| Auth 公開 | `POST /register/confirm` | journeys/email_register_full |
|
||||||
|
| Auth 公開 | `POST /register/resend` | smoke/auth_public |
|
||||||
|
| Auth 公開 | `POST /register/social/start` | smoke/auth_public (happy) |
|
||||||
|
| Auth 公開 | `GET /register/social/callback` | smoke/auth_public (negative — TODO happy) |
|
||||||
|
| Auth 公開 | `POST /login` | journeys/login_refresh + smoke/auth_public (negative) |
|
||||||
|
| Auth 公開 | `POST /token/refresh` | journeys/login_refresh + smoke/auth_public (negative) |
|
||||||
|
| Auth 公開 | `POST /token/exchange` | journeys/token_exchange (negative — TODO happy) |
|
||||||
|
| Auth 公開 | `POST /login/social/start` | smoke/auth_public (happy) |
|
||||||
|
| Auth 公開 | `GET /login/social/callback` | smoke/auth_public (negative — TODO happy) |
|
||||||
|
| Auth Bearer | `POST /logout` | smoke/auth_bearer + journeys/email_register_full |
|
||||||
|
| Member | `GET /me` | smoke/member + all journeys |
|
||||||
|
| Member | `PATCH /me` | smoke/member + journeys/email_register_full |
|
||||||
|
| Member | `POST /me/verifications/email/start` | smoke/member + journeys/email_verify |
|
||||||
|
| Member | `POST /me/verifications/email/confirm` | smoke/member (negative) + journeys/email_verify (happy) |
|
||||||
|
| Member | `POST /me/verifications/phone/start` | smoke/member + journeys/phone_verify |
|
||||||
|
| Member | `POST /me/verifications/phone/confirm` | smoke/member (negative) + journeys/phone_verify (happy) |
|
||||||
|
| Member | `GET /me/totp` | smoke/member + journeys/totp_full |
|
||||||
|
| Member | `POST /me/totp/enroll-start` | smoke/member + journeys/totp_full |
|
||||||
|
| Member | `POST /me/totp/enroll-confirm` | smoke/member (negative) + journeys/totp_full (happy) |
|
||||||
|
| Member | `POST /me/totp/verify` | smoke/member (negative) + journeys/totp_full (happy) |
|
||||||
|
| Member | `POST /me/totp/backup-codes` | smoke/member (negative) + journeys/totp_full (happy) |
|
||||||
|
| Member | `DELETE /me/totp` | smoke/member + journeys/totp_full |
|
||||||
|
| Perm 讀 | `GET /permissions/catalog` | smoke/permission_read + journeys/rbac_admin |
|
||||||
|
| Perm 讀 | `GET /permissions/me` | smoke/permission_read + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `GET /roles` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `POST /roles` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `PATCH /roles/:id` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `DELETE /roles/:id` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `GET /roles/:id/permissions` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `PUT /roles/:id/permissions` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `GET /users/:uid/roles` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `POST /users/:uid/roles` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `DELETE /users/:uid/roles/:role_id` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
| Perm 管理 | `GET /role-mappings` | smoke/permission_admin |
|
||||||
|
| Perm 管理 | `PUT /role-mappings` | smoke/permission_admin |
|
||||||
|
| Perm 管理 | `DELETE /role-mappings` | smoke/permission_admin |
|
||||||
|
| Perm 管理 | `POST /policy/reload` | smoke/permission_admin + journeys/rbac_admin |
|
||||||
|
|
||||||
|
## RBAC 系列(需 admin seed)
|
||||||
|
|
||||||
|
`journeys/rbac_admin.js` 需要 `ADMIN_EMAIL` / `ADMIN_PASSWORD` 環境變數。`make k6-seed-admin`
|
||||||
|
會跑 [cmd/k6-seed-admin](../../cmd/k6-seed-admin/main.go):
|
||||||
|
|
||||||
|
1. 用 API 註冊一個固定的 `k6-admin@k6.local`
|
||||||
|
2. 從 MailHog 撈 OTP 完成 confirm
|
||||||
|
3. 寫入 permission catalog + 預設 system roles(透過 `internal/model/permission/seed`)
|
||||||
|
4. 指派 `tenant_admin` 給該 UID
|
||||||
|
5. 把 `ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID` 寫到 `k6.env`
|
||||||
|
|
||||||
|
`k6-seed-admin` 是冪等的,重跑沒事。
|
||||||
|
|
||||||
|
沒跑 seed 時,`rbac_admin.js` 會印 skip notice 並 exit 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。
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Journey: business phone verification end-to-end (SMS OTP via Redis)
|
||||||
|
//
|
||||||
|
// Endpoints exercised:
|
||||||
|
// POST /api/v1/auth/register
|
||||||
|
// POST /api/v1/auth/register/confirm
|
||||||
|
// POST /api/v1/members/me/verifications/phone/start
|
||||||
|
// POST /api/v1/members/me/verifications/phone/confirm
|
||||||
|
// GET /api/v1/members/me (verify business_phone_verified flag is true)
|
||||||
|
//
|
||||||
|
// SMS OTP source: the mock SMS sender (when WithMockRedis is wired by the
|
||||||
|
// notification factory in k6 mode) writes the SMS body to Redis at
|
||||||
|
// "dev:notification:last:sms:<phone>". fetchSMSOTP polls that key.
|
||||||
|
//
|
||||||
|
// k6/experimental/redis requires k6 v0.46+.
|
||||||
|
import { get, post, checkEnvelope } from '../lib/http.js';
|
||||||
|
import { registerAndConfirm } from '../lib/auth.js';
|
||||||
|
import { fetchSMSOTP } from '../lib/otp.js';
|
||||||
|
import { unique } from '../lib/config.js';
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
thresholds: { checks: ['rate==1.0'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a deterministic-ish unique E.164 number per iteration to avoid
|
||||||
|
// collisions across concurrent runs. Use +886912 + 7 digits derived from the
|
||||||
|
// VU+iter+timestamp.
|
||||||
|
function uniquePhone() {
|
||||||
|
const ts = Date.now() % 1_000_000_0;
|
||||||
|
const vu = (typeof __VU !== 'undefined' && __VU) || 0;
|
||||||
|
const iter = (typeof __ITER !== 'undefined' && __ITER) || 0;
|
||||||
|
const suffix = String(ts + vu * 100 + iter).padStart(7, '0').slice(-7);
|
||||||
|
return `+886912${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function () {
|
||||||
|
const { tokens } = registerAndConfirm();
|
||||||
|
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||||
|
|
||||||
|
const phone = uniquePhone();
|
||||||
|
|
||||||
|
const startRes = post(
|
||||||
|
'/api/v1/members/me/verifications/phone/start',
|
||||||
|
{ target: phone },
|
||||||
|
bearer,
|
||||||
|
);
|
||||||
|
const start = checkEnvelope(startRes, 'POST /me/verifications/phone/start').data;
|
||||||
|
if (!start.challenge_id) throw new Error('phone/start: missing challenge_id');
|
||||||
|
|
||||||
|
const { code } = await fetchSMSOTP(phone);
|
||||||
|
if (!code) throw new Error(`could not extract SMS OTP for ${phone}`);
|
||||||
|
|
||||||
|
const confirmRes = post(
|
||||||
|
'/api/v1/members/me/verifications/phone/confirm',
|
||||||
|
{ challenge_id: start.challenge_id, code },
|
||||||
|
bearer,
|
||||||
|
);
|
||||||
|
checkEnvelope(confirmRes, 'POST /me/verifications/phone/confirm');
|
||||||
|
|
||||||
|
const me = checkEnvelope(get('/api/v1/members/me', bearer), 'GET /members/me (post-phone-verify)').data;
|
||||||
|
if (me.business_phone !== phone) {
|
||||||
|
throw new Error(`business_phone not set: got=${me.business_phone}`);
|
||||||
|
}
|
||||||
|
if (me.business_phone_verified !== true) {
|
||||||
|
throw new Error(`business_phone_verified should be true: got=${me.business_phone_verified}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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)');
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
// OTP retrieval helpers (P1 skeleton).
|
||||||
|
//
|
||||||
|
// fetchEmailOTP(email) → reads the latest MailHog mail for `email`, scans body
|
||||||
|
// for a 6-digit code. Full implementation in P2 lib.
|
||||||
|
// fetchSMSOTP(phone) → reads "dev:notification:last:sms:<phone>" from Redis
|
||||||
|
// (k6/experimental/redis). Implementation in P3.
|
||||||
|
//
|
||||||
|
// Both poll for up to cfg.otpPollTimeoutMs because Notifier writes happen
|
||||||
|
// asynchronously (Send goroutine + Redis hook).
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { sleep } from 'k6';
|
||||||
|
// k6 v2.0+ uses 'k6/x/redis' (the experimental module was promoted). The
|
||||||
|
// default export is a namespace; the constructor is `redis.Client`.
|
||||||
|
import redis from 'k6/x/redis';
|
||||||
|
import { cfg } from './config.js';
|
||||||
|
|
||||||
|
const SIX_DIGITS = /\b(\d{6})\b/;
|
||||||
|
// CSS hex colors (#aabbcc) look like 6-digit OTPs to a naive regex; strip
|
||||||
|
// them before scanning. We also strip quoted-printable soft-line-breaks
|
||||||
|
// (`=\n`) since MailHog returns bodies QP-encoded.
|
||||||
|
const CSS_HEX = /#[0-9a-fA-F]{6}\b/g;
|
||||||
|
const QP_SOFT_BREAK = /=\r?\n/g;
|
||||||
|
|
||||||
|
function sleepMs(ms) {
|
||||||
|
sleep(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractOTPFromText returns the LAST 6-digit number found (after stripping
|
||||||
|
// CSS hex colors). "Last" because the OTP is typically rendered near the
|
||||||
|
// bottom of the email body, after the header/branding markup.
|
||||||
|
export function extractOTPFromText(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const cleaned = String(text).replace(QP_SOFT_BREAK, '').replace(CSS_HEX, '');
|
||||||
|
const all = cleaned.match(/\b\d{6}\b/g);
|
||||||
|
if (!all || all.length === 0) return '';
|
||||||
|
return all[all.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEmailOTP polls MailHog's /api/v2/search?kind=to&query=<email>
|
||||||
|
// until a 6-digit OTP can be parsed out of the most recent message body.
|
||||||
|
//
|
||||||
|
// opts.since (ms epoch, default 0): ignore mails delivered before this ts —
|
||||||
|
// needed when the same address has multiple OTPs in MailHog (e.g. register
|
||||||
|
// then email-verify), so the verify step picks up the new mail rather than
|
||||||
|
// the register one.
|
||||||
|
// opts.limit (default 5): number of latest mails to inspect each poll.
|
||||||
|
export function fetchEmailOTP(email, opts = {}) {
|
||||||
|
const since = opts.since || 0;
|
||||||
|
const limit = opts.limit || 5;
|
||||||
|
const deadline = Date.now() + cfg.otpPollTimeoutMs;
|
||||||
|
const u = `${cfg.mailhogUrl}/api/v2/search?kind=to&query=${encodeURIComponent(email)}&start=0&limit=${limit}`;
|
||||||
|
let last = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const res = http.get(u);
|
||||||
|
if (res.status === 200) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
const items = (body && body.items) || [];
|
||||||
|
for (const item of items) {
|
||||||
|
const created = item.Created ? Date.parse(item.Created) : 0;
|
||||||
|
if (created && created < since) continue;
|
||||||
|
const candidates = [
|
||||||
|
item.Content && item.Content.Body,
|
||||||
|
item.MIME && item.MIME.Parts && item.MIME.Parts.map((p) => p.Body).join('\n'),
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const c of candidates) {
|
||||||
|
const code = extractOTPFromText(c);
|
||||||
|
if (code) return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length > 0) last = `no 6-digit code in ${items.length} items (since=${since})`;
|
||||||
|
} catch (e) {
|
||||||
|
last = `parse-error: ${e}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last = `mailhog status ${res.status}`;
|
||||||
|
}
|
||||||
|
sleepMs(cfg.otpPollIntervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(`fetchEmailOTP(${email}) timed out: ${last}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchSMSOTP polls Redis key "dev:notification:last:sms:<phone>" set by the
|
||||||
|
// mock SMS sender (see internal/model/notification/provider/sms/mock_sender.go).
|
||||||
|
//
|
||||||
|
// opts.prevBody: when supplied, the poll ignores writes whose body equals
|
||||||
|
// prevBody. This is essential when the same phone has two OTPs in a single
|
||||||
|
// test (e.g. register-resend or re-verify); the caller passes the body it
|
||||||
|
// already consumed so the helper waits for the next one.
|
||||||
|
//
|
||||||
|
// Requires k6 v2.0+ which exposes the redis client at k6/x/redis (the
|
||||||
|
// `k6/experimental/redis` module was removed in v2.0). Statically imported
|
||||||
|
// above so this works in default k6 compatibility mode.
|
||||||
|
export async function fetchSMSOTP(phone, opts = {}) {
|
||||||
|
const prev = opts.prevBody || '';
|
||||||
|
const [host, port] = cfg.redisAddr.split(':');
|
||||||
|
const client = new redis.Client({ socket: { host: host, port: parseInt(port || '6379', 10) } });
|
||||||
|
const key = `dev:notification:last:sms:${phone}`;
|
||||||
|
|
||||||
|
const deadline = Date.now() + cfg.otpPollTimeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const body = await client.get(key);
|
||||||
|
if (body && body !== prev) {
|
||||||
|
const code = extractOTPFromText(body);
|
||||||
|
if (code) return { code, body };
|
||||||
|
}
|
||||||
|
sleepMs(cfg.otpPollIntervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(`fetchSMSOTP(${phone}) timed out`);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue