package logic import ( "context" "net/http" "os" "strings" "testing" // 测试进程不会执行 main.go 的驱动注册,这里显式引入 MySQL 驱动 _ "github.com/gogf/gf/contrib/drivers/mysql/v2" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" ) // ============================================================================ // 软删除真实库集成测试(默认跳过;需真实 MySQL) // // 运行:$env:IT_DB=1; go test ./internal/logic/ -run TestSoftDelete -count=1 -v // // 说明: // - 连的是 dev 库(默认 mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox,可用 IT_DB_LINK 覆盖)。 // 测试进程 cwd 是包目录,GoFrame 未必能找到 manifest/config,故这里显式 SetConfig。 // - 仅使用带 __probe 前缀 / 极大假 enterprise_id 的临时数据,结束即清理。 // - 若 users 缺 deleted_at/deleted_from 列或 employees 表不存在,测试会按 migrate 的定义补建, // 与线上启动迁移一致(幂等)。 // ============================================================================ func requireIT(t *testing.T) { t.Helper() if os.Getenv("IT_DB") == "" { t.Skip("set IT_DB=1 以运行真实库集成测试") } link := os.Getenv("IT_DB_LINK") if link == "" { link = "mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox" } gdb.SetConfig(gdb.Config{ "default": gdb.ConfigGroup{ gdb.ConfigNode{Link: link}, }, }) } // ensureUserSoftDeleteCols 确保 users 具备 deleted_at / deleted_from 列(与 migrateColumns 一致)。 func ensureUserSoftDeleteCols(ctx context.Context, t *testing.T) { t.Helper() cols := []struct{ name, ddl string }{ {"deleted_at", "ALTER TABLE `users` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"}, {"deleted_from", "ALTER TABLE `users` ADD COLUMN `deleted_from` text COMMENT '软删前唯一键原值(JSON),便于恢复'"}, } for _, c := range cols { exists, err := columnExists(ctx, "users", c.name) if err != nil { t.Fatalf("columnExists users.%s: %v", c.name, err) } if !exists { if _, err = g.DB().Exec(ctx, c.ddl); err != nil { t.Fatalf("add users.%s: %v", c.name, err) } } } } // ensureEmployeesTable 确保 employees 表存在(与 migrateTables 的 DDL 一致)。 func ensureEmployeesTable(ctx context.Context, t *testing.T) { t.Helper() ddl := "CREATE TABLE IF NOT EXISTS `employees` (" + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + "`enterprise_id` bigint NOT NULL," + "`name` varchar(64) NOT NULL," + "`phone` varchar(20) NOT NULL," + "`dept` varchar(64) NOT NULL DEFAULT ''," + "`remark` varchar(255) NOT NULL DEFAULT ''," + "`status` tinyint NOT NULL DEFAULT 1," + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," + "`deleted_from` text COMMENT '软删前 phone 原值(JSON),便于恢复'," + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + "PRIMARY KEY (`id`)," + "UNIQUE KEY `uk_ent_phone` (`enterprise_id`,`phone`)," + "KEY `idx_ent_name` (`enterprise_id`,`name`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业员工库(跨活动复用)'" if _, err := g.DB().Exec(ctx, ddl); err != nil { t.Fatalf("ensure employees: %v", err) } } // ensureToolsSoftDeleteCol 确保 tools 具备 deleted_at 列(与 migrateColumns 一致;dev 库未必跑过迁移)。 func ensureToolsSoftDeleteCol(ctx context.Context, t *testing.T) { t.Helper() exists, err := columnExists(ctx, "tools", "deleted_at") if err != nil { t.Fatalf("columnExists tools.deleted_at: %v", err) } if !exists { if _, err = g.DB().Exec(ctx, "ALTER TABLE `tools` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"); err != nil { t.Fatalf("add tools.deleted_at: %v", err) } } } // TestSoftDeleteUsers_ReRegisterSucceeds 软删用户后:未删列表看不到 + 原 openid/用户名释放 + // 同 openid/用户名可重新注册 + 重复软删幂等。 func TestSoftDeleteUsers_ReRegisterSucceeds(t *testing.T) { requireIT(t) ctx := context.Background() ensureUserSoftDeleteCols(ctx, t) const openid = "__probe_openid_softdel__" const username = "__probe_user_softdel__" var probeId int64 cleanup := func() { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid=? OR username=?", openid, username) if probeId != 0 { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", probeId) } } cleanup() defer cleanup() id, err := g.Model(consts.TableUsers).Data(g.Map{ "openid": openid, "username": username, "nickname": "probe", "level_key": consts.DefaultLevelKey, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("insert probe user: %v", err) } probeId = id if err = softDeleteUsers(ctx, []int64{id}); err != nil { t.Fatalf("softDeleteUsers: %v", err) } // 1) 过滤:未删列表(deleted_at IS NULL)查不到 if n, _ := g.Model(consts.TableUsers).Where("openid", openid).WhereNull("deleted_at").Count(); n != 0 { t.Fatalf("软删用户仍出现在未删列表: n=%d", n) } // 2) 墓碑化:原 openid / username 已释放(不过滤也查不到该原值) if n, _ := g.Model(consts.TableUsers).Where("openid", openid).Count(); n != 0 { t.Fatalf("原 openid 仍被占用: n=%d", n) } if n, _ := g.Model(consts.TableUsers).Where("username", username).Count(); n != 0 { t.Fatalf("原 username 仍被占用: n=%d", n) } // 3) 重新注册同 openid + username 必须成功 if _, err = g.Model(consts.TableUsers).Data(g.Map{ "openid": openid, "username": username, "nickname": "probe2", "level_key": consts.DefaultLevelKey, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).Insert(); err != nil { t.Fatalf("同 openid/username 重新注册失败: %v", err) } // 4) 幂等:再次软删同一已删 id 不报错 if err = softDeleteUsers(ctx, []int64{id}); err != nil { t.Fatalf("重复软删不幂等: %v", err) } } // TestSoftDeleteEmployees_ReAddSucceeds 软删员工后:同企业同手机号可重新录入(uk_ent_phone 复合唯一)+ 幂等。 func TestSoftDeleteEmployees_ReAddSucceeds(t *testing.T) { requireIT(t) ctx := context.Background() ensureEmployeesTable(ctx, t) const entId int64 = 9223372036854775 // 极大假企业 id,避开真实数据 const phone = "__probe_phone__" cleanup := func() { _, _ = g.DB().Exec(ctx, "DELETE FROM employees WHERE enterprise_id=?", entId) } cleanup() defer cleanup() id, err := g.Model(consts.TableEmployees).Data(g.Map{ "enterprise_id": entId, "name": "probe", "phone": phone, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("insert probe employee: %v", err) } if err = softDeleteEmployees(ctx, []int64{id}); err != nil { t.Fatalf("softDeleteEmployees: %v", err) } // 过滤 if n, _ := g.Model(consts.TableEmployees).Where("enterprise_id", entId).Where("phone", phone).WhereNull("deleted_at").Count(); n != 0 { t.Fatalf("软删员工仍在未删列表: n=%d", n) } // 同企业同手机号重新录入必须成功 if _, err = g.Model(consts.TableEmployees).Data(g.Map{ "enterprise_id": entId, "name": "probe2", "phone": phone, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).Insert(); err != nil { t.Fatalf("同企业同手机号重新录入失败: %v", err) } // 幂等 if err = softDeleteEmployees(ctx, []int64{id}); err != nil { t.Fatalf("重复软删不幂等: %v", err) } } // TestSoftDeleteUsers_UsernameSquattingBlocked 抢注通道关闭的真实库验证(回归修复前的漏洞): // // 修复前:软删 victim 会把 username 改成短墓碑 "#del",其长度 5-20, // 落在 username 的合法区间(required|length:3,32)内,**可被攻击者提前抢注**。 // 抢注成功后,软删 victim 就会撞 uk_username → AdminUserBatch 整体报错 → 整个批量删除失败。 // // 本测试:先让攻击者注册 username="#del"(修复前这能插进去,正说明漏洞真实存在), // 再软删 victim;断言软删仍成功(长墓碑 >32,攻击者抢不到), // 且 victim.username 等于长墓碑、deleted_from 保存了原值。 func TestSoftDeleteUsers_UsernameSquattingBlocked(t *testing.T) { requireIT(t) ctx := context.Background() ensureUserSoftDeleteCols(ctx, t) const victimOpenid = "__probe_victim_openid_squat__" const victimName = "__probe_victim_name_squat__" const attackerOpenid = "__probe_attacker_openid_squat__" var victimId, attackerId int64 cleanup := func() { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid IN (?,?)", victimOpenid, attackerOpenid) _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE username=?", victimName) // victim 被软删后 openid/username 已墓碑化,按主键删;同时按 deleted_from 扫历史残留探测行 if victimId != 0 { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", victimId) } if attackerId != 0 { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", attackerId) } _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE deleted_from LIKE ?", "%"+victimOpenid+"%") } cleanup() defer cleanup() // 1) 先插 victim,拿到 victimId(短墓碑名依赖它) id, err := g.Model(consts.TableUsers).Data(g.Map{ "openid": victimOpenid, "username": victimName, "nickname": "victim", "level_key": consts.DefaultLevelKey, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("insert victim: %v", err) } victimId = id // 2) 攻击者抢注「修复前会生成的短墓碑」——修复前它是合法且能插入的 squatName := tombstoneShort(victimId) if len(squatName) < 3 || len(squatName) > 32 { t.Fatalf("抢注名 %q 长度 %d 不在合法区间,测试前提不成立", squatName, len(squatName)) } attackerId, err = g.Model(consts.TableUsers).Data(g.Map{ "openid": attackerOpenid, "username": squatName, "nickname": "attacker", "level_key": consts.DefaultLevelKey, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("攻击者抢注短墓碑名 %q 失败(测试前提不成立): %v", squatName, err) } // 3) 软删 victim:修复后必须成功(长墓碑不与攻击者占用的短墓碑相撞) if err = softDeleteUsers(ctx, []int64{victimId}); err != nil { t.Fatalf("软删 victim 失败(抢注通道未关闭?): %v", err) } // 4) 直接查库(raw SQL,绕过 ORM 软删过滤):victim.username 必须是长墓碑 rows, err := g.DB().GetAll(ctx, "SELECT username, deleted_from, deleted_at FROM users WHERE id=?", victimId) if err != nil { t.Fatalf("query victim: %v", err) } if len(rows) != 1 { t.Fatalf("victim 行数=%d 期望 1", len(rows)) } if got := rows[0]["username"].String(); got != tombstoneLong(victimId) { t.Fatalf("victim.username=%q 期望长墓碑 %q", got, tombstoneLong(victimId)) } // 5) deleted_from 保存原值,便于恢复 if df := rows[0]["deleted_from"].String(); !strings.Contains(df, victimName) { t.Fatalf("deleted_from=%q 未保存原 username %q", df, victimName) } // 6) ORM 软删过滤:Model 查询看不到已软删 victim(GoFrame v2.10 soft-time 自动过滤) if n, _ := g.Model(consts.TableUsers).Where("id", victimId).Count(); n != 0 { t.Fatalf("Model 仍能看到已软删 victim: n=%d", n) } // 7) 原 username 已释放:未删用户里查不到该原值 if n, _ := g.Model(consts.TableUsers).Where("username", victimName).Count(); n != 0 { t.Fatalf("原 username 仍被占用: n=%d", n) } } // TestSoftDeleteBatchDelete_Idempotent 幂等契约(回归 existingIdSet 被框架软删过滤的真 bug): // 对同一批 id **连续 delete 两次**,第二次仍须 success=N / failed=0。 // // 修复前:existingIdSet 用 Model 查询,被框架自动追加 `deleted_at IS NULL` → 已软删 id 查不出 // // → 落进 missing → 返回 failed,违反「重复删除已删 id 计成功」的冻结契约。 // // 修复后:existingIdSet 加 `.Unscoped()`,软删行照常可见 → 计入 ok。 func TestSoftDeleteBatchDelete_Idempotent(t *testing.T) { requireIT(t) // AdminUserBatch 内部会 WriteAudit → CtxAdminId → g.RequestFromCtx(ctx),需要对非 nil 的 // *ghttp.Request(否则 GetCtxVar 在 nil 上解引用 panic)。这里构造一个最小请求上下文, // 使该链路可跑(审计是旁路,写失败也仅告警)。 req := &ghttp.Request{Request: &http.Request{}} ctx := req.Context() ensureUserSoftDeleteCols(ctx, t) ensureEmployeesTable(ctx, t) ensureToolsSoftDeleteCol(ctx, t) // 审计是旁路(WriteAudit 失败仅告警),但若该表存在会留下记录;清理本次产生、admin_id=0 的批量删除审计 defer func() { _, _ = g.DB().Exec(ctx, "DELETE FROM `"+consts.TableAdminAuditLog+"` WHERE admin_id=0 AND action IN ('user.batch_delete','tool.batch_delete','employee.batch_delete')") }() // ---------- users(existingIdSet + softDeleteUsers 路径)---------- const uOpenid = "__probe_idem_openid__" const uName = "__probe_idem_name__" var uid int64 uCleanup := func() { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid=? OR username=?", uOpenid, uName) if uid != 0 { _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", uid) } _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE deleted_from LIKE ?", "%"+uOpenid+"%") } uCleanup() defer uCleanup() uid, err := g.Model(consts.TableUsers).Data(g.Map{ "openid": uOpenid, "username": uName, "nickname": "idem", "level_key": consts.DefaultLevelKey, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("insert probe user: %v", err) } ur1, err := AdminUserBatch(ctx, &v1.UserBatchReq{Ids: []int64{uid}, Action: "delete"}) if err != nil { t.Fatalf("user 第1次 delete: %v", err) } if ur1.Success != 1 || ur1.Failed != 0 { t.Fatalf("user 第1次 delete 期望 success=1/failed=0,实得 %d/%d failures=%+v", ur1.Success, ur1.Failed, ur1.Failures) } ur2, err := AdminUserBatch(ctx, &v1.UserBatchReq{Ids: []int64{uid}, Action: "delete"}) if err != nil { t.Fatalf("user 第2次 delete: %v", err) } if ur2.Success != 1 || ur2.Failed != 0 { t.Fatalf("user 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v(existingIdSet 漏了 Unscoped?)", ur2.Success, ur2.Failed, ur2.Failures) } // ---------- tools(existingIdSet + softDeleteSimple 路径)---------- const toolKey = "__probe_idem_tool__" var tid int64 tCleanup := func() { _, _ = g.DB().Exec(ctx, "DELETE FROM tools WHERE tool_key=?", toolKey) if tid != 0 { _, _ = g.DB().Exec(ctx, "DELETE FROM tools WHERE id=?", tid) } } tCleanup() defer tCleanup() tid, err = g.Model(consts.TableTools).Data(g.Map{ "tool_key": toolKey, "module_key": "image", "name": "idem-probe", "icon": "picture", "description": "probe", "sort": 999, "is_hot": 0, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("insert probe tool: %v", err) } tr1, err := AdminToolsBatch(ctx, &v1.ToolsBatchReq{Ids: []int64{tid}, Action: "delete"}) if err != nil { t.Fatalf("tool 第1次 delete: %v", err) } if tr1.Success != 1 || tr1.Failed != 0 { t.Fatalf("tool 第1次 delete 期望 success=1/failed=0,实得 %d/%d", tr1.Success, tr1.Failed) } tr2, err := AdminToolsBatch(ctx, &v1.ToolsBatchReq{Ids: []int64{tid}, Action: "delete"}) if err != nil { t.Fatalf("tool 第2次 delete: %v", err) } if tr2.Success != 1 || tr2.Failed != 0 { t.Fatalf("tool 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v", tr2.Success, tr2.Failed, tr2.Failures) } // ---------- employees(existingIdSet + softDeleteEmployees 路径)---------- const entId int64 = 9223372036854774 // 极大假企业 id,避开真实数据 const ePhone = "__probe_idem_phone__" var eid int64 eCleanup := func() { _, _ = g.DB().Exec(ctx, "DELETE FROM employees WHERE enterprise_id=?", entId) } eCleanup() defer eCleanup() eid, err = g.Model(consts.TableEmployees).Data(g.Map{ "enterprise_id": entId, "name": "idem-probe", "phone": ePhone, "status": 1, "created_at": gtime.Now(), "updated_at": gtime.Now(), }).InsertAndGetId() if err != nil { t.Fatalf("insert probe employee: %v", err) } er1, err := AdminEmployeeBatch(ctx, &v1.EmployeeBatchReq{Ids: []int64{eid}, Action: "delete"}) if err != nil { t.Fatalf("employee 第1次 delete: %v", err) } if er1.Success != 1 || er1.Failed != 0 { t.Fatalf("employee 第1次 delete 期望 success=1/failed=0,实得 %d/%d", er1.Success, er1.Failed) } er2, err := AdminEmployeeBatch(ctx, &v1.EmployeeBatchReq{Ids: []int64{eid}, Action: "delete"}) if err != nil { t.Fatalf("employee 第2次 delete: %v", err) } if er2.Success != 1 || er2.Failed != 0 { t.Fatalf("employee 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v", er2.Success, er2.Failed, er2.Failures) } }