Files
junhong_cmp_fiber/tests/unit/data_permission_scope_test.go
huang eaa70ac255 feat: 实现 RBAC 权限系统和数据权限控制 (004-rbac-data-permission)
主要功能:
- 实现完整的 RBAC 权限系统(账号、角色、权限的多对多关联)
- 基于 owner_id + shop_id 的自动数据权限过滤
- 使用 PostgreSQL WITH RECURSIVE 查询下级账号
- Redis 缓存优化下级账号查询性能(30分钟过期)
- 支持多租户数据隔离和层级权限管理

技术实现:
- 新增 Account、Role、Permission 模型及关联关系表
- 实现 GORM Scopes 自动应用数据权限过滤
- 添加数据库迁移脚本(000002_rbac_data_permission、000003_add_owner_id_shop_id)
- 完善错误码定义(1010-1027 为 RBAC 相关错误)
- 重构 main.go 采用函数拆分提高可读性

测试覆盖:
- 添加 Account、Role、Permission 的集成测试
- 添加数据权限过滤的单元测试和集成测试
- 添加下级账号查询和缓存的单元测试
- 添加 API 回归测试确保向后兼容

文档更新:
- 更新 README.md 添加 RBAC 功能说明
- 更新 CLAUDE.md 添加技术栈和开发原则
- 添加 docs/004-rbac-data-permission/ 功能总结和使用指南

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:44:06 +08:00

304 lines
9.3 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 unit
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/tests/testutils"
)
// TestDataPermissionScope_RootUser 测试 root 用户跳过数据权限过滤
func TestDataPermissionScope_RootUser(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgres.NewAccountStore(db, redisClient)
ctx := context.Background()
// 创建 root 用户
rootUser := &model.Account{
Username: "root_user",
Phone: "13800000000",
Password: "hashed_password",
UserType: constants.UserTypeRoot,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, db.Create(rootUser).Error)
// 创建测试数据表(模拟业务表)
type TestData struct {
ID uint `gorm:"primarykey"`
Name string
OwnerID uint
ShopID uint
}
require.NoError(t, db.AutoMigrate(&TestData{}))
// 插入测试数据(不同的 owner_id 和 shop_id
testData := []TestData{
{Name: "data1", OwnerID: 1, ShopID: 100},
{Name: "data2", OwnerID: 2, ShopID: 200},
{Name: "data3", OwnerID: 3, ShopID: 300},
}
require.NoError(t, db.Create(&testData).Error)
// 设置 root 用户上下文
ctxWithRoot := middleware.SetUserContext(ctx, rootUser.ID, constants.UserTypeRoot, 100)
// 查询(应该返回所有数据,不过滤)
var results []TestData
err := db.WithContext(ctxWithRoot).
Scopes(postgres.DataPermissionScope(ctxWithRoot, accountStore)).
Find(&results).Error
require.NoError(t, err)
assert.Len(t, results, 3, "root 用户应该看到所有数据")
}
// TestDataPermissionScope_NormalUser 测试普通用户数据权限过滤
func TestDataPermissionScope_NormalUser(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgres.NewAccountStore(db, redisClient)
ctx := context.Background()
// 创建账号层级: A -> B
accountA := &model.Account{
Username: "user_a",
Phone: "13800000001",
Password: "hashed_password",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, db.Create(accountA).Error)
shopIDA := uint(100)
accountA.ShopID = &shopIDA
require.NoError(t, db.Save(accountA).Error)
accountB := &model.Account{
Username: "user_b",
Phone: "13800000002",
Password: "hashed_password",
UserType: constants.UserTypeAgent,
ParentID: &accountA.ID,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, db.Create(accountB).Error)
shopIDB := uint(100)
accountB.ShopID = &shopIDB
require.NoError(t, db.Save(accountB).Error)
// 创建测试数据表
type TestData struct {
ID uint `gorm:"primarykey"`
Name string
OwnerID uint
ShopID uint
}
require.NoError(t, db.AutoMigrate(&TestData{}))
// 插入测试数据
testData := []TestData{
{Name: "data_a", OwnerID: accountA.ID, ShopID: 100}, // A 的数据
{Name: "data_b", OwnerID: accountB.ID, ShopID: 100}, // B 的数据
{Name: "data_c", OwnerID: 999, ShopID: 100}, // 其他用户数据(同店铺)
{Name: "data_d", OwnerID: accountA.ID, ShopID: 200}, // A 的数据(不同店铺)
}
require.NoError(t, db.Create(&testData).Error)
// A 登录查询(应该看到 A 和 B 的数据,同店铺)
ctxWithA := middleware.SetUserContext(ctx, accountA.ID, constants.UserTypePlatform, 100)
var resultsA []TestData
err := db.WithContext(ctxWithA).
Scopes(postgres.DataPermissionScope(ctxWithA, accountStore)).
Find(&resultsA).Error
require.NoError(t, err)
assert.Len(t, resultsA, 2, "A 应该看到自己和下级 B 的数据")
// B 登录查询(只能看到自己的数据)
ctxWithB := middleware.SetUserContext(ctx, accountB.ID, constants.UserTypeAgent, 100)
var resultsB []TestData
err = db.WithContext(ctxWithB).
Scopes(postgres.DataPermissionScope(ctxWithB, accountStore)).
Find(&resultsB).Error
require.NoError(t, err)
assert.Len(t, resultsB, 1, "B 只能看到自己的数据")
assert.Equal(t, "data_b", resultsB[0].Name)
}
// TestDataPermissionScope_ShopIsolation 测试店铺隔离
func TestDataPermissionScope_ShopIsolation(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgres.NewAccountStore(db, redisClient)
ctx := context.Background()
// 创建两个账号(同一层级,不同店铺)
shopID100 := uint(100)
accountA := &model.Account{
Username: "user_a",
Phone: "13800000001",
Password: "hashed_password",
UserType: constants.UserTypePlatform,
ShopID: &shopID100,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, db.Create(accountA).Error)
shopID200 := uint(200)
accountB := &model.Account{
Username: "user_b",
Phone: "13800000002",
Password: "hashed_password",
UserType: constants.UserTypePlatform,
ShopID: &shopID200,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, db.Create(accountB).Error)
// 创建测试数据表
type TestData struct {
ID uint `gorm:"primarykey"`
Name string
OwnerID uint
ShopID uint
}
require.NoError(t, db.AutoMigrate(&TestData{}))
// 插入测试数据
testData := []TestData{
{Name: "data_shop100", OwnerID: accountA.ID, ShopID: 100},
{Name: "data_shop200", OwnerID: accountB.ID, ShopID: 200},
}
require.NoError(t, db.Create(&testData).Error)
// A 登录查询(只能看到店铺 100 的数据)
ctxWithA := middleware.SetUserContext(ctx, accountA.ID, constants.UserTypePlatform, 100)
var resultsA []TestData
err := db.WithContext(ctxWithA).
Scopes(postgres.DataPermissionScope(ctxWithA, accountStore)).
Find(&resultsA).Error
require.NoError(t, err)
assert.Len(t, resultsA, 1, "A 只能看到店铺 100 的数据")
assert.Equal(t, "data_shop100", resultsA[0].Name)
// B 登录查询(只能看到店铺 200 的数据)
ctxWithB := middleware.SetUserContext(ctx, accountB.ID, constants.UserTypePlatform, 200)
var resultsB []TestData
err = db.WithContext(ctxWithB).
Scopes(postgres.DataPermissionScope(ctxWithB, accountStore)).
Find(&resultsB).Error
require.NoError(t, err)
assert.Len(t, resultsB, 1, "B 只能看到店铺 200 的数据")
assert.Equal(t, "data_shop200", resultsB[0].Name)
}
// TestDataPermissionScope_NoUserContext 测试无用户上下文时不过滤
func TestDataPermissionScope_NoUserContext(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgres.NewAccountStore(db, redisClient)
ctx := context.Background()
// 创建测试数据表
type TestData struct {
ID uint `gorm:"primarykey"`
Name string
OwnerID uint
ShopID uint
}
require.NoError(t, db.AutoMigrate(&TestData{}))
// 插入测试数据
testData := []TestData{
{Name: "data1", OwnerID: 1, ShopID: 100},
{Name: "data2", OwnerID: 2, ShopID: 200},
}
require.NoError(t, db.Create(&testData).Error)
// 使用没有用户信息的上下文查询(不过滤,可能是系统任务)
var results []TestData
err := db.WithContext(ctx).
Scopes(postgres.DataPermissionScope(ctx, accountStore)).
Find(&results).Error
require.NoError(t, err)
assert.Len(t, results, 0, "无用户上下文时应该返回空数据(根据 scopes.go 的实现)")
}
// TestDataPermissionScope_ErrorHandling 测试查询下级 ID 失败时的降级策略
func TestDataPermissionScope_ErrorHandling(t *testing.T) {
db, redisClient := testutils.SetupTestDB(t)
defer testutils.TeardownTestDB(t, db, redisClient)
accountStore := postgres.NewAccountStore(db, redisClient)
ctx := context.Background()
// 创建测试账号
accountA := &model.Account{
Username: "user_a",
Phone: "13800000001",
Password: "hashed_password",
UserType: constants.UserTypePlatform,
Status: constants.StatusEnabled,
Creator: 1,
Updater: 1,
}
require.NoError(t, db.Create(accountA).Error)
shopIDA := uint(100)
accountA.ShopID = &shopIDA
require.NoError(t, db.Save(accountA).Error)
// 创建测试数据表
type TestData struct {
ID uint `gorm:"primarykey"`
Name string
OwnerID uint
ShopID uint
}
require.NoError(t, db.AutoMigrate(&TestData{}))
// 插入测试数据
testData := []TestData{
{Name: "data_a", OwnerID: accountA.ID, ShopID: 100},
{Name: "data_b", OwnerID: 999, ShopID: 100},
}
require.NoError(t, db.Create(&testData).Error)
// 关闭 Redis 连接以模拟错误(递归查询失败)
redisClient.Close()
// 使用 A 的上下文查询(降级策略:只返回自己的数据)
ctxWithA := middleware.SetUserContext(ctx, accountA.ID, constants.UserTypePlatform, 100)
var resultsA []TestData
err := db.WithContext(ctxWithA).
Scopes(postgres.DataPermissionScope(ctxWithA, accountStore)).
Find(&resultsA).Error
require.NoError(t, err)
// 降级策略应该只返回自己的数据
assert.Len(t, resultsA, 1, "查询下级 ID 失败时,应该降级为只返回自己的数据")
assert.Equal(t, "data_a", resultsA[0].Name)
}