|
|
@@ -26,12 +26,14 @@
|
|
|
package handler
|
|
|
|
|
|
import (
|
|
|
+ "bytes"
|
|
|
"context"
|
|
|
"crypto/rand"
|
|
|
"crypto/rsa"
|
|
|
"crypto/x509"
|
|
|
"encoding/json"
|
|
|
"encoding/pem"
|
|
|
+ "io"
|
|
|
"net/http"
|
|
|
"net/http/httptest"
|
|
|
"os"
|
|
|
@@ -65,6 +67,7 @@ type orgEnv struct {
|
|
|
gdb *gorm.DB
|
|
|
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=不缓存,测试每次重读)。
|
|
|
@@ -106,6 +109,29 @@ func (e *orgEnv) seedRBAC() {
|
|
|
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 路由。
|
|
|
func orgE2eSetup(t *testing.T) *orgEnv {
|
|
|
t.Helper()
|
|
|
@@ -155,6 +181,16 @@ func orgE2eSetup(t *testing.T) *orgEnv {
|
|
|
if err != nil {
|
|
|
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"})
|
|
|
if err != nil {
|
|
|
t.Fatalf("NewAuth: %v", err)
|
|
|
@@ -170,7 +206,13 @@ func orgE2eSetup(t *testing.T) *orgEnv {
|
|
|
org.Get("/campuses", h.Campuses)
|
|
|
org.Get("/campuses/:campusId", h.Campus)
|
|
|
|
|
|
- return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer, resolver: newE2eResolver(gdb)}
|
|
|
+ // 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 重名冲突)。
|
|
|
@@ -249,6 +291,32 @@ func (e *orgEnv) get(path, bearer string) *env {
|
|
|
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 集合)。
|
|
|
func instList(t *testing.T, data json.RawMessage) []int64 {
|
|
|
t.Helper()
|
|
|
@@ -451,3 +519,90 @@ func TestOrgE2E_MenuTreeRealDB(t *testing.T) {
|
|
|
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)
|
|
|
+ }
|
|
|
+}
|