Files
sundynix-agentix/sundynix-gateway/internal/middleware/tenant_role_test.go
T
Blizzard bf5be08e96 feat(rbac): 租户角色门控落地(极简版) —— viewer/billing_admin 只读不可提交任务
角色此前能分配但不生效(viewer 也能烧租户积分)。本次让角色真正挡动作:

- store.RoleRank 角色层级:owner(4)>admin(3)>member(2)>viewer/billing_admin(1),
  未知/非成员=0;billing_admin 定位为「财务只读」不跑任务
- 可复用中间件 RequireTenantRole(db, minRole),抽 MemberRoleResolver 小接口便于单测
- POST /tasks 挂 ≥member 门控:唯一会烧租户积分的入口(KB 入库不计租户),挡住即够
- 桌面端 StudioView 收 readOnly:viewer 时「运行」禁用+只读提示(UX 兜底,真闸在后端)
- 测试:RoleRank 纯逻辑 + 中间件 7 门控 case(owner/admin/member 放行,
  viewer/billing_admin/非成员 403,未登录 401)

实机验证(gateway+docker):viewer 提交 403 → 升 member 202 → 降回 viewer 403 →
owner 自租户 202(solo 用户不受影响),三态可逆、角色驱动、中间件先于 handler。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:49:24 +08:00

64 lines
2.0 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())
}
})
}
}