|
@@ -26,12 +26,14 @@
|
|
|
package handler
|
|
package handler
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
|
|
+ "bytes"
|
|
|
"context"
|
|
"context"
|
|
|
"crypto/rand"
|
|
"crypto/rand"
|
|
|
"crypto/rsa"
|
|
"crypto/rsa"
|
|
|
"crypto/x509"
|
|
"crypto/x509"
|
|
|
"encoding/json"
|
|
"encoding/json"
|
|
|
"encoding/pem"
|
|
"encoding/pem"
|
|
|
|
|
+ "io"
|
|
|
"net/http"
|
|
"net/http"
|
|
|
"net/http/httptest"
|
|
"net/http/httptest"
|
|
|
"os"
|
|
"os"
|
|
@@ -48,10 +50,15 @@ import (
|
|
|
infra "yuansiwei/unified-backend/internal/infra/db"
|
|
infra "yuansiwei/unified-backend/internal/infra/db"
|
|
|
appredis "yuansiwei/unified-backend/internal/infra/redis"
|
|
appredis "yuansiwei/unified-backend/internal/infra/redis"
|
|
|
"yuansiwei/unified-backend/internal/org/model"
|
|
"yuansiwei/unified-backend/internal/org/model"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/org/rbac"
|
|
|
|
|
+ orgRepo "yuansiwei/unified-backend/internal/org/repository"
|
|
|
orgservice "yuansiwei/unified-backend/internal/org/service"
|
|
orgservice "yuansiwei/unified-backend/internal/org/service"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由)。
|
|
|
|
|
|
|
+// ptrStr 字符串指针辅助(e2e 测试用,构造可空字段的指针值)。
|
|
|
|
|
+func ptrStr(s string) *string { return &s }
|
|
|
|
|
+
|
|
|
|
|
+// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由 + RBAC 解析器)。
|
|
|
type orgEnv struct {
|
|
type orgEnv struct {
|
|
|
t *testing.T
|
|
t *testing.T
|
|
|
app *fiber.App
|
|
app *fiber.App
|
|
@@ -59,6 +66,70 @@ type orgEnv struct {
|
|
|
revocation *appredis.RevocationStore
|
|
revocation *appredis.RevocationStore
|
|
|
gdb *gorm.DB
|
|
gdb *gorm.DB
|
|
|
issuer *token.Issuer
|
|
issuer *token.Issuer
|
|
|
|
|
+ resolver *rbac.Resolver // T6.5-d:真 DB 菜单树/权限码解析(超管配置端点 + menuTree 下发 e2e)
|
|
|
|
|
+ rbacH *RBACHandler // T6.5-e:超管矩阵配置端点(/org/role-permissions/:roleCode)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newE2eResolver 构造真 DB 的 RBAC 解析器(平台级 PermissionRepo + TTL 0=不缓存,测试每次重读)。
|
|
|
|
|
+func newE2eResolver(gdb *gorm.DB) *rbac.Resolver {
|
|
|
|
|
+ return rbac.NewResolver(orgRepo.NewPermissionRepo(gdb), 0)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// seedRBAC 落最小集权限点 + 角色矩阵(超管配置端点 + menuTree 下发 e2e 用)。
|
|
|
|
|
+// - 权限点:org.institution.list(menu)/ org.institution.get(button)/ org.campus.list(menu)/
|
|
|
|
|
+// org.campus.get(button),对齐 core-model-ddl.sql v1.1 最小集种子;
|
|
|
|
|
+// - 矩阵:academic 拥有 4 个 org 权限点(菜单 2 + 按钮 2)。
|
|
|
|
|
+func (e *orgEnv) seedRBAC() {
|
|
|
|
|
+ e.t.Helper()
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ now := time.Now()
|
|
|
|
|
+ must := func(err error, what string) {
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ e.t.Fatalf("%s: %v", what, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ instList := "org.institution.list"
|
|
|
|
|
+ instGet := "org.institution.get"
|
|
|
|
|
+ campusList := "org.campus.list"
|
|
|
|
|
+ campusGet := "org.campus.get"
|
|
|
|
|
+ must(e.gdb.WithContext(ctx).Create(&model.Permission{
|
|
|
|
|
+ Code: instList, Type: model.PermissionTypeMenu, Name: "机构管理", Path: ptrStr("/org/institutions"), Sort: 1, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
+ }).Error, "插入权限 org.institution.list")
|
|
|
|
|
+ must(e.gdb.WithContext(ctx).Create(&model.Permission{
|
|
|
|
|
+ Code: instGet, Type: model.PermissionTypeButton, Name: "机构详情", ParentCode: ptrStr(instList), Sort: 2, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
+ }).Error, "插入权限 org.institution.get")
|
|
|
|
|
+ must(e.gdb.WithContext(ctx).Create(&model.Permission{
|
|
|
|
|
+ Code: campusList, Type: model.PermissionTypeMenu, Name: "校区管理", Path: ptrStr("/org/campuses"), Sort: 3, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
+ }).Error, "插入权限 org.campus.list")
|
|
|
|
|
+ must(e.gdb.WithContext(ctx).Create(&model.Permission{
|
|
|
|
|
+ Code: campusGet, Type: model.PermissionTypeButton, Name: "校区详情", ParentCode: ptrStr(campusList), Sort: 4, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
+ }).Error, "插入权限 org.campus.get")
|
|
|
|
|
+ // 矩阵:academic 角色拥有全部 4 个 org 权限点
|
|
|
|
|
+ repo := orgRepo.NewPermissionRepo(e.gdb)
|
|
|
|
|
+ must(repo.SaveRolePermissions(ctx, "academic", []string{instList, instGet, campusList, campusGet}), "配置 academic 矩阵")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// seedRoles 落 t_role 角色字典行(T6.5-e 超管配置端点的 validateRole 依赖 t_role 字典存在性)。
|
|
|
|
|
+// 至少落 super_admin + academic(矩阵配置/读取的目标角色;其余角色按需)。
|
|
|
|
|
+func (e *orgEnv) seedRoles(codes ...string) {
|
|
|
|
|
+ e.t.Helper()
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ now := time.Now()
|
|
|
|
|
+ must := func(err error, what string) {
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ e.t.Fatalf("%s: %v", what, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, code := range codes {
|
|
|
|
|
+ scopeLevel := model.ScopeLevelInstitution
|
|
|
|
|
+ if code == model.RoleSuperAdmin {
|
|
|
|
|
+ scopeLevel = model.ScopeLevelPlatform
|
|
|
|
|
+ }
|
|
|
|
|
+ must(e.gdb.WithContext(ctx).Create(&model.RoleModel{
|
|
|
|
|
+ RoleCode: code, RoleName: code, ScopeLevel: scopeLevel, LoginAllowed: code != model.RoleMarket,
|
|
|
|
|
+ CreatedAt: now,
|
|
|
|
|
+ }).Error, "插入角色 "+code)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。
|
|
// orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。
|
|
@@ -73,10 +144,16 @@ func orgE2eSetup(t *testing.T) *orgEnv {
|
|
|
t.Fatalf("NewDB: %v", err)
|
|
t.Fatalf("NewDB: %v", err)
|
|
|
}
|
|
}
|
|
|
t.Cleanup(func() { _ = infra.Close(gdb) })
|
|
t.Cleanup(func() { _ = infra.Close(gdb) })
|
|
|
- if err := infra.Migrate(gdb, &model.Institution{}, &model.Campus{}); err != nil {
|
|
|
|
|
|
|
+ // 迁移 org 域全 7 表(含 RBAC:t_permission/t_role_permission,T6.5-d 路由白名单 + 菜单树 e2e 用)
|
|
|
|
|
+ if err := infra.Migrate(gdb, model.AllTables()...); err != nil {
|
|
|
t.Fatalf("Migrate: %v", err)
|
|
t.Fatalf("Migrate: %v", err)
|
|
|
}
|
|
}
|
|
|
- t.Cleanup(func() { _ = gdb.Migrator().DropTable("t_institution", "t_campus") })
|
|
|
|
|
|
|
+ t.Cleanup(func() {
|
|
|
|
|
+ _ = gdb.Migrator().DropTable(
|
|
|
|
|
+ "t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link",
|
|
|
|
|
+ "t_permission", "t_role_permission",
|
|
|
|
|
+ )
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
mr, err := miniredis.Run()
|
|
mr, err := miniredis.Run()
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -104,6 +181,16 @@ func orgE2eSetup(t *testing.T) *orgEnv {
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
t.Fatalf("org handler New: %v", err)
|
|
t.Fatalf("org handler New: %v", err)
|
|
|
}
|
|
}
|
|
|
|
|
+ // 共享解析器(TTL 0=不缓存,每次重读真 DB):供 T6.5-d menuTree 断言 + T6.5-e 保存后 Invalidate。
|
|
|
|
|
+ resolver := newE2eResolver(gdb)
|
|
|
|
|
+ rbacSvc, err := orgservice.NewRolePermissionService(gdb, orgRepo.NewPermissionRepo(gdb), resolver)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("NewRolePermissionService: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ rbacH, err := NewRBAC(RBACConfig{Svc: rbacSvc})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("NewRBAC: %v", err)
|
|
|
|
|
+ }
|
|
|
mw, err := idhandler.NewAuth(idhandler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
|
|
mw, err := idhandler.NewAuth(idhandler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
t.Fatalf("NewAuth: %v", err)
|
|
t.Fatalf("NewAuth: %v", err)
|
|
@@ -111,13 +198,21 @@ func orgE2eSetup(t *testing.T) *orgEnv {
|
|
|
|
|
|
|
|
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
org := app.Group("/org")
|
|
org := app.Group("/org")
|
|
|
- org.Use(mw)
|
|
|
|
|
|
|
+ // 验签 + 路由角色白名单(T6.5-d 接口强制层):org 目录允许 staff 角色(academic/finance/teacher),
|
|
|
|
|
+ // 业务级(student/parent)拒绝 1003,超管经 SuperAdmin 恒放行。whitelist 与 main.go 生产路由一致。
|
|
|
|
|
+ org.Use(mw, idhandler.RequireRole("academic", "finance", "teacher"))
|
|
|
org.Get("/institutions", h.Institutions)
|
|
org.Get("/institutions", h.Institutions)
|
|
|
org.Get("/institutions/:institutionId", h.Institution)
|
|
org.Get("/institutions/:institutionId", h.Institution)
|
|
|
org.Get("/campuses", h.Campuses)
|
|
org.Get("/campuses", h.Campuses)
|
|
|
org.Get("/campuses/:campusId", h.Campus)
|
|
org.Get("/campuses/:campusId", h.Campus)
|
|
|
|
|
|
|
|
- return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer}
|
|
|
|
|
|
|
+ // RBAC 矩阵配置(T6.5-e):独立兄弟组 + 超管白名单(与 main.go 一致;非超管 1003)。
|
|
|
|
|
+ rbacCfg := app.Group("/org/role-permissions")
|
|
|
|
|
+ rbacCfg.Use(mw, idhandler.RequireRole("super_admin"))
|
|
|
|
|
+ rbacCfg.Get("/:roleCode", rbacH.RolePermissions)
|
|
|
|
|
+ rbacCfg.Put("/:roleCode", rbacH.SaveRolePermissions)
|
|
|
|
|
+
|
|
|
|
|
+ return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer, resolver: resolver, rbacH: rbacH}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。
|
|
// genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。
|
|
@@ -196,6 +291,32 @@ func (e *orgEnv) get(path, bearer string) *env {
|
|
|
return &en
|
|
return &en
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// put 带 Bearer 头 PUT(JSON 请求体)并解信封(T6.5-e 矩阵保存用;HTTP 恒 200,断 body.code)。
|
|
|
|
|
+// body 为请求体 JSON 字节(nil 时不发 body = 清空矩阵)。
|
|
|
|
|
+func (e *orgEnv) put(path, bearer string, body []byte) *env {
|
|
|
|
|
+ e.t.Helper()
|
|
|
|
|
+ var reader io.Reader
|
|
|
|
|
+ if body != nil {
|
|
|
|
|
+ reader = bytes.NewReader(body)
|
|
|
|
|
+ }
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPut, path, reader)
|
|
|
|
|
+ if body != nil {
|
|
|
|
|
+ req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
+ }
|
|
|
|
|
+ if bearer != "" {
|
|
|
|
|
+ req.Header.Set("Authorization", "Bearer "+bearer)
|
|
|
|
|
+ }
|
|
|
|
|
+ resp, err := e.app.Test(req, -1)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ e.t.Fatalf("PUT %s 请求失败: %v", path, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var en env
|
|
|
|
|
+ if err := json.NewDecoder(resp.Body).Decode(&en); err != nil {
|
|
|
|
|
+ e.t.Fatalf("PUT %s 解析响应: %v", path, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return &en
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// instList 从 PageData 解机构列表(institutionId 集合)。
|
|
// instList 从 PageData 解机构列表(institutionId 集合)。
|
|
|
func instList(t *testing.T, data json.RawMessage) []int64 {
|
|
func instList(t *testing.T, data json.RawMessage) []int64 {
|
|
|
t.Helper()
|
|
t.Helper()
|
|
@@ -323,3 +444,165 @@ func TestOrgE2E_ScopeMissingID(t *testing.T) {
|
|
|
t.Fatalf("教务跨机构 code=%d(期望 1004)", r.Code)
|
|
t.Fatalf("教务跨机构 code=%d(期望 1004)", r.Code)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// TestOrgE2E_RouteRoleWhitelist 路由角色白名单(T6.5-d 接口强制层):
|
|
|
|
|
+// 白名单 [academic, finance, teacher] + 超管放行:
|
|
|
|
|
+// - student(业务级,不在白名单)→ 1003(即使有有效 token);
|
|
|
|
|
+// - teacher(在白名单)→ 200(正常放行,进入数据层 scoping);
|
|
|
|
|
+// - super_admin(超管恒放行)→ 200(不查白名单)。
|
|
|
|
|
+//
|
|
|
|
|
+// 验证"已登录但角色不符"被中间件拦在数据层之前(code 1003 而非 1004/5002)。
|
|
|
|
|
+func TestOrgE2E_RouteRoleWhitelist(t *testing.T) {
|
|
|
|
|
+ e := orgE2eSetup(t)
|
|
|
|
|
+ e.seedOrg()
|
|
|
|
|
+
|
|
|
|
|
+ // student 业务级角色:有有效 token 但角色不在 /org 白名单 → 1003
|
|
|
|
|
+ student := e.issue(token.Claims{
|
|
|
|
|
+ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"student"}, InstitutionID: 11, CampusID: 3,
|
|
|
|
|
+ })
|
|
|
|
|
+ if r := e.get("/org/institutions", student); r.Code != 1003 {
|
|
|
|
|
+ t.Fatalf("student 访问 /org/institutions code=%d(期望 1003 无权限,路由白名单拦截)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+ if r := e.get("/org/campuses", student); r.Code != 1003 {
|
|
|
|
|
+ t.Fatalf("student 访问 /org/campuses code=%d(期望 1003)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // teacher 在白名单:正常放行(进入数据层 scoping,返回本机构/校区)
|
|
|
|
|
+ teacher := e.issue(token.Claims{
|
|
|
|
|
+ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
|
|
|
|
|
+ })
|
|
|
|
|
+ if r := e.get("/org/institutions", teacher); r.Code != 200 {
|
|
|
|
|
+ t.Fatalf("teacher(白名单内)访问 /org/institutions code=%d(期望 200)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // super_admin:超管恒放行(不查白名单)
|
|
|
|
|
+ super := e.issue(token.Claims{
|
|
|
|
|
+ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+ if r := e.get("/org/institutions", super); r.Code != 200 {
|
|
|
|
|
+ t.Fatalf("super_admin 访问 /org/institutions code=%d(期望 200 超管放行)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestOrgE2E_MenuTreeRealDB 真 DB 菜单树/权限码解析(T6.5-d 展示层 e2e):
|
|
|
|
|
+// 种最小集权限点 + academic 矩阵后,Resolver 解析 academic 角色 →
|
|
|
|
|
+// - Permissions = 4 个 code(2 菜单 + 2 按钮);
|
|
|
|
|
+// - MenuTree = 2 个顶级 menu 节点(button 不进树)。
|
|
|
|
|
+//
|
|
|
|
|
+// 验证 PermissionRepo + rbac.Resolver 在真 GORM 仓储下联动正确。
|
|
|
|
|
+func TestOrgE2E_MenuTreeRealDB(t *testing.T) {
|
|
|
|
|
+ e := orgE2eSetup(t)
|
|
|
|
|
+ e.seedRBAC()
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+
|
|
|
|
|
+ codes, err := e.resolver.Permissions(ctx, "academic")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("Permissions: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(codes) != 4 {
|
|
|
|
|
+ t.Fatalf("academic Permissions=%v(期望 4 个 org 权限码)", codes)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tree, err := e.resolver.MenuTree(ctx, "academic")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("MenuTree: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(tree) != 2 {
|
|
|
|
|
+ t.Fatalf("academic MenuTree 顶级=%v(期望 2:机构/校区,button 不进树)", tree)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 超管全量:同样 4 权限码(代码层放行,不查矩阵)
|
|
|
|
|
+ superCodes, err := e.resolver.Permissions(ctx, "super_admin")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("Permissions(super_admin): %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(superCodes) != 4 {
|
|
|
|
|
+ t.Fatalf("super_admin Permissions=%v(期望全量 4 个)", superCodes)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestOrgE2E_RolePermConfig 超管角色-权限点矩阵配置端点(T6.5-e 配置后台 e2e):
|
|
|
|
|
+// - 白名单:非超管(academic)访问 /org/role-permissions/academic → 1003;
|
|
|
|
|
+// - 读取:超管 GET /org/role-permissions/academic → 200,assigned 含 4 个 org 权限码 + dictionary 4 项;
|
|
|
|
|
+// - 保存:超管 PUT 全量替换 academic 矩阵为 2 码 → 200;GET 回读 assigned=2 码(保存即全量替换);
|
|
|
|
|
+// - 校验:PUT 含未知权限码 → 2001;PUT 非法 roleCode → 2001。
|
|
|
|
|
+//
|
|
|
|
|
+// 验证 RolePermissionService 的 validateRole/validateCodes/SaveRolePermissions +
|
|
|
|
|
+// RBACHandler 的路由白名单 + 真 DB 删旧插新事务 + 保存后 Invalidate 联动。
|
|
|
|
|
+func TestOrgE2E_RolePermConfig(t *testing.T) {
|
|
|
|
|
+ e := orgE2eSetup(t)
|
|
|
|
|
+ e.seedRBAC()
|
|
|
|
|
+ e.seedRoles("academic", "super_admin")
|
|
|
|
|
+
|
|
|
|
|
+ super := e.issue(token.Claims{
|
|
|
|
|
+ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+ academic := e.issue(token.Claims{
|
|
|
|
|
+ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ① 非超管访问配置端点 → 1003(路由白名单拦截,非 200/1004)
|
|
|
|
|
+ if r := e.get("/org/role-permissions/academic", academic); r.Code != 1003 {
|
|
|
|
|
+ t.Fatalf("academic 访问配置端点 code=%d(期望 1003 无权限)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+ if r := e.put("/org/role-permissions/academic", academic, []byte(`{"permissions":["org.campus.list"]}`)); r.Code != 1003 {
|
|
|
|
|
+ t.Fatalf("academic 保存配置 code=%d(期望 1003 无权限)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ② 超管读取 academic 矩阵:assigned 4 码 + dictionary 4 项
|
|
|
|
|
+ got := e.get("/org/role-permissions/academic", super)
|
|
|
|
|
+ if got.Code != 200 {
|
|
|
|
|
+ t.Fatalf("超管读 academic 矩阵 code=%d msg=%s(期望 200)", got.Code, got.Message)
|
|
|
|
|
+ }
|
|
|
|
|
+ var view struct {
|
|
|
|
|
+ RoleCode string `json:"roleCode"`
|
|
|
|
|
+ Assigned []string `json:"assigned"`
|
|
|
|
|
+ Dictionary []struct {
|
|
|
|
|
+ Code string `json:"code"`
|
|
|
|
|
+ } `json:"dictionary"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(got.Data, &view); err != nil {
|
|
|
|
|
+ t.Fatalf("解析 RolePermissionView: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if view.RoleCode != "academic" || len(view.Assigned) != 4 || len(view.Dictionary) != 4 {
|
|
|
|
|
+ t.Fatalf("RolePermissionView 不符:roleCode=%q assigned=%d dict=%d(期望 academic/4/4)",
|
|
|
|
|
+ view.RoleCode, len(view.Assigned), len(view.Dictionary))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ③ 超管保存:全量替换 academic 为 2 码(org.institution.list + org.campus.list)
|
|
|
|
|
+ body, _ := json.Marshal(map[string][]string{
|
|
|
|
|
+ "permissions": {"org.institution.list", "org.campus.list"},
|
|
|
|
|
+ })
|
|
|
|
|
+ if r := e.put("/org/role-permissions/academic", super, body); r.Code != 200 {
|
|
|
|
|
+ t.Fatalf("超管保存 academic 矩阵 code=%d msg=%s(期望 200)", r.Code, r.Message)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 回读:assigned 应为 2 码(全量替换语义;原 4 码被删)
|
|
|
|
|
+ got2 := e.get("/org/role-permissions/academic", super)
|
|
|
|
|
+ if got2.Code != 200 {
|
|
|
|
|
+ t.Fatalf("保存后回读 code=%d(期望 200)", got2.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+ var view2 struct {
|
|
|
|
|
+ Assigned []string `json:"assigned"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(got2.Data, &view2); err != nil {
|
|
|
|
|
+ t.Fatalf("解析回读: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(view2.Assigned) != 2 {
|
|
|
|
|
+ t.Fatalf("保存后 assigned=%v(期望 2 码,全量替换)", view2.Assigned)
|
|
|
|
|
+ }
|
|
|
|
|
+ // Resolver 经 Invalidate 后重读:academic 权限码同步为 2 个(本实例缓存已清)
|
|
|
|
|
+ if codes, _ := e.resolver.Permissions(context.Background(), "academic"); len(codes) != 2 {
|
|
|
|
|
+ t.Fatalf("Invalidate 后 resolver.Permissions(academic)=%v(期望 2 码)", codes)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ④ 校验:含未知权限码 → 2001
|
|
|
|
|
+ badBody, _ := json.Marshal(map[string][]string{
|
|
|
|
|
+ "permissions": {"org.institution.list", "not.exist.code"},
|
|
|
|
|
+ })
|
|
|
|
|
+ if r := e.put("/org/role-permissions/academic", super, badBody); r.Code != 2001 {
|
|
|
|
|
+ t.Fatalf("含未知权限码 code=%d(期望 2001 参数错误)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 校验:非法 roleCode → 2001
|
|
|
|
|
+ if r := e.put("/org/role-permissions/ghost_role", super, body); r.Code != 2001 {
|
|
|
|
|
+ t.Fatalf("非法 roleCode code=%d(期望 2001 角色码非法)", r.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|