|
@@ -0,0 +1,719 @@
|
|
|
|
|
+// AC-14 路由接线 e2e(**四条断言的守卫**——本文件的价值不在于"接口能返回 200")。
|
|
|
|
|
+//
|
|
|
|
|
+// 覆盖结构(每条对到 AC-14 验收项原文):
|
|
|
|
|
+//
|
|
|
|
|
+// ① 40 端点注册表与契约逐条对齐 → TestRouteRegistryMatchesContract
|
|
|
|
|
+// (含"不含 18/19/20"与"/pay 段本批不挂"的反向断言)
|
|
|
|
|
+// ② 断言①:全局 `method+path` 唯一 → TestAssertUniqueMethodPath(正例 + 变异反例)
|
|
|
|
|
+// ③ 断言②:一级段 ⊆ {/admin,/student,/pay} → TestFirstSegmentWhitelist(正例 + 变异反例)
|
|
|
|
|
+// ④ 断言④(行为):静态段优先 → TestStaticSegmentBeatsParam
|
|
|
|
|
+// 被 `:id` 吃掉时 `GET /admin/activities/discounts/usable` 会返回 **2002**(`:id` 解析
|
|
|
|
|
+// "discounts" 失败),比"路由表里存在这条"更能证明顺序正确。
|
|
|
|
|
+//
|
|
|
|
|
+// 另含:全部 40 条都必须过 Auth(无 token → 1001)/ 角色白名单真的生效(非白名单角色 1003)/
|
|
|
|
|
+// RBAC 种子幂等 + 与路由表 1:1 + **种子在真 Casbin 引擎下可判定**(含 `:param` 不过度匹配)。
|
|
|
|
|
+//
|
|
|
|
|
+// 断言纪律:只断 body 契约(HTTP 恒 200 + body.code,铁律 6),不断 HTTP 状态码。
|
|
|
|
|
+package handler
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/gofiber/fiber/v2"
|
|
|
|
|
+ "gorm.io/gorm"
|
|
|
|
|
+
|
|
|
|
|
+ identityhandler "yuansiwei/unified-backend/internal/identity/handler"
|
|
|
|
|
+ infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
|
|
+ infrarabc "yuansiwei/unified-backend/internal/infra/rbac"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/infra/response"
|
|
|
|
|
+ appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 测试装配
|
|
|
|
|
+
|
|
|
|
|
+// newAcademicRouteTestApp 构造**只挂 academic 域**的 app(断言② 的合法断言对象)。
|
|
|
|
|
+//
|
|
|
|
|
+// 中间件链与生产一致:`stubAuth(验签+作用域注入)→ identity.RequireRole(本行白名单) → handler`。
|
|
|
|
|
+// `stubAuth` 用 `X-Test-Token` 模拟登录态(缺省 = 未登录 → 1001),`X-Test-Scope` 选角色。
|
|
|
|
|
+func newAcademicRouteTestApp(t *testing.T, db *gorm.DB, opt RegisterOptions) (*fiber.App, *Registration) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if opt.Auth == nil {
|
|
|
|
|
+ opt.Auth = stubAuth()
|
|
|
|
|
+ }
|
|
|
|
|
+ if opt.RoleGuard == nil {
|
|
|
|
|
+ // 用**生产同一个**工厂(identity/handler.RequireRole):若测试自写一份白名单判定,
|
|
|
|
|
+ // 装配处把 guard 传错也测不出来。
|
|
|
|
|
+ opt.RoleGuard = identityhandler.RequireRole
|
|
|
|
|
+ }
|
|
|
|
|
+ app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
|
|
+ reg, err := Register(app, h, opt)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("Register 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return app, reg
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// stubAuth 模拟 identity 的验签中间件:无 token → 1001;有 token → 按 `X-Test-Scope` 注入作用域。
|
|
|
|
|
+func stubAuth() fiber.Handler {
|
|
|
|
|
+ return func(c *fiber.Ctx) error {
|
|
|
|
|
+ if strings.TrimSpace(c.Get("X-Test-Token")) == "" {
|
|
|
|
|
+ return response.Fail(c, infraerrors.ErrUnauth) // 1001
|
|
|
|
|
+ }
|
|
|
|
|
+ switch c.Get("X-Test-Scope") {
|
|
|
|
|
+ case "super":
|
|
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
|
|
+ InstitutionID: 1, CampusID: 0, SuperAdmin: true,
|
|
|
|
|
+ UserID: "01HADMIN0000000000000001", Role: RoleSuperAdmin,
|
|
|
|
|
+ })
|
|
|
|
|
+ case "academic":
|
|
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
|
|
+ InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ UserID: "01HACADEMIC0000000000001", Role: RoleAcademic,
|
|
|
|
|
+ })
|
|
|
|
|
+ case "teacher":
|
|
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
|
|
+ InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ UserID: "01HTEACHER00000000000001", Role: RoleTeacher,
|
|
|
|
|
+ })
|
|
|
|
|
+ case "student":
|
|
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
|
|
+ InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ UserID: "01HSTUDENT00000000000001", Role: RoleStudent,
|
|
|
|
|
+ })
|
|
|
|
|
+ default:
|
|
|
|
|
+ appscope.Set(c, testScope()) // finance / 机构1 / 校区10
|
|
|
|
|
+ }
|
|
|
|
|
+ return c.Next()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// requestPath 把路由模板转成可请求的具体路径(`/admin/activities/:id` → `/admin/activities/1`)。
|
|
|
|
|
+func requestPath(tmpl string) string {
|
|
|
|
|
+ segs := strings.Split(tmpl, "/")
|
|
|
|
|
+ for i, seg := range segs {
|
|
|
|
|
+ if strings.HasPrefix(seg, ":") {
|
|
|
|
|
+ segs[i] = "1"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(segs, "/")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ① 注册表 ↔ 契约
|
|
|
|
|
+
|
|
|
|
|
+// TestRouteRegistryMatchesContract 40 条端点与契约 §2.1/§2.2/§2.3 逐条对齐。
|
|
|
|
|
+//
|
|
|
|
|
+// 期望值**手写**(测试即契约快照):与 `route.go` 的生成逻辑不同源,才有对齐价值——
|
|
|
|
|
+// 若也写成"从路由表推导",这条用例就只能证明"路由表等于自己"。
|
|
|
|
|
+func TestRouteRegistryMatchesContract(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ routes := Routes(h)
|
|
|
|
|
+
|
|
|
|
|
+ want := []struct {
|
|
|
|
|
+ no int
|
|
|
|
|
+ method string
|
|
|
|
|
+ path string
|
|
|
|
|
+ roles string // 逗号连接,便于逐条目视核对契约
|
|
|
|
|
+ }{
|
|
|
|
|
+ {1, "GET", "/admin/finance/accounts/:userId", "super_admin,finance,academic"},
|
|
|
|
|
+ {2, "GET", "/admin/finance/accounts/:userId/refundable", "super_admin,finance,academic"},
|
|
|
|
|
+ {3, "POST", "/admin/finance/recharges", "super_admin,finance"},
|
|
|
|
|
+ {4, "POST", "/admin/finance/expenditures", "super_admin,finance"},
|
|
|
|
|
+ {5, "POST", "/admin/finance/refunds", "super_admin,finance"},
|
|
|
|
|
+ {6, "POST", "/admin/finance/refunds/custom", "super_admin,finance"},
|
|
|
|
|
+ {7, "POST", "/admin/finance/transfers", "super_admin,finance"},
|
|
|
|
|
+ {8, "POST", "/admin/finance/bad-debts", "super_admin,finance"},
|
|
|
|
|
+ {9, "POST", "/admin/finance/cleared-balances", "super_admin,finance"},
|
|
|
|
|
+ {10, "POST", "/admin/finance/spends", "super_admin,finance,academic"},
|
|
|
|
|
+ {11, "GET", "/admin/finance/logs", "super_admin,finance,academic"},
|
|
|
|
|
+ {12, "GET", "/admin/finance/requests", "super_admin,finance,academic"},
|
|
|
|
|
+ {13, "POST", "/admin/finance/requests", "super_admin,finance,academic"},
|
|
|
|
|
+ {14, "POST", "/admin/finance/requests/:id/approve", "super_admin,finance"},
|
|
|
|
|
+ {15, "POST", "/admin/finance/requests/:id/reject", "super_admin,finance"},
|
|
|
|
|
+ // 端点 16:契约写"申请人本人 或 super_admin/finance";路由层是粗粒度门禁,
|
|
|
|
|
+ // 必须覆盖"可能成为申请人"的角色(端点 13 允许 academic 建单),"本人"由 Service 判。
|
|
|
|
|
+ {16, "POST", "/admin/finance/requests/:id/cancel", "super_admin,finance,academic"},
|
|
|
|
|
+ {17, "GET", "/student/bill", "student,parent,super_admin"},
|
|
|
|
|
+ {21, "GET", "/admin/finance/dashboard", "super_admin,finance,academic"},
|
|
|
|
|
+ {22, "GET", "/admin/activities", "super_admin,academic,finance,teacher"},
|
|
|
|
|
+ {23, "POST", "/admin/activities", "super_admin,academic"},
|
|
|
|
|
+ {24, "GET", "/admin/activities/:id", "super_admin,academic,teacher"},
|
|
|
|
|
+ {25, "PUT", "/admin/activities/:id", "super_admin,academic"},
|
|
|
|
|
+ {26, "POST", "/admin/activities/:id/sign", "super_admin,academic,student,parent"},
|
|
|
|
|
+ {27, "POST", "/admin/activities/:id/cancel-sign", "super_admin,academic,student,parent"},
|
|
|
|
|
+ {28, "GET", "/admin/activities/:id/signeds", "super_admin,academic,finance"},
|
|
|
|
|
+ {29, "POST", "/admin/activities/:id/recommend-settle", "super_admin,academic"},
|
|
|
|
|
+ {30, "POST", "/admin/activities/:id/cancel-recharge", "super_admin,finance"},
|
|
|
|
|
+ {31, "GET", "/admin/activities/discounts/usable", "super_admin,academic,finance"},
|
|
|
|
|
+ {32, "POST", "/admin/activities/discounts/use", "super_admin,academic,finance"},
|
|
|
|
|
+ {33, "POST", "/admin/activities/discounts/refund", "super_admin,academic,finance"},
|
|
|
|
|
+ {34, "POST", "/admin/activities/discounts/refund-by-activity", "super_admin,academic,finance"},
|
|
|
|
|
+ {35, "POST", "/admin/activities/recharge-gifts/use", "super_admin,academic,finance"},
|
|
|
|
|
+ {36, "POST", "/admin/activities/recharge-gifts/refund", "super_admin,academic,finance"},
|
|
|
|
|
+ {37, "POST", "/admin/activities/:id/visits", "super_admin,academic,teacher"},
|
|
|
|
|
+ {38, "GET", "/admin/divide-into/tree", "super_admin,academic"},
|
|
|
|
|
+ {39, "POST", "/admin/divide-into/members", "super_admin,academic"},
|
|
|
|
|
+ {40, "PUT", "/admin/divide-into/members/:userId/quota", "super_admin,academic"},
|
|
|
|
|
+ {41, "POST", "/admin/divide-into/logs", "super_admin,academic"},
|
|
|
|
|
+ {42, "DELETE", "/admin/divide-into/logs", "super_admin,academic"},
|
|
|
|
|
+ {43, "GET", "/admin/divide-into/logs", "super_admin,academic,finance"},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if len(routes) != len(want) {
|
|
|
|
|
+ t.Fatalf("路由条数 = %d,期望 %d(本批只挂已交付的 40 个端点)", len(routes), len(want))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
|
|
|
|
|
+ // 两者是不同的纪律——端点覆盖面按集合校验(下面),顺序另有三条断言(本函数末尾)。
|
|
|
|
|
+ type contractRow struct {
|
|
|
|
|
+ no int
|
|
|
|
|
+ roles string
|
|
|
|
|
+ }
|
|
|
|
|
+ gotByKey := make(map[string]contractRow, len(routes))
|
|
|
|
|
+ for _, r := range routes {
|
|
|
|
|
+ if _, dup := gotByKey[r.Key()]; dup {
|
|
|
|
|
+ t.Errorf("%s 在路由表里出现了两次", r.Key())
|
|
|
|
|
+ }
|
|
|
|
|
+ gotByKey[r.Key()] = contractRow{no: r.No, roles: strings.Join(r.Roles, ",")}
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, w := range want {
|
|
|
|
|
+ key := w.method + " " + w.path
|
|
|
|
|
+ got, ok := gotByKey[key]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ t.Errorf("端点 %d(%s %s)未注册", w.no, w.method, w.path)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.no != w.no {
|
|
|
|
|
+ t.Errorf("%s 的端点号 = %d,期望 %d", key, got.no, w.no)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.roles != w.roles {
|
|
|
|
|
+ t.Errorf("端点 %d 角色白名单 = %q,期望 %q", w.no, got.roles, w.roles)
|
|
|
|
|
+ }
|
|
|
|
|
+ delete(gotByKey, key)
|
|
|
|
|
+ }
|
|
|
|
|
+ for key := range gotByKey {
|
|
|
|
|
+ t.Errorf("注册了契约外的路由 %s(本批只挂已交付的 40 个端点)", key)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
|
|
|
|
|
+ // 看起来像"已实现但返回怪码"(前端不会看到 404,会去查业务逻辑)。
|
|
|
|
|
+ for _, r := range routes {
|
|
|
|
|
+ if strings.HasPrefix(r.Path, "/pay") {
|
|
|
|
|
+ t.Errorf("端点 %d(%s %s)不该在本批注册:18/19/20 handler 尚未实现", r.No, r.Method, r.Path)
|
|
|
|
|
+ }
|
|
|
|
|
+ switch r.No {
|
|
|
|
|
+ case 18, 19, 20:
|
|
|
|
|
+ t.Errorf("端点 %d 不该在本批注册", r.No)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 静态段先于 `:id`(断言④ 的**顺序**部分;行为断言见 TestStaticSegmentBeatsParam)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ⚠ 实测结论(变异测试记录,见交付报告):本批 40 条路径**不存在会被 `:id` 吃掉的同深度冲突**
|
|
|
|
|
+ // —— `:id` 是 3 段(`/admin/activities/:id`),而 `discounts/*` / `recharge-gifts/*` 都是 4 段,
|
|
|
|
|
+ // Fiber 按段数匹配,3 段路由匹配不上 4 段请求。因此"把 `:id` 提到静态段之前"这条变异
|
|
|
|
|
+ // **不会**让 `GET /admin/activities/discounts/usable` 变 2002(已实测:仍是 200)。
|
|
|
|
|
+ // 纪律仍然保留(同级静态优先),并且下面用一个**真正会冲突的同深度路由对**证明守卫不是恒过。
|
|
|
|
|
+ assertStaticBeforeParam(t, routes)
|
|
|
|
|
+
|
|
|
|
|
+ // 守卫自证:同一对路由,正确顺序 → 无违规;反序 → 必须报出违规。
|
|
|
|
|
+ // 这对路由(`/admin/x/:id/sign` vs `/admin/x/discounts/sign`)才是 Fiber 里真会被吃掉
|
|
|
|
|
+ // 的形态:**同 method、同段数、同尾段、中间一位是字面 vs `:param`**。
|
|
|
|
|
+ badPair := []Route{
|
|
|
|
|
+ {No: 1, Method: fiber.MethodPost, Path: "/admin/x/:id/sign", Roles: []string{RoleAcademic}},
|
|
|
|
|
+ {No: 2, Method: fiber.MethodPost, Path: "/admin/x/discounts/sign", Roles: []string{RoleAcademic}},
|
|
|
|
|
+ }
|
|
|
|
|
+ if violations := staticShadowViolations(badPair); len(violations) != 1 {
|
|
|
|
|
+ t.Errorf("顺序守卫未检出同深度冲突(本应 1 条):%v", violations)
|
|
|
|
|
+ }
|
|
|
|
|
+ goodPair := []Route{badPair[1], badPair[0]}
|
|
|
|
|
+ if violations := staticShadowViolations(goodPair); len(violations) != 0 {
|
|
|
|
|
+ t.Errorf("顺序守卫对正确顺序误报:%v", violations)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// assertStaticBeforeParam 检查"同 method、同段数、某一位是字面段 vs `:param`"的路由对,
|
|
|
|
|
+// 字面段必须**先注册**(后注册会被 `:param` 吃掉:Fiber 按注册顺序匹配)。
|
|
|
|
|
+func assertStaticBeforeParam(t *testing.T, routes []Route) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ for _, v := range staticShadowViolations(routes) {
|
|
|
|
|
+ t.Error(v)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// staticShadowViolations 返回"会被 `:param` 吃掉"的路由对描述(空 = 顺序正确)。
|
|
|
|
|
+//
|
|
|
|
|
+// 纯函数,便于单测直接构造正/反序路由对验证守卫本身(守卫要能被证伪,见设计 §10.3)。
|
|
|
|
|
+func staticShadowViolations(routes []Route) []string {
|
|
|
|
|
+ violations := make([]string, 0, 2)
|
|
|
|
|
+ for i, param := range routes {
|
|
|
|
|
+ if !strings.Contains(param.Path, ":") {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ for j, literal := range routes {
|
|
|
|
|
+ if j <= i || literal.Method != param.Method {
|
|
|
|
|
+ continue // 只看"参数段先注册、字面段后注册"这一错误方向
|
|
|
|
|
+ }
|
|
|
|
|
+ if shadowedBy(param.Path, literal.Path) {
|
|
|
|
|
+ violations = append(violations, fmt.Sprintf(
|
|
|
|
|
+ "注册顺序错误:端点 %d(%s %s)注册在端点 %d(%s %s)之后,"+
|
|
|
|
|
+ "静态段会被 `:param` 吃掉(表现是 2002:id 解析失败)",
|
|
|
|
|
+ param.No, param.Method, param.Path, literal.No, literal.Method, literal.Path))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return violations
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// shadowedBy 判断 paramPath(形如 `/x/:id/y`)是否会吃掉 literalPath(形如 `/x/discounts/y`):
|
|
|
|
|
+// 同深度、逐段相同、且 paramPath 在该位是 `:param` 而 literalPath 是字面段。
|
|
|
|
|
+func shadowedBy(paramPath, literalPath string) bool {
|
|
|
|
|
+ pc := pathSegments(paramPath)
|
|
|
|
|
+ lc := pathSegments(literalPath)
|
|
|
|
|
+ if len(pc) != len(lc) {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ wildcard := false
|
|
|
|
|
+ for i := range pc {
|
|
|
|
|
+ if isParamSegment(pc[i]) {
|
|
|
|
|
+ wildcard = true // 该位是参数 → 能匹配 literalPath 的字面段
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if pc[i] != lc[i] {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return wildcard
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ② 断言①:method+path 唯一
|
|
|
|
|
+
|
|
|
|
|
+// TestAssertUniqueMethodPath 断言① 的正例 + 变异反例。
|
|
|
|
|
+//
|
|
|
|
|
+// 变异反例的意义:Fiber 对重复 `method+path` 不报错、静默丢弃后注册者,
|
|
|
|
|
+// 若断言写错(如只比较 path、或只比对本域前 N 条),就抓不到这类缺陷。
|
|
|
|
|
+func TestAssertUniqueMethodPath(t *testing.T) {
|
|
|
|
|
+ // 正例:完整 40 条 + healthz(模拟全局表里有别的域的路由)
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
|
|
|
|
|
+ app.Get("/healthz", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
|
|
|
|
+ if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
|
|
|
|
|
+ t.Fatalf("40 条路由不该有重复:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(reg.Routes) != 40 {
|
|
|
|
|
+ t.Fatalf("注册条数 = %d,期望 40", len(reg.Routes))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)
|
|
|
|
|
+ if err := AssertUniqueMethodPath([]fiber.Route{
|
|
|
|
|
+ {Method: "GET", Path: "/admin/finance/requests"},
|
|
|
|
|
+ {Method: "POST", Path: "/admin/finance/requests"},
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("同 path 不同 method 不该被判重复:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 变异反例 A:纯函数层重复
|
|
|
|
|
+ if err := AssertUniqueMethodPath([]fiber.Route{
|
|
|
|
|
+ {Method: "GET", Path: "/admin/finance/logs"},
|
|
|
|
|
+ {Method: "GET", Path: "/admin/finance/logs"},
|
|
|
|
|
+ }); err == nil {
|
|
|
|
|
+ t.Fatal("断言① 未检出 method+path 重复(守卫失效)")
|
|
|
|
|
+ } else if !strings.Contains(err.Error(), "GET /admin/finance/logs") {
|
|
|
|
|
+ t.Errorf("断言① 报错未指出重复项:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 变异反例 B:装配层真注册一条重复路由 → Register 必须直接失败
|
|
|
|
|
+ // (模拟"第二批端点复制粘贴时把某条重复注册"这一真实事故)。
|
|
|
|
|
+ h, hErr := New(Config{DB: db})
|
|
|
|
|
+ if hErr != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", hErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ mutated := append([]Route{}, Routes(h)...)
|
|
|
|
|
+ mutated = append(mutated, Route{
|
|
|
|
|
+ No: 999, Method: fiber.MethodGet, Path: PathFinanceLogs, Name: "重复注册变异",
|
|
|
|
|
+ Roles: []string{RoleFinance}, Handler: h.ListLogs,
|
|
|
|
|
+ })
|
|
|
|
|
+ mutApp := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
|
|
+ if _, err := Register(mutApp, h, RegisterOptions{
|
|
|
|
|
+ Auth: stubAuth(), RoleGuard: identityhandler.RequireRole, Routes: mutated,
|
|
|
|
|
+ }); err == nil {
|
|
|
|
|
+ t.Fatal("Register 未拦住重复 method+path(断言① 在装配路径上失效)")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ③ 断言②:一级段白名单
|
|
|
|
|
+
|
|
|
|
|
+// TestFirstSegmentWhitelist 断言② 的正例 + **变异反例**(证明它不是"恒过")。
|
|
|
|
|
+//
|
|
|
|
|
+// 危险失败形态说明:若把断言写成"先过滤掉 /auth、/org 再判",则它永远绿——
|
|
|
|
|
+// 与本用例的变异反例(`/auth/xxx` 必须变红)正好互为验证。
|
|
|
|
|
+func TestFirstSegmentWhitelist(t *testing.T) {
|
|
|
|
|
+ // 正例:40 条只占 /admin 与 /student 两个一级段
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ _, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
|
|
|
|
|
+ wantPrefixes := []string{"/admin", "/student"}
|
|
|
|
|
+ if strings.Join(reg.Prefixes, ",") != strings.Join(wantPrefixes, ",") {
|
|
|
|
|
+ t.Fatalf("一级段集合 = %v,期望 %v", reg.Prefixes, wantPrefixes)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := AssertFirstSegments(reg.Prefixes); err != nil {
|
|
|
|
|
+ t.Fatalf("本域一级段不该越界:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 纯函数层:/auth 与 /org 必须被拒(academic 不得重复注册他域路径)
|
|
|
|
|
+ for _, bad := range [][]string{
|
|
|
|
|
+ {"/admin", "/auth"},
|
|
|
|
|
+ {"/org"},
|
|
|
|
|
+ {"/admin", "/student", "/pay", "/teaching"},
|
|
|
|
|
+ } {
|
|
|
|
|
+ if err := AssertFirstSegments(bad); err == nil {
|
|
|
|
|
+ t.Errorf("断言② 未拒 %v(守卫失效:这会让 academic 悄悄顶掉 identity/org 的 handler)", bad)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 变异反例(装配层):在 academic 装配里注册一条 `/auth/xxx` → Register 必须失败。
|
|
|
|
|
+ // 这正是团队纪律要求的"能自证失败":删掉注入行即转绿(变异实测输出见交付报告)。
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ mutated := append([]Route{}, Routes(h)...)
|
|
|
|
|
+ mutated = append(mutated, Route{
|
|
|
|
|
+ No: 998, Method: fiber.MethodGet, Path: "/auth/xxx", Name: "越界一级段变异",
|
|
|
|
|
+ Roles: []string{RoleSuperAdmin}, Handler: h.GetAccount,
|
|
|
|
|
+ })
|
|
|
|
|
+ mutApp := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
|
|
+ if _, err := Register(mutApp, h, RegisterOptions{
|
|
|
|
|
+ Auth: stubAuth(), RoleGuard: identityhandler.RequireRole, Routes: mutated,
|
|
|
|
|
+ }); err == nil {
|
|
|
|
|
+ t.Fatal("Register 未拦住 /auth/xxx(断言② 在装配路径上失效 = 恒过)")
|
|
|
|
|
+ } else if !strings.Contains(err.Error(), "/auth") {
|
|
|
|
|
+ t.Errorf("断言② 报错未指出越界段:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ④ 静态段优先(行为)
|
|
|
|
|
+
|
|
|
|
|
+// TestStaticSegmentBeatsParam `GET /admin/activities/discounts/usable` 必须 **200**。
|
|
|
|
|
+//
|
|
|
|
|
+// 若它被 `/admin/activities/:id` 吃掉,`:id="discounts"` 解析失败 → **2002**。
|
|
|
|
|
+// 这条比"路由表里存在这条"可靠得多:路由表里两条都在,被吃掉时表里照样存在。
|
|
|
|
|
+func TestStaticSegmentBeatsParam(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app, _ := newAcademicRouteTestApp(t, db, RegisterOptions{})
|
|
|
|
|
+
|
|
|
|
|
+ code, msg, _ := doJSON(t, app, fiber.MethodGet,
|
|
|
|
|
+ "/admin/activities/discounts/usable?userId=01HSTUDENT00000000000001&institutionId=1&campusId=10",
|
|
|
|
|
+ nil, "X-Test-Token", "t")
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("GET /admin/activities/discounts/usable = code %d(%s),期望 200 ——"+
|
|
|
|
|
+ "2002 说明被 :id 吃掉;403/1003 说明白名单或装配有问题", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 对照:真正走 `:id` 的详情端点也必须通(证明两条路由各自命中自己的 handler)
|
|
|
|
|
+ code, msg, _ = doJSON(t, app, fiber.MethodGet, "/admin/activities/1", nil, "X-Test-Token", "t")
|
|
|
|
|
+ if code == 2002 {
|
|
|
|
|
+ t.Fatalf("GET /admin/activities/1 = 2002(%s):`:id` 路由本身被静态段破坏了", msg)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ⑤ Auth / 角色白名单真的生效
|
|
|
|
|
+
|
|
|
|
|
+// TestAllRoutesRequireAuth 40 条**全部**需登录:不带 token 一律 1001。
|
|
|
|
|
+//
|
|
|
|
|
+// 这条防的是"某条端点漏挂 Auth"(公开暴露财务写接口),是最容易在复制粘贴中出错的一处。
|
|
|
|
|
+func TestAllRoutesRequireAuth(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app, reg := newAcademicRouteTestApp(t, db, RegisterOptions{})
|
|
|
|
|
+ for _, r := range reg.Routes {
|
|
|
|
|
+ code, msg, _ := doJSON(t, app, r.Method, requestPath(r.Path), nil) // 无 X-Test-Token
|
|
|
|
|
+ if code != 1001 {
|
|
|
|
|
+ t.Errorf("端点 %d(%s %s)无 token 时 code = %d(%s),期望 1001(未登录)——该端点漏挂 Auth?",
|
|
|
|
|
+ r.No, r.Method, r.Path, code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestRoleGuardApplied 白名单真的在路由层生效(非白名单角色 → 1003,白名单角色进 handler)。
|
|
|
|
|
+func TestRoleGuardApplied(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app, _ := newAcademicRouteTestApp(t, db, RegisterOptions{})
|
|
|
|
|
+
|
|
|
|
|
+ // 端点 3(充值)白名单 = super_admin/finance;academic → 1003
|
|
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathFinanceRecharges, nil,
|
|
|
|
|
+ "X-Test-Token", "t", "X-Test-Scope", "academic"); code != 1003 {
|
|
|
|
|
+ t.Errorf("端点3 academic 调充值 = %d(%s),期望 1003(白名单外)", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 同一端点 finance 通过守卫(进入 handler 后因缺 body → 2001,但**不是** 1003)
|
|
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathFinanceRecharges, nil,
|
|
|
|
|
+ "X-Test-Token", "t"); code == 1003 {
|
|
|
|
|
+ t.Errorf("端点3 finance 被 1003 挡住(%s):守卫过度收紧,种子/白名单不一致", msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 端点 17(学生账单)白名单 = student/parent/super_admin;finance → 1003(跨端越权)
|
|
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathStudentBill, nil,
|
|
|
|
|
+ "X-Test-Token", "t"); code != 1003 {
|
|
|
|
|
+ t.Errorf("端点17 finance 调学生账单 = %d(%s),期望 1003", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 端点 26(报名)含 student → 不 1003
|
|
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodPost, "/admin/activities/1/sign", nil,
|
|
|
|
|
+ "X-Test-Token", "t", "X-Test-Scope", "student"); code == 1003 {
|
|
|
|
|
+ t.Errorf("端点26 student 被 1003 挡住(%s):白名单写错", msg)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ⑥ RBAC 种子
|
|
|
|
|
+
|
|
|
|
|
+// TestRBACSeedMatchesRoutes 种子与路由表 **1:1**(挂什么就种什么,含反向可查)。
|
|
|
|
|
+func TestRBACSeedMatchesRoutes(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ routes := Routes(h)
|
|
|
|
|
+ seed, err := BuildRBACSeed(routes)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("BuildRBACSeed 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 权限点:一端点一 code,且不重复
|
|
|
|
|
+ if len(seed.Permissions) != len(routes) {
|
|
|
|
|
+ t.Fatalf("权限点 %d 条,期望 %d(每端点一个)", len(seed.Permissions), len(routes))
|
|
|
|
|
+ }
|
|
|
|
|
+ codeSet := make(map[string]struct{}, len(seed.Permissions))
|
|
|
|
|
+ for _, p := range seed.Permissions {
|
|
|
|
|
+ if _, dup := codeSet[p.Code]; dup {
|
|
|
|
|
+ t.Errorf("权限点 code 重复:%s", p.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+ codeSet[p.Code] = struct{}{}
|
|
|
|
|
+ if p.Type != "button" {
|
|
|
|
|
+ t.Errorf("权限点 %s type = %q,本批只生成 button(菜单树不由路由表推导)", p.Code, p.Type)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // casbin 规则:每条都对应"某角色 + 某路由 path",且 path 必须在路由表里
|
|
|
|
|
+ pathSet := make(map[string]struct{}, len(routes))
|
|
|
|
|
+ for _, r := range routes {
|
|
|
|
|
+ pathSet[r.Path] = struct{}{}
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, rule := range seed.CasbinRules {
|
|
|
|
|
+ if rule.Ptype != "p" || rule.V1 != "*" {
|
|
|
|
|
+ t.Errorf("策略行 ptype/dom 非法:%+v(应与既有 /org 种子一致:p + dom=*)", rule)
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, ok := pathSet[rule.V2]; !ok {
|
|
|
|
|
+ t.Errorf("策略路径 %q 不在路由表里(种子漂移)", rule.V2)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 每个端点 × 每个白名单角色都必须有策略覆盖(合并 method 后:同 path 只留一条)
|
|
|
|
|
+ covered := make(map[string]string, len(seed.CasbinRules))
|
|
|
|
|
+ for _, rule := range seed.CasbinRules {
|
|
|
|
|
+ covered[rule.V0+" "+rule.V2] = rule.V3
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, r := range routes {
|
|
|
|
|
+ for _, role := range r.Roles {
|
|
|
|
|
+ methods, ok := covered[role+" "+r.Path]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ t.Errorf("端点 %d(%s %s)缺少角色 %s 的策略", r.No, r.Method, r.Path, role)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(methods, r.Method) {
|
|
|
|
|
+ t.Errorf("端点 %d 策略 %s @ %s 的 method 正则 %q 未覆盖 %s", r.No, role, r.Path, methods, r.Method)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestRBACSeedIdempotent 种子**幂等**:连跑两次,行数不变、第二次插入 0 行。
|
|
|
|
|
+func TestRBACSeedIdempotent(t *testing.T) {
|
|
|
|
|
+ db := newRBACSeedTestDB(t)
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ routes := Routes(h)
|
|
|
|
|
+
|
|
|
|
|
+ first, err := SeedRBAC(t.Context(), db, routes)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("首次 SeedRBAC 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if first.InsertedCasbinRules != int64(first.CasbinRules) {
|
|
|
|
|
+ t.Errorf("首次插入策略 %d 行,期望全部 %d 行", first.InsertedCasbinRules, first.CasbinRules)
|
|
|
|
|
+ }
|
|
|
|
|
+ if first.InsertedPermissions != int64(first.Permissions) {
|
|
|
|
|
+ t.Errorf("首次插入权限点 %d 行,期望 %d 行", first.InsertedPermissions, first.Permissions)
|
|
|
|
|
+ }
|
|
|
|
|
+ if first.InsertedRolePermissions != int64(first.RolePermissions) {
|
|
|
|
|
+ t.Errorf("首次插入矩阵 %d 行,期望 %d 行", first.InsertedRolePermissions, first.RolePermissions)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ second, err := SeedRBAC(t.Context(), db, routes)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("二次 SeedRBAC 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if second.InsertedCasbinRules != 0 || second.InsertedPermissions != 0 || second.InsertedRolePermissions != 0 {
|
|
|
|
|
+ t.Errorf("二次执行仍插入行(非幂等):casbin=%d perm=%d rolePerm=%d",
|
|
|
|
|
+ second.InsertedCasbinRules, second.InsertedPermissions, second.InsertedRolePermissions)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 行数与生成数一致(唯一键吸收重复,不产生多余行)
|
|
|
|
|
+ var casbinCount, permCount, rolePermCount int64
|
|
|
|
|
+ db.Raw("SELECT COUNT(*) FROM casbin_rule").Scan(&casbinCount)
|
|
|
|
|
+ db.Raw("SELECT COUNT(*) FROM t_permission").Scan(&permCount)
|
|
|
|
|
+ db.Raw("SELECT COUNT(*) FROM t_role_permission").Scan(&rolePermCount)
|
|
|
|
|
+ if casbinCount != int64(first.CasbinRules) {
|
|
|
|
|
+ t.Errorf("casbin_rule 行数 = %d,期望 %d", casbinCount, first.CasbinRules)
|
|
|
|
|
+ }
|
|
|
|
|
+ if permCount != int64(first.Permissions) {
|
|
|
|
|
+ t.Errorf("t_permission 行数 = %d,期望 %d", permCount, first.Permissions)
|
|
|
|
|
+ }
|
|
|
|
|
+ if rolePermCount != int64(first.RolePermissions) {
|
|
|
|
|
+ t.Errorf("t_role_permission 行数 = %d,期望 %d", rolePermCount, first.RolePermissions)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestRBACSeedEnforcedByCasbin 种子在**真 Casbin 引擎**下可判定(含 `:param` 不过度匹配)。
|
|
|
|
|
+//
|
|
|
|
|
+// 这是"路径保留 Fiber `:param` 而非照抄 `/org/*` 的 `*`"这一决定的唯一硬证据:
|
|
|
|
|
+// `*` 在 keyMatch2 里是跨 `/` 的 `.*`,会把 `POST /admin/activities/1/sign` 也放给 teacher。
|
|
|
|
|
+func TestRBACSeedEnforcedByCasbin(t *testing.T) {
|
|
|
|
|
+ db := newRBACSeedTestDB(t)
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := SeedRBAC(t.Context(), db, Routes(h)); err != nil {
|
|
|
|
|
+ t.Fatalf("SeedRBAC 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ enf, err := infrarabc.NewEnforcer(db)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("NewEnforcer 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ engine := enf.Get()
|
|
|
|
|
+
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ role string
|
|
|
|
|
+ method string
|
|
|
|
|
+ path string
|
|
|
|
|
+ want bool
|
|
|
|
|
+ }{
|
|
|
|
|
+ {"端点3 充值:finance 放行", "finance", "POST", "/admin/finance/recharges", true},
|
|
|
|
|
+ {"端点3 充值:academic 拒绝", "academic", "POST", "/admin/finance/recharges", false},
|
|
|
|
|
+ {"端点1 账户快照:academic 放行(:userId 通配)", "academic", "GET", "/admin/finance/accounts/01HUSER", true},
|
|
|
|
|
+ {"端点14 工单通过:finance 放行", "finance", "POST", "/admin/finance/requests/9/approve", true},
|
|
|
|
|
+ {"端点14 审批:academic 拒绝(审批仅 finance/super_admin)", "academic", "POST", "/admin/finance/requests/9/approve", false},
|
|
|
|
|
+ {"端点24 详情:teacher 放行", "teacher", "GET", "/admin/activities/9", true},
|
|
|
|
|
+ {"端点26 报名:teacher 拒绝(:param 不跨段匹配)", "teacher", "POST", "/admin/activities/9/sign", false},
|
|
|
|
|
+ {"端点31 可用赠送:finance 放行(静态段)", "finance", "GET", "/admin/activities/discounts/usable", true},
|
|
|
|
|
+ {"端点17 学生账单:student 放行", "student", "GET", "/student/bill", true},
|
|
|
|
|
+ {"端点17 学生账单:finance 拒绝(跨端)", "finance", "GET", "/student/bill", false},
|
|
|
|
|
+ {"端点42 删分账流水:DELETE 与 GET 分开授权", "academic", "DELETE", "/admin/divide-into/logs", true},
|
|
|
|
|
+ {"端点43 分账流水:GET 也放行", "academic", "GET", "/admin/divide-into/logs", true},
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, c := range cases {
|
|
|
|
|
+ got, eerr := engine.Enforce(c.role, "*", c.path, c.method)
|
|
|
|
|
+ if eerr != nil {
|
|
|
|
|
+ t.Fatalf("%s:Enforce 出错: %v", c.name, eerr)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got != c.want {
|
|
|
|
|
+ t.Errorf("%s:Enforce(%s, *, %s, %s) = %v,期望 %v",
|
|
|
|
|
+ c.name, c.role, c.path, c.method, got, c.want)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestSeedSQLLooksLikeExistingDDL 渲染出的 SQL 与既有 `core-model-ddl.sql` 写法同构
|
|
|
|
|
+// (`INSERT IGNORE` + 反引号列名 + 五列 ptype/v0..v3),便于直接追加进文档而不用改写。
|
|
|
|
|
+func TestSeedSQLLooksLikeExistingDDL(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ seed, err := BuildRBACSeed(Routes(h))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("BuildRBACSeed 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ sqlText, err := seed.SQL("mysql")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("SQL 渲染失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Logf("academic RBAC 种子:casbin_rule=%d 条 / t_permission=%d 条 / t_role_permission=%d 条",
|
|
|
|
|
+ len(seed.CasbinRules), len(seed.Permissions), len(seed.RolePermissions))
|
|
|
|
|
+ for _, want := range []string{
|
|
|
|
|
+ "INSERT IGNORE INTO `casbin_rule` (`ptype`, `v0`, `v1`, `v2`, `v3`) VALUES",
|
|
|
|
|
+ "INSERT IGNORE INTO `t_permission`",
|
|
|
|
|
+ "INSERT IGNORE INTO `t_role_permission`",
|
|
|
|
|
+ "'p', 'finance', '*', '/admin/finance/recharges', 'POST'",
|
|
|
|
|
+ "'p', 'teacher', '*', '/admin/activities/:id', 'GET'",
|
|
|
|
|
+ } {
|
|
|
|
|
+ if !strings.Contains(sqlText, want) {
|
|
|
|
|
+ t.Errorf("种子 SQL 缺少 %q", want)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := seed.SQL("postgres"); err == nil {
|
|
|
|
|
+ t.Error("不支持的方言应报错,而不是静默产出非法 SQL")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newRBACSeedTestDB 建三张 RBAC 表(与目标态 DDL 的唯一键一致)+ 复用 academic 测试库。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么在测试里手写 CREATE TABLE 而不复用 org 的 GORM model:
|
|
|
|
|
+// academic 不得 import org 域模型(依赖方向);这里只要**唯一键形态**一致,
|
|
|
|
|
+// 幂等的判定依据恰好就是唯一键(uk_policy / PK code / uk_role_permission)。
|
|
|
|
|
+func newRBACSeedTestDB(t *testing.T) *gorm.DB {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ stmts := []string{
|
|
|
|
|
+ "CREATE TABLE `casbin_rule` (" +
|
|
|
|
|
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, ptype TEXT NOT NULL DEFAULT '', " +
|
|
|
|
|
+ "v0 TEXT NOT NULL DEFAULT '', v1 TEXT NOT NULL DEFAULT '', " +
|
|
|
|
|
+ "v2 TEXT NOT NULL DEFAULT '', v3 TEXT NOT NULL DEFAULT '', " +
|
|
|
|
|
+ "UNIQUE (ptype, v0, v1, v2, v3))",
|
|
|
|
|
+ "CREATE TABLE `t_permission` (" +
|
|
|
|
|
+ "code TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '', " +
|
|
|
|
|
+ "parent_code TEXT, path TEXT, icon TEXT, sort INTEGER NOT NULL DEFAULT 0, " +
|
|
|
|
|
+ "status TEXT NOT NULL DEFAULT 'active')",
|
|
|
|
|
+ "CREATE TABLE `t_role_permission` (" +
|
|
|
|
|
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, role_code TEXT NOT NULL, " +
|
|
|
|
|
+ "permission_code TEXT NOT NULL, UNIQUE (role_code, permission_code))",
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, stmt := range stmts {
|
|
|
|
|
+ if err := db.Exec(stmt).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("建 RBAC 表失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return db
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestFirstSegmentsSorted 一级段的输出顺序稳定(断言② 的报错文案与快照都依赖它)。
|
|
|
|
|
+func TestFirstSegmentsSorted(t *testing.T) {
|
|
|
|
|
+ got := FirstSegments([]Route{
|
|
|
|
|
+ {Path: "/student/bill"},
|
|
|
|
|
+ {Path: "/pay/orders"},
|
|
|
|
|
+ {Path: "/admin/finance/logs"},
|
|
|
|
|
+ {Path: "/admin/activities"},
|
|
|
|
|
+ })
|
|
|
|
|
+ want := []string{"/admin", "/pay", "/student"}
|
|
|
|
|
+ if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
|
|
|
+ t.Fatalf("FirstSegments = %v,期望 %v(去重 + 升序)", got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+ sorted := sort.SliceIsSorted(got, func(i, j int) bool { return got[i] < got[j] })
|
|
|
|
|
+ if !sorted {
|
|
|
|
|
+ t.Fatalf("FirstSegments 未排序:%v", got)
|
|
|
|
|
+ }
|
|
|
|
|
+ // /pay 是白名单内的段(端点 18/19/20 将来挂这里),不得报错
|
|
|
|
|
+ if err := AssertFirstSegments(got); err != nil {
|
|
|
|
|
+ t.Fatalf("/pay 属白名单,不该报错:%v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|