diff --git a/cmd/migrate/migration/version/1786700011000_gen_menu_apis.go b/cmd/migrate/migration/version/1786700011000_gen_menu_apis.go new file mode 100644 index 00000000..db5f005a --- /dev/null +++ b/cmd/migrate/migration/version/1786700011000_gen_menu_apis.go @@ -0,0 +1,126 @@ +package version + +import ( + "errors" + "runtime" + + "gorm.io/gorm" + + "go-admin/app/admin/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +// Bind the code generator's APIs to its two menus, and grant them to every +// role that already holds one of those menus. +// +// The generator's routes were in CasbinExclude, or mounted without +// AuthCheckRole, so any account that could log in reached them. They now go +// through AuthCheckRole. A role is granted an API through the menus it is +// given: SysRole's update writes a casbin_rule for every API bound to each of +// its menus in sys_menu_api_rule. The seed data bound none to the +// generator's menus, so without this a role given 代码生成 would be refused +// by every endpoint the page calls. +// +// Binding alone only reaches roles saved after this migration. Roles that +// already hold a generator menu are granted here, so a deployment that gave +// the generator to a role keeps it working across the upgrade. The admin +// role is let through by AuthCheckRole without a policy and needs none. +// +// Menus are found by component, not id: a deployment may have renumbered +// them, and one that deleted a menu has nobody to grant and nothing to bind. +// An API missing from sys_api is created, since its row is what the binding +// points at. +// +// Ordered after 1786700003000 (the soft-delete conversion), so the runtime +// models under app/admin/models are used, not cmd/migrate/migration/models - +// see schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700011000GenMenuApis) +} + +// genMenuApis is which page calls which endpoint, read off go-admin-ui's +// src/api/tools/gen.ts and the views under dev-tools/gen. +var genMenuApis = []struct { + component string + apis []models.SysApi +}{ + {"/dev-tools/gen/index", []models.SysApi{ + {Title: "代码生成表列表", Path: "/api/v1/sys/tables/page", Action: "GET"}, + {Title: "数据库表列表", Path: "/api/v1/db/tables/page", Action: "GET"}, + {Title: "数据表列列表", Path: "/api/v1/db/columns/page", Action: "GET"}, + {Title: "导入表", Path: "/api/v1/sys/tables/info", Action: "POST"}, + {Title: "删除表配置", Path: "/api/v1/sys/tables/info/:tableId", Action: "DELETE"}, + {Title: "生成预览通过id获取", Path: "/api/v1/gen/preview/:tableId", Action: "GET"}, + {Title: "数据库表生成到项目", Path: "/api/v1/gen/toproject/:tableId", Action: "GET"}, + {Title: "生成api带文件", Path: "/api/v1/gen/apitofile/:tableId", Action: "GET"}, + {Title: "数据库表生成到DB", Path: "/api/v1/gen/todb/:tableId", Action: "GET"}, + }}, + {"/dev-tools/gen/editTable", []models.SysApi{ + {Title: "表配置详情", Path: "/api/v1/sys/tables/info/:tableId", Action: "GET"}, + {Title: "按表名查询表配置", Path: "/api/v1/sys/tables/info", Action: "GET"}, + {Title: "修改表配置", Path: "/api/v1/sys/tables/info", Action: "PUT"}, + {Title: "关系表数据【代码生成】", Path: "/api/v1/gen/tabletree", Action: "GET"}, + }}, +} + +func _1786700011000GenMenuApis(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := bindGenMenuApis(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +// bindGenMenuApis is split out so tests can run it without sys_migration. +func bindGenMenuApis(tx *gorm.DB) error { + for _, m := range genMenuApis { + var menu models.SysMenu + err := tx.Where("component = ?", m.component).First(&menu).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + if err != nil { + return err + } + + apis := make([]models.SysApi, 0, len(m.apis)) + for _, want := range m.apis { + var api models.SysApi + err := tx.Where("path = ? AND action = ?", want.Path, want.Action).First(&api).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + api = want + api.Type = "SYS" + err = tx.Create(&api).Error + } + if err != nil { + return err + } + apis = append(apis, api) + } + if err := tx.Model(&menu).Association("SysApi").Append(apis); err != nil { + return err + } + + var roleKeys []string + if err := tx.Model(&models.SysRole{}). + Joins("JOIN sys_role_menu ON sys_role_menu.role_id = sys_role.role_id"). + Where("sys_role_menu.menu_id = ?", menu.MenuId). + Distinct().Pluck("sys_role.role_key", &roleKeys).Error; err != nil { + return err + } + for _, key := range roleKeys { + for _, a := range apis { + 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=?)", + key, a.Path, a.Action, key, a.Path, a.Action, + ).Error; err != nil { + return err + } + } + } + } + return nil +} diff --git a/cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go new file mode 100644 index 00000000..fab2726d --- /dev/null +++ b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_dialects_test.go @@ -0,0 +1,113 @@ +package version + +import ( + "os" + "testing" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + + adminmodels "go-admin/app/admin/models" +) + +// bindGenMenuApis writes casbin_rule with INSERT ... SELECT ... WHERE NOT +// EXISTS and sys_menu_api_rule through gorm's association code; both are +// dialect-sensitive, and the other tests for it run on SQLite only. This runs +// the same grant on every database CI has. + +const mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN" + +func mysqlDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv(mysqlDSNEnv) + if dsn == "" { + if os.Getenv("CI") != "" { + t.Fatalf("%s is not set while CI is: the MySQL migration tests must not skip here", mysqlDSNEnv) + } + t.Skipf("%s is not set; skipping the MySQL migration tests", mysqlDSNEnv) + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("connecting to %s: %v", mysqlDSNEnv, err) + } + return db +} + +// casbinRuleRow is the shape gorm-adapter gives casbin_rule, declared here so +// each dialect builds the table its own way. +type casbinRuleRow struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Ptype string `gorm:"size:100"` + V0 string `gorm:"size:100"` + V1 string `gorm:"size:100"` + V2 string `gorm:"size:100"` + V3 string `gorm:"size:100"` + V4 string `gorm:"size:100"` + V5 string `gorm:"size:100"` +} + +func (casbinRuleRow) TableName() string { return "casbin_rule" } + +func grantOn(t *testing.T, db *gorm.DB) { + t.Helper() + tables := []any{&casbinRuleRow{}, &adminmodels.SysApi{}, &adminmodels.SysMenu{}, &adminmodels.SysRole{}} + drop := func() { + for _, join := range []string{"sys_menu_api_rule", "sys_role_menu", "sys_role_dept"} { + if db.Migrator().HasTable(join) { + if err := db.Migrator().DropTable(join); err != nil { + t.Fatal(err) + } + } + } + for _, m := range tables { + if db.Migrator().HasTable(m) { + if err := db.Migrator().DropTable(m); err != nil { + t.Fatal(err) + } + } + } + } + drop() + t.Cleanup(drop) + if err := db.AutoMigrate(tables...); err != nil { + t.Fatal(err) + } + for _, m := range []adminmodels.SysMenu{ + {MenuName: "Gen", Component: "/dev-tools/gen/index", MenuType: "C"}, + {MenuName: "EditTable", Component: "/dev-tools/gen/editTable", MenuType: "C"}, + } { + if err := db.Create(&m).Error; err != nil { + t.Fatal(err) + } + } + var gen adminmodels.SysMenu + if err := db.Where("component = ?", "/dev-tools/gen/index").First(&gen).Error; err != nil { + t.Fatal(err) + } + role := adminmodels.SysRole{RoleKey: "developer", RoleName: "developer"} + if err := db.Create(&role).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", role.RoleId, gen.MenuId).Error; err != nil { + t.Fatal(err) + } + + for i := 0; i < 2; i++ { + if err := bindGenMenuApis(db); err != nil { + t.Fatalf("run %d: %v", i+1, err) + } + } + + var n int64 + db.Model(&casbinRuleRow{}).Where("ptype = 'p' AND v0 = ?", "developer").Count(&n) + if n != 9 { + t.Errorf("developer holds %d policies after two runs, want 9", n) + } + if bound := db.Model(&gen).Association("SysApi").Count(); bound != 9 { + t.Errorf("代码生成 is bound to %d APIs after two runs, want 9", bound) + } +} + +func TestGenMenuApisOnMySQL(t *testing.T) { grantOn(t, mysqlDB(t)) } +func TestGenMenuApisOnPostgres(t *testing.T) { grantOn(t, postgresDB(t)) } +func TestGenMenuApisOnSQLServer(t *testing.T) { grantOn(t, sqlserverDB(t)) } diff --git a/cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go new file mode 100644 index 00000000..c3f96d1f --- /dev/null +++ b/cmd/migrate/migration/version/1786700011000_gen_menu_apis_test.go @@ -0,0 +1,187 @@ +package version + +import ( + "sort" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "go-admin/app/admin/models" +) + +const ( + genMenuId = 261 + editMenuId = 262 +) + +// openGenMenuDB builds the tables bindGenMenuApis reads and writes, with the +// two generator menus under the ids config/db.sql gives them. +func openGenMenuDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(new(models.SysApi), new(models.SysMenu), new(models.SysRole)); err != nil { + t.Fatal(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.Fatal(err) + } + for _, m := range []models.SysMenu{ + {MenuId: genMenuId, MenuName: "Gen", Component: "/dev-tools/gen/index", MenuType: "C"}, + {MenuId: editMenuId, MenuName: "EditTable", Component: "/dev-tools/gen/editTable", MenuType: "C"}, + } { + if err := db.Create(&m).Error; err != nil { + t.Fatal(err) + } + } + return db +} + +func addRole(t *testing.T, db *gorm.DB, id int, key string, menus ...int) { + t.Helper() + if err := db.Create(&models.SysRole{RoleId: id, RoleKey: key, RoleName: key}).Error; err != nil { + t.Fatal(err) + } + for _, m := range menus { + if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", id, m).Error; err != nil { + t.Fatal(err) + } + } +} + +func policies(t *testing.T, db *gorm.DB, role string) []string { + t.Helper() + var rows []struct{ V1, V2 string } + if err := db.Raw("SELECT v1, v2 FROM casbin_rule WHERE ptype = 'p' AND v0 = ?", role).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.V2+" "+r.V1) + } + sort.Strings(out) + return out +} + +func boundApis(t *testing.T, db *gorm.DB, menuId int) int { + t.Helper() + menu := models.SysMenu{MenuId: menuId} + return int(db.Model(&menu).Association("SysApi").Count()) +} + +func TestGenMenuApisGrantsWhatARoleAlreadyHolds(t *testing.T) { + db := openGenMenuDB(t) + addRole(t, db, 2, "developer", genMenuId, editMenuId) + addRole(t, db, 3, "editor", editMenuId) + addRole(t, db, 4, "clerk") + + if err := bindGenMenuApis(db); err != nil { + t.Fatal(err) + } + + if n := boundApis(t, db, genMenuId); n != 9 { + t.Errorf("代码生成 is bound to %d APIs, want 9", n) + } + if n := boundApis(t, db, editMenuId); n != 4 { + t.Errorf("代码生成修改 is bound to %d APIs, want 4", n) + } + if got := policies(t, db, "developer"); len(got) != 13 { + t.Errorf("developer holds %d policies, want 13: %v", len(got), got) + } + want := []string{ + "GET /api/v1/gen/tabletree", + "GET /api/v1/sys/tables/info", + "GET /api/v1/sys/tables/info/:tableId", + "PUT /api/v1/sys/tables/info", + } + if got := policies(t, db, "editor"); !equal(got, want) { + t.Errorf("editor holds %v, want %v", got, want) + } + if got := policies(t, db, "clerk"); len(got) != 0 { + t.Errorf("clerk, who holds neither menu, was granted %v", got) + } +} + +// The seed data already registers most of these APIs. Binding must point at +// those rows, not add a second row per API next to each. +func TestGenMenuApisReusesRegisteredApis(t *testing.T) { + db := openGenMenuDB(t) + seeded := models.SysApi{Id: 32, Title: "数据库表生成到项目", Path: "/api/v1/gen/toproject/:tableId", Action: "GET", Type: "SYS"} + if err := db.Create(&seeded).Error; err != nil { + t.Fatal(err) + } + + if err := bindGenMenuApis(db); err != nil { + t.Fatal(err) + } + + var n int64 + db.Model(&models.SysApi{}).Where("path = ? AND action = ?", seeded.Path, seeded.Action).Count(&n) + if n != 1 { + t.Errorf("%d rows for %s, want the seeded one only", n, seeded.Path) + } + var total int64 + db.Model(&models.SysApi{}).Count(&total) + if total != 13 { + t.Errorf("sys_api holds %d rows, want 13: the 12 missing created and the seeded one reused", total) + } +} + +func TestGenMenuApisRunTwiceAddsNothing(t *testing.T) { + db := openGenMenuDB(t) + addRole(t, db, 2, "developer", genMenuId, editMenuId) + + for i := 0; i < 2; i++ { + if err := bindGenMenuApis(db); err != nil { + t.Fatalf("run %d: %v", i+1, err) + } + } + if n := boundApis(t, db, genMenuId); n != 9 { + t.Errorf("代码生成 is bound to %d APIs after two runs, want 9", n) + } + if got := policies(t, db, "developer"); len(got) != 13 { + t.Errorf("developer holds %d policies after two runs, want 13", len(got)) + } +} + +func TestGenMenuApisSkipsADeletedMenuAndADeletedRole(t *testing.T) { + db := openGenMenuDB(t) + if err := db.Delete(&models.SysMenu{MenuId: editMenuId}).Error; err != nil { + t.Fatal(err) + } + addRole(t, db, 5, "gone", genMenuId) + if err := db.Model(&models.SysRole{}).Where("role_id = ?", 5). + Update("deleted_at", time.Now().UnixMilli()).Error; err != nil { + t.Fatal(err) + } + + if err := bindGenMenuApis(db); err != nil { + t.Fatal(err) + } + if n := boundApis(t, db, editMenuId); n != 0 { + t.Errorf("a deleted menu was bound to %d APIs", n) + } + if got := policies(t, db, "gone"); len(got) != 0 { + t.Errorf("a deleted role was granted %v", got) + } +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}