mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-22 02:27:57 +00:00
1786700008000 added sys_menu.seed_code and left it NULL on every row that was already there. That is right for the host's own hand-placed menus: there is nothing to derive one from. An application's rows are in that population too, and for those it is derivable - menu_name is what identified them before the column existed. The natural-key lookup missed them, so a reseed inserted a second copy beside each one, and the new unique index could not object, because NULL never collides on MySQL, PostgreSQL or SQLite and is filtered out of the index on SQL Server. Claimed when the application is seeded rather than by a backfill migration. The value is only derivable where the spec's own Code is in hand: menuName concatenates two pascalCase strings and does not reverse, so a migration looking at menu_name alone would be guessing. For the same reason more than one match is refused and named rather than picked from - attaching an application's menu to whichever row the database returned first is the failure this is meant to prevent, not a smaller version of it. The match is scoped to the application's own app_code, so a row belonging to another application, or to the host, is not claimed. An adopted row then goes through the ordinary repair, so it comes out carrying what the spec says rather than what it held from before. Three degradations turn the new assertions red: not adopting at all, picking a row when there is more than one, and dropping the app_code from the match.
1254 lines
46 KiB
Go
1254 lines
46 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
gormlogger "gorm.io/gorm/logger"
|
|
|
|
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)
|
|
}
|
|
// sys_app_casbin_grant is where grantToAdminRole records which policies
|
|
// this install created, so an uninstall can tell them from the ones
|
|
// somebody granted by hand. In a real database it is created by
|
|
// 1786700007000, which is a framework migration and therefore runs ahead
|
|
// of every application's - version strings sort bare digits before any
|
|
// app-prefixed one.
|
|
if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{},
|
|
&models.SysAppCasbinGrant{}); 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// newSeedTestDB's AutoMigrate builds a unique index on seed_code alone,
|
|
// because SysMenu.SeedCode is the only field in the struct carrying the
|
|
// uk_sys_menu_app_seed_code_del tag - app_code already carries a different,
|
|
// non-unique index name of its own, and the embedded ModelTime's
|
|
// DeletedAt (aliased from go-admin-core) cannot be given a third one. The
|
|
// real migration (cmd/migrate/migration/version/1786700008000_seed_natural_keys.go)
|
|
// never lets AutoMigrate touch this table for exactly that reason: it
|
|
// builds the composite (app_code, seed_code, deleted_at) index by hand
|
|
// instead. Reproduce that by hand here too, so a test that seeds two rows
|
|
// sharing a seed_code under different deleted_at values sees what a real
|
|
// install would, not gorm's narrower default.
|
|
func useCompositeSeedCodeIndex(t *testing.T, db *gorm.DB) {
|
|
t.Helper()
|
|
if db.Migrator().HasIndex(&models.SysMenu{}, "uk_sys_menu_app_seed_code_del") {
|
|
if err := db.Migrator().DropIndex(&models.SysMenu{}, "uk_sys_menu_app_seed_code_del"); err != nil {
|
|
t.Fatalf("drop the single-column seed_code index: %v", err)
|
|
}
|
|
}
|
|
if err := db.Exec(
|
|
"CREATE UNIQUE INDEX uk_sys_menu_app_seed_code_del ON sys_menu (app_code, seed_code, deleted_at)",
|
|
).Error; err != nil {
|
|
t.Fatalf("create the composite seed_code index: %v", err)
|
|
}
|
|
}
|
|
|
|
// A retried migration - one that failed partway through and is run again,
|
|
// or simply run twice by mistake - must not create a second sys_api or
|
|
// sys_menu row for the same (appCode, natural key). This is the defect the
|
|
// demo site hit in production: duplicate sys_menu/casbin_rule rows from a
|
|
// bare tx.Create on a natural key nothing was checking.
|
|
func TestSeedMenusIsIdempotentAcrossARetry(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
menus := []seed.MenuSpec{
|
|
{Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10},
|
|
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Sort: 1, ApiCodes: []string{"list"}},
|
|
}
|
|
apis := []seed.ApiSpec{
|
|
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"},
|
|
}
|
|
|
|
run := func() {
|
|
t.Helper()
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
}
|
|
run()
|
|
firstMenuIDs := allMenuIDs(t, db, "order")
|
|
firstApiIDs := allApiIDs(t, db, "order")
|
|
|
|
run() // the retry
|
|
|
|
if got := allMenuIDs(t, db, "order"); !sameIDs(got, firstMenuIDs) {
|
|
t.Errorf("sys_menu ids after retry = %v, want unchanged %v (a second call inserted new rows)", got, firstMenuIDs)
|
|
}
|
|
if got := allApiIDs(t, db, "order"); !sameIDs(got, firstApiIDs) {
|
|
t.Errorf("sys_api ids after retry = %v, want unchanged %v (a second call inserted new rows)", got, firstApiIDs)
|
|
}
|
|
|
|
assertRowCount(t, db, "sys_api", 1)
|
|
assertRowCount(t, db, "sys_menu", 2)
|
|
assertRowCount(t, db, "sys_menu_api_rule", 1)
|
|
assertRowCount(t, db, "sys_role_menu", 2)
|
|
assertRowCount(t, db, "casbin_rule", 1)
|
|
}
|
|
|
|
// Only a live row counts as "already written". A row a prior, unrelated
|
|
// soft-delete already retired must not be reused - seedApis/seedMenuTree
|
|
// have to insert a fresh one under the same natural key, the same way the
|
|
// unique indexes 1786700008000_seed_natural_keys.go builds only bind live
|
|
// rows.
|
|
func TestSeedMenusOnlyReusesLiveRows(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
menus := []seed.MenuSpec{{Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10}}
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
// Soft-delete both rows this first call wrote, as if an operator (or an
|
|
// earlier uninstall) had retired them, independently of this migration
|
|
// ever running again.
|
|
if err := db.Exec("UPDATE sys_menu SET deleted_at = 1").Error; err != nil {
|
|
t.Fatalf("soft-delete sys_menu: %v", err)
|
|
}
|
|
if err := db.Exec("UPDATE sys_api SET deleted_at = 1").Error; err != nil {
|
|
t.Fatalf("soft-delete sys_api: %v", err)
|
|
}
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus after soft-delete: %v", err)
|
|
}
|
|
|
|
// Two rows total: the soft-deleted original, plus a fresh one - not the
|
|
// dead row resurrected in place, and not left with zero live rows.
|
|
assertRowCount(t, db, "sys_menu", 2)
|
|
assertRowCount(t, db, "sys_api", 2)
|
|
|
|
var liveMenus, liveApis int64
|
|
db.Model(&models.SysMenu{}).Where("app_code = ?", "order").Count(&liveMenus)
|
|
db.Model(&models.SysApi{}).Where("app_code = ?", "order").Count(&liveApis)
|
|
if liveMenus != 1 {
|
|
t.Errorf("live sys_menu rows = %d, want 1", liveMenus)
|
|
}
|
|
if liveApis != 1 {
|
|
t.Errorf("live sys_api rows = %d, want 1", liveApis)
|
|
}
|
|
}
|
|
|
|
// app_code is part of the natural key, not a descriptive column alongside
|
|
// it. Two applications that happen to register an identical (path, action)
|
|
// or seed_code must each get their own row - reusing one app's row for
|
|
// another's install would make an uninstall of the first delete a row the
|
|
// second considers its own.
|
|
func TestSeedMenusScopesTheNaturalKeyByAppCode(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
menus := []seed.MenuSpec{{Code: "dir", Kind: contractmodels.Directory, Title: "Dir", Sort: 10}}
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Shared endpoint", Path: "/api/v1/shared", Method: "GET"}}
|
|
|
|
for _, appCode := range []string{"order", "billing"} {
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, appCode, menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus(%q): %v", appCode, err)
|
|
}
|
|
}
|
|
|
|
var apiRows []models.SysApi
|
|
if err := db.Where("path = ? AND action = ?", "/api/v1/shared", "GET").
|
|
Order("app_code").Find(&apiRows).Error; err != nil {
|
|
t.Fatalf("read sys_api: %v", err)
|
|
}
|
|
if len(apiRows) != 2 {
|
|
t.Fatalf("sys_api has %d row(s) for the shared (path, action), want 2 - one per app", len(apiRows))
|
|
}
|
|
if apiRows[0].AppCode != "billing" || apiRows[1].AppCode != "order" {
|
|
t.Errorf("sys_api app_codes = [%s %s], want [billing order]", apiRows[0].AppCode, apiRows[1].AppCode)
|
|
}
|
|
|
|
var menuRows []models.SysMenu
|
|
if err := db.Where("seed_code = ?", "dir").Order("app_code").Find(&menuRows).Error; err != nil {
|
|
t.Fatalf("read sys_menu: %v", err)
|
|
}
|
|
if len(menuRows) != 2 {
|
|
t.Fatalf("sys_menu has %d row(s) for the shared seed_code, want 2 - one per app", len(menuRows))
|
|
}
|
|
if menuRows[0].AppCode != "billing" || menuRows[1].AppCode != "order" {
|
|
t.Errorf("sys_menu app_codes = [%s %s], want [billing order]", menuRows[0].AppCode, menuRows[1].AppCode)
|
|
}
|
|
}
|
|
|
|
func allMenuIDs(t *testing.T, db *gorm.DB, appCode string) []int {
|
|
t.Helper()
|
|
var rows []models.SysMenu
|
|
if err := db.Where("app_code = ?", appCode).Order("menu_id").Find(&rows).Error; err != nil {
|
|
t.Fatalf("read sys_menu: %v", err)
|
|
}
|
|
ids := make([]int, len(rows))
|
|
for i, r := range rows {
|
|
ids[i] = r.MenuId
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func allApiIDs(t *testing.T, db *gorm.DB, appCode string) []int {
|
|
t.Helper()
|
|
var rows []models.SysApi
|
|
if err := db.Where("app_code = ?", appCode).Order("id").Find(&rows).Error; err != nil {
|
|
t.Fatalf("read sys_api: %v", err)
|
|
}
|
|
ids := make([]int, len(rows))
|
|
for i, r := range rows {
|
|
ids[i] = r.Id
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func sameIDs(a, b []int) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func assertRowCount(t *testing.T, db *gorm.DB, table string, want int64) {
|
|
t.Helper()
|
|
var n int64
|
|
if err := db.Table(table).Count(&n).Error; err != nil {
|
|
t.Fatalf("count %s: %v", table, err)
|
|
}
|
|
if n != want {
|
|
t.Errorf("%s has %d row(s), want %d", table, n, want)
|
|
}
|
|
}
|
|
|
|
// A retried migration does not just risk inserting a second copy of a row
|
|
// it already wrote (that gap is closed above) - the reuse path itself has
|
|
// to leave the row in the same state a fresh insert would have. Before
|
|
// this defect was fixed, the reuse branch (seed.go's "case err == nil")
|
|
// stopped at reusing the row's id and skipped everything a fresh insert
|
|
// does afterwards: the sys_menu_api_rule binding gorm's association save
|
|
// writes as part of Create, and the paths UPDATE that follows Create as a
|
|
// separate statement. A row a prior attempt inserted but did not finish -
|
|
// exactly the shape design doc §1.5 says a non-transactional retry can
|
|
// leave behind - would be "found" and then left broken forever, with the
|
|
// migration reporting success.
|
|
//
|
|
// existingHalfWrittenMenu inserts a sys_menu row the way seedMenuTree's own
|
|
// tx.Create leaves one when interrupted immediately afterwards: the row
|
|
// exists with its natural key, but paths was never computed and no
|
|
// sys_menu_api_rule binding was ever written for it - Create's association
|
|
// save and the paths UPDATE are each a separate statement from the row
|
|
// insert itself.
|
|
func existingHalfWrittenMenu(t *testing.T, db *gorm.DB, appCode, seedCode string, parentID int) models.SysMenu {
|
|
t.Helper()
|
|
code := seedCode
|
|
row := models.SysMenu{
|
|
MenuName: menuName(appCode, seedCode),
|
|
AppCode: appCode,
|
|
SeedCode: &code,
|
|
ParentId: parentID,
|
|
Visible: "0",
|
|
IsFrame: "1",
|
|
// Paths deliberately left "" - never computed, the same as a row
|
|
// whose Create succeeded but whose follow-up paths UPDATE never ran.
|
|
}
|
|
if err := db.Create(&row).Error; err != nil {
|
|
t.Fatalf("seed half-written menu %q: %v", seedCode, err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
func bindingCount(t *testing.T, db *gorm.DB, menuID, apiID int) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := db.Table("sys_menu_api_rule").
|
|
Where("sys_menu_menu_id = ? AND sys_api_id = ?", menuID, apiID).Count(&n).Error; err != nil {
|
|
t.Fatalf("count sys_menu_api_rule: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestSeedMenusRepairsAnIncompleteExistingRow is the reproduction case:
|
|
// both paths and the api binding are missing on the row seedMenuTree finds
|
|
// through its idempotency check, the shape a real interrupted retry leaves
|
|
// behind. Run against the unfixed reuse branch, this must fail - that is
|
|
// what proves the defect is real rather than a three-way guess.
|
|
func TestSeedMenusRepairsAnIncompleteExistingRow(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
|
apiRows, err := seedApis(db, "order", apis)
|
|
if err != nil {
|
|
t.Fatalf("seedApis: %v", err)
|
|
}
|
|
|
|
dir := existingHalfWrittenMenu(t, db, "order", "dir", 0)
|
|
list := existingHalfWrittenMenu(t, db, "order", "list", dir.MenuId)
|
|
|
|
menus := []seed.MenuSpec{
|
|
{Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10},
|
|
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Sort: 1, ApiCodes: []string{"list"}},
|
|
}
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
wantDirPaths := "/0/" + strconv.Itoa(dir.MenuId)
|
|
wantListPaths := wantDirPaths + "/" + strconv.Itoa(list.MenuId)
|
|
|
|
var gotDir, gotList models.SysMenu
|
|
if err := db.First(&gotDir, dir.MenuId).Error; err != nil {
|
|
t.Fatalf("read dir: %v", err)
|
|
}
|
|
if err := db.First(&gotList, list.MenuId).Error; err != nil {
|
|
t.Fatalf("read list: %v", err)
|
|
}
|
|
if gotDir.Paths != wantDirPaths {
|
|
t.Errorf("dir.Paths = %q, want %q - a retried install left a root menu with no materialized path", gotDir.Paths, wantDirPaths)
|
|
}
|
|
if gotList.Paths != wantListPaths {
|
|
t.Errorf("list.Paths = %q, want %q - a retried install left the seeded subtree with a broken materialized path", gotList.Paths, wantListPaths)
|
|
}
|
|
if n := bindingCount(t, db, list.MenuId, apiRows["list"].Id); n != 1 {
|
|
t.Errorf("sys_menu_api_rule binding count for list = %d, want 1 - a retried install left the menu with its api granted to no one", n)
|
|
}
|
|
}
|
|
|
|
// soloMenuSpec is a single, parent-less menu with one api binding - the
|
|
// smallest shape that can exhibit "paths wrong" and "binding missing"
|
|
// independently of each other, used by the three tests below to isolate
|
|
// one repair path at a time from TestSeedMenusRepairsAnIncompleteExistingRow's
|
|
// combined (both broken) case.
|
|
func soloMenuSpec() []seed.MenuSpec {
|
|
return []seed.MenuSpec{{Code: "solo", Kind: contractmodels.Menu, Title: "Solo", Sort: 1, ApiCodes: []string{"list"}}}
|
|
}
|
|
|
|
// Only the binding is missing; paths is already correct. The repair must
|
|
// add the binding and must not touch the already-correct paths value.
|
|
func TestSeedMenusRepairsOnlyAMissingBinding(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
|
apiRows, err := seedApis(db, "order", apis)
|
|
if err != nil {
|
|
t.Fatalf("seedApis: %v", err)
|
|
}
|
|
|
|
solo := existingHalfWrittenMenu(t, db, "order", "solo", 0)
|
|
wantPaths := "/0/" + strconv.Itoa(solo.MenuId)
|
|
if err := db.Model(&models.SysMenu{}).Where("menu_id = ?", solo.MenuId).
|
|
Update("paths", wantPaths).Error; err != nil {
|
|
t.Fatalf("set paths: %v", err)
|
|
}
|
|
// The binding is deliberately left unwritten.
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", soloMenuSpec(), apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
var got models.SysMenu
|
|
if err := db.First(&got, solo.MenuId).Error; err != nil {
|
|
t.Fatalf("read solo: %v", err)
|
|
}
|
|
if got.Paths != wantPaths {
|
|
t.Errorf("paths changed from %q to %q; repairing a missing binding must not touch an already-correct path", wantPaths, got.Paths)
|
|
}
|
|
if n := bindingCount(t, db, solo.MenuId, apiRows["list"].Id); n != 1 {
|
|
t.Errorf("binding count = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// Only paths is missing; the binding already exists (as if Create's own
|
|
// association write had succeeded but the paths UPDATE that follows it
|
|
// never ran). The repair must fix paths and must not duplicate the
|
|
// already-correct binding.
|
|
func TestSeedMenusRepairsOnlyMissingPaths(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
|
apiRows, err := seedApis(db, "order", apis)
|
|
if err != nil {
|
|
t.Fatalf("seedApis: %v", err)
|
|
}
|
|
|
|
solo := existingHalfWrittenMenu(t, db, "order", "solo", 0)
|
|
if err := db.Exec(
|
|
"INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) VALUES (?, ?)",
|
|
solo.MenuId, apiRows["list"].Id,
|
|
).Error; err != nil {
|
|
t.Fatalf("seed binding: %v", err)
|
|
}
|
|
// solo.Paths is deliberately left "" by existingHalfWrittenMenu.
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", soloMenuSpec(), apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
wantPaths := "/0/" + strconv.Itoa(solo.MenuId)
|
|
var got models.SysMenu
|
|
if err := db.First(&got, solo.MenuId).Error; err != nil {
|
|
t.Fatalf("read solo: %v", err)
|
|
}
|
|
if got.Paths != wantPaths {
|
|
t.Errorf("paths = %q, want %q", got.Paths, wantPaths)
|
|
}
|
|
if n := bindingCount(t, db, solo.MenuId, apiRows["list"].Id); n != 1 {
|
|
t.Errorf("binding count = %d, want 1 - repairing paths must not duplicate an already-correct binding", n)
|
|
}
|
|
}
|
|
|
|
// capturingLogger records every SQL statement gorm actually executes, so a
|
|
// test can assert that a fully-consistent retry performs no write at all -
|
|
// not just that its net effect happens to be zero rows changed. Mirrors
|
|
// common/actions/crud_shim_test.go's logger of the same name and shape;
|
|
// duplicated locally rather than exported and shared, matching how small
|
|
// gorm-facing test doubles are kept next to the test that needs them
|
|
// elsewhere in this repository.
|
|
type capturingLogger struct {
|
|
gormlogger.Interface
|
|
mu sync.Mutex
|
|
stmts []string
|
|
}
|
|
|
|
func (l *capturingLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
|
sql, _ := fc()
|
|
l.mu.Lock()
|
|
l.stmts = append(l.stmts, sql)
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
func (l *capturingLogger) all() string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return strings.Join(l.stmts, "\n")
|
|
}
|
|
|
|
// Both paths and the binding are already correct - the ordinary shape of
|
|
// every retry after the first one succeeds in full. Repairing an
|
|
// already-consistent row must not touch it: paths is read-before-write and
|
|
// so must not be UPDATEd at all (asserted directly, by statement, since the
|
|
// code gates that call behind a value comparison); the binding's own
|
|
// insert is guarded by WHERE NOT EXISTS the same way grantToAdminRole's
|
|
// already are, so its row count staying put is the meaningful claim - the
|
|
// guarded statement itself may still be sent, the same way it already is
|
|
// for sys_role_menu/casbin_rule.
|
|
func TestSeedMenusFullyConsistentRowCausesNoPathsUpdate(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
|
menus := soloMenuSpec()
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus (first): %v", err)
|
|
}
|
|
|
|
var apiRows []models.SysApi
|
|
db.Where("app_code = ?", "order").Find(&apiRows)
|
|
var soloRow models.SysMenu
|
|
if err := db.Where("app_code = ? AND seed_code = ?", "order", "solo").First(&soloRow).Error; err != nil {
|
|
t.Fatalf("read solo after first call: %v", err)
|
|
}
|
|
if soloRow.Paths == "" {
|
|
t.Fatalf("solo.Paths is empty after the first call; the fixture itself is broken, not what this test means to check")
|
|
}
|
|
wantBindings := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id)
|
|
if wantBindings != 1 {
|
|
t.Fatalf("binding count after the first call = %d, want 1; the fixture itself is broken", wantBindings)
|
|
}
|
|
|
|
capturing := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Info)}
|
|
captured := db.Session(&gorm.Session{Logger: capturing})
|
|
|
|
if err := captured.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus (retry): %v", err)
|
|
}
|
|
|
|
all := strings.ToUpper(capturing.all())
|
|
if strings.Contains(all, "UPDATE") && strings.Contains(all, "SYS_MENU") && strings.Contains(all, "PATHS") {
|
|
t.Errorf("a fully consistent retry executed a paths UPDATE against sys_menu:\n%s", capturing.all())
|
|
}
|
|
if got := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id); got != 1 {
|
|
t.Errorf("binding count after the retry = %d, want 1 (unchanged)", got)
|
|
}
|
|
}
|
|
|
|
// An administrator can bind a menu to an additional api by hand through
|
|
// the menu management UI - a sys_menu_api_rule row for an api never in
|
|
// s.ApiCodes at all. A retried SeedMenus call must not touch it: deleting
|
|
// every binding for the menu and reinserting only what s.ApiCodes lists
|
|
// would wipe it out silently, the same shape as sys_role_menu/casbin_rule
|
|
// getting zeroed by SysRole.Update's FullSaveAssociations save - just with
|
|
// this code as the actor instead of the victim this time.
|
|
func TestSeedMenusPreservesAHandAddedBinding(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
|
menus := soloMenuSpec()
|
|
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus (first): %v", err)
|
|
}
|
|
|
|
var soloRow models.SysMenu
|
|
if err := db.Where("app_code = ? AND seed_code = ?", "order", "solo").First(&soloRow).Error; err != nil {
|
|
t.Fatalf("read solo: %v", err)
|
|
}
|
|
|
|
// An api this call's ApiSpec list never mentions - standing in for one
|
|
// belonging to some other feature entirely, bound to this menu by an
|
|
// administrator, not by any SeedMenus call.
|
|
handAdded := models.SysApi{Path: "/api/v1/order/export", Action: "GET", Type: "SYS", AppCode: "order"}
|
|
if err := db.Create(&handAdded).Error; err != nil {
|
|
t.Fatalf("seed the hand-added api: %v", err)
|
|
}
|
|
if err := db.Exec(
|
|
"INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) VALUES (?, ?)",
|
|
soloRow.MenuId, handAdded.Id,
|
|
).Error; err != nil {
|
|
t.Fatalf("seed the hand-added binding: %v", err)
|
|
}
|
|
|
|
// A retry with the exact same specs - solo's ApiCodes still names only
|
|
// "list".
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
|
}); err != nil {
|
|
t.Fatalf("SeedMenus (retry): %v", err)
|
|
}
|
|
|
|
if n := bindingCount(t, db, soloRow.MenuId, handAdded.Id); n != 1 {
|
|
t.Errorf("hand-added binding count = %d, want 1 - a retry silently deleted a binding it does not own", n)
|
|
}
|
|
|
|
var apiRows []models.SysApi
|
|
db.Where("app_code = ? AND path = ?", "order", "/api/v1/order").Find(&apiRows)
|
|
if len(apiRows) != 1 {
|
|
t.Fatalf("seeded api not found as expected: %+v", apiRows)
|
|
}
|
|
if n := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id); n != 1 {
|
|
t.Errorf("the seed's own binding count = %d, want 1 - it must survive the retry too", n)
|
|
}
|
|
}
|
|
|
|
// Every policy grantToAdminRole creates has to be written down, or an
|
|
// uninstall has no way to tell this app's grants from a hand-made one and
|
|
// leaves all of them behind.
|
|
func TestSeedMenusRecordsTheGrantsItCreated(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
role := seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{
|
|
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
|
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Insert-fm"},
|
|
}
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
for _, a := range apis {
|
|
var n int64
|
|
db.Model(&models.SysAppCasbinGrant{}).
|
|
Where("app_code = ? AND ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?",
|
|
"order", role.RoleKey, a.Path, a.Method).
|
|
Count(&n)
|
|
if n != 1 {
|
|
t.Errorf("ledger rows for %s %s = %d, want 1", a.Method, a.Path, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A policy that was already there was not created by this install, so it is
|
|
// not this app's to take away later. Recording it would mean an uninstall
|
|
// deletes a grant somebody else made, and deletes it silently - the opposite
|
|
// mistake leaves a policy behind, which the uninstall reports.
|
|
func TestSeedMenusDoesNotClaimAPolicyItDidNotCreate(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
role := seedAdminRole(t, db)
|
|
|
|
if err := db.Exec(
|
|
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', ?, '/api/v1/order', 'GET', '', '', '')",
|
|
role.RoleKey,
|
|
).Error; err != nil {
|
|
t.Fatalf("pre-existing policy: %v", err)
|
|
}
|
|
|
|
apis := []seed.ApiSpec{
|
|
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
|
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Insert-fm"},
|
|
}
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
|
t.Fatalf("SeedMenus: %v", err)
|
|
}
|
|
|
|
var claimed int64
|
|
db.Model(&models.SysAppCasbinGrant{}).
|
|
Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&claimed)
|
|
if claimed != 0 {
|
|
t.Errorf("the ledger claimed a policy that was already there (%d rows)", claimed)
|
|
}
|
|
// The one it did create is still recorded: the skip is per policy, not
|
|
// for the whole call.
|
|
var created int64
|
|
db.Model(&models.SysAppCasbinGrant{}).
|
|
Where("v1 = ? AND v2 = ?", "/api/v1/order", "POST").Count(&created)
|
|
if created != 1 {
|
|
t.Errorf("ledger rows for the policy it did create = %d, want 1", created)
|
|
}
|
|
// And the pre-existing policy itself is untouched.
|
|
var policies int64
|
|
db.Table("casbin_rule").Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&policies)
|
|
if policies != 1 {
|
|
t.Errorf("casbin_rule rows = %d, want the one that was already there", policies)
|
|
}
|
|
}
|
|
|
|
// A migration that failed partway is re-run whole. The ledger must come out
|
|
// of a second run the same as the first, not with a duplicate or an error
|
|
// from its own unique index.
|
|
func TestSeedMenusLedgerSurvivesARetry(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{
|
|
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
|
t.Fatalf("SeedMenus run %d: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
var n int64
|
|
db.Model(&models.SysAppCasbinGrant{}).Count(&n)
|
|
if n != 1 {
|
|
t.Errorf("ledger has %d rows after two runs, want 1", n)
|
|
}
|
|
}
|
|
|
|
// The ledger's own guard against a duplicate, which the plain retry above
|
|
// never reaches: there the policy still exists, so the insert is skipped
|
|
// before the ledger is touched at all. This is the case that does reach it -
|
|
// the policy row was removed while its ledger entry stayed, so the seed
|
|
// creates the policy again and writes a ledger entry that is already there.
|
|
// A plain insert would abort the whole seed on the ledger's unique index.
|
|
func TestSeedMenusLedgerToleratesAnEntryWhosePolicyWasRemoved(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
seedAdminRole(t, db)
|
|
|
|
apis := []seed.ApiSpec{
|
|
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
|
}
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
|
t.Fatalf("first run: %v", err)
|
|
}
|
|
if err := db.Exec("DELETE FROM casbin_rule WHERE v1 = ? AND v2 = ?", "/api/v1/order", "GET").Error; err != nil {
|
|
t.Fatalf("removing the policy: %v", err)
|
|
}
|
|
var ledger int64
|
|
db.Model(&models.SysAppCasbinGrant{}).Count(&ledger)
|
|
if ledger != 1 {
|
|
t.Fatalf("the ledger entry is gone, so this test is not set up: %d rows", ledger)
|
|
}
|
|
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
|
t.Fatalf("second run: %v", err)
|
|
}
|
|
|
|
db.Model(&models.SysAppCasbinGrant{}).Count(&ledger)
|
|
if ledger != 1 {
|
|
t.Errorf("ledger has %d rows, want 1", ledger)
|
|
}
|
|
var policies int64
|
|
db.Table("casbin_rule").Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&policies)
|
|
if policies != 1 {
|
|
t.Errorf("the policy was not put back: %d rows", policies)
|
|
}
|
|
}
|
|
|
|
// The tree is built from parent_id, not from paths. A menu whose parent was
|
|
// removed and written again kept parent_id on the dead row while its paths
|
|
// named the new one, so the menu was gone from the sidebar and the migration
|
|
// said it had succeeded.
|
|
func TestSeedMenusRepairsParentIdAfterTheParentWasRemoved(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
|
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("first seed: %v", err)
|
|
}
|
|
var dir models.SysMenu
|
|
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&dir).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Delete(&models.SysMenu{}, "menu_id = ?", dir.MenuId).Error; err != nil {
|
|
t.Fatalf("removing the parent: %v", err)
|
|
}
|
|
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("second seed: %v", err)
|
|
}
|
|
|
|
var newDir, list models.SysMenu
|
|
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&newDir).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if newDir.MenuId == dir.MenuId {
|
|
t.Fatal("the removed parent was reused, so this test proves nothing")
|
|
}
|
|
if list.ParentId != newDir.MenuId {
|
|
t.Errorf("parent_id = %d, want the new parent %d; the menu hangs off a row that is gone",
|
|
list.ParentId, newDir.MenuId)
|
|
}
|
|
if want := newDir.Paths + "/" + strconv.Itoa(list.MenuId); list.Paths != want {
|
|
t.Errorf("paths = %q, want %q", list.Paths, want)
|
|
}
|
|
}
|
|
|
|
// A menu somebody added under a seeded one is not in any spec, so nothing but
|
|
// this would ever rewrite its path when its ancestor moves.
|
|
func TestSeedMenusMovesTheSubtreeUnderARepairedMenu(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
|
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("first seed: %v", err)
|
|
}
|
|
var dir, list models.SysMenu
|
|
db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&dir)
|
|
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list)
|
|
|
|
// By hand, under the seeded menu, the way an administrator would.
|
|
hand := models.SysMenu{MenuName: "HandMade", Title: "By hand", MenuType: contractmodels.Menu,
|
|
ParentId: list.MenuId}
|
|
if err := db.Create(&hand).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hand.Paths = list.Paths + "/" + strconv.Itoa(hand.MenuId)
|
|
db.Model(&models.SysMenu{}).Where("menu_id = ?", hand.MenuId).Update("paths", hand.Paths)
|
|
|
|
// Rows whose paths start with the moving one's as a string and are not
|
|
// underneath it as a path. /0/1/2 is a string prefix of /0/1/20, and a
|
|
// LIKE on the bare prefix cannot tell the two apart - so these have to
|
|
// be built against the path that actually moves, which is the one this
|
|
// repair rewrites.
|
|
var decoys []models.SysMenu
|
|
for _, suffix := range []string{"0", "1", "9"} {
|
|
d := models.SysMenu{MenuName: "Decoy" + suffix, Title: "decoy", MenuType: contractmodels.Menu}
|
|
if err := db.Create(&d).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d.Paths = list.Paths + suffix
|
|
db.Model(&models.SysMenu{}).Where("menu_id = ?", d.MenuId).Update("paths", d.Paths)
|
|
decoys = append(decoys, d)
|
|
}
|
|
|
|
if err := db.Delete(&models.SysMenu{}, "menu_id = ?", dir.MenuId).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("second seed: %v", err)
|
|
}
|
|
|
|
var newList, movedHand models.SysMenu
|
|
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&newList)
|
|
db.Where("menu_id = ?", hand.MenuId).First(&movedHand)
|
|
if want := newList.Paths + "/" + strconv.Itoa(hand.MenuId); movedHand.Paths != want {
|
|
t.Errorf("the hand-made menu's paths = %q, want %q; it no longer names its ancestors",
|
|
movedHand.Paths, want)
|
|
}
|
|
for _, d := range decoys {
|
|
var after models.SysMenu
|
|
db.Where("menu_id = ?", d.MenuId).First(&after)
|
|
if after.Paths != d.Paths {
|
|
t.Errorf("decoy %d moved from %q to %q; a prefix match caught a row that is not underneath",
|
|
d.MenuId, d.Paths, after.Paths)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An application that renames a menu or moves its component in a new version
|
|
// had the change ignored: the row was found and returned untouched.
|
|
func TestSeedMenusRefreshesWhatTheSpecDecides(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("first seed: %v", err)
|
|
}
|
|
// An administrator hides it. That is not something the spec expresses,
|
|
// so a reseed has no business turning it back on.
|
|
if err := db.Model(&models.SysMenu{}).Where("app_code = ? AND seed_code = ?", "order", "list").
|
|
Update("visible", "1").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
menus2, apis2 := orderMenuSpecs("Sales orders", "apps/order/list/index")
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus2, apis2); err != nil {
|
|
t.Fatalf("second seed: %v", err)
|
|
}
|
|
|
|
var list models.SysMenu
|
|
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list)
|
|
if list.Title != "Sales orders" {
|
|
t.Errorf("title = %q, want the new one", list.Title)
|
|
}
|
|
if list.Component != "apps/order/list/index" {
|
|
t.Errorf("component = %q, want the new one", list.Component)
|
|
}
|
|
if list.Visible != "1" {
|
|
t.Errorf("visible = %q; a reseed unhid a menu an administrator had hidden", list.Visible)
|
|
}
|
|
}
|
|
|
|
// orderMenuSpecs is a two-level tree plus one api, parameterised on the two
|
|
// columns the upgrade test changes.
|
|
func orderMenuSpecs(title, component string) ([]seed.MenuSpec, []seed.ApiSpec) {
|
|
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: title, Path: "list", Component: component, Sort: 1, ApiCodes: []string{"list"}},
|
|
}
|
|
apis := []seed.ApiSpec{
|
|
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
|
}
|
|
return menus, apis
|
|
}
|
|
|
|
// 1786700008000 added seed_code and left it NULL on every row already there.
|
|
// An application's rows are in that population, and the natural-key lookup
|
|
// misses them, so the seed used to insert a second copy beside each one -
|
|
// which the unique index cannot object to, because NULL never collides.
|
|
func TestSeedMenusAdoptsARowWrittenBeforeSeedCodeExisted(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
// What an older SeedMenus left: app_code set, seed_code absent, and the
|
|
// name that identified it then.
|
|
legacy := models.SysMenu{
|
|
MenuName: menuName("order", "dir"), AppCode: "order", Title: "the old title",
|
|
MenuType: contractmodels.Directory, Path: "/apps/order", Component: "Layout", Sort: 10,
|
|
}
|
|
if err := db.Create(&legacy).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
var rows []models.SysMenu
|
|
if err := db.Where("app_code = ? AND menu_name = ?", "order", menuName("order", "dir")).
|
|
Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("%d rows carry that name; the row from before the column existed was not found", len(rows))
|
|
}
|
|
if rows[0].MenuId != legacy.MenuId {
|
|
t.Errorf("menu_id = %d, want the row that was already there (%d)", rows[0].MenuId, legacy.MenuId)
|
|
}
|
|
if rows[0].SeedCode == nil || *rows[0].SeedCode != "dir" {
|
|
t.Errorf("seed_code = %v, want it claimed", rows[0].SeedCode)
|
|
}
|
|
// Adopted and then repaired, like any other existing row.
|
|
if rows[0].Title != "Order Example" {
|
|
t.Errorf("title = %q; the adopted row was not brought up to the spec", rows[0].Title)
|
|
}
|
|
}
|
|
|
|
// menuName concatenates two pascalCase strings and pascalCase is not
|
|
// injective, so two specs can land on one name. Picking one of several rows
|
|
// would attach an application's menu to whichever the database returned
|
|
// first.
|
|
func TestSeedMenusRefusesAnAmbiguousAdoption(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
row := models.SysMenu{
|
|
MenuName: menuName("order", "dir"), AppCode: "order", Title: fmt.Sprintf("copy %d", i),
|
|
MenuType: contractmodels.Directory,
|
|
}
|
|
if err := db.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
|
err := (adminSeeder{}).SeedMenus(db, "order", menus, apis)
|
|
if err == nil {
|
|
t.Fatal("an ambiguous adoption was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "2 rows") || !strings.Contains(err.Error(), "by hand") {
|
|
t.Errorf("error = %q, it has to say how many and that it is not deciding", err)
|
|
}
|
|
// And it did not write a third.
|
|
var n int64
|
|
db.Model(&models.SysMenu{}).Where("app_code = ? AND menu_name = ?", "order", menuName("order", "dir")).Count(&n)
|
|
if n != 2 {
|
|
t.Errorf("%d rows carry that name; the refusal still inserted", n)
|
|
}
|
|
}
|
|
|
|
// A row belonging to another application, or to the host, carries a different
|
|
// app_code and is not this application's to claim.
|
|
func TestSeedMenusDoesNotAdoptAnotherApplicationsRow(t *testing.T) {
|
|
db := newSeedTestDB(t)
|
|
useCompositeSeedCodeIndex(t, db)
|
|
seedAdminRole(t, db)
|
|
|
|
other := models.SysMenu{
|
|
MenuName: menuName("order", "dir"), AppCode: "crm", Title: "crm's own",
|
|
MenuType: contractmodels.Directory,
|
|
}
|
|
if err := db.Create(&other).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
|
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
var after models.SysMenu
|
|
db.Where("menu_id = ?", other.MenuId).First(&after)
|
|
if after.SeedCode != nil || after.Title != "crm's own" {
|
|
t.Errorf("another application's row was claimed: %+v", after)
|
|
}
|
|
var mine models.SysMenu
|
|
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&mine).Error; err != nil {
|
|
t.Fatalf("this application's own row was not created: %v", err)
|
|
}
|
|
}
|