org_e2e_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. // org 域越权端到端测试(T6 验收核心:跨校区/跨机构访问被拒)。
  2. //
  3. // 用真 GORM 仓储 + 真 Issuer + miniredis + NewAuth 验签中间件,走 HTTP 全链路,
  4. // 同时覆盖多租户防线的**两道**(架构规格 §3.5):
  5. // 1. Service 层 assertInScope(显式 1004):请求 query 显式携带的 institutionId/campusId
  6. // 与 token 作用域冲突 → code 1004(前端据此校正 scope);
  7. // 2. Repository 层 Scoping(数据层强制过滤):即便请求不带显式 id,查询也强制带
  8. // institution_id/campus_id 过滤——非超管永远查不到别家机构/别校校区的数据。
  9. //
  10. // 场景矩阵:
  11. // - 老师(inst=11, campus=3,校区锁定):
  12. // GET /org/institutions → 只见机构 11(机构 22 被 Scoping 排除,数据层)
  13. // GET /org/campuses → 只见校区 3(校区 4 被机构+校区双重 scoping 排除)
  14. // GET /org/institutions?institutionId=22 → 1004(跨机构,Service 显式)
  15. // GET /org/campuses?campusId=4 → 1004(跨校区,Service 显式)
  16. // GET /org/institutions/22 → 1004(跨机构单查,Service 显式)
  17. // - 平台超管(super_admin):GET /org/institutions → 见全部机构(11 + 22)
  18. // - 无 token:/org/* → 1001(中间件未登录)
  19. //
  20. // 运行前提(同身份域 e2e 纪律:DSN 走环境变量,不入库):
  21. //
  22. // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
  23. // go test ./internal/org/handler/ -run TestOrgE2E -v
  24. //
  25. // 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。
  26. package handler
  27. import (
  28. "bytes"
  29. "context"
  30. "crypto/rand"
  31. "crypto/rsa"
  32. "crypto/x509"
  33. "encoding/json"
  34. "encoding/pem"
  35. "io"
  36. "net/http"
  37. "net/http/httptest"
  38. "os"
  39. "testing"
  40. "time"
  41. miniredis "github.com/alicebob/miniredis/v2"
  42. "github.com/gofiber/fiber/v2"
  43. "gorm.io/gorm"
  44. idhandler "yuansiwei/unified-backend/internal/identity/handler"
  45. "yuansiwei/unified-backend/internal/identity/token"
  46. "yuansiwei/unified-backend/internal/infra/config"
  47. infra "yuansiwei/unified-backend/internal/infra/db"
  48. appredis "yuansiwei/unified-backend/internal/infra/redis"
  49. "yuansiwei/unified-backend/internal/org/model"
  50. "yuansiwei/unified-backend/internal/org/rbac"
  51. orgRepo "yuansiwei/unified-backend/internal/org/repository"
  52. orgservice "yuansiwei/unified-backend/internal/org/service"
  53. )
  54. // ptrStr 字符串指针辅助(e2e 测试用,构造可空字段的指针值)。
  55. func ptrStr(s string) *string { return &s }
  56. // orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由 + RBAC 解析器)。
  57. type orgEnv struct {
  58. t *testing.T
  59. app *fiber.App
  60. pubKeys []string
  61. revocation *appredis.RevocationStore
  62. gdb *gorm.DB
  63. issuer *token.Issuer
  64. resolver *rbac.Resolver // T6.5-d:真 DB 菜单树/权限码解析(超管配置端点 + menuTree 下发 e2e)
  65. rbacH *RBACHandler // T6.5-e:超管矩阵配置端点(/org/role-permissions/:roleCode)
  66. }
  67. // newE2eResolver 构造真 DB 的 RBAC 解析器(平台级 PermissionRepo + TTL 0=不缓存,测试每次重读)。
  68. func newE2eResolver(gdb *gorm.DB) *rbac.Resolver {
  69. return rbac.NewResolver(orgRepo.NewPermissionRepo(gdb), 0)
  70. }
  71. // seedRBAC 落最小集权限点 + 角色矩阵(超管配置端点 + menuTree 下发 e2e 用)。
  72. // - 权限点:org.institution.list(menu)/ org.institution.get(button)/ org.campus.list(menu)/
  73. // org.campus.get(button),对齐 core-model-ddl.sql v1.1 最小集种子;
  74. // - 矩阵:academic 拥有 4 个 org 权限点(菜单 2 + 按钮 2)。
  75. func (e *orgEnv) seedRBAC() {
  76. e.t.Helper()
  77. ctx := context.Background()
  78. now := time.Now()
  79. must := func(err error, what string) {
  80. if err != nil {
  81. e.t.Fatalf("%s: %v", what, err)
  82. }
  83. }
  84. instList := "org.institution.list"
  85. instGet := "org.institution.get"
  86. campusList := "org.campus.list"
  87. campusGet := "org.campus.get"
  88. must(e.gdb.WithContext(ctx).Create(&model.Permission{
  89. Code: instList, Type: model.PermissionTypeMenu, Name: "机构管理", Path: ptrStr("/org/institutions"), Sort: 1, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
  90. }).Error, "插入权限 org.institution.list")
  91. must(e.gdb.WithContext(ctx).Create(&model.Permission{
  92. Code: instGet, Type: model.PermissionTypeButton, Name: "机构详情", ParentCode: ptrStr(instList), Sort: 2, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
  93. }).Error, "插入权限 org.institution.get")
  94. must(e.gdb.WithContext(ctx).Create(&model.Permission{
  95. Code: campusList, Type: model.PermissionTypeMenu, Name: "校区管理", Path: ptrStr("/org/campuses"), Sort: 3, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
  96. }).Error, "插入权限 org.campus.list")
  97. must(e.gdb.WithContext(ctx).Create(&model.Permission{
  98. Code: campusGet, Type: model.PermissionTypeButton, Name: "校区详情", ParentCode: ptrStr(campusList), Sort: 4, Status: model.PermissionStatusActive, CreatedAt: now, UpdatedAt: now,
  99. }).Error, "插入权限 org.campus.get")
  100. // 矩阵:academic 角色拥有全部 4 个 org 权限点
  101. repo := orgRepo.NewPermissionRepo(e.gdb)
  102. must(repo.SaveRolePermissions(ctx, "academic", []string{instList, instGet, campusList, campusGet}), "配置 academic 矩阵")
  103. }
  104. // seedRoles 落 t_role 角色字典行(T6.5-e 超管配置端点的 validateRole 依赖 t_role 字典存在性)。
  105. // 至少落 super_admin + academic(矩阵配置/读取的目标角色;其余角色按需)。
  106. func (e *orgEnv) seedRoles(codes ...string) {
  107. e.t.Helper()
  108. ctx := context.Background()
  109. now := time.Now()
  110. must := func(err error, what string) {
  111. if err != nil {
  112. e.t.Fatalf("%s: %v", what, err)
  113. }
  114. }
  115. for _, code := range codes {
  116. scopeLevel := model.ScopeLevelInstitution
  117. if code == model.RoleSuperAdmin {
  118. scopeLevel = model.ScopeLevelPlatform
  119. }
  120. must(e.gdb.WithContext(ctx).Create(&model.RoleModel{
  121. RoleCode: code, RoleName: code, ScopeLevel: scopeLevel, LoginAllowed: code != model.RoleMarket,
  122. CreatedAt: now,
  123. }).Error, "插入角色 "+code)
  124. }
  125. }
  126. // orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。
  127. func orgE2eSetup(t *testing.T) *orgEnv {
  128. t.Helper()
  129. dsn := os.Getenv("UNIFIED_TEST_DSN")
  130. if dsn == "" {
  131. t.Skip("未设置 UNIFIED_TEST_DSN,跳过 org 越权端到端测试")
  132. }
  133. gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60})
  134. if err != nil {
  135. t.Fatalf("NewDB: %v", err)
  136. }
  137. t.Cleanup(func() { _ = infra.Close(gdb) })
  138. // 迁移 org 域全 7 表(含 RBAC:t_permission/t_role_permission,T6.5-d 路由白名单 + 菜单树 e2e 用)
  139. if err := infra.Migrate(gdb, model.AllTables()...); err != nil {
  140. t.Fatalf("Migrate: %v", err)
  141. }
  142. t.Cleanup(func() {
  143. _ = gdb.Migrator().DropTable(
  144. "t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link",
  145. "t_permission", "t_role_permission",
  146. )
  147. })
  148. mr, err := miniredis.Run()
  149. if err != nil {
  150. t.Fatalf("miniredis.Run: %v", err)
  151. }
  152. t.Cleanup(mr.Close)
  153. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  154. if err != nil {
  155. t.Fatalf("NewClient: %v", err)
  156. }
  157. t.Cleanup(func() { _ = rc.Close() })
  158. refreshStore := appredis.NewRefreshStore(rc)
  159. revocation := appredis.NewRevocationStore(rc, "revoked")
  160. privPEM, pubKeys := genOrgRSAKey(t)
  161. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  162. if err != nil {
  163. t.Fatalf("NewIssuer: %v", err)
  164. }
  165. svc, err := orgservice.NewOrgService(gdb)
  166. if err != nil {
  167. t.Fatalf("NewOrgService: %v", err)
  168. }
  169. h, err := New(Config{Svc: svc})
  170. if err != nil {
  171. t.Fatalf("org handler New: %v", err)
  172. }
  173. // 共享解析器(TTL 0=不缓存,每次重读真 DB):供 T6.5-d menuTree 断言 + T6.5-e 保存后 Invalidate。
  174. resolver := newE2eResolver(gdb)
  175. rbacSvc, err := orgservice.NewRolePermissionService(gdb, orgRepo.NewPermissionRepo(gdb), resolver)
  176. if err != nil {
  177. t.Fatalf("NewRolePermissionService: %v", err)
  178. }
  179. rbacH, err := NewRBAC(RBACConfig{Svc: rbacSvc})
  180. if err != nil {
  181. t.Fatalf("NewRBAC: %v", err)
  182. }
  183. mw, err := idhandler.NewAuth(idhandler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
  184. if err != nil {
  185. t.Fatalf("NewAuth: %v", err)
  186. }
  187. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  188. org := app.Group("/org")
  189. // 验签 + 路由角色白名单(T6.5-d 接口强制层):org 目录允许 staff 角色(academic/finance/teacher),
  190. // 业务级(student/parent)拒绝 1003,超管经 SuperAdmin 恒放行。whitelist 与 main.go 生产路由一致。
  191. org.Use(mw, idhandler.RequireRole("academic", "finance", "teacher"))
  192. org.Get("/institutions", h.Institutions)
  193. org.Get("/institutions/:institutionId", h.Institution)
  194. org.Get("/campuses", h.Campuses)
  195. org.Get("/campuses/:campusId", h.Campus)
  196. // RBAC 矩阵配置(T6.5-e):独立兄弟组 + 超管白名单(与 main.go 一致;非超管 1003)。
  197. rbacCfg := app.Group("/org/role-permissions")
  198. rbacCfg.Use(mw, idhandler.RequireRole("super_admin"))
  199. rbacCfg.Get("/:roleCode", rbacH.RolePermissions)
  200. rbacCfg.Put("/:roleCode", rbacH.SaveRolePermissions)
  201. return &orgEnv{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, issuer: issuer, resolver: resolver, rbacH: rbacH}
  202. }
  203. // genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。
  204. func genOrgRSAKey(t *testing.T) (string, []string) {
  205. t.Helper()
  206. key, err := rsa.GenerateKey(rand.Reader, 2048)
  207. if err != nil {
  208. t.Fatalf("生成 RSA 私钥: %v", err)
  209. }
  210. privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
  211. pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
  212. if err != nil {
  213. t.Fatalf("序列化公钥: %v", err)
  214. }
  215. pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
  216. return privPEM, []string{pubPEM}
  217. }
  218. // issue 用真 Issuer 签一个 access(指定作用域 claims)。
  219. func (e *orgEnv) issue(claims token.Claims) string {
  220. e.t.Helper()
  221. access, _, err := e.issuer.Issue(context.Background(), claims)
  222. if err != nil {
  223. e.t.Fatalf("Issue: %v", err)
  224. }
  225. return access
  226. }
  227. // seedOrg 落两所机构 + 两校区(inst11/campus3、inst22/campus4,raw SQL 保证 id 确定)。
  228. func (e *orgEnv) seedOrg() {
  229. e.t.Helper()
  230. ctx := context.Background()
  231. now := time.Now()
  232. must := func(err error, what string) {
  233. if err != nil {
  234. e.t.Fatalf("%s: %v", what, err)
  235. }
  236. }
  237. must(e.gdb.WithContext(ctx).Create(&model.Institution{
  238. InstitutionID: 11, InstitutionName: "实验一中", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
  239. }).Error, "插入机构 11")
  240. must(e.gdb.WithContext(ctx).Create(&model.Institution{
  241. InstitutionID: 22, InstitutionName: "实验二中", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
  242. }).Error, "插入机构 22")
  243. must(e.gdb.WithContext(ctx).Create(&model.Campus{
  244. CampusID: 3, InstitutionID: 11, CampusName: "一中本部", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
  245. }).Error, "插入校区 3")
  246. must(e.gdb.WithContext(ctx).Create(&model.Campus{
  247. CampusID: 4, InstitutionID: 22, CampusName: "二中本部", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now,
  248. }).Error, "插入校区 4")
  249. }
  250. // orgEnv envelope(统一信封;Data 留原始 JSON 供按需解析)。
  251. type env struct {
  252. Code int `json:"code"`
  253. Message string `json:"message"`
  254. Data json.RawMessage `json:"data"`
  255. Success bool `json:"success"`
  256. }
  257. // get 带 Bearer 头 GET 并解信封(HTTP 恒 200,断 body.code)。
  258. func (e *orgEnv) get(path, bearer string) *env {
  259. e.t.Helper()
  260. req := httptest.NewRequest(http.MethodGet, path, nil)
  261. if bearer != "" {
  262. req.Header.Set("Authorization", "Bearer "+bearer)
  263. }
  264. resp, err := e.app.Test(req, -1)
  265. if err != nil {
  266. e.t.Fatalf("GET %s 请求失败: %v", path, err)
  267. }
  268. var en env
  269. if err := json.NewDecoder(resp.Body).Decode(&en); err != nil {
  270. e.t.Fatalf("GET %s 解析响应: %v", path, err)
  271. }
  272. return &en
  273. }
  274. // put 带 Bearer 头 PUT(JSON 请求体)并解信封(T6.5-e 矩阵保存用;HTTP 恒 200,断 body.code)。
  275. // body 为请求体 JSON 字节(nil 时不发 body = 清空矩阵)。
  276. func (e *orgEnv) put(path, bearer string, body []byte) *env {
  277. e.t.Helper()
  278. var reader io.Reader
  279. if body != nil {
  280. reader = bytes.NewReader(body)
  281. }
  282. req := httptest.NewRequest(http.MethodPut, path, reader)
  283. if body != nil {
  284. req.Header.Set("Content-Type", "application/json")
  285. }
  286. if bearer != "" {
  287. req.Header.Set("Authorization", "Bearer "+bearer)
  288. }
  289. resp, err := e.app.Test(req, -1)
  290. if err != nil {
  291. e.t.Fatalf("PUT %s 请求失败: %v", path, err)
  292. }
  293. var en env
  294. if err := json.NewDecoder(resp.Body).Decode(&en); err != nil {
  295. e.t.Fatalf("PUT %s 解析响应: %v", path, err)
  296. }
  297. return &en
  298. }
  299. // instList 从 PageData 解机构列表(institutionId 集合)。
  300. func instList(t *testing.T, data json.RawMessage) []int64 {
  301. t.Helper()
  302. var pd struct {
  303. List []struct {
  304. InstitutionID int64 `json:"institutionId"`
  305. } `json:"list"`
  306. Total int64 `json:"total"`
  307. }
  308. if err := json.Unmarshal(data, &pd); err != nil {
  309. t.Fatalf("解析机构 PageData: %v", err)
  310. }
  311. out := make([]int64, 0, len(pd.List))
  312. for _, it := range pd.List {
  313. out = append(out, it.InstitutionID)
  314. }
  315. return out
  316. }
  317. // TestOrgE2E_CampusLocked 老师(校区锁定 inst=11/campus=3):
  318. // 数据层 Scoping 只给本机构机构 + 本校区;显式跨机构/跨校区 id → 1004。
  319. func TestOrgE2E_CampusLocked(t *testing.T) {
  320. e := orgE2eSetup(t)
  321. e.seedOrg()
  322. teacher := e.issue(token.Claims{
  323. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
  324. })
  325. // 数据层:机构列表只见本机构(11),机构 22 被 Scoping 排除
  326. inst := e.get("/org/institutions", teacher)
  327. if inst.Code != 200 {
  328. t.Fatalf("机构列表 code=%d msg=%s(期望 200)", inst.Code, inst.Message)
  329. }
  330. got := instList(t, inst.Data)
  331. if len(got) != 1 || got[0] != 11 {
  332. t.Fatalf("机构列表=%v(期望仅 [11],机构 22 须被 scoping 排除)", got)
  333. }
  334. // 数据层:校区列表只见本校区(3),校区 4(别家机构+别校)被双重 scoping 排除
  335. camp := e.get("/org/campuses", teacher)
  336. if camp.Code != 200 {
  337. t.Fatalf("校区列表 code=%d msg=%s(期望 200)", camp.Code, camp.Message)
  338. }
  339. var cpd struct {
  340. List []struct {
  341. CampusID int64 `json:"campusId"`
  342. InstitutionID int64 `json:"institutionId"`
  343. } `json:"list"`
  344. }
  345. if err := json.Unmarshal(camp.Data, &cpd); err != nil {
  346. t.Fatalf("解析校区 PageData: %v", err)
  347. }
  348. if len(cpd.List) != 1 || cpd.List[0].CampusID != 3 {
  349. t.Fatalf("校区列表=%+v(期望仅 [campus 3])", cpd.List)
  350. }
  351. // Service 显式 1004:跨机构 institutionId=22
  352. if r := e.get("/org/institutions?institutionId=22", teacher); r.Code != 1004 {
  353. t.Fatalf("跨机构机构列表 code=%d(期望 1004 作用域越界)", r.Code)
  354. }
  355. // Service 显式 1004:跨校区 campusId=4(校区锁定 token)
  356. if r := e.get("/org/campuses?campusId=4", teacher); r.Code != 1004 {
  357. t.Fatalf("跨校区校区列表 code=%d(期望 1004 作用域越界)", r.Code)
  358. }
  359. // Service 显式 1004:跨机构单查
  360. if r := e.get("/org/institutions/22", teacher); r.Code != 1004 {
  361. t.Fatalf("跨机构单查 code=%d(期望 1004)", r.Code)
  362. }
  363. }
  364. // TestOrgE2E_SuperAdmin 平台超管:跨机构可见全部机构(Scoping 跳过机构过滤)。
  365. func TestOrgE2E_SuperAdmin(t *testing.T) {
  366. e := orgE2eSetup(t)
  367. e.seedOrg()
  368. super := e.issue(token.Claims{
  369. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
  370. })
  371. inst := e.get("/org/institutions", super)
  372. if inst.Code != 200 {
  373. t.Fatalf("超管机构列表 code=%d msg=%s(期望 200)", inst.Code, inst.Message)
  374. }
  375. got := instList(t, inst.Data)
  376. // 超管不加作用域:应见两所机构(11 + 22)
  377. seen := map[int64]bool{}
  378. for _, v := range got {
  379. seen[v] = true
  380. }
  381. if len(got) != 2 || !seen[11] || !seen[22] {
  382. t.Fatalf("超管机构列表=%v(期望 [11 22] 全见)", got)
  383. }
  384. }
  385. // TestOrgE2E_Unauth 无 token:/org/* 一律 1001(中间件未登录拦截)。
  386. func TestOrgE2E_Unauth(t *testing.T) {
  387. e := orgE2eSetup(t)
  388. e.seedOrg()
  389. for _, path := range []string{"/org/institutions", "/org/campuses", "/org/institutions/11"} {
  390. if r := e.get(path, ""); r.Code != 1001 {
  391. t.Fatalf("%s 无 token code=%d(期望 1001 未登录)", path, r.Code)
  392. }
  393. }
  394. }
  395. // TestOrgE2E_ScopeMissingID 老师不带任何显式 id:仍只返回本机构/本校区(Scoping 兜底,非裸查)。
  396. func TestOrgE2E_ScopeMissingID(t *testing.T) {
  397. e := orgE2eSetup(t)
  398. e.seedOrg()
  399. teacher := e.issue(token.Claims{
  400. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
  401. })
  402. // 不带 institutionId/campusId:数据层 scoping 仍强制 inst=11 + campus=3
  403. inst := e.get("/org/institutions", teacher)
  404. if inst.Code != 200 {
  405. t.Fatalf("code=%d(期望 200)", inst.Code)
  406. }
  407. got := instList(t, inst.Data)
  408. if len(got) != 1 || got[0] != 11 {
  409. t.Fatalf("缺 id 时机构列表=%v(期望仅 [11],scoping 兜底)", got)
  410. }
  411. // 带别家机构 id 但角色是机构级(教务):campus=0 可下钻,但跨机构仍 1004
  412. academic := e.issue(token.Claims{
  413. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 0,
  414. })
  415. if r := e.get("/org/institutions?institutionId=22", academic); r.Code != 1004 {
  416. t.Fatalf("教务跨机构 code=%d(期望 1004)", r.Code)
  417. }
  418. }
  419. // TestOrgE2E_RouteRoleWhitelist 路由角色白名单(T6.5-d 接口强制层):
  420. // 白名单 [academic, finance, teacher] + 超管放行:
  421. // - student(业务级,不在白名单)→ 1003(即使有有效 token);
  422. // - teacher(在白名单)→ 200(正常放行,进入数据层 scoping);
  423. // - super_admin(超管恒放行)→ 200(不查白名单)。
  424. //
  425. // 验证"已登录但角色不符"被中间件拦在数据层之前(code 1003 而非 1004/5002)。
  426. func TestOrgE2E_RouteRoleWhitelist(t *testing.T) {
  427. e := orgE2eSetup(t)
  428. e.seedOrg()
  429. // student 业务级角色:有有效 token 但角色不在 /org 白名单 → 1003
  430. student := e.issue(token.Claims{
  431. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"student"}, InstitutionID: 11, CampusID: 3,
  432. })
  433. if r := e.get("/org/institutions", student); r.Code != 1003 {
  434. t.Fatalf("student 访问 /org/institutions code=%d(期望 1003 无权限,路由白名单拦截)", r.Code)
  435. }
  436. if r := e.get("/org/campuses", student); r.Code != 1003 {
  437. t.Fatalf("student 访问 /org/campuses code=%d(期望 1003)", r.Code)
  438. }
  439. // teacher 在白名单:正常放行(进入数据层 scoping,返回本机构/校区)
  440. teacher := e.issue(token.Claims{
  441. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3,
  442. })
  443. if r := e.get("/org/institutions", teacher); r.Code != 200 {
  444. t.Fatalf("teacher(白名单内)访问 /org/institutions code=%d(期望 200)", r.Code)
  445. }
  446. // super_admin:超管恒放行(不查白名单)
  447. super := e.issue(token.Claims{
  448. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
  449. })
  450. if r := e.get("/org/institutions", super); r.Code != 200 {
  451. t.Fatalf("super_admin 访问 /org/institutions code=%d(期望 200 超管放行)", r.Code)
  452. }
  453. }
  454. // TestOrgE2E_MenuTreeRealDB 真 DB 菜单树/权限码解析(T6.5-d 展示层 e2e):
  455. // 种最小集权限点 + academic 矩阵后,Resolver 解析 academic 角色 →
  456. // - Permissions = 4 个 code(2 菜单 + 2 按钮);
  457. // - MenuTree = 2 个顶级 menu 节点(button 不进树)。
  458. //
  459. // 验证 PermissionRepo + rbac.Resolver 在真 GORM 仓储下联动正确。
  460. func TestOrgE2E_MenuTreeRealDB(t *testing.T) {
  461. e := orgE2eSetup(t)
  462. e.seedRBAC()
  463. ctx := context.Background()
  464. codes, err := e.resolver.Permissions(ctx, "academic")
  465. if err != nil {
  466. t.Fatalf("Permissions: %v", err)
  467. }
  468. if len(codes) != 4 {
  469. t.Fatalf("academic Permissions=%v(期望 4 个 org 权限码)", codes)
  470. }
  471. tree, err := e.resolver.MenuTree(ctx, "academic")
  472. if err != nil {
  473. t.Fatalf("MenuTree: %v", err)
  474. }
  475. if len(tree) != 2 {
  476. t.Fatalf("academic MenuTree 顶级=%v(期望 2:机构/校区,button 不进树)", tree)
  477. }
  478. // 超管全量:同样 4 权限码(代码层放行,不查矩阵)
  479. superCodes, err := e.resolver.Permissions(ctx, "super_admin")
  480. if err != nil {
  481. t.Fatalf("Permissions(super_admin): %v", err)
  482. }
  483. if len(superCodes) != 4 {
  484. t.Fatalf("super_admin Permissions=%v(期望全量 4 个)", superCodes)
  485. }
  486. }
  487. // TestOrgE2E_RolePermConfig 超管角色-权限点矩阵配置端点(T6.5-e 配置后台 e2e):
  488. // - 白名单:非超管(academic)访问 /org/role-permissions/academic → 1003;
  489. // - 读取:超管 GET /org/role-permissions/academic → 200,assigned 含 4 个 org 权限码 + dictionary 4 项;
  490. // - 保存:超管 PUT 全量替换 academic 矩阵为 2 码 → 200;GET 回读 assigned=2 码(保存即全量替换);
  491. // - 校验:PUT 含未知权限码 → 2001;PUT 非法 roleCode → 2001。
  492. //
  493. // 验证 RolePermissionService 的 validateRole/validateCodes/SaveRolePermissions +
  494. // RBACHandler 的路由白名单 + 真 DB 删旧插新事务 + 保存后 Invalidate 联动。
  495. func TestOrgE2E_RolePermConfig(t *testing.T) {
  496. e := orgE2eSetup(t)
  497. e.seedRBAC()
  498. e.seedRoles("academic", "super_admin")
  499. super := e.issue(token.Claims{
  500. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0,
  501. })
  502. academic := e.issue(token.Claims{
  503. Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 0,
  504. })
  505. // ① 非超管访问配置端点 → 1003(路由白名单拦截,非 200/1004)
  506. if r := e.get("/org/role-permissions/academic", academic); r.Code != 1003 {
  507. t.Fatalf("academic 访问配置端点 code=%d(期望 1003 无权限)", r.Code)
  508. }
  509. if r := e.put("/org/role-permissions/academic", academic, []byte(`{"permissions":["org.campus.list"]}`)); r.Code != 1003 {
  510. t.Fatalf("academic 保存配置 code=%d(期望 1003 无权限)", r.Code)
  511. }
  512. // ② 超管读取 academic 矩阵:assigned 4 码 + dictionary 4 项
  513. got := e.get("/org/role-permissions/academic", super)
  514. if got.Code != 200 {
  515. t.Fatalf("超管读 academic 矩阵 code=%d msg=%s(期望 200)", got.Code, got.Message)
  516. }
  517. var view struct {
  518. RoleCode string `json:"roleCode"`
  519. Assigned []string `json:"assigned"`
  520. Dictionary []struct {
  521. Code string `json:"code"`
  522. } `json:"dictionary"`
  523. }
  524. if err := json.Unmarshal(got.Data, &view); err != nil {
  525. t.Fatalf("解析 RolePermissionView: %v", err)
  526. }
  527. if view.RoleCode != "academic" || len(view.Assigned) != 4 || len(view.Dictionary) != 4 {
  528. t.Fatalf("RolePermissionView 不符:roleCode=%q assigned=%d dict=%d(期望 academic/4/4)",
  529. view.RoleCode, len(view.Assigned), len(view.Dictionary))
  530. }
  531. // ③ 超管保存:全量替换 academic 为 2 码(org.institution.list + org.campus.list)
  532. body, _ := json.Marshal(map[string][]string{
  533. "permissions": {"org.institution.list", "org.campus.list"},
  534. })
  535. if r := e.put("/org/role-permissions/academic", super, body); r.Code != 200 {
  536. t.Fatalf("超管保存 academic 矩阵 code=%d msg=%s(期望 200)", r.Code, r.Message)
  537. }
  538. // 回读:assigned 应为 2 码(全量替换语义;原 4 码被删)
  539. got2 := e.get("/org/role-permissions/academic", super)
  540. if got2.Code != 200 {
  541. t.Fatalf("保存后回读 code=%d(期望 200)", got2.Code)
  542. }
  543. var view2 struct {
  544. Assigned []string `json:"assigned"`
  545. }
  546. if err := json.Unmarshal(got2.Data, &view2); err != nil {
  547. t.Fatalf("解析回读: %v", err)
  548. }
  549. if len(view2.Assigned) != 2 {
  550. t.Fatalf("保存后 assigned=%v(期望 2 码,全量替换)", view2.Assigned)
  551. }
  552. // Resolver 经 Invalidate 后重读:academic 权限码同步为 2 个(本实例缓存已清)
  553. if codes, _ := e.resolver.Permissions(context.Background(), "academic"); len(codes) != 2 {
  554. t.Fatalf("Invalidate 后 resolver.Permissions(academic)=%v(期望 2 码)", codes)
  555. }
  556. // ④ 校验:含未知权限码 → 2001
  557. badBody, _ := json.Marshal(map[string][]string{
  558. "permissions": {"org.institution.list", "not.exist.code"},
  559. })
  560. if r := e.put("/org/role-permissions/academic", super, badBody); r.Code != 2001 {
  561. t.Fatalf("含未知权限码 code=%d(期望 2001 参数错误)", r.Code)
  562. }
  563. // 校验:非法 roleCode → 2001
  564. if r := e.put("/org/role-permissions/ghost_role", super, body); r.Code != 2001 {
  565. t.Fatalf("非法 roleCode code=%d(期望 2001 角色码非法)", r.Code)
  566. }
  567. }