scope_test.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. package scope
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "github.com/gofiber/fiber/v2"
  8. gormsqlite "github.com/glebarez/sqlite"
  9. "gorm.io/gorm"
  10. )
  11. // testCourse 带 institution_id/campus_id 列的 fixture model(scope 包不依赖业务域,
  12. // 测试用本地 model 承载两列,DryRun 捕获 SQL 验证 scoping 注入)。
  13. type testCourse struct {
  14. ID int64 `gorm:"column:id;primaryKey"`
  15. InstitutionID int64 `gorm:"column:institution_id"`
  16. CampusID int64 `gorm:"column:campus_id"`
  17. Name string `gorm:"column:name"`
  18. }
  19. func (testCourse) TableName() string { return "t_course" }
  20. // newDryRunDB 建 GORM DryRun 句柄(仅构造 SQL,不真正读写数据)。
  21. //
  22. // 选用 glebarez/sqlite(纯 Go,基于 modernc.org/sqlite,**无 CGO**)内存库:
  23. // - mysql 驱动在 gorm.Open 时会 ping TCP 建连,DryRun 也跳不过该初始化握手;
  24. // - 纯 Go SQLite `:memory:` 进程内运行,无网络/外部依赖,且 **CGO_ENABLED=0 下可编译可运行**
  25. // (对比 gorm.io/driver/sqlite 依赖 mattn/go-sqlite3,CGO_ENABLED=0 时是 stub 会失败);
  26. // - DryRun 渲染的 WHERE 片段(institution_id/campus_id 过滤)与方言无关,断言逻辑不受影响。
  27. // 故本包单测在 CI/容器(常 CGO_ENABLED=0)亦稳定通过。
  28. func newDryRunDB(t *testing.T) *gorm.DB {
  29. t.Helper()
  30. db, err := gorm.Open(gormsqlite.Open(":memory:"), &gorm.Config{DryRun: true})
  31. if err != nil {
  32. t.Fatalf("gorm.Open 失败: %v", err)
  33. }
  34. return db
  35. }
  36. // sqlOf 执行 DryRun Find,返回生成的 SQL 字符串。
  37. func sqlOf(t *testing.T, db *gorm.DB, q *gorm.DB) string {
  38. t.Helper()
  39. var out []testCourse
  40. if err := q.Model(&testCourse{}).Find(&out).Error; err != nil {
  41. t.Fatalf("DryRun Find 失败: %v", err)
  42. }
  43. return q.Statement.SQL.String()
  44. }
  45. // TestScopingNormalCampusScoped 普通用户 + 校区级表 → 强制 institution_id + campus_id。
  46. func TestScopingNormalCampusScoped(t *testing.T) {
  47. db := newDryRunDB(t)
  48. base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7}, CampusScoped: true}
  49. q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
  50. sql := sqlOf(t, db, q)
  51. if !strings.Contains(sql, "institution_id = ?") {
  52. t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
  53. }
  54. if !strings.Contains(sql, "campus_id = ?") {
  55. t.Errorf("CampusScoped=true 且 campus>0 应含 campus_id 过滤,SQL=%q", sql)
  56. }
  57. }
  58. // TestScopingInstitutionOnly 机构级资源(CampusScoped=false)→ 仅 institution_id,不加 campus_id。
  59. func TestScopingInstitutionOnly(t *testing.T) {
  60. db := newDryRunDB(t)
  61. base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7}, CampusScoped: false}
  62. q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
  63. sql := sqlOf(t, db, q)
  64. if !strings.Contains(sql, "institution_id = ?") {
  65. t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
  66. }
  67. if strings.Contains(sql, "campus_id = ?") {
  68. t.Errorf("CampusScoped=false 不应含 campus_id 过滤(表无此列,加会 SQL 报错),SQL=%q", sql)
  69. }
  70. }
  71. // TestScopingSuperAdmin 平台超管 → 不加任何作用域过滤(跨机构)。
  72. func TestScopingSuperAdmin(t *testing.T) {
  73. db := newDryRunDB(t)
  74. base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}, CampusScoped: true}
  75. q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
  76. sql := sqlOf(t, db, q)
  77. if strings.Contains(sql, "institution_id = ?") || strings.Contains(sql, "campus_id = ?") {
  78. t.Errorf("超管不应加作用域过滤,SQL=%q", sql)
  79. }
  80. }
  81. // TestScopingNilScopePanics 无作用域上下文进入数据访问层 → panic(fail-fast 纵深防御)。
  82. func TestScopingNilScopePanics(t *testing.T) {
  83. db := newDryRunDB(t)
  84. base := BaseRepo{DB: db, Sc: nil, CampusScoped: true}
  85. defer func() {
  86. if r := recover(); r == nil {
  87. t.Errorf("Sc=nil 应 panic(拒绝无作用域裸查),但正常返回了")
  88. }
  89. }()
  90. _ = base.Scoping(db)
  91. }
  92. // TestScopingZeroCampus 普通用户 + CampusID=0(机构级视角,不限校区)→ 仅 institution_id。
  93. func TestScopingZeroCampus(t *testing.T) {
  94. db := newDryRunDB(t)
  95. base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 0}, CampusScoped: true}
  96. q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
  97. sql := sqlOf(t, db, q)
  98. if !strings.Contains(sql, "institution_id = ?") {
  99. t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
  100. }
  101. if strings.Contains(sql, "campus_id = ?") {
  102. t.Errorf("campus_id=0 不应加校区过滤(0=不限),SQL=%q", sql)
  103. }
  104. }
  105. // TestSetFromFiber 验证作用域写入/读取 fiber.Ctx 局部值往返(真实 *fiber.Ctx,经 app.Test)。
  106. func TestSetFromFiber(t *testing.T) {
  107. // nil 防御
  108. if got := FromFiber(nil); got != nil {
  109. t.Errorf("FromFiber(nil) 应返回 nil,得 %+v", got)
  110. }
  111. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  112. app.Get("/echo", func(c *fiber.Ctx) error {
  113. sc := &Scope{InstitutionID: 9, CampusID: 3, UserID: "01ABC", Role: "teacher", SuperAdmin: false}
  114. Set(c, sc) // 写入
  115. got := FromFiber(c) // 读回
  116. if got == nil {
  117. return c.Status(fiber.StatusInternalServerError).SendString("FromFiber returned nil")
  118. }
  119. if got.InstitutionID != 9 || got.CampusID != 3 || got.UserID != "01ABC" || got.Role != "teacher" || got.SuperAdmin {
  120. return c.Status(fiber.StatusInternalServerError).SendString("scope roundtrip mismatch")
  121. }
  122. return c.SendString("ok")
  123. })
  124. req := httptest.NewRequest(http.MethodGet, "/echo", nil)
  125. resp, err := app.Test(req, -1)
  126. if err != nil {
  127. t.Fatalf("app.Test 失败: %v", err)
  128. }
  129. if resp.StatusCode != fiber.StatusOK {
  130. t.Fatalf("roundtrip 状态码=%d(期望 200)", resp.StatusCode)
  131. }
  132. }