Files
go-admin/cmd/migrate/uninstall_test.go
zhangwenjian 37aece9791 test: share one sys_app fixture, and count through the helper that checks
Two tests in this package each wrote out what an installed sys_app row looks
like, field for field, and a third inlined the same Create with a different
status. One appRow(t, db, code, status) now covers all three, so a new NOT
NULL column on SysApp is one edit rather than three.

One assertion counted with a bare db.Model(...).Count(&n) and dropped the
error that call returns. A failing query leaves n at zero, which is exactly
what that assertion wanted to see - so the test would have passed on a broken
query. The package already had a count helper that fails on the error, and
this now uses it.

Also a cycle reached from outside itself. The existing case walks straight
into its own cycle from the first code, so the path trimming had nothing to do
and replacing it with the untrimmed path left the test green - the trimming
was never covered. With a requiring b, b requiring c and c requiring b, the
untrimmed report names a as part of a cycle it is not in, and the test goes
red.
2026-09-11 08:21:35 +08:00

501 lines
18 KiB
Go

package migrate
import (
"strconv"
"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)
}
appRow(t, db, code, adminmodels.AppInstalled)
}
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")
}
}
// findOrphanPolicies chunks its OR chain because a driver runs out of
// placeholders long before an application runs out of endpoints. The
// boundary is where an off-by-one hides: a chunk size that drops the last
// element of each batch, or one that never advances, both leave policies
// unreported and nothing says so.
func TestFindOrphanPoliciesCoversEveryPathAcrossChunks(t *testing.T) {
db := newUninstallDB(t)
// Deliberately not a multiple of the chunk size, so the last batch is
// short, and large enough to need three of them.
const n = 205
keys := make([]policyKey, 0, n)
for i := 0; i < n; i++ {
path := "/api/v1/thing" + strconv.Itoa(i)
keys = append(keys, policyKey{V1: path, V2: "GET"})
if err := db.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', ?, 'GET', '', '', '')",
path,
).Error; err != nil {
t.Fatalf("seeding policy %d: %v", i, err)
}
}
// One policy that must not match: a path no key names.
if err := db.Exec(
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', '/api/v1/elsewhere', 'GET', '', '', '')",
).Error; err != nil {
t.Fatalf("seeding the control policy: %v", err)
}
found, err := findOrphanPolicies(db, keys)
if err != nil {
t.Fatalf("findOrphanPolicies: %v", err)
}
if len(found) != n {
t.Fatalf("found %d policies, want %d", len(found), n)
}
seen := make(map[string]bool, len(found))
for _, f := range found {
seen[f.V1] = true
if f.V1 == "/api/v1/elsewhere" {
t.Error("a path no key names was reported")
}
}
for _, k := range keys {
if !seen[k.V1] {
t.Errorf("%s was not reported", k.V1)
}
}
}
// An application may register apis with no menus at all - endpoints another
// service calls - so either of the id lists an uninstall reads can be empty.
// The guard in front of the join-table delete turns out not to be what makes
// this work: GORM renders IN with an empty slice as a condition that matches
// nothing, rather than the empty IN list that would be a syntax error in raw
// SQL, and removing the guard leaves this test green. It stays as an explicit
// statement of intent rather than a reliance on that rendering.
func TestUninstallWithApisButNoMenus(t *testing.T) {
db := newUninstallDB(t)
apis := []seed.ApiSpec{
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
}
if err := db.Transaction(func(tx *gorm.DB) error {
return seed.SeedMenus(tx, "hooks", nil, apis)
}); err != nil {
t.Fatalf("seeding: %v", err)
}
rep, err := uninstall(db, "hooks")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if rep.Apis != 1 {
t.Errorf("removed %d api(s), want 1", rep.Apis)
}
if rep.Policies != 1 {
t.Errorf("removed %d policy(ies), want 1", rep.Policies)
}
if n := count(t, db, "casbin_rule", "", nil); n != 0 {
t.Errorf("casbin_rule has %d row(s)", n)
}
}
// The mirror case: menus and no apis at all.
func TestUninstallWithMenusButNoApis(t *testing.T) {
db := newUninstallDB(t)
menus := []seed.MenuSpec{
{Code: "dir", Kind: "M", Title: "Reports", Path: "/apps/reports", Component: "Layout", Sort: 10},
}
if err := db.Transaction(func(tx *gorm.DB) error {
return seed.SeedMenus(tx, "reports", menus, nil)
}); err != nil {
t.Fatalf("seeding: %v", err)
}
rep, err := uninstall(db, "reports")
if err != nil {
t.Fatalf("uninstall: %v", err)
}
if rep.Menus != 1 {
t.Errorf("removed %d menu(s), want 1", rep.Menus)
}
if n := count(t, db, "sys_menu", "app_code = ?", "reports"); n != 0 {
t.Errorf("sys_menu has %d row(s)", n)
}
if len(rep.Orphans) != 0 {
t.Errorf("an application with no apis reported orphans: %v", rep.Orphans)
}
}