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) } }