|
@@ -0,0 +1,325 @@
|
|
|
|
|
+// 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"
|
|
|
|
|
+ orgservice "yuansiwei/unified-backend/internal/org/service"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// orgEnv 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 验签中间件 + /org 路由)。
|
|
|
|
|
+type orgEnv struct {
|
|
|
|
|
+ t *testing.T
|
|
|
|
|
+ app *fiber.App
|
|
|
|
|
+ pubKeys []string
|
|
|
|
|
+ revocation *appredis.RevocationStore
|
|
|
|
|
+ gdb *gorm.DB
|
|
|
|
|
+ issuer *token.Issuer
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 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) })
|
|
|
|
|
+ if err := infra.Migrate(gdb, &model.Institution{}, &model.Campus{}); err != nil {
|
|
|
|
|
+ t.Fatalf("Migrate: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Cleanup(func() { _ = gdb.Migrator().DropTable("t_institution", "t_campus") })
|
|
|
|
|
+
|
|
|
|
|
+ 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")
|
|
|
|
|
+ org.Use(mw)
|
|
|
|
|
+ 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}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|