package userrole import ( "context" "fmt" "testing" "time" "perms-system-server/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/core/stores/sqlx" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 L-R11-2 —— Delete 族的 SELECT 从 * 裁剪到 id/userId/roleId。 // 行为契约不得因此回归: // 1) 每一行被删除后,id 维度 + 复合维度(userId+roleId)的缓存 key 必须全部失效; // 2) 裁剪后仍然能正确处理"同一 userId 下多条 role 绑定"的批量失效,绝不能只删到 // 第一条行的缓存 key 就提前返回; // 3) 经过 Delete 后再 FindOne 必须回落到 DB 的 ErrNotFound,不能沿用缓存里的旧行。 // // 这里选 sys_user_role 作为三个 Delete-Tx 家族(userrole/roleperm/userperm/perm)的 // 代表:其 DeleteByRoleIdTx / DeleteByUserIdForProductTx / DeleteByUserIdAndRoleIdsTx // 三个方法都走同一条 SELECT-only-key-cols → DELETE 的路径,成一族即覆盖整个收敛面。 // --------------------------------------------------------------------------- func seedUserRoleWithPrimedCache(t *testing.T, ctx context.Context, m SysUserRoleModel, userId, roleId int64) int64 { t.Helper() ts := time.Now().Unix() res, err := m.Insert(ctx, &SysUserRole{ UserId: userId, RoleId: roleId, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) _, err = m.FindOne(ctx, id) require.NoError(t, err, "FindOne 为 id 维度缓存预热") _, err = m.FindOneByUserIdRoleId(ctx, userId, roleId) require.NoError(t, err, "FindOneByUserIdRoleId 为复合维度缓存预热") return id } func assertCacheKeysGone(t *testing.T, rds *redis.Redis, idKey, compositeKey string) { t.Helper() got, err := rds.Get(idKey) require.NoError(t, err) assert.Empty(t, got, "L-R11-2:id 维度缓存 key %q 应被 DELETE 一并失效", idKey) got, err = rds.Get(compositeKey) require.NoError(t, err) assert.Empty(t, got, "L-R11-2:复合维度缓存 key %q 应被 DELETE 一并失效", compositeKey) } // TC-1062: L-R11-2 —— DeleteByRoleIdTx 对多行同时失效 id + composite 缓存 func TestSysUserRoleModel_DeleteByRoleIdTx_InvalidatesAllKeyCols(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf) cachePrefix := testutil.GetTestCachePrefix() roleId := randUserRoleId() u1, u2 := randUserRoleId(), randUserRoleId() if u1 == u2 { u2 = u1 + 1 } id1 := seedUserRoleWithPrimedCache(t, ctx, m, u1, roleId) id2 := seedUserRoleWithPrimedCache(t, ctx, m, u2, roleId) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "sys_user_role", id1, id2) }) require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.DeleteByRoleIdTx(c, session, roleId) })) // L-R11-2 裁剪 SELECT 为三列后,构造缓存 key 需要的 id/userId/roleId 仍被携带回来。 // 任何一条遗漏都会让这里的断言炸掉(旧行的缓存还在,FindOne 就能从缓存拿到"幽灵行")。 for _, c := range []struct { id, uid int64 }{{id1, u1}, {id2, u2}} { idKey := fmt.Sprintf("%s:cache:sysUserRole:id:%v", cachePrefix, c.id) compKey := fmt.Sprintf("%s:cache:sysUserRole:userId:roleId:%v:%v", cachePrefix, c.uid, roleId) assertCacheKeysGone(t, rds, idKey, compKey) } // 终态真相:两行都不应再存在于 DB。 for _, id := range []int64{id1, id2} { _, err := m.FindOne(ctx, id) assert.ErrorIs(t, err, ErrNotFound, "L-R11-2:DELETE 已提交,FindOne 必须回落 DB 读到 ErrNotFound;"+ "若仍查到旧行说明裁剪后的 SELECT 漏掉了 id 列,id 维度缓存仍在") } } // TC-1063: L-R11-2 —— DeleteByUserIdAndRoleIdsTx 的批量 IN 路径 func TestSysUserRoleModel_DeleteByUserIdAndRoleIdsTx_InvalidatesAllKeyCols(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix()) rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf) cachePrefix := testutil.GetTestCachePrefix() userId := randUserRoleId() r1, r2, r3 := randUserRoleId(), randUserRoleId()+1, randUserRoleId()+2 id1 := seedUserRoleWithPrimedCache(t, ctx, m, userId, r1) id2 := seedUserRoleWithPrimedCache(t, ctx, m, userId, r2) // r3 作为"不在删除集合内"的对照组:这一行不得被误伤 id3 := seedUserRoleWithPrimedCache(t, ctx, m, userId, r3) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "sys_user_role", id1, id2, id3) }) require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.DeleteByUserIdAndRoleIdsTx(c, session, userId, []int64{r1, r2}) })) // 被删的两条:id+composite 都必须失效。 for _, c := range []struct { id, rid int64 }{{id1, r1}, {id2, r2}} { idKey := fmt.Sprintf("%s:cache:sysUserRole:id:%v", cachePrefix, c.id) compKey := fmt.Sprintf("%s:cache:sysUserRole:userId:roleId:%v:%v", cachePrefix, userId, c.rid) assertCacheKeysGone(t, rds, idKey, compKey) _, err := m.FindOne(ctx, c.id) assert.ErrorIs(t, err, ErrNotFound) } // 未被删的第 3 条:DB 仍在,FindOne 必须成功。 got, err := m.FindOne(ctx, id3) require.NoError(t, err, "L-R11-2 防误伤:IN (r1, r2) 不得把 r3 的行带走;"+ "若失败,说明裁剪后 SELECT 把对照组的 key 也返回并被 ExecCtx 一并失效") assert.Equal(t, id3, got.Id) assert.Equal(t, r3, got.RoleId) }