// org 域越权端到端测试(T6 验收核心:跨校区/跨机构访问被拒)。 // // 用真 GORM 仓储 + 真 Issuer + miniredis + NewAuth 验签中间件,走 HTTP 全链路, // 同时覆盖多租户防线的**两道**(架构规格 §3.5): // 1. Service 层 assertInScope(显式 1004):请求 query 显式携带的 institutionId/campusId // 与 token 作用域冲突 → code 1004(前端据此校正 scope); // 2. Repository 层 Scoping(数据层强制过滤):即便请求不带显式 id,查询也强制带 // institution_id/campus_id 过滤——非超管永远查不到别家机构/别校校区的数据。 // // 场景矩阵: // - 老师(inst=11, campus=3,校区锁定): // GET /org/institutions → 只见机构 11(机构 22 被 Scoping 排除,数据层) // GET /org/campuses → 只见校区 3(校区 4 被机构+校区双重 scoping 排除) // GET /org/institutions?institutionId=22 → 1004(跨机构,Service 显式) // GET /org/campuses?campusId=4 → 1004(跨校区,Service 显式) // GET /org/institutions/22 → 1004(跨机构单查,Service 显式) // - 平台超管(super_admin):GET /org/institutions → 见全部机构(11 + 22) // - 无 token:/org/* → 1001(中间件未登录) // // 运行前提(同身份域 e2e 纪律:DSN 走环境变量,不入库): // // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local // go test ./internal/org/handler/ -run TestOrgE2E -v // // 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。 package handler import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "net/http" "net/http/httptest" "os" "testing" "time" miniredis "github.com/alicebob/miniredis/v2" "github.com/gofiber/fiber/v2" "gorm.io/gorm" idhandler "yuansiwei/unified-backend/internal/identity/handler" "yuansiwei/unified-backend/internal/identity/token" "yuansiwei/unified-backend/internal/infra/config" infra "yuansiwei/unified-backend/internal/infra/db" appredis "yuansiwei/unified-backend/internal/infra/redis" "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" ) // ptrStr 字符串指针辅助(e2e 测试用,构造可空字段的指针值)。 func ptrStr(s string) *string { return &s } // orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由 + RBAC 解析器)。 type orgEnv struct { t *testing.T app *fiber.App pubKeys []string revocation *appredis.RevocationStore gdb *gorm.DB issuer *token.Issuer resolver *rbac.Resolver // T6.5-d:真 DB 菜单树/权限码解析(超管配置端点 + menuTree 下发 e2e) } // 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 矩阵") } // orgE2eSetup 建真库 + AutoMigrate(t_institution/t_campus) + miniredis + 真 Issuer + OrgService + /org 路由。 func orgE2eSetup(t *testing.T) *orgEnv { t.Helper() dsn := os.Getenv("UNIFIED_TEST_DSN") if dsn == "" { t.Skip("未设置 UNIFIED_TEST_DSN,跳过 org 越权端到端测试") } gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60}) if err != nil { t.Fatalf("NewDB: %v", err) } t.Cleanup(func() { _ = infra.Close(gdb) }) // 迁移 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.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() if err != nil { t.Fatalf("miniredis.Run: %v", err) } t.Cleanup(mr.Close) rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()}) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = rc.Close() }) refreshStore := appredis.NewRefreshStore(rc) revocation := appredis.NewRevocationStore(rc, "revoked") privPEM, pubKeys := genOrgRSAKey(t) issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation) if err != nil { t.Fatalf("NewIssuer: %v", err) } svc, err := orgservice.NewOrgService(gdb) if err != nil { t.Fatalf("NewOrgService: %v", err) } h, err := New(Config{Svc: svc}) if err != nil { t.Fatalf("org handler New: %v", err) } mw, err := idhandler.NewAuth(idhandler.AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"}) if err != nil { t.Fatalf("NewAuth: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) org := app.Group("/org") // 验签 + 路由角色白名单(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/:institutionId", h.Institution) 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)} } // genOrgRSAKey 生成 RS2048 私钥 + 公钥 PEM(本包私有,避免与身份域测试 helper 重名冲突)。 func genOrgRSAKey(t *testing.T) (string, []string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("生成 RSA 私钥: %v", err) } privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) if err != nil { t.Fatalf("序列化公钥: %v", err) } pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})) return privPEM, []string{pubPEM} } // issue 用真 Issuer 签一个 access(指定作用域 claims)。 func (e *orgEnv) issue(claims token.Claims) string { e.t.Helper() access, _, err := e.issuer.Issue(context.Background(), claims) if err != nil { e.t.Fatalf("Issue: %v", err) } return access } // seedOrg 落两所机构 + 两校区(inst11/campus3、inst22/campus4,raw SQL 保证 id 确定)。 func (e *orgEnv) seedOrg() { 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) } } must(e.gdb.WithContext(ctx).Create(&model.Institution{ InstitutionID: 11, InstitutionName: "实验一中", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now, }).Error, "插入机构 11") must(e.gdb.WithContext(ctx).Create(&model.Institution{ InstitutionID: 22, InstitutionName: "实验二中", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now, }).Error, "插入机构 22") must(e.gdb.WithContext(ctx).Create(&model.Campus{ CampusID: 3, InstitutionID: 11, CampusName: "一中本部", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now, }).Error, "插入校区 3") must(e.gdb.WithContext(ctx).Create(&model.Campus{ CampusID: 4, InstitutionID: 22, CampusName: "二中本部", Status: model.StatusActive, CreatedAt: now, UpdatedAt: now, }).Error, "插入校区 4") } // orgEnv envelope(统一信封;Data 留原始 JSON 供按需解析)。 type env struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` Success bool `json:"success"` } // get 带 Bearer 头 GET 并解信封(HTTP 恒 200,断 body.code)。 func (e *orgEnv) get(path, bearer string) *env { e.t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := e.app.Test(req, -1) if err != nil { e.t.Fatalf("GET %s 请求失败: %v", path, err) } var en env if err := json.NewDecoder(resp.Body).Decode(&en); err != nil { e.t.Fatalf("GET %s 解析响应: %v", path, err) } return &en } // instList 从 PageData 解机构列表(institutionId 集合)。 func instList(t *testing.T, data json.RawMessage) []int64 { t.Helper() var pd struct { List []struct { InstitutionID int64 `json:"institutionId"` } `json:"list"` Total int64 `json:"total"` } if err := json.Unmarshal(data, &pd); err != nil { t.Fatalf("解析机构 PageData: %v", err) } out := make([]int64, 0, len(pd.List)) for _, it := range pd.List { out = append(out, it.InstitutionID) } return out } // TestOrgE2E_CampusLocked 老师(校区锁定 inst=11/campus=3): // 数据层 Scoping 只给本机构机构 + 本校区;显式跨机构/跨校区 id → 1004。 func TestOrgE2E_CampusLocked(t *testing.T) { e := orgE2eSetup(t) e.seedOrg() teacher := e.issue(token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, }) // 数据层:机构列表只见本机构(11),机构 22 被 Scoping 排除 inst := e.get("/org/institutions", teacher) if inst.Code != 200 { t.Fatalf("机构列表 code=%d msg=%s(期望 200)", inst.Code, inst.Message) } got := instList(t, inst.Data) if len(got) != 1 || got[0] != 11 { t.Fatalf("机构列表=%v(期望仅 [11],机构 22 须被 scoping 排除)", got) } // 数据层:校区列表只见本校区(3),校区 4(别家机构+别校)被双重 scoping 排除 camp := e.get("/org/campuses", teacher) if camp.Code != 200 { t.Fatalf("校区列表 code=%d msg=%s(期望 200)", camp.Code, camp.Message) } var cpd struct { List []struct { CampusID int64 `json:"campusId"` InstitutionID int64 `json:"institutionId"` } `json:"list"` } if err := json.Unmarshal(camp.Data, &cpd); err != nil { t.Fatalf("解析校区 PageData: %v", err) } if len(cpd.List) != 1 || cpd.List[0].CampusID != 3 { t.Fatalf("校区列表=%+v(期望仅 [campus 3])", cpd.List) } // Service 显式 1004:跨机构 institutionId=22 if r := e.get("/org/institutions?institutionId=22", teacher); r.Code != 1004 { t.Fatalf("跨机构机构列表 code=%d(期望 1004 作用域越界)", r.Code) } // Service 显式 1004:跨校区 campusId=4(校区锁定 token) if r := e.get("/org/campuses?campusId=4", teacher); r.Code != 1004 { t.Fatalf("跨校区校区列表 code=%d(期望 1004 作用域越界)", r.Code) } // Service 显式 1004:跨机构单查 if r := e.get("/org/institutions/22", teacher); r.Code != 1004 { t.Fatalf("跨机构单查 code=%d(期望 1004)", r.Code) } } // TestOrgE2E_SuperAdmin 平台超管:跨机构可见全部机构(Scoping 跳过机构过滤)。 func TestOrgE2E_SuperAdmin(t *testing.T) { e := orgE2eSetup(t) e.seedOrg() super := e.issue(token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0, }) inst := e.get("/org/institutions", super) if inst.Code != 200 { t.Fatalf("超管机构列表 code=%d msg=%s(期望 200)", inst.Code, inst.Message) } got := instList(t, inst.Data) // 超管不加作用域:应见两所机构(11 + 22) seen := map[int64]bool{} for _, v := range got { seen[v] = true } if len(got) != 2 || !seen[11] || !seen[22] { t.Fatalf("超管机构列表=%v(期望 [11 22] 全见)", got) } } // TestOrgE2E_Unauth 无 token:/org/* 一律 1001(中间件未登录拦截)。 func TestOrgE2E_Unauth(t *testing.T) { e := orgE2eSetup(t) e.seedOrg() for _, path := range []string{"/org/institutions", "/org/campuses", "/org/institutions/11"} { if r := e.get(path, ""); r.Code != 1001 { t.Fatalf("%s 无 token code=%d(期望 1001 未登录)", path, r.Code) } } } // TestOrgE2E_ScopeMissingID 老师不带任何显式 id:仍只返回本机构/本校区(Scoping 兜底,非裸查)。 func TestOrgE2E_ScopeMissingID(t *testing.T) { e := orgE2eSetup(t) e.seedOrg() teacher := e.issue(token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, }) // 不带 institutionId/campusId:数据层 scoping 仍强制 inst=11 + campus=3 inst := e.get("/org/institutions", teacher) if inst.Code != 200 { t.Fatalf("code=%d(期望 200)", inst.Code) } got := instList(t, inst.Data) if len(got) != 1 || got[0] != 11 { t.Fatalf("缺 id 时机构列表=%v(期望仅 [11],scoping 兜底)", got) } // 带别家机构 id 但角色是机构级(教务):campus=0 可下钻,但跨机构仍 1004 academic := e.issue(token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 0, }) if r := e.get("/org/institutions?institutionId=22", academic); r.Code != 1004 { 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) } }