Files
qitongxue-api/internal/logic/softdelete_it_test.go
2026-09-29 10:57:01 +08:00

427 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<victimId>",其长度 5-20,
// 落在 username 的合法区间(required|length:3,32)内,**可被攻击者提前抢注**。
// 抢注成功后,软删 victim 就会撞 uk_username → AdminUserBatch 整体报错 → 整个批量删除失败。
//
// 本测试:先让攻击者注册 username="#del<victimId>"(修复前这能插进去,正说明漏洞真实存在),
// 再软删 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)
}
}