From aa539c061ff108af5e78cb7b88bb9dcf8a56af5f Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Wed, 9 Sep 2026 12:35:35 +0800 Subject: [PATCH] =?UTF-8?q?feat=E2=9C=A8:=20uninstall=20one=20application'?= =?UTF-8?q?s=20menus,=20apis=20and=20grants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate uninstall ` removes what an application's install wrote and leaves the application's own tables alone. Removing an order module is not the same decision as destroying the orders, and nothing here can tell an operator who is done with it from one who will reinstall tomorrow. One transaction, and this one really is one: every statement is DML or a SELECT, so unlike an install there is no DDL to commit it out from under itself. Child rows go first, while the ids that identify them can still be read from their parents, and the api paths are read before the rows carrying them are deleted. The two join tables need no ledger and get none. menu_id is a surrogate key, so a sys_role_menu or sys_menu_api_rule row can only have come from a menu this application wrote - there is no "looks like it but is not". A column on sys_role_menu would have been worse than unnecessary: SysRole.Update deletes a role's rows and writes them back through GORM's many2many, which does not carry extra columns, so the column would be blanked the first time anybody edited a role, silently. There is a test that performs that edit and then uninstalls. casbin_rule is the opposite case, because its key is business text somebody else may have written for their own reasons. Policies are removed one at a time, by exact tuple, and only the ones the ledger says this install created. A tuple the ledger names that is no longer there is reported, not treated as a failure - the uninstall wanted it gone and it is gone. Then, with everything the ledger could speak for already dealt with, a read-only pass lists the policies still naming this application's paths: those are grants somebody made by hand, they are about to point at APIs that no longer exist, and they are not this command's to delete. The two lists stay separate because they mean different things - one is something of ours that had already gone, the other is somebody else's grant now pointing at nothing - and merged into one "could not remove" list neither would be actionable. sys_migration's rows for the application go too. Without that a reinstall finds every version applied, runs no migration, seeds nothing, and reports success. It is the easiest step to leave out, because a migration record does not look like the application's data. A sys_app row is not required. `migrate` with no subcommand applies every registered migration, an application's included, so an application can have all of its rows without ever having gone through the installer - and that is the case where nothing else can clean up after it. Eleven degradations were applied one at a time, each red on the assertion it was aimed at: skipping either join table, deleting sys_role_menu without its filter, skipping sys_migration, deleting sys_migration without its filter, matching policies by path instead of by ledger tuple, dropping the orphan pass, treating a missing policy as a failure, leaving the ledger behind, soft deleting sys_menu instead of removing it, and running the whole thing outside a transaction. --- cmd/migrate/server.go | 33 +++ cmd/migrate/uninstall.go | 290 +++++++++++++++++++++++++ cmd/migrate/uninstall_test.go | 393 ++++++++++++++++++++++++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 cmd/migrate/uninstall.go create mode 100644 cmd/migrate/uninstall_test.go diff --git a/cmd/migrate/server.go b/cmd/migrate/server.go index 337ecc5d..93222f97 100644 --- a/cmd/migrate/server.go +++ b/cmd/migrate/server.go @@ -64,6 +64,15 @@ var ( runInstall(args[0]) }, } + uninstallCmd = &cobra.Command{ + Use: "uninstall ", + Short: "Remove one application's menus, apis and permission grants; its own tables are left alone", + Example: "go-admin migrate uninstall order -c config/settings.yml", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runUninstall(args[0]) + }, + } ) // fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移 @@ -82,6 +91,7 @@ func init() { StartCmd.AddCommand(statusCmd) StartCmd.AddCommand(installCmd) + StartCmd.AddCommand(uninstallCmd) } func run() { @@ -297,6 +307,29 @@ func runInstall(code string) { ) } +func runUninstall(code string) { + config.Setup( + file.NewSource(file.WithPath(configYml)), + func() { + database.Setup() + db, err := resolveDB() + if err != nil { + exitOnError(os.Stderr, err) + return + } + // No manifest lookup. An application whose code has already been + // taken out of the binary registers nothing, and that is exactly + // when somebody needs to clear its rows out of the database. + rep, err := uninstall(db, code) + if err != nil { + exitOnError(os.Stderr, err) + return + } + reportUninstall(os.Stdout, rep) + }, + ) +} + func genFile() error { t1, err := template.ParseFiles("template/migrate.template") if err != nil { diff --git a/cmd/migrate/uninstall.go b/cmd/migrate/uninstall.go new file mode 100644 index 00000000..c61e5ab0 --- /dev/null +++ b/cmd/migrate/uninstall.go @@ -0,0 +1,290 @@ +package migrate + +import ( + "errors" + "fmt" + "io" + "strings" + + "gorm.io/gorm" + + adminmodels "go-admin/app/admin/models" + "go-admin/cmd/migrate/migration" + commonmodels "go-admin/common/models" +) + +// policyKey is one casbin_rule row identified the way casbin_rule is unique: +// by its tuple, not by its id. Ids do not survive SysRole.Update, which +// removes a role's policies and adds them back. +type policyKey struct { + Ptype string `gorm:"column:ptype"` + V0 string `gorm:"column:v0"` + V1 string `gorm:"column:v1"` + V2 string `gorm:"column:v2"` + V3 string `gorm:"column:v3"` + V4 string `gorm:"column:v4"` + V5 string `gorm:"column:v5"` +} + +func (p policyKey) String() string { + return fmt.Sprintf("%s %s %s %s", p.Ptype, p.V0, p.V1, p.V2) +} + +// uninstallReport is what an uninstall removed, and what it deliberately did +// not. +type uninstallReport struct { + Code string + // Found says whether sys_app had a row. An application whose migrations + // were applied by plain `migrate` rather than by `install` has its menus + // and its permissions without ever having had one. + Found bool + Version string + Menus int64 + Apis int64 + Bindings int64 + RoleMenus int64 + Policies int64 + Migrations int64 + // Skipped are ledger entries whose casbin_rule row was not there any + // more: something this install created and something else removed. + Skipped []policyKey + // Orphans are policies naming this application's paths that no ledger + // entry claims - somebody granted this app's API to another role by + // hand. Reported, never deleted. + Orphans []policyKey +} + +// uninstall removes one application's menus, APIs and permission grants. +// +// It does not touch the application's own tables. Removing an order module +// is not the same decision as destroying the orders, and nothing here can +// tell the operator apart from someone who will reinstall tomorrow. +// +// One transaction, and this one really is one: every statement below is DML +// or a SELECT, so unlike an install there is no DDL to commit it out from +// under itself. Child rows go first, while the ids that identify them can +// still be read from the parents. +// +// A sys_app row is not required. `migrate` with no subcommand applies every +// registered migration, an application's included, so an application can +// have all of its data without ever having gone through the installer. +func uninstall(db *gorm.DB, code string) (uninstallReport, error) { + code = migration.NormalizeAppCode(code) + rep := uninstallReport{Code: code} + if code == "" { + return rep, errors.New("no app code given") + } + if code == migration.FrameworkAppCode { + return rep, fmt.Errorf("%q is the framework's own migrations; there is no uninstall for those", code) + } + + err := db.Transaction(func(tx *gorm.DB) error { + row, found, err := loadApp(tx, code) + if err != nil { + return err + } + rep.Found = found + if found { + rep.Version = row.Version + } + + // 1 and 2. Read before deleting: sys_api's rows are about to go, and + // step 5b needs their paths. + // + // Unscoped throughout. A row this application wrote that somebody + // soft-deleted from the UI is still this application's row, and + // leaving it behind would leave its join rows pointing at it. + var menuIDs []int + if err := tx.Unscoped().Model(&adminmodels.SysMenu{}). + Where("app_code = ?", code).Pluck("menu_id", &menuIDs).Error; err != nil { + return fmt.Errorf("reading this app's menus: %w", err) + } + var apiIDs []int + if err := tx.Unscoped().Model(&adminmodels.SysApi{}). + Where("app_code = ?", code).Pluck("id", &apiIDs).Error; err != nil { + return fmt.Errorf("reading this app's apis: %w", err) + } + var apiKeys []policyKey + if err := tx.Unscoped().Model(&adminmodels.SysApi{}). + Where("app_code = ?", code). + Select("path as v1, action as v2").Scan(&apiKeys).Error; err != nil { + return fmt.Errorf("reading this app's api paths: %w", err) + } + + // 3. The many2many rows behind SysMenu.SysApi. Either side is enough + // to make a row this application's. + if len(menuIDs) > 0 || len(apiIDs) > 0 { + q := tx.Table("sys_menu_api_rule") + switch { + case len(menuIDs) > 0 && len(apiIDs) > 0: + q = q.Where("sys_menu_menu_id IN ? OR sys_api_id IN ?", menuIDs, apiIDs) + case len(menuIDs) > 0: + q = q.Where("sys_menu_menu_id IN ?", menuIDs) + default: + q = q.Where("sys_api_id IN ?", apiIDs) + } + res := q.Delete(nil) + if res.Error != nil { + return fmt.Errorf("removing menu/api bindings: %w", res.Error) + } + rep.Bindings = res.RowsAffected + } + + // 4. Role assignments. menu_id is a surrogate key, so a row here can + // only have come from a menu this application wrote - there is no + // "looks like it but is not". That is why this needs no ledger, and + // why a column on sys_role_menu would have been wrong: SysRole.Update + // deletes a role's rows and writes them back through GORM's + // many2many, which does not carry extra columns, so any such column + // would be silently blanked the first time somebody edits a role. + if len(menuIDs) > 0 { + res := tx.Table("sys_role_menu").Where("menu_id IN ?", menuIDs).Delete(nil) + if res.Error != nil { + return fmt.Errorf("removing role assignments: %w", res.Error) + } + rep.RoleMenus = res.RowsAffected + } + + // 5. Policies, by ledger, one at a time and by exact tuple. + var grants []adminmodels.SysAppCasbinGrant + if err := tx.Where("app_code = ?", code).Find(&grants).Error; err != nil { + return fmt.Errorf("reading the grant ledger: %w", err) + } + for _, g := range grants { + k := policyKey{Ptype: g.Ptype, V0: g.V0, V1: g.V1, V2: g.V2, V3: g.V3, V4: g.V4, V5: g.V5} + res := tx.Table("casbin_rule"). + Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?", + k.Ptype, k.V0, k.V1, k.V2, k.V3, k.V4, k.V5). + Delete(nil) + if res.Error != nil { + return fmt.Errorf("removing policy %s: %w", k, res.Error) + } + if res.RowsAffected == 0 { + // Something this install created is not there any more. Not + // an error: the uninstall's job was to remove it and it is + // gone. Reported because a policy this app created and did + // not remove means something else rewrote casbin_rule. + rep.Skipped = append(rep.Skipped, k) + continue + } + rep.Policies += res.RowsAffected + } + // The ledger's job ends here whether or not each row matched. Left + // behind it would only grow, and a reinstall writes its own entries. + if err := tx.Where("app_code = ?", code). + Delete(&adminmodels.SysAppCasbinGrant{}).Error; err != nil { + return fmt.Errorf("clearing the grant ledger: %w", err) + } + + // 5b. Read-only. By now every policy the ledger could speak for has + // been dealt with, so a policy still matching one of this app's paths + // is one the ledger never claimed - somebody granted this app's API + // to another role by hand. Business rule 3 says do not delete what + // is not ours; without this step nobody would ever learn it is + // there, pointing at an API that is about to stop existing. + orphans, err := findOrphanPolicies(tx, apiKeys) + if err != nil { + return err + } + rep.Orphans = orphans + + // 6 and 7. + res := tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysApi{}) + if res.Error != nil { + return fmt.Errorf("removing this app's apis: %w", res.Error) + } + rep.Apis = res.RowsAffected + + res = tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysMenu{}) + if res.Error != nil { + return fmt.Errorf("removing this app's menus: %w", res.Error) + } + rep.Menus = res.RowsAffected + + // 8. Without this a reinstall finds every version already applied, + // runs no migration, seeds nothing, and reports success. It is the + // easiest step to leave out, because a migration record does not + // look like the application's data. + res = tx.Where("app_code = ?", code).Delete(&commonmodels.Migration{}) + if res.Error != nil { + return fmt.Errorf("removing this app's migration records: %w", res.Error) + } + rep.Migrations = res.RowsAffected + + // 9. + if found { + if err := tx.Where("app_code = ?", code). + Delete(&adminmodels.SysApp{}).Error; err != nil { + return fmt.Errorf("removing the sys_app row: %w", err) + } + } + return nil + }) + if err != nil { + return uninstallReport{Code: code}, err + } + return rep, nil +} + +// findOrphanPolicies looks for policies naming any of this application's +// paths. +// +// Written as an OR chain rather than a row-value IN, which MySQL and modern +// SQLite accept and SQL Server does not; this repository supports all of +// them. Chunked because a driver's placeholder limit is reached long before +// an application runs out of endpoints. +func findOrphanPolicies(tx *gorm.DB, keys []policyKey) ([]policyKey, error) { + const chunk = 100 + var out []policyKey + for start := 0; start < len(keys); start += chunk { + end := start + chunk + if end > len(keys) { + end = len(keys) + } + clauses := make([]string, 0, end-start) + args := make([]any, 0, (end-start)*2) + for _, k := range keys[start:end] { + clauses = append(clauses, "(v1 = ? AND v2 = ?)") + args = append(args, k.V1, k.V2) + } + var found []policyKey + if err := tx.Table("casbin_rule"). + Where("ptype = ? AND ("+strings.Join(clauses, " OR ")+")", append([]any{"p"}, args...)...). + Scan(&found).Error; err != nil { + return nil, fmt.Errorf("looking for policies nothing claims: %w", err) + } + out = append(out, found...) + } + return out, nil +} + +// reportUninstall prints what went and what stayed. +// +// The two lists are separate because they mean different things: one is +// something this application created that had already gone, the other is +// somebody else's grant that is now pointing at nothing. Merged into one +// "could not remove" list, neither would be actionable. +func reportUninstall(w io.Writer, rep uninstallReport) { + if !rep.Found { + fmt.Fprintf(w, "%s had no sys_app row; removed what its migrations had written\n", rep.Code) + } else { + fmt.Fprintf(w, "uninstalled %s %s\n", rep.Code, rep.Version) + } + fmt.Fprintf(w, "removed: %d menu(s), %d api(s), %d binding(s), %d role assignment(s), %d policy(ies), %d migration record(s)\n", + rep.Menus, rep.Apis, rep.Bindings, rep.RoleMenus, rep.Policies, rep.Migrations) + fmt.Fprintln(w, "the application's own tables were not touched.") + + if len(rep.Skipped) > 0 { + fmt.Fprintf(w, "\n%d policy(ies) this install had created were already gone:\n", len(rep.Skipped)) + for _, k := range rep.Skipped { + fmt.Fprintf(w, " %s\n", k) + } + } + if len(rep.Orphans) > 0 { + fmt.Fprintf(w, "\n%d policy(ies) name this application's paths and were granted by somebody else, so they were left alone:\n", len(rep.Orphans)) + for _, k := range rep.Orphans { + fmt.Fprintf(w, " %s\n", k) + } + fmt.Fprintln(w, "they now point at APIs that no longer exist. Harmless to the running server, and yours to clear up.") + } +} diff --git a/cmd/migrate/uninstall_test.go b/cmd/migrate/uninstall_test.go new file mode 100644 index 00000000..127f63da --- /dev/null +++ b/cmd/migrate/uninstall_test.go @@ -0,0 +1,393 @@ +package migrate + +import ( + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + adminmodels "go-admin/app/admin/models" + _ "go-admin/app/admin/service" // registers the seeder SeedMenus dispatches to + "go-admin/cmd/migrate/migration" + commonmodels "go-admin/common/models" +) + +const adminRoleKey = "admin" + +// newUninstallDB builds every table an install writes to, plus one table +// standing in for the application's own data, which an uninstall must not +// touch. +func newUninstallDB(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.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &adminmodels.SysMenu{}, &adminmodels.SysApi{}, &adminmodels.SysRole{}, + &adminmodels.SysApp{}, &adminmodels.SysAppCasbinGrant{}, &commonmodels.Migration{}, + ); err != nil { + t.Fatalf("automigrate: %v", err) + } + // casbin_rule has no GORM model in this repository; the columns are the + // ones grantToAdminRole's INSERT addresses. + 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) + } + if err := db.Exec(`CREATE TABLE app_order (id INTEGER PRIMARY KEY, note TEXT)`).Error; err != nil { + t.Fatalf("create app_order: %v", err) + } + if err := db.Exec(`INSERT INTO app_order (id, note) VALUES (1, 'a real order')`).Error; err != nil { + t.Fatalf("seed app_order: %v", err) + } + if err := db.Create(&adminmodels.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey}).Error; err != nil { + t.Fatalf("seed admin role: %v", err) + } + return db +} + +// specsFor builds one application's menus and apis. The paths carry the app +// code, because two applications do not share an endpoint - and if a fixture +// let them, the second one's policies would already exist and its ledger +// would legitimately come out empty, which would make it a useless control. +func specsFor(code string) ([]seed.MenuSpec, []seed.ApiSpec) { + menus := []seed.MenuSpec{ + {Code: "dir", Kind: "M", Title: code + " example", Path: "/apps/" + code, Component: "Layout", Sort: 10}, + {Code: "list", Parent: "dir", Kind: "C", Title: code, Path: "list", Component: "apps/" + code + "/index", Sort: 1, ApiCodes: []string{"list"}}, + } + apis := []seed.ApiSpec{ + {Code: "list", Title: code + " list", Path: "/api/v1/" + code, Method: "GET", Handle: "apis." + code + ".GetPage-fm"}, + {Code: "create", Title: "create " + code, Path: "/api/v1/" + code, Method: "POST", Handle: "apis." + code + ".Insert-fm"}, + } + return menus, apis +} + +// seedApp runs the real seeding path, so what the uninstaller has to undo is +// what an install actually writes rather than a hand-built approximation. +func seedApp(t *testing.T, db *gorm.DB, code string) { + t.Helper() + menus, apis := specsFor(code) + if err := db.Transaction(func(tx *gorm.DB) error { + return seed.SeedMenus(tx, code, menus, apis) + }); err != nil { + t.Fatalf("seeding %q: %v", code, err) + } + if err := db.Create(&commonmodels.Migration{ + Version: code + "-1786800001000", AppCode: code, ApplyTime: time.Now(), + }).Error; err != nil { + t.Fatalf("recording the migration for %q: %v", code, err) + } + if err := db.Create(&adminmodels.SysApp{ + AppCode: code, Name: code, Version: "1.0.0", Status: adminmodels.AppInstalled, + }).Error; err != nil { + t.Fatalf("recording sys_app for %q: %v", code, err) + } +} + +func count(t *testing.T, db *gorm.DB, table, where string, args ...any) int64 { + t.Helper() + var n int64 + q := db.Table(table) + if where != "" { + q = q.Where(where, args...) + } + if err := q.Count(&n).Error; err != nil { + t.Fatalf("counting %s: %v", table, err) + } + return n +} + +// A3: everything the install wrote goes, and the application's own table does +// not. +func TestUninstallRemovesWhatWasSeededAndNothingElse(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + + if count(t, db, "sys_menu", "app_code = ?", "order") == 0 { + t.Fatal("nothing was seeded, so this test proves nothing") + } + + rep, err := uninstall(db, "order") + if err != nil { + t.Fatalf("uninstall: %v", err) + } + if !rep.Found { + t.Error("the sys_app row was not found") + } + + for _, c := range []struct { + table, where string + args []any + }{ + {"sys_menu", "app_code = ?", []any{"order"}}, + {"sys_api", "app_code = ?", []any{"order"}}, + {"sys_menu_api_rule", "", nil}, + {"sys_role_menu", "", nil}, + {"casbin_rule", "v1 = ?", []any{"/api/v1/order"}}, + {"sys_app_casbin_grant", "app_code = ?", []any{"order"}}, + {"sys_migration", "app_code = ?", []any{"order"}}, + {"sys_app", "app_code = ?", []any{"order"}}, + } { + if n := count(t, db, c.table, c.where, c.args...); n != 0 { + t.Errorf("%s still has %d row(s)", c.table, n) + } + } + if n := count(t, db, "app_order", "", nil); n != 1 { + t.Errorf("app_order has %d row(s); the application's own data is not the uninstaller's to remove", n) + } + if len(rep.Skipped) != 0 || len(rep.Orphans) != 0 { + t.Errorf("a clean uninstall reported skipped=%v orphans=%v", rep.Skipped, rep.Orphans) + } + if rep.Menus == 0 || rep.Apis == 0 || rep.Policies == 0 || rep.Migrations == 0 { + t.Errorf("the report says nothing was removed: %+v", rep) + } +} + +// Uninstalling one application must not reach into another's rows. Every +// delete here is filtered, and a missing filter is invisible on a database +// with only one application in it. +func TestUninstallLeavesAnotherApplicationAlone(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + seedApp(t, db, "crm") + + before := map[string]int64{ + "sys_menu": count(t, db, "sys_menu", "app_code = ?", "crm"), + "sys_api": count(t, db, "sys_api", "app_code = ?", "crm"), + "sys_app_casbin_grant": count(t, db, "sys_app_casbin_grant", "app_code = ?", "crm"), + "sys_migration": count(t, db, "sys_migration", "app_code = ?", "crm"), + "sys_app": count(t, db, "sys_app", "app_code = ?", "crm"), + } + for k, v := range before { + if v == 0 { + t.Fatalf("crm has no rows in %s, so this test proves nothing", k) + } + } + crmBindings := count(t, db, "sys_menu_api_rule", "", nil) + crmRoleMenus := count(t, db, "sys_role_menu", "", nil) + + if _, err := uninstall(db, "order"); err != nil { + t.Fatalf("uninstall: %v", err) + } + + for k, v := range before { + if n := count(t, db, k, "app_code = ?", "crm"); n != v { + t.Errorf("%s for crm went from %d to %d", k, v, n) + } + } + // crm's own bindings and role rows are half of each total, and must be + // exactly what is left. + if n := count(t, db, "sys_menu_api_rule", "", nil); n != crmBindings/2 { + t.Errorf("sys_menu_api_rule = %d, want %d (crm's half)", n, crmBindings/2) + } + if n := count(t, db, "sys_role_menu", "", nil); n != crmRoleMenus/2 { + t.Errorf("sys_role_menu = %d, want %d (crm's half)", n, crmRoleMenus/2) + } + // crm's policies name a different path, so they are untouched. + if n := count(t, db, "casbin_rule", "v1 = ?", "/api/v1/order"); n != 0 { + t.Errorf("order's policies survived: %d", n) + } +} + +// A6b: somebody granted this application's API to another role by hand. That +// grant is not in the ledger, is not this uninstall's to remove, and would +// otherwise vanish from view entirely. +func TestUninstallReportsAGrantSomebodyElseMade(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + + if err := db.Exec( + "INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', '/api/v1/order', 'GET', '', '', '')", + ).Error; err != nil { + t.Fatalf("hand-made grant: %v", err) + } + + rep, err := uninstall(db, "order") + if err != nil { + t.Fatalf("uninstall: %v", err) + } + if n := count(t, db, "casbin_rule", "v0 = ?", "ops"); n != 1 { + t.Errorf("somebody else's grant was deleted (%d rows left)", n) + } + if len(rep.Orphans) != 1 { + t.Fatalf("orphans = %v, want the one hand-made grant", rep.Orphans) + } + if rep.Orphans[0].V0 != "ops" { + t.Errorf("orphan = %+v", rep.Orphans[0]) + } + // The admin grants it did own are gone. + if n := count(t, db, "casbin_rule", "v0 = ?", adminRoleKey); n != 0 { + t.Errorf("%d of this app's own policies survived", n) + } + + var out strings.Builder + reportUninstall(&out, rep) + if !strings.Contains(out.String(), "ops") || !strings.Contains(out.String(), "left alone") { + t.Errorf("the report does not say what was left behind: %q", out.String()) + } +} + +// A6a: a policy this install created is not there any more. Not an error - +// the uninstall wanted it gone and it is - but reported, because something +// else rewrote casbin_rule. +func TestUninstallReportsALedgerEntryWhosePolicyIsGone(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + + if err := db.Exec("DELETE FROM casbin_rule WHERE v2 = 'POST'").Error; err != nil { + t.Fatalf("removing a policy: %v", err) + } + + rep, err := uninstall(db, "order") + if err != nil { + t.Fatalf("a missing policy made the uninstall fail: %v", err) + } + if len(rep.Skipped) != 1 { + t.Fatalf("skipped = %v, want the one that had gone", rep.Skipped) + } + if rep.Skipped[0].V2 != "POST" { + t.Errorf("skipped = %+v", rep.Skipped[0]) + } + if n := count(t, db, "sys_app_casbin_grant", "", nil); n != 0 { + t.Errorf("the ledger kept %d row(s); its job ends with the uninstall", n) + } + // It still committed: a skip is a reported branch, not a failure. + if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != 0 { + t.Errorf("the transaction rolled back over a skip: sys_menu has %d row(s)", n) + } +} + +// G5/A4: without this the reinstall finds every version applied, runs no +// migration, seeds nothing, and reports success. +func TestUninstallClearsThisAppsMigrationRecordsOnly(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + if err := db.Create(&commonmodels.Migration{ + Version: "1786700001000", AppCode: "", ApplyTime: time.Now(), + }).Error; err != nil { + t.Fatalf("framework migration row: %v", err) + } + + if _, err := uninstall(db, "order"); err != nil { + t.Fatalf("uninstall: %v", err) + } + if n := count(t, db, "sys_migration", "app_code = ?", "order"); n != 0 { + t.Errorf("sys_migration still has %d row(s) for order; a reinstall would seed nothing", n) + } + if n := count(t, db, "sys_migration", "app_code = ?", ""); n != 1 { + t.Errorf("the framework's own migration record was removed (%d left)", n) + } +} + +// A11: sys_role_menu is found by menu id, not by a column on it. A column +// would have been blanked the first time somebody edited a role, because +// SysRole.Update deletes the role's rows and writes them back through GORM's +// many2many, which does not carry extra columns. This reproduces that edit. +func TestUninstallSurvivesARoleMenuRewrite(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + + var roleID int + if err := db.Model(&adminmodels.SysRole{}).Where("role_key = ?", adminRoleKey). + Pluck("role_id", &roleID).Error; err != nil { + t.Fatalf("reading the admin role: %v", err) + } + var menuIDs []int + if err := db.Model(&adminmodels.SysMenu{}).Where("app_code = ?", "order"). + Pluck("menu_id", &menuIDs).Error; err != nil { + t.Fatalf("reading menus: %v", err) + } + if len(menuIDs) == 0 { + t.Fatal("no menus were seeded") + } + // What SysRole.Update does: drop every row for the role, then write them + // back with nothing but the two keys. + if err := db.Exec("DELETE FROM sys_role_menu WHERE role_id = ?", roleID).Error; err != nil { + t.Fatalf("clearing role menus: %v", err) + } + for _, id := range menuIDs { + if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", roleID, id).Error; err != nil { + t.Fatalf("rewriting role menus: %v", err) + } + } + + rep, err := uninstall(db, "order") + if err != nil { + t.Fatalf("uninstall: %v", err) + } + if rep.RoleMenus != int64(len(menuIDs)) { + t.Errorf("removed %d role assignment(s), want %d", rep.RoleMenus, len(menuIDs)) + } + if n := count(t, db, "sys_role_menu", "", nil); n != 0 { + t.Errorf("sys_role_menu still has %d row(s) after a role edit", n) + } +} + +// `migrate` with no subcommand applies every registered migration, an +// application's included, so an application can have all of its rows and +// never have had a sys_app row. Refusing to clean that up would leave the +// only case where nothing else can. +func TestUninstallWorksWithoutASysAppRow(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + if err := db.Where("app_code = ?", "order").Delete(&adminmodels.SysApp{}).Error; err != nil { + t.Fatalf("removing the sys_app row: %v", err) + } + + rep, err := uninstall(db, "order") + if err != nil { + t.Fatalf("uninstall: %v", err) + } + if rep.Found { + t.Error("the report claims a sys_app row that was not there") + } + if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != 0 { + t.Errorf("sys_menu still has %d row(s)", n) + } + var out strings.Builder + reportUninstall(&out, rep) + if !strings.Contains(out.String(), "no sys_app row") { + t.Errorf("the report does not say the row was missing: %q", out.String()) + } +} + +// One transaction, and it really is one: nothing here runs DDL, so unlike an +// install there is nothing to commit it out from under itself. +func TestUninstallRollsBackAsAWhole(t *testing.T) { + db := newUninstallDB(t) + seedApp(t, db, "order") + menusBefore := count(t, db, "sys_menu", "app_code = ?", "order") + policiesBefore := count(t, db, "casbin_rule", "", nil) + + // Step 8's table is gone, so the uninstall fails after it has already + // deleted menus, apis, bindings and policies. + if err := db.Migrator().DropTable(&commonmodels.Migration{}); err != nil { + t.Fatalf("dropping sys_migration: %v", err) + } + + if _, err := uninstall(db, "order"); err == nil { + t.Fatal("the uninstall reported success with sys_migration missing") + } + if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != menusBefore { + t.Errorf("sys_menu = %d, want %d: the failed uninstall did not roll back", n, menusBefore) + } + if n := count(t, db, "casbin_rule", "", nil); n != policiesBefore { + t.Errorf("casbin_rule = %d, want %d: the failed uninstall did not roll back", n, policiesBefore) + } +} + +func TestUninstallRefusesTheFrameworkCode(t *testing.T) { + db := newUninstallDB(t) + if _, err := uninstall(db, migration.FrameworkAppCode); err == nil { + t.Fatal("the framework was uninstalled") + } +}