|
|
@@ -0,0 +1,145 @@
|
|
|
+package scope
|
|
|
+
|
|
|
+import (
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "strings"
|
|
|
+ "testing"
|
|
|
+
|
|
|
+ "github.com/gofiber/fiber/v2"
|
|
|
+ gormsqlite "github.com/glebarez/sqlite"
|
|
|
+ "gorm.io/gorm"
|
|
|
+)
|
|
|
+
|
|
|
+// testCourse 带 institution_id/campus_id 列的 fixture model(scope 包不依赖业务域,
|
|
|
+// 测试用本地 model 承载两列,DryRun 捕获 SQL 验证 scoping 注入)。
|
|
|
+type testCourse struct {
|
|
|
+ ID int64 `gorm:"column:id;primaryKey"`
|
|
|
+ InstitutionID int64 `gorm:"column:institution_id"`
|
|
|
+ CampusID int64 `gorm:"column:campus_id"`
|
|
|
+ Name string `gorm:"column:name"`
|
|
|
+}
|
|
|
+
|
|
|
+func (testCourse) TableName() string { return "t_course" }
|
|
|
+
|
|
|
+// newDryRunDB 建 GORM DryRun 句柄(仅构造 SQL,不真正读写数据)。
|
|
|
+//
|
|
|
+// 选用 glebarez/sqlite(纯 Go,基于 modernc.org/sqlite,**无 CGO**)内存库:
|
|
|
+// - mysql 驱动在 gorm.Open 时会 ping TCP 建连,DryRun 也跳不过该初始化握手;
|
|
|
+// - 纯 Go SQLite `:memory:` 进程内运行,无网络/外部依赖,且 **CGO_ENABLED=0 下可编译可运行**
|
|
|
+// (对比 gorm.io/driver/sqlite 依赖 mattn/go-sqlite3,CGO_ENABLED=0 时是 stub 会失败);
|
|
|
+// - DryRun 渲染的 WHERE 片段(institution_id/campus_id 过滤)与方言无关,断言逻辑不受影响。
|
|
|
+// 故本包单测在 CI/容器(常 CGO_ENABLED=0)亦稳定通过。
|
|
|
+func newDryRunDB(t *testing.T) *gorm.DB {
|
|
|
+ t.Helper()
|
|
|
+ db, err := gorm.Open(gormsqlite.Open(":memory:"), &gorm.Config{DryRun: true})
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("gorm.Open 失败: %v", err)
|
|
|
+ }
|
|
|
+ return db
|
|
|
+}
|
|
|
+
|
|
|
+// sqlOf 执行 DryRun Find,返回生成的 SQL 字符串。
|
|
|
+func sqlOf(t *testing.T, db *gorm.DB, q *gorm.DB) string {
|
|
|
+ t.Helper()
|
|
|
+ var out []testCourse
|
|
|
+ if err := q.Model(&testCourse{}).Find(&out).Error; err != nil {
|
|
|
+ t.Fatalf("DryRun Find 失败: %v", err)
|
|
|
+ }
|
|
|
+ return q.Statement.SQL.String()
|
|
|
+}
|
|
|
+
|
|
|
+// TestScopingNormalCampusScoped 普通用户 + 校区级表 → 强制 institution_id + campus_id。
|
|
|
+func TestScopingNormalCampusScoped(t *testing.T) {
|
|
|
+ db := newDryRunDB(t)
|
|
|
+ base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7}, CampusScoped: true}
|
|
|
+ q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
|
|
|
+ sql := sqlOf(t, db, q)
|
|
|
+ if !strings.Contains(sql, "institution_id = ?") {
|
|
|
+ t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
|
|
|
+ }
|
|
|
+ if !strings.Contains(sql, "campus_id = ?") {
|
|
|
+ t.Errorf("CampusScoped=true 且 campus>0 应含 campus_id 过滤,SQL=%q", sql)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestScopingInstitutionOnly 机构级资源(CampusScoped=false)→ 仅 institution_id,不加 campus_id。
|
|
|
+func TestScopingInstitutionOnly(t *testing.T) {
|
|
|
+ db := newDryRunDB(t)
|
|
|
+ base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7}, CampusScoped: false}
|
|
|
+ q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
|
|
|
+ sql := sqlOf(t, db, q)
|
|
|
+ if !strings.Contains(sql, "institution_id = ?") {
|
|
|
+ t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
|
|
|
+ }
|
|
|
+ if strings.Contains(sql, "campus_id = ?") {
|
|
|
+ t.Errorf("CampusScoped=false 不应含 campus_id 过滤(表无此列,加会 SQL 报错),SQL=%q", sql)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestScopingSuperAdmin 平台超管 → 不加任何作用域过滤(跨机构)。
|
|
|
+func TestScopingSuperAdmin(t *testing.T) {
|
|
|
+ db := newDryRunDB(t)
|
|
|
+ base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}, CampusScoped: true}
|
|
|
+ q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
|
|
|
+ sql := sqlOf(t, db, q)
|
|
|
+ if strings.Contains(sql, "institution_id = ?") || strings.Contains(sql, "campus_id = ?") {
|
|
|
+ t.Errorf("超管不应加作用域过滤,SQL=%q", sql)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestScopingNilScopePanics 无作用域上下文进入数据访问层 → panic(fail-fast 纵深防御)。
|
|
|
+func TestScopingNilScopePanics(t *testing.T) {
|
|
|
+ db := newDryRunDB(t)
|
|
|
+ base := BaseRepo{DB: db, Sc: nil, CampusScoped: true}
|
|
|
+ defer func() {
|
|
|
+ if r := recover(); r == nil {
|
|
|
+ t.Errorf("Sc=nil 应 panic(拒绝无作用域裸查),但正常返回了")
|
|
|
+ }
|
|
|
+ }()
|
|
|
+ _ = base.Scoping(db)
|
|
|
+}
|
|
|
+
|
|
|
+// TestScopingZeroCampus 普通用户 + CampusID=0(机构级视角,不限校区)→ 仅 institution_id。
|
|
|
+func TestScopingZeroCampus(t *testing.T) {
|
|
|
+ db := newDryRunDB(t)
|
|
|
+ base := BaseRepo{DB: db, Sc: &Scope{InstitutionID: 42, CampusID: 0}, CampusScoped: true}
|
|
|
+ q := base.Scoping(db.Session(&gorm.Session{NewDB: true}))
|
|
|
+ sql := sqlOf(t, db, q)
|
|
|
+ if !strings.Contains(sql, "institution_id = ?") {
|
|
|
+ t.Errorf("应含 institution_id 过滤,SQL=%q", sql)
|
|
|
+ }
|
|
|
+ if strings.Contains(sql, "campus_id = ?") {
|
|
|
+ t.Errorf("campus_id=0 不应加校区过滤(0=不限),SQL=%q", sql)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestSetFromFiber 验证作用域写入/读取 fiber.Ctx 局部值往返(真实 *fiber.Ctx,经 app.Test)。
|
|
|
+func TestSetFromFiber(t *testing.T) {
|
|
|
+ // nil 防御
|
|
|
+ if got := FromFiber(nil); got != nil {
|
|
|
+ t.Errorf("FromFiber(nil) 应返回 nil,得 %+v", got)
|
|
|
+ }
|
|
|
+
|
|
|
+ app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
+ app.Get("/echo", func(c *fiber.Ctx) error {
|
|
|
+ sc := &Scope{InstitutionID: 9, CampusID: 3, UserID: "01ABC", Role: "teacher", SuperAdmin: false}
|
|
|
+ Set(c, sc) // 写入
|
|
|
+ got := FromFiber(c) // 读回
|
|
|
+ if got == nil {
|
|
|
+ return c.Status(fiber.StatusInternalServerError).SendString("FromFiber returned nil")
|
|
|
+ }
|
|
|
+ if got.InstitutionID != 9 || got.CampusID != 3 || got.UserID != "01ABC" || got.Role != "teacher" || got.SuperAdmin {
|
|
|
+ return c.Status(fiber.StatusInternalServerError).SendString("scope roundtrip mismatch")
|
|
|
+ }
|
|
|
+ return c.SendString("ok")
|
|
|
+ })
|
|
|
+ req := httptest.NewRequest(http.MethodGet, "/echo", nil)
|
|
|
+ resp, err := app.Test(req, -1)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("app.Test 失败: %v", err)
|
|
|
+ }
|
|
|
+ if resp.StatusCode != fiber.StatusOK {
|
|
|
+ t.Fatalf("roundtrip 状态码=%d(期望 200)", resp.StatusCode)
|
|
|
+ }
|
|
|
+}
|