Просмотр исходного кода

test: T8 测试基线固化(credential/id/scope 单测 + CGO 端口性)

新增 3 个零测试包的单元测试(17 例,全绿):
- internal/identity/credential:argon2 往返 / md5_salt / sha_salt 路由 +
  惰性升级标记、hex 大小写折叠、未知 version、空哈希 7 例
- internal/identity/id:ULID 生成(26位+Validate) / 时间序 / Crockford 校验
  (拒 I/L/O/U) / 同毫秒 1000 唯一性 4 例
- internal/infra/scope:DryRun 捕获 SQL 验证 scoping 注入
  (普通双过滤 / 机构级仅 inst / 超管零过滤 / nil panic / campus=0 不加校区)
  + fiber Set/FromFiber 局部值往返 6 例

CGO 端口性:scope DryRun 句柄选用 glebarez/sqlite(纯 Go,modernc,
CGO_ENABLED=0 可编译可运行),替代 mysql(Open 时 ping TCP)与
gorm.io/driver/sqlite(mattn CGO,CGO=0 为 stub)。
go test ./... -short 在 CGO_ENABLED=1 与 =0 双模式均 0 失败。
Q 2 дней назад
Родитель
Сommit
38f6ee646b
5 измененных файлов с 364 добавлено и 0 удалено
  1. 8 0
      go.mod
  2. 19 0
      go.sum
  3. 112 0
      internal/identity/credential/credential_test.go
  4. 80 0
      internal/identity/id/id_test.go
  5. 145 0
      internal/infra/scope/scope_test.go

+ 8 - 0
go.mod

@@ -4,6 +4,7 @@ go 1.27
 
 require (
 	github.com/alicebob/miniredis/v2 v2.34.0
+	github.com/glebarez/sqlite v1.11.0
 	github.com/go-playground/validator/v10 v10.27.0
 	github.com/gofiber/fiber/v2 v2.52.16
 	github.com/golang-jwt/jwt/v5 v5.3.1
@@ -21,7 +22,9 @@ require (
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+	github.com/glebarez/go-sqlite v1.21.2 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
@@ -33,6 +36,7 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.51.0 // indirect
@@ -42,4 +46,8 @@ require (
 	golang.org/x/net v0.34.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
 	golang.org/x/text v0.22.0 // indirect
+	modernc.org/libc v1.22.5 // indirect
+	modernc.org/mathutil v1.5.0 // indirect
+	modernc.org/memory v1.5.0 // indirect
+	modernc.org/sqlite v1.23.1 // indirect
 )

+ 19 - 0
go.sum

@@ -16,8 +16,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
+github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
+github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
+github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -32,6 +38,8 @@ github.com/gofiber/fiber/v2 v2.52.16 h1:lBemN7sjeTPwEdK/9AIAQ3YpYpzEZefiWI/vQT/6
 github.com/gofiber/fiber/v2 v2.52.16/go.mod h1:dFf1PKExrrSfg/hvoP2yXL35W3bGlzZf71KX+IYzkpg=
 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -57,6 +65,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
 github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
@@ -95,3 +106,11 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
 gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
+modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
+modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
+modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

+ 112 - 0
internal/identity/credential/credential_test.go

@@ -0,0 +1,112 @@
+package credential
+
+import (
+	"context"
+	"crypto/md5"
+	"crypto/sha1"
+	"encoding/hex"
+	"strings"
+	"testing"
+)
+
+func md5hex(s string) string { sum := md5.Sum([]byte(s)); return hex.EncodeToString(sum[:]) }
+func sha1hex(s string) string { sum := sha1.Sum([]byte(s)); return hex.EncodeToString(sum[:]) }
+
+// TestHashArgon2RoundTrip 验证 argon2 哈希往返(新注册/改密/惰性升级入口)。
+func TestHashArgon2RoundTrip(t *testing.T) {
+	enc, err := HashArgon2("X#9f2k")
+	if err != nil {
+		t.Fatalf("HashArgon2 失败: %v", err)
+	}
+	if !strings.HasPrefix(enc, "argon2id$v=19$") {
+		t.Errorf("argon2 编码串应前缀 argon2id$v=19$,得 %q", enc)
+	}
+	if !VerifyArgon2(enc, "X#9f2k") {
+		t.Errorf("正确密码应匹配")
+	}
+	if VerifyArgon2(enc, "wrong") {
+		t.Errorf("错误密码应不匹配")
+	}
+}
+
+// TestCheckMD5Salt 验证教务旧算法 md5(password+salt) 路由 + 惰性升级标记。
+func TestCheckMD5Salt(t *testing.T) {
+	const pw, salt = "P@ssw0rd", "aBcDeF"
+	stored := md5hex(pw + salt)
+	res, err := Check(context.Background(), pw, stored, salt, HashVersionMD5Salt)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match || !res.NeedsUpgrade {
+		t.Errorf("md5_salt 命中应 Match=true 且 NeedsUpgrade=true,得 %+v", res)
+	}
+	// 错误密码
+	res2, _ := Check(context.Background(), "nope", stored, salt, HashVersionMD5Salt)
+	if res2.Match {
+		t.Errorf("md5 错误密码应不匹配")
+	}
+}
+
+// TestCheckShASalt 验证教学旧算法 sha1(password+salt) 路由。
+func TestCheckShASalt(t *testing.T) {
+	const pw, salt = "teach123", "sAlT12"
+	stored := sha1hex(pw + salt)
+	res, err := Check(context.Background(), pw, stored, salt, HashVersionShASalt)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match || !res.NeedsUpgrade {
+		t.Errorf("sha_salt 命中应 Match=true 且 NeedsUpgrade=true,得 %+v", res)
+	}
+}
+
+// TestCheckArgon2 验证目标算法 argon2 路由(命中不触发升级)。
+func TestCheckArgon2(t *testing.T) {
+	enc, _ := HashArgon2("argon-pw")
+	res, err := Check(context.Background(), "argon-pw", enc, "", HashVersionArgon2)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match || res.NeedsUpgrade {
+		t.Errorf("argon2 命中应 Match=true 且 NeedsUpgrade=false(已是终态),得 %+v", res)
+	}
+}
+
+// TestCheckHexCaseFold 验证旧 hex 大写 storedHash 折叠比较(历史脚本大小写差异,数据保真例外)。
+func TestCheckHexCaseFold(t *testing.T) {
+	const pw, salt = "fold123", "zz"
+	storedUpper := strings.ToUpper(md5hex(pw + salt))
+	res, err := Check(context.Background(), pw, storedUpper, salt, HashVersionMD5Salt)
+	if err != nil {
+		t.Fatalf("Check 意外错误: %v", err)
+	}
+	if !res.Match {
+		t.Errorf("大写 hex storedHash 应折叠命中,得 Match=%v", res.Match)
+	}
+}
+
+// TestCheckUnknownVersion 验证未知 hash_version 拒绝(非密码错误,走告警分支)。
+func TestCheckUnknownVersion(t *testing.T) {
+	res, err := Check(context.Background(), "pw", "abc", "", "weird_version")
+	if err == nil {
+		t.Fatalf("未知 version 应报错")
+	}
+	if !strings.Contains(err.Error(), "weird_version") {
+		t.Errorf("错误应含实际 version 值,得 %q", err.Error())
+	}
+	if res.Match {
+		t.Errorf("未知 version 不应 Match")
+	}
+	_ = ErrUnknownVersion
+}
+
+// TestCheckEmptyHash 验证空密码哈希(账号无凭证,需运维重建)。
+func TestCheckEmptyHash(t *testing.T) {
+	_, err := Check(context.Background(), "pw", "", "salt", HashVersionMD5Salt)
+	if err == nil {
+		t.Fatalf("空 storedHash 应报错")
+	}
+	if !strings.Contains(err.Error(), "无密码哈希") {
+		t.Errorf("错误应提示无密码哈希,得 %q", err.Error())
+	}
+}

+ 80 - 0
internal/identity/id/id_test.go

@@ -0,0 +1,80 @@
+package id
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+// TestNewCanonicalUserID 验证 ULID 生成基本性质(26 位、Crockford、可解码)。
+func TestNewCanonicalUserID(t *testing.T) {
+	uid, err := NewCanonicalUserID()
+	if err != nil {
+		t.Fatalf("NewCanonicalUserID 失败: %v", err)
+	}
+	if len(uid) != Length {
+		t.Fatalf("ULID 长度应为 %d,得 %d(%q)", Length, len(uid), uid)
+	}
+	if !Validate(uid) {
+		t.Fatalf("生成的 ULID 应通过 Validate:%q", uid)
+	}
+}
+
+// TestNewCanonicalUserIDAt 验证指定时间戳 + 时间有序性(前 48bit 毫秒时间戳)。
+func TestNewCanonicalUserIDAt(t *testing.T) {
+	base := time.Date(2026, 11, 7, 10, 0, 0, 0, time.UTC)
+	uidA, err := NewCanonicalUserIDAt(base)
+	if err != nil {
+		t.Fatalf("NewCanonicalUserIDAt 失败: %v", err)
+	}
+	uidB, err := NewCanonicalUserIDAt(base.Add(2 * time.Second))
+	if err != nil {
+		t.Fatalf("NewCanonicalUserIDAt(+2s) 失败: %v", err)
+	}
+	// 时间戳后移 → ULID 字典序应更大(前 48bit 时间戳递增)
+	if !(uidA < uidB) {
+		t.Errorf("时间有序的 ULID 应满足 uidA(%s) < uidB(%s)", uidA, uidB)
+	}
+}
+
+// TestValidate 校验合法/非法 ULID。
+func TestValidate(t *testing.T) {
+	uid, _ := NewCanonicalUserID()
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		{uid, true},                                  // 合法(新生成)
+		{strings.ToLower(uid), false},                // 小写非法(统一大写)
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4T", true},         // 标准样例
+		{"abc", false},                               // 长度不符
+		{"", false},                                  // 空
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4I", false},        // 含 I(Crockford 剔除)
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4L", false},        // 含 L
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4O", false},        // 含 O
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4U", false},        // 含 U
+		{"01GAQF0Z6C1V9Y3E5H7K2M8B4T0", false},       // 27 位(长度)
+	}
+	for i, c := range cases {
+		if got := Validate(c.in); got != c.want {
+			t.Errorf("case %d: Validate(%q)=%v, want %v", i, c.in, got, c.want)
+		}
+	}
+}
+
+// TestNewCanonicalUserIDUniqueness 验证并发/批量生成的唯一性(80bit 随机熵)。
+func TestNewCanonicalUserIDUniqueness(t *testing.T) {
+	seen := make(map[string]struct{}, 1000)
+	// 同一毫秒内批量生成(随机部分保证唯一)
+	fixed := time.Date(2026, 11, 7, 8, 0, 0, 0, time.UTC)
+	for i := 0; i < 1000; i++ {
+		uid, err := NewCanonicalUserIDAt(fixed)
+		if err != nil {
+			t.Fatalf("第 %d 次生成失败: %v", i, err)
+		}
+		if _, dup := seen[uid]; dup {
+			t.Fatalf("第 %d 次生成重复 ULID:%s(随机熵不足)", i, uid)
+		}
+		seen[uid] = struct{}{}
+	}
+}

+ 145 - 0
internal/infra/scope/scope_test.go

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