Files
sundynix-agentix/sundynix-gateway/internal/middleware/tenant_role_test.go
T
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

89 lines
2.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// fakeRoleResolver 按 (tenant,user) 返回预置角色,用于隔离测试门控逻辑(不碰 DB)。
type fakeRoleResolver struct{ role string }
func (f fakeRoleResolver) MemberRole(context.Context, string, string) string { return f.role }
// 组装一条 /tasks 路由,模拟 RequireAuth+TenantContext 已注入 uid/tenant_id,再挂门控。
func roleGateEngine(uid, tenant, memberRole, minRole string) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
if uid != "" {
c.Set(CtxUserID, uid)
}
if tenant != "" {
c.Set(CtxTenantID, tenant)
}
c.Next()
})
r.POST("/tasks", RequireTenantRole(fakeRoleResolver{role: memberRole}, minRole),
func(c *gin.Context) { c.String(http.StatusOK, "submitted") })
return r
}
func TestRequireTenantRole_Gate(t *testing.T) {
cases := []struct {
name string
uid string
tenant string
memberRole string
wantCode int
}{
{"owner 放行", "u1", "t1", store.RoleOwner, http.StatusOK},
{"admin 放行", "u1", "t1", store.RoleAdmin, http.StatusOK},
{"member 放行", "u1", "t1", store.RoleMember, http.StatusOK},
{"billing_admin 拦下(财务只读不能提交)", "u1", "t1", store.RoleBillingAdmin, http.StatusForbidden},
{"viewer 拦下(只读不能提交)", "u1", "t1", store.RoleViewer, http.StatusForbidden},
{"非成员(空角色) 拦下", "u1", "t1", "", http.StatusForbidden},
{"未登录 401", "", "t1", store.RoleOwner, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := roleGateEngine(tc.uid, tc.tenant, tc.memberRole, store.RoleMember)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/tasks", nil))
if w.Code != tc.wantCode {
t.Errorf("状态码=%d, 期望 %dbody=%s", w.Code, tc.wantCode, w.Body.String())
}
})
}
}
// 成员自管写路由用 ≥admin 门槛(薄 Web 面):member 也只能看不能动。
func TestRequireTenantRole_AdminGate(t *testing.T) {
cases := []struct {
name string
memberRole string
wantCode int
}{
{"owner 放行", store.RoleOwner, http.StatusOK},
{"admin 放行", store.RoleAdmin, http.StatusOK},
{"member 拦下(成员管理要 admin)", store.RoleMember, http.StatusForbidden},
{"viewer 拦下", store.RoleViewer, http.StatusForbidden},
{"billing_admin 拦下", store.RoleBillingAdmin, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := roleGateEngine("u1", "t1", tc.memberRole, store.RoleAdmin)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/tasks", nil))
if w.Code != tc.wantCode {
t.Errorf("状态码=%d, 期望 %dbody=%s", w.Code, tc.wantCode, w.Body.String())
}
})
}
}