mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-21 10:13:01 +00:00
grantToAdminRole does two independent things - it grants the menus to the admin role and writes a casbin rule per api - and SeedMenus skipped the whole call whenever the menu list came back empty. An application is free to register apis with no menus: endpoints another service calls, a webhook, a UI mounted somewhere else. Those installs wrote their sys_api rows and then no casbin rule for any of them, so every one of those endpoints was denied to everyone, admin included - from a migration that reported success and left rows in the table to prove it had run. There is nothing to look at afterwards that says what went wrong. Guard on both lists instead, so nothing registered stays a no-op and apis alone still get granted. Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
292 lines
9.9 KiB
Go
292 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// An application is free to register apis with no menus at all - endpoints
|
|
// another service calls, or a UI mounted somewhere else. Skipping
|
|
// grantToAdminRole on an empty menu list wrote the sys_api rows and then no
|
|
// casbin rule for them, so every one of those endpoints was denied to
|
|
// everyone including admin, from a migration that reported success.
|
|
func TestSeedMenusGrantsApisWhenThereAreNoMenus(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
role := seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{
|
|
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
|
|
{Code: "sync", Title: "Sync", Path: "/api/v1/sync", Method: "GET", Handle: "hook.Sync"},
|
|
}
|
|
if err := (adminSeeder{}).SeedMenus(db, "hooks", nil, apis); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
var apiCount int64
|
|
db.Model(&models.SysApi{}).Where("app_code = ?", "hooks").Count(&apiCount)
|
|
if apiCount != int64(len(apis)) {
|
|
t.Fatalf("sys_api rows = %d, want %d", apiCount, len(apis))
|
|
}
|
|
|
|
for _, a := range apis {
|
|
var n int64
|
|
db.Table("casbin_rule").
|
|
Where("ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?", role.RoleKey, a.Path, a.Method).
|
|
Count(&n)
|
|
if n != 1 {
|
|
t.Errorf("casbin_rule for %s %s = %d rows, want 1: the endpoint is denied to admin", a.Method, a.Path, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The other half of the same guard: nothing registered at all must stay a
|
|
// no-op rather than start touching sys_role_menu or casbin_rule.
|
|
func TestSeedMenusWithNothingRegisteredWritesNothing(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
seedAdminRole(t, db)
|
|
|
|
if err := (adminSeeder{}).SeedMenus(db, "empty", nil, nil); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
for _, table := range []string{"casbin_rule", "sys_role_menu"} {
|
|
var n int64
|
|
db.Table(table).Count(&n)
|
|
if n != 0 {
|
|
t.Errorf("%s has %d rows, want 0", table, n)
|
|
}
|
|
}
|
|
}
|