diff --git a/app/admin/service/seed.go b/app/admin/service/seed.go new file mode 100644 index 00000000..e61453c4 --- /dev/null +++ b/app/admin/service/seed.go @@ -0,0 +1,302 @@ +package service + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "gorm.io/gorm" + + contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed" + + "go-admin/app/admin/models" +) + +// adminSeeder is go-admin's own implementation of seed.Seeder: it turns the +// MenuSpec/ApiSpec values a third-party application asks for into rows +// across the four tables a visible, working menu entry needs - sys_api, +// sys_menu, sys_menu_api_rule, and sys_role_menu/casbin_rule - following the +// same shape cmd/migrate/migration/version/1786700001000_demo_menu.go +// already hand-writes for the host's own demo module. +// +// See go-admin-core's docs/contract.md, "Application-supplied menu and API +// entries", for the requirements this satisfies, and the security note on +// seed.Seeder for what this boundary does and does not protect against: an +// application already holds the same *gorm.DB this receives and could write +// sys_menu/sys_api/casbin_rule directly, bypassing this entirely. +type adminSeeder struct{} + +func init() { + seed.RegisterSeeder(adminSeeder{}) +} + +// adminRoleKey is the role every seeded menu is granted to. This mirrors +// 1786700001000_demo_menu.go's own convention rather than inventing a +// second one: MenuSpec carries no "which roles should see this" field for a +// Seeder to consult instead, and admin is the one role guaranteed to exist +// once the framework's own seed data has run. +const adminRoleKey = "admin" + +// menuSortRange is what sys_menu.sort's column type actually holds. +// +// sort is `gorm:"size:4"`, which MySQL builds as a tinyint (-128..127); +// sqlite ignores the width and accepts anything, so this only ever surfaces +// on a real install, mid-migration, as Error 1264 - by which point the +// migration has already run other, non-transactional DDL that will not be +// retried. tools/checksilent's menu-sort-overflow check catches this for +// every MenuSpec-shaped literal committed to this repository, but it walks +// the repository's own source tree: a third-party application living in the +// module cache is invisible to it. This is the equivalent check for that +// application, run when its migration actually calls SeedMenus rather than +// never. +const ( + menuSortMin = -128 + menuSortMax = 127 +) + +func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, apis []seed.ApiSpec) error { + apiRows, err := seedApis(tx, appCode, apis) + if err != nil { + return fmt.Errorf("seed: app %q: apis: %w", appCode, err) + } + + menuIDs, err := seedMenuTree(tx, appCode, menus, apiRows) + if err != nil { + return fmt.Errorf("seed: app %q: menus: %w", appCode, err) + } + + if len(menuIDs) == 0 { + return nil + } + return grantToAdminRole(tx, menuIDs, apiRows) +} + +// seedApis writes one sys_api row per ApiSpec and returns them keyed by +// ApiSpec.Code, so seedMenuTree can resolve a MenuSpec's ApiCodes into the +// rows sys_menu_api_rule needs to reference. +// +// sys_api.id is left to autoincrement rather than assigned by the caller, +// unlike 1786700001000_demo_menu.go's hand-picked ids: that migration is +// the one file tools/checksilent's menu-id-collision check can see, because +// it lives in this repository; nothing plays that role for a third-party +// application's ids in the module cache. Never accepting a caller-chosen id +// here removes the collision this Seeder has no way to detect instead of +// trying to detect it after the fact. +func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) { + seen := make(map[string]bool, len(apis)) + rows := make(map[string]models.SysApi, len(apis)) + for _, a := range apis { + if a.Code == "" { + return nil, errors.New("ApiSpec.Code must not be empty") + } + if seen[a.Code] { + return nil, fmt.Errorf("duplicate ApiSpec.Code %q", a.Code) + } + seen[a.Code] = true + + row := models.SysApi{ + Handle: a.Handle, + Title: a.Title, + Path: a.Path, + Action: a.Method, + Type: "SYS", + AppCode: appCode, + } + if err := tx.Create(&row).Error; err != nil { + return nil, fmt.Errorf("api %q: %w", a.Code, err) + } + rows[a.Code] = row + } + return rows, nil +} + +// seedMenuTree writes one sys_menu row per MenuSpec, resolving Parent/Code +// references into parent_id/paths, and returns every menu id created so the +// caller can grant them to a role. +// +// Specs do not have to be given in parent-before-child order: this makes +// repeated passes over the remaining specs, creating whichever ones have +// their Parent (if any) already created, until every spec is placed. A +// spec whose Parent never resolves - naming a Code missing from this call, +// or only reachable through a cycle - stops making progress and is reported +// rather than looping forever. +func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows map[string]models.SysApi) ([]int, error) { + byCode := make(map[string]seed.MenuSpec, len(specs)) + for _, s := range specs { + if s.Code == "" { + return nil, errors.New("MenuSpec.Code must not be empty") + } + if _, dup := byCode[s.Code]; dup { + return nil, fmt.Errorf("duplicate MenuSpec.Code %q", s.Code) + } + if err := validateMenuSpec(s); err != nil { + return nil, fmt.Errorf("%q: %w", s.Code, err) + } + byCode[s.Code] = s + } + + created := make(map[string]models.SysMenu, len(specs)) + ids := make([]int, 0, len(specs)) + + for len(created) < len(specs) { + progressed := false + for _, s := range specs { + if _, done := created[s.Code]; done { + continue + } + + var parentRow models.SysMenu + if s.Parent != "" { + parent, ok := created[s.Parent] + if !ok { + if _, exists := byCode[s.Parent]; !exists { + return nil, fmt.Errorf("%q: Parent %q is not a Code in this call", s.Code, s.Parent) + } + continue // s.Parent exists but has not been created yet; retry next pass + } + parentRow = parent + } + + row := models.SysMenu{ + MenuName: menuName(appCode, s.Code), + Title: s.Title, + Icon: s.Icon, + Path: s.Path, + MenuType: s.Kind, + Permission: s.Permission, + ParentId: parentRow.MenuId, + Component: s.Component, + Sort: s.Sort, + // Hidden by default and marked as an external frame, the + // same defaults 1786700001000_demo_menu.go seeds its own + // menu with: a freshly installed application's menu should + // not need an administrator to first find and unhide it. + Visible: "0", + IsFrame: "1", + AppCode: appCode, + } + for _, code := range s.ApiCodes { + api, ok := apiRows[code] + if !ok { + return nil, fmt.Errorf("%q: ApiCodes references %q, which is not an ApiSpec.Code in this call", s.Code, code) + } + // The full row, not just {Id: api.Id}: gorm's many2many + // association save upserts an associated row whose primary + // key is already set, so a stub carrying only Id would + // overwrite every other column of an sys_api row this same + // call just wrote with zero values. + row.SysApi = append(row.SysApi, api) + } + + if err := tx.Create(&row).Error; err != nil { + return nil, fmt.Errorf("%q: %w", s.Code, err) + } + + // paths is a materialized path from the root ("/0"), built from + // ids that only exist once the row above is created - the same + // two-step create-then-update 1786700001000_demo_menu.go's + // hand-assigned ids let it do in one literal, sequenced here + // instead. + if s.Parent == "" { + row.Paths = "/0/" + strconv.Itoa(row.MenuId) + } else { + row.Paths = parentRow.Paths + "/" + strconv.Itoa(row.MenuId) + } + if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId). + Update("paths", row.Paths).Error; err != nil { + return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err) + } + + created[s.Code] = row + ids = append(ids, row.MenuId) + progressed = true + } + if !progressed { + return nil, fmt.Errorf("unresolved Parent reference(s) among %d remaining spec(s); check for a cycle", len(specs)-len(created)) + } + } + return ids, nil +} + +// validateMenuSpec rejects the malformed input tools/checksilent's +// menu-sort-overflow and Kind-adjacent checks would catch for an in-tree +// seed but cannot for a third-party application's - see menuSortRange's doc +// comment. +func validateMenuSpec(s seed.MenuSpec) error { + switch s.Kind { + case contractmodels.Directory, contractmodels.Menu, contractmodels.Button: + default: + return fmt.Errorf("Kind %q is not one of Directory/Menu/Button", s.Kind) + } + if s.Sort < menuSortMin || s.Sort > menuSortMax { + return fmt.Errorf("Sort %d does not fit sys_menu.sort's tinyint column (%d..%d)", s.Sort, menuSortMin, menuSortMax) + } + return nil +} + +// menuName synthesizes sys_menu.menu_name from appCode and the spec's Code, +// since MenuSpec carries no field of its own for it - contract/seed's +// package doc says a MenuSpec is what rendering a menu and checking a +// button permission need, not a mirror of sys_menu's columns. +// +// PascalCasing both and concatenating them, rather than using Code alone, +// is what keeps two applications that both picked the plain word "list" as +// a Code from producing the identical menu_name: the frontend's keep-alive +// cache matches a route by this exact string, not by (appCode, Code), so a +// collision there is a UI bug, not a database error, and nothing else here +// would ever surface it. +func menuName(appCode, code string) string { + return pascalCase(appCode) + pascalCase(code) +} + +func pascalCase(s string) string { + var b strings.Builder + for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' }) { + b.WriteString(strings.ToUpper(part[:1])) + b.WriteString(part[1:]) + } + return b.String() +} + +// grantToAdminRole is sys_role_menu and casbin_rule: the two tables +// go-admin-core's contract.md requires alongside sys_menu/sys_api, without +// which a seeded menu is invisible to every role and its apis are +// authorized for no one. +// +// It follows 1786700001000_demo_menu.go's exact pattern, including +// tolerating a missing admin role: a database that has not yet run the +// framework's own seed data (config/db.sql, inside 1599190683659_tables.go) +// has nothing to grant to yet, and namespacedKey's ordering guarantee - every +// framework migration sorts before every app-prefixed one - means that +// should not happen in practice, but failing this call over it would be +// worse than a menu with no grant yet. +func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysApi) error { + var role models.SysRole + if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + for _, id := range menuIDs { + if err := tx.Exec( + "INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = ? AND menu_id = ?)", + role.RoleId, id, role.RoleId, id, + ).Error; err != nil { + return err + } + } + + for _, a := range apiRows { + if err := tx.Exec( + "INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)", + role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action, + ).Error; err != nil { + return err + } + } + return nil +} diff --git a/app/admin/service/seed_test.go b/app/admin/service/seed_test.go new file mode 100644 index 00000000..0e5558aa --- /dev/null +++ b/app/admin/service/seed_test.go @@ -0,0 +1,239 @@ +package service + +import ( + "errors" + "strconv" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed" + + "go-admin/app/admin/models" +) + +// newSeedTestDB builds the tables adminSeeder.SeedMenus writes to. sys_menu, +// sys_api, sys_role and sys_role_menu (GORM's own join table for +// SysRole.SysMenu) come from AutoMigrate; casbin_rule does not have a GORM +// model anywhere in this codebase - see 1786700001000_demo_menu.go's own +// comment on why models.CasbinRule (-> sys_casbin_rule) is the wrong table - +// so it is created directly, matching the columns grantToAdminRole's INSERT +// addresses. +func newSeedTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + if err := db.Exec(`CREATE TABLE casbin_rule ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT + )`).Error; err != nil { + t.Fatalf("create casbin_rule: %v", err) + } + return db +} + +func seedAdminRole(t *testing.T, db *gorm.DB) models.SysRole { + t.Helper() + role := models.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey} + if err := db.Create(&role).Error; err != nil { + t.Fatalf("seed admin role: %v", err) + } + return role +} + +// This is the acceptance case go-admin-core's docs/contract.md requires: one +// SeedMenus call populates all four tables a visible, working menu entry +// needs, every row tagged with the appCode it was called with, and the +// parent/child tree resolved into sys_menu's parent_id/paths. +func TestSeedMenusPopulatesAllFourTables(t *testing.T) { + db := newSeedTestDB(t) + seedAdminRole(t, db) + + menus := []seed.MenuSpec{ + {Code: "dir", Kind: contractmodels.Directory, Title: "Order Example", Path: "/apps/order", Component: "Layout", Sort: 10}, + {Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Path: "list", Component: "apps/order/order/index", Sort: 1, ApiCodes: []string{"list"}}, + {Code: "btn-create", Parent: "list", Kind: contractmodels.Button, Title: "Create", Permission: "order:order:create", Sort: 1}, + } + apis := []seed.ApiSpec{ + {Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"}, + } + + err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }) + if err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + var apiRows []models.SysApi + if err := db.Find(&apiRows).Error; err != nil { + t.Fatal(err) + } + if len(apiRows) != 1 || apiRows[0].AppCode != "order" || apiRows[0].Path != "/api/v1/order" { + t.Fatalf("sys_api = %+v", apiRows) + } + + var menuRows []models.SysMenu + if err := db.Order("sort").Find(&menuRows).Error; err != nil { + t.Fatal(err) + } + if len(menuRows) != 3 { + t.Fatalf("sys_menu has %d rows, want 3: %+v", len(menuRows), menuRows) + } + byName := map[string]models.SysMenu{} + for _, m := range menuRows { + if m.AppCode != "order" { + t.Errorf("menu %q app_code = %q, want order", m.MenuName, m.AppCode) + } + byName[m.MenuName] = m + } + dir, ok := byName[menuName("order", "dir")] + if !ok || dir.ParentId != 0 || dir.Paths != "/0/"+strconv.Itoa(dir.MenuId) { + t.Fatalf("dir menu = %+v", dir) + } + list, ok := byName[menuName("order", "list")] + if !ok || list.ParentId != dir.MenuId || list.Paths != dir.Paths+"/"+strconv.Itoa(list.MenuId) { + t.Fatalf("list menu = %+v (dir=%+v)", list, dir) + } + btn, ok := byName[menuName("order", "btn-create")] + if !ok || btn.ParentId != list.MenuId { + t.Fatalf("btn menu = %+v (list=%+v)", btn, list) + } + + // sys_menu_api_rule: gorm's own many2many join table for SysMenu.SysApi. + var joinCount int64 + if err := db.Table("sys_menu_api_rule"). + Where("sys_menu_menu_id = ? AND sys_api_id = ?", list.MenuId, apiRows[0].Id). + Count(&joinCount).Error; err != nil { + t.Fatal(err) + } + if joinCount != 1 { + t.Errorf("sys_menu_api_rule has %d row(s) linking list to its api, want 1", joinCount) + } + + // sys_role_menu: every seeded menu granted to the admin role. + var roleMenuCount int64 + if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil { + t.Fatal(err) + } + if roleMenuCount != 3 { + t.Errorf("sys_role_menu has %d row(s), want 3 (one per seeded menu)", roleMenuCount) + } + + // casbin_rule: the api's path/method granted to the admin role. + var casbinCount int64 + if err := db.Table("casbin_rule"). + Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", adminRoleKey, "/api/v1/order", "GET"). + Count(&casbinCount).Error; err != nil { + t.Fatal(err) + } + if casbinCount != 1 { + t.Errorf("casbin_rule has %d matching row(s), want 1", casbinCount) + } +} + +// A database that has not run the framework's own seed data yet (no admin +// role) must not fail SeedMenus - 1786700001000_demo_menu.go tolerates +// exactly the same condition for the host's own demo module. +func TestSeedMenusToleratesMissingAdminRole(t *testing.T) { + db := newSeedTestDB(t) + + err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", []seed.MenuSpec{ + {Code: "dir", Kind: contractmodels.Directory, Title: "Order"}, + }, nil) + }) + if err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + var roleMenuCount int64 + if err := db.Table("sys_role_menu").Count(&roleMenuCount).Error; err != nil { + t.Fatal(err) + } + if roleMenuCount != 0 { + t.Errorf("sys_role_menu has %d row(s) with no role to grant to", roleMenuCount) + } +} + +func TestSeedMenusRejectsMalformedSpecs(t *testing.T) { + cases := []struct { + name string + menus []seed.MenuSpec + apis []seed.ApiSpec + want string + }{ + { + name: "duplicate menu code", + menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory}, {Code: "a", Kind: contractmodels.Directory}}, + want: `duplicate MenuSpec.Code "a"`, + }, + { + name: "unresolved parent", + menus: []seed.MenuSpec{{Code: "a", Parent: "missing", Kind: contractmodels.Menu}}, + want: `Parent "missing" is not a Code in this call`, + }, + { + name: "unresolved api code", + menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Menu, ApiCodes: []string{"missing"}}}, + want: `ApiCodes references "missing"`, + }, + { + name: "unknown kind", + menus: []seed.MenuSpec{{Code: "a", Kind: "X"}}, + want: `Kind "X" is not one of Directory/Menu/Button`, + }, + { + name: "sort overflows a tinyint", + menus: []seed.MenuSpec{{Code: "a", Kind: contractmodels.Directory, Sort: 900}}, + want: `Sort 900 does not fit sys_menu.sort's tinyint column`, + }, + { + name: "duplicate api code", + apis: []seed.ApiSpec{{Code: "x"}, {Code: "x"}}, + want: `duplicate ApiSpec.Code "x"`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db := newSeedTestDB(t) + err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", tc.menus, tc.apis) + }) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to contain %q", err, tc.want) + } + }) + } +} + +// TestSeederIsRegistered pins the registration itself, not the behaviour. +// +// Every other test here calls adminSeeder{}.SeedMenus directly, which proves +// the implementation is right and proves nothing about whether anything ever +// reaches it: delete the RegisterSeeder call in init() and they all stay +// green, while a real migrate fails with ErrNoSeeder and no menu is written. +// Going through the package-level SeedMenus is what closes that gap - it is +// the door an application actually knocks on. +func TestSeederIsRegistered(t *testing.T) { + db := newSeedTestDB(t) + err := db.Transaction(func(tx *gorm.DB) error { + return seed.SeedMenus(tx, "probe", []seed.MenuSpec{{ + Code: "root", Kind: contractmodels.Directory, Title: "Probe", Sort: 1, + }}, nil) + }) + if errors.Is(err, seed.ErrNoSeeder) { + t.Fatal("no Seeder is registered: an application's SeedMenus would write no menu at all") + } + if err != nil { + t.Fatalf("SeedMenus through the package-level entry point: %v", err) + } +}