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") + } +}