Files
Blizzard 65340616df feat(gateway): 租户自助管理接口 —— 薄 Web 面增量1
SaaS P3 收口第一刀。成员管理此前只有平台超管口径(/admin/tenants/:id/*),
租户 owner 无法自助管自己的组织;建租户同样只有 admin 能做。

- 成员自助四路由(作用于活跃租户,id 取 ctx 不接受路径任意租户):
  GET /tenants/current/members(≥viewer) + POST/PUT/DELETE(≥admin+审计)。
  RequireTenantRole 是 RBAC 极简版预留的架子,这次直接挂上,零改动生效。
- handler 挡两个平台超管可以、租户 admin 不该有的动作:邀请为 owner;
  借 AddMemberByEmail 的 upsert 语义「重新邀请」owner 实现降权——后者
  是真实的越权路径,admin 拿 owner 邮箱重邀成 viewer 就把 owner 拉下马。
- POST /me/tenants 自助建组织:创建者即 owner、建完切入;slug 用雪花 id
  生成不让用户填(全局唯一,自助场景撞名只添乱);owner 没挂上按错误返回
  而不是 warn+200(孤儿租户没人能管)。
- CORS 支持逗号分隔多 origin(desktop 预览/薄 Web 面/admin 不同源):命中
  白名单回显请求 Origin+Vary;单值/通配/生产未配置全拒的旧语义不变。
- 测试:RequireTenantRole 补 ≥admin 门槛用例;cors 四场景单测。
  live 验证 13 项全过(门槛/owner 保护/降权攻击挡下/CORS 回显/审计落痕)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:33:04 +08:00

94 lines
2.8 KiB
Go

package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// corsEngine 起一个只挂 cors() 的引擎(env 由各用例 t.Setenv 预置)。
func corsEngine() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(cors())
r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
return r
}
func doCORS(t *testing.T, r *gin.Engine, method, origin string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, "/ping", nil)
if origin != "" {
req.Header.Set("Origin", origin)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestCORS_MultiOrigin(t *testing.T) {
t.Setenv("CORS_ALLOW_ORIGIN", "http://a.example,http://b.example")
t.Setenv("APP_ENV", "")
t.Setenv("GIN_MODE", "")
r := corsEngine()
// 命中白名单 → 回显请求方 + Vary: Origin(多 origin 时 ACAO 只能发单值)。
w := doCORS(t, r, http.MethodGet, "http://b.example")
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://b.example" {
t.Errorf("命中白名单应回显请求 Origin,得到 %q", got)
}
if w.Header().Get("Vary") != "Origin" {
t.Errorf("多 origin 回显必须带 Vary: Origin,否则缓存串源")
}
// 未命中 → 不发 ACAO(浏览器按同源策略拦截)。
w = doCORS(t, r, http.MethodGet, "http://evil.example")
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("白名单外的 Origin 不应发 ACAO,得到 %q", got)
}
}
func TestCORS_SingleOriginLegacy(t *testing.T) {
t.Setenv("CORS_ALLOW_ORIGIN", "http://only.example")
t.Setenv("APP_ENV", "")
t.Setenv("GIN_MODE", "")
r := corsEngine()
// 单值配置保持旧行为:不带 Origin 的请求(探活/curl)也能看到头。
w := doCORS(t, r, http.MethodGet, "")
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://only.example" {
t.Errorf("单值配置应无条件直写,得到 %q", got)
}
}
func TestCORS_DevWildcardAndPreflight(t *testing.T) {
t.Setenv("CORS_ALLOW_ORIGIN", "")
t.Setenv("APP_ENV", "")
t.Setenv("GIN_MODE", "")
r := corsEngine()
w := doCORS(t, r, http.MethodGet, "http://whatever.example")
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("开发缺省应放行 *,得到 %q", got)
}
// 预检直接 204 短路。
w = doCORS(t, r, http.MethodOptions, "http://whatever.example")
if w.Code != http.StatusNoContent {
t.Errorf("OPTIONS 预检应 204,得到 %d", w.Code)
}
}
func TestCORS_ProdUnsetDeniesAll(t *testing.T) {
t.Setenv("CORS_ALLOW_ORIGIN", "")
t.Setenv("APP_ENV", "prod")
t.Setenv("GIN_MODE", "")
r := corsEngine()
w := doCORS(t, r, http.MethodGet, "http://a.example")
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("生产未配置不应发 ACAO,得到 %q", got)
}
}