res_field.go/res_field_option.go(字段定义)、res_query_field.go/res_query_field_option.go(查询字段)、res_change_row.go(变更行)、res_mask_field.go(字段脱敏标记)及对应测试文件,配合此前已提交的 resource.go/res_interceptor.go 组成完整的 resx 资源实现包。
591 lines
24 KiB
Go
591 lines
24 KiB
Go
package resx_test
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
"github.com/samber/do/v2"
|
||
"github.com/samber/lo"
|
||
"github.com/stretchr/testify/suite"
|
||
|
||
"git.fsdpf.net/go/db"
|
||
"git.fsdpf.net/go/db/engine"
|
||
"git.fsdpf.net/go/db/exp"
|
||
"git.fsdpf.net/go/req"
|
||
"git.fsdpf.net/go/req/resx"
|
||
|
||
_ "git.fsdpf.net/go/db/dialect/mysql"
|
||
)
|
||
|
||
// maskFieldTest 验证字段级权限(ResField.GetRoles):SELECT 时按权限把字段替换成 MaskFunc 给的
|
||
// 哨兵值(resx.MaskField 显式标记的列才处理,未标记的列即使字段本身受限也不处理),INSERT/
|
||
// UPDATE 时把没有写权限的字段静默丢弃(见 normalizeSaveValue)。
|
||
type maskFieldTest struct {
|
||
suite.Suite
|
||
}
|
||
|
||
func TestMaskField(t *testing.T) {
|
||
suite.Run(t, new(maskFieldTest))
|
||
}
|
||
|
||
// testUser 是一个可以自定义角色的 req.User 测试替身;contracts-v2/base 的 user 类型是私有的,
|
||
// 没有导出"构造一个自定义角色用户"的方式,这里按同样的语义(含超级管理员豁免)自己实现一个。
|
||
type testUser struct {
|
||
roles []string
|
||
}
|
||
|
||
func (u testUser) ID() int64 { return 1 }
|
||
func (u testUser) Uuid() string { return "test-user-uuid" }
|
||
func (u testUser) Username() string { return "test" }
|
||
func (u testUser) Nickname() string { return "测试用户" }
|
||
func (u testUser) GetUserInfo() map[string]any { return nil }
|
||
func (u testUser) Roles() []string { return u.roles }
|
||
func (u testUser) IsAnonymous() bool { return false }
|
||
func (u testUser) Runtime() req.UserRuntime { return req.NewUserRuntime() }
|
||
func (u testUser) HasUserRoles(roles ...string) bool {
|
||
return lo.Contains(u.roles, "ffffffff-ffff-ffff-ffff-ffffffffffff") || len(lo.Intersect(u.roles, roles)) > 0
|
||
}
|
||
|
||
// newApp 构造一个绑定了 sqlmock 的容器,同时注册 req.GetResource——用同一个容器构造出来的资源,
|
||
// 按 code/table 都能查到(MaskField 解析带表名的别名要用它)。register 用来把资源加进查找表,
|
||
// 调用方在 resx.New(...) 之后手动调一次。
|
||
func (t *maskFieldTest) newApp() (do.Injector, sqlmock.Sqlmock, func(req.Resource)) {
|
||
mockDB, mock, err := sqlmock.New()
|
||
t.Require().NoError(err)
|
||
|
||
app := do.New()
|
||
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
|
||
return engine.Mock(map[string]engine.MockDBConfig{
|
||
"default": {Driver: "mysql", Mock: mockDB},
|
||
}), nil
|
||
})
|
||
|
||
registry := map[string]req.Resource{}
|
||
do.Provide(app, func(i do.Injector) (req.GetResource, error) {
|
||
return func(code string) (req.Resource, bool) {
|
||
r, ok := registry[code]
|
||
return r, ok
|
||
}, nil
|
||
})
|
||
|
||
register := func(res req.Resource) {
|
||
registry[res.GetCode()] = res
|
||
registry[res.GetTable()] = res
|
||
}
|
||
|
||
return app, mock, register
|
||
}
|
||
|
||
// newResource 构造一个绑定了 sqlmock 的 User 资源,字段列表自定义
|
||
func (t *maskFieldTest) newResource(fields ...req.ResField) (req.Resource, sqlmock.Sqlmock) {
|
||
app, mock, register := t.newApp()
|
||
|
||
res := resx.New(app, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(fields...),
|
||
)
|
||
register(res)
|
||
|
||
return res, mock
|
||
}
|
||
|
||
var (
|
||
nameField = resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString))
|
||
salaryField = resx.NewResField("salary", "User", resx.FieldWithName("薪资"), resx.FieldWithRoles("hr"), resx.FieldWithDataType(req.ResInteger))
|
||
deptBudgetFld = resx.NewResField("budget", "Department", resx.FieldWithName("预算"), resx.FieldWithRoles("finance"), resx.FieldWithDataType(req.ResInteger))
|
||
categorySecretFld = resx.NewResField("secret", "Category", resx.FieldWithName("秘密"), resx.FieldWithRoles("admin"), resx.FieldWithDataType(req.ResInteger))
|
||
companyBudgetFld = resx.NewResField("budget", "Company", resx.FieldWithName("预算"), resx.FieldWithRoles("finance"), resx.FieldWithDataType(req.ResInteger))
|
||
managerSalaryFld = resx.NewResField("salary", "Manager", resx.FieldWithName("薪资"), resx.FieldWithRoles("hr"), resx.FieldWithDataType(req.ResInteger))
|
||
)
|
||
|
||
// newJoinedApp 构造两个互相独立的资源(User -> users、Department -> departments),都注册进同一个
|
||
// 容器的 req.GetResource,方便下面几个 JOIN 相关用例复用
|
||
func (t *maskFieldTest) newJoinedApp() (user, dept req.Resource) {
|
||
app, _, register := t.newApp()
|
||
|
||
dept = resx.New(app, "Department", "departments",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(deptBudgetFld),
|
||
)
|
||
register(dept)
|
||
|
||
user = resx.New(app, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(nameField),
|
||
)
|
||
register(user)
|
||
|
||
return user, dept
|
||
}
|
||
|
||
// newChainedJoinApp 构造管理结构里的链式关联(User -> Department -> Company):Company 不是直接
|
||
// 挂在 User 下面,是通过 Department 关联的第二个 JOIN 子句——用来验证 resolveJoinTable 遍历
|
||
// clauses.Joins() 时不是只认第一个 JOIN,后面链上的也能正确解析出别名对应的真实表。
|
||
func (t *maskFieldTest) newChainedJoinApp() (user, dept, company req.Resource) {
|
||
app, _, register := t.newApp()
|
||
|
||
company = resx.New(app, "Company", "companies",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(companyBudgetFld),
|
||
)
|
||
register(company)
|
||
|
||
dept = resx.New(app, "Department", "departments",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(deptBudgetFld),
|
||
)
|
||
register(dept)
|
||
|
||
user = resx.New(app, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(nameField),
|
||
)
|
||
register(user)
|
||
|
||
return user, dept, company
|
||
}
|
||
|
||
// newStarJoinApp 构造管理结构里的并列关联(User -> Department、User -> Manager):Department 和
|
||
// Manager 都直接挂在 User 下面,是两条独立的 JOIN 子句,不是链式关联——用来验证同一个查询里,
|
||
// 两个不同 JOIN 别名各自独立解析,互不干扰。
|
||
func (t *maskFieldTest) newStarJoinApp() (user, dept, manager req.Resource) {
|
||
app, _, register := t.newApp()
|
||
|
||
dept = resx.New(app, "Department", "departments",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(deptBudgetFld),
|
||
)
|
||
register(dept)
|
||
|
||
manager = resx.New(app, "Manager", "managers",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(managerSalaryFld),
|
||
)
|
||
register(manager)
|
||
|
||
user = resx.New(app, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(nameField),
|
||
)
|
||
register(user)
|
||
|
||
return user, dept, manager
|
||
}
|
||
|
||
// -------- SELECT * 默认展开 --------
|
||
|
||
// TestDefaultSelect_MasksRestrictedField 没有权限时,SELECT * 展开后受限字段被替换成哨兵值
|
||
func (t *maskFieldTest) TestDefaultSelect_MasksRestrictedField() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`name`")
|
||
t.Contains(sql, "-999999999 AS `salary`", "受限字段应该被替换成内置的数字哨兵值,而不是裸列")
|
||
}
|
||
|
||
// TestDefaultSelect_RoleMatchShowsRealColumn 用户角色命中 Roles 时,字段按真实列查询,不脱敏
|
||
func (t *maskFieldTest) TestDefaultSelect_RoleMatchShowsRealColumn() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}).Select(db.Star()).Executor().ToSQL()
|
||
|
||
// 没有字段需要脱敏,直接维持原来的 SELECT *,不会被改写成展开的列表
|
||
t.Contains(sql, "SELECT *")
|
||
t.NotContains(sql, "-999999999")
|
||
}
|
||
|
||
// TestDefaultSelect_NoRestrictedFieldsLeavesWildcard 没有任何字段配置 Roles 时,SELECT * 不展开、不改写
|
||
func (t *maskFieldTest) TestDefaultSelect_NoRestrictedFieldsLeavesWildcard() {
|
||
res, _ := t.newResource(nameField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL()
|
||
|
||
t.Contains(sql, "SELECT *")
|
||
}
|
||
|
||
// -------- 显式列:只处理 resx.MaskField 标记过的列 --------
|
||
|
||
// TestExplicitColumn_MaskFieldMarkedColumnGetsMasked 标记过的列,没权限时会被替换
|
||
func (t *maskFieldTest) TestExplicitColumn_MaskFieldMarkedColumnGetsMasked() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(db.C("name"), resx.MaskField("salary").As("salary")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`name`")
|
||
t.Contains(sql, "-999999999 AS `salary`")
|
||
}
|
||
|
||
// TestExplicitColumn_UnmarkedColumnNeverMasked 没有用 MaskField 标记的列,即使字段本身受限也
|
||
// 原样保留——标记是显式的,dataProcessor 不会替调用方去猜哪一列对应哪个字段
|
||
func (t *maskFieldTest) TestExplicitColumn_UnmarkedColumnNeverMasked() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(db.C("salary")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`salary`")
|
||
t.NotContains(sql, "-999999999")
|
||
}
|
||
|
||
// TestQualifiedWildcard_OwnAliasExpandsAndMasks db.T(alias).All()(限定到某个别名的全部列)如果
|
||
// 限定的别名就是当前资源自己(GetDBTable 默认用资源 code 当别名),效果等价于裸的 db.Star(),
|
||
// 同样会展开成显式字段列表、按权限脱敏——不是只有 db.Star() 才会触发展开逻辑
|
||
func (t *maskFieldTest) TestQualifiedWildcard_OwnAliasExpandsAndMasks() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(db.T("User").All()).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`name`")
|
||
t.Contains(sql, "-999999999 AS `salary`")
|
||
}
|
||
|
||
// TestQualifiedWildcard_OtherAliasNeverMasked db.T(alias).All() 如果限定的是别的别名(比如 JOIN
|
||
// 进来的另一个资源),不会被当成"选当前资源自己的全部列",不会展开、不会脱敏——这属于"JOIN 进来
|
||
// 的字段够不到"的已知限制,不是这里要解决的问题
|
||
func (t *maskFieldTest) TestQualifiedWildcard_OtherAliasNeverMasked() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(db.T("Department").All()).
|
||
Executor().ToSQL()
|
||
|
||
t.NotContains(sql, "-999999999")
|
||
}
|
||
|
||
// TestExplicitColumn_CustomAliasPreserved MaskField(...).As(alias) 替换后仍然用调用方指定的别名,
|
||
// 不是恒用字段码
|
||
func (t *maskFieldTest) TestExplicitColumn_CustomAliasPreserved() {
|
||
res, _ := t.newResource(salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(resx.MaskField("salary").As("employee_salary")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "AS `employee_salary`")
|
||
t.NotContains(sql, "AS `salary`")
|
||
}
|
||
|
||
// TestExplicitColumn_JoinedResourceFieldMasked MaskField("Department.budget") 这种带表名的形式,
|
||
// 在 JOIN 查询里能正确解析成 Department 资源的 budget 字段——别名 "Department" 换成真实表名
|
||
// "departments",再用 req.GetResource 按 table 查到资源,不是靠"别名就是资源 code"硬编码猜的。
|
||
// JOIN 用的是 res.GetTableExpr().As(alias)(跟 orm-v2 实际 JOIN 构造方式一致),命中的是
|
||
// aliasedRealTable 里"字面量包了一层 identifier"那条分支。
|
||
func (t *maskFieldTest) TestExplicitColumn_JoinedResourceFieldMasked() {
|
||
user, dept := t.newJoinedApp()
|
||
|
||
sql, _, _ := user.GetDBTable(testUser{}).
|
||
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`name`")
|
||
t.Contains(sql, "-999999999 AS `budget`")
|
||
}
|
||
|
||
// TestExplicitColumn_JoinedResourceFieldRoleMatchNotMasked 同样是 JOIN 进来的字段,用户角色命中
|
||
// Department.budget 的 Roles 时不脱敏,按真实列查询——JOIN 场景下权限判断跟当前资源自己的字段
|
||
// 走的是同一套 hasFieldAccess,不因为是"别的资源的字段"就被跳过或者反而更严格
|
||
func (t *maskFieldTest) TestExplicitColumn_JoinedResourceFieldRoleMatchNotMasked() {
|
||
user, dept := t.newJoinedApp()
|
||
|
||
sql, _, _ := user.GetDBTable(testUser{roles: []string{"finance"}}).
|
||
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`budget`")
|
||
t.NotContains(sql, "-999999999")
|
||
}
|
||
|
||
// TestExplicitColumn_DirectIdentifierAliasJoinResolves JOIN 用 db.T(table).As(alias) 直接在
|
||
// identifier 上起别名(不经过 GetTableExpr 那层字面量包装)时,一样能正确解析——命中的是
|
||
// aliasedRealTable 里"Aliased() 直接就是 identifier"那条分支,跟上面字面量包装的分支不是同一条
|
||
// 代码路径,两条都要覆盖到
|
||
func (t *maskFieldTest) TestExplicitColumn_DirectIdentifierAliasJoinResolves() {
|
||
user, _ := t.newJoinedApp()
|
||
|
||
sql, _, _ := user.GetDBTable(testUser{}).
|
||
LeftJoin(db.T("departments").As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "-999999999 AS `budget`")
|
||
}
|
||
|
||
// TestExplicitColumn_SelfJoinDifferentAliasesResolveIndependently 同一个资源自连接时,两个别名
|
||
// (Child 是当前资源自己默认别名的覆盖,Parent 是 JOIN 进来的第二份自己)都不等于资源自己的
|
||
// code("Category")——这正是当初不能直接"别名当 code 用"、必须找真实表名再查的原因,这里验证两个
|
||
// 别名各自独立解析都是对的,互不影响
|
||
func (t *maskFieldTest) TestExplicitColumn_SelfJoinDifferentAliasesResolveIndependently() {
|
||
app, _, register := t.newApp()
|
||
|
||
category := resx.New(app, "Category", "categories",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(categorySecretFld),
|
||
)
|
||
register(category)
|
||
|
||
sql, _, _ := category.GetDBTable(testUser{}, req.WithAlias("Child")).
|
||
LeftJoin(category.GetTableExpr().As("Parent"), db.On(db.I("Child.pid").Eq(db.I("Parent.id")))).
|
||
Select(
|
||
resx.MaskField("Child.secret").As("child_secret"),
|
||
resx.MaskField("Parent.secret").As("parent_secret"),
|
||
).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "-999999999 AS `child_secret`")
|
||
t.Contains(sql, "-999999999 AS `parent_secret`")
|
||
}
|
||
|
||
// TestExplicitColumn_ChainedJoinResolvesThirdTable User -> Department -> Company 链式 JOIN,
|
||
// Company 挂在第二个 JOIN 子句上(不是第一个):验证 resolveJoinTable 能找到它,进而查到 Company
|
||
// 资源上配置了 Roles 的字段并正确脱敏
|
||
func (t *maskFieldTest) TestExplicitColumn_ChainedJoinResolvesThirdTable() {
|
||
user, dept, company := t.newChainedJoinApp()
|
||
|
||
sql, _, _ := user.GetDBTable(testUser{}).
|
||
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
LeftJoin(company.GetTableExpr().As("Company"), db.On(db.I("Department.company_id").Eq(db.I("Company.id")))).
|
||
Select(db.C("name"), resx.MaskField("Company.budget").As("budget")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`name`")
|
||
t.Contains(sql, "-999999999 AS `budget`")
|
||
}
|
||
|
||
// TestExplicitColumn_StarJoinResolvesEachBranchIndependently User -> Department、User -> Manager
|
||
// 两条并列的 JOIN(都直接挂在 User 下面,不是链式关联):命中 Department.budget 的 finance 角色
|
||
// 和没命中 Manager.salary 的 hr 角色分开判断,验证同一次查询里两个 JOIN 分支各自独立解析、互不
|
||
// 串扰
|
||
func (t *maskFieldTest) TestExplicitColumn_StarJoinResolvesEachBranchIndependently() {
|
||
user, dept, manager := t.newStarJoinApp()
|
||
|
||
sql, _, _ := user.GetDBTable(testUser{roles: []string{"finance"}}).
|
||
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
LeftJoin(manager.GetTableExpr().As("Manager"), db.On(db.I("User.manager_id").Eq(db.I("Manager.id")))).
|
||
Select(
|
||
db.C("name"),
|
||
resx.MaskField("Department.budget").As("dept_budget"),
|
||
resx.MaskField("Manager.salary").As("mgr_salary"),
|
||
).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "`dept_budget`")
|
||
t.NotContains(sql, "-999999999 AS `dept_budget`")
|
||
t.Contains(sql, "-999999999 AS `mgr_salary`")
|
||
}
|
||
|
||
// TestExplicitColumn_FieldNotFoundOnResourceErrors 别名能解析到资源,但资源上没有这个字段
|
||
// (字段名写错了)——报错而不是静默不脱敏
|
||
func (t *maskFieldTest) TestExplicitColumn_FieldNotFoundOnResourceErrors() {
|
||
user, dept := t.newJoinedApp()
|
||
|
||
_, _, err := user.GetDBTable(testUser{}).
|
||
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
Select(db.C("name"), resx.MaskField("Department.no_such_field").As("x")).
|
||
Executor().ToSQL()
|
||
|
||
t.Error(err)
|
||
}
|
||
|
||
// TestExplicitColumn_UnresolvableAliasErrors 标记的表名在查询里根本不存在(也不是任何资源的
|
||
// code/table/uuid),说明调用方写错了标记——报错而不是静默不脱敏
|
||
func (t *maskFieldTest) TestExplicitColumn_UnresolvableAliasErrors() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
_, _, err := res.GetDBTable(testUser{}).
|
||
Select(db.C("name"), resx.MaskField("NoSuchResource.budget").As("budget")).
|
||
Executor().ToSQL()
|
||
|
||
t.Error(err)
|
||
}
|
||
|
||
// TestExplicitColumn_GetResourceUnavailableErrors 容器里根本没注册 req.GetResource(不是"查不到
|
||
// 这个资源",是查找能力本身都拿不到)时,跟其它解析失败的场景(TestExplicitColumn_
|
||
// UnresolvableAliasErrors 等)一样报错,而不是静默脱敏或放行——查不到就是查不到,不能因为查不到
|
||
// 的原因是 DI 而不是资源本身,就换一套更宽松的处理方式
|
||
func (t *maskFieldTest) TestExplicitColumn_GetResourceUnavailableErrors() {
|
||
mockDB, _, err := sqlmock.New()
|
||
t.Require().NoError(err)
|
||
|
||
container := do.New()
|
||
do.Provide(container, func(i do.Injector) (engine.Engine, error) {
|
||
return engine.Mock(map[string]engine.MockDBConfig{
|
||
"default": {Driver: "mysql", Mock: mockDB},
|
||
}), nil
|
||
})
|
||
// 故意不注册 req.GetResource
|
||
|
||
res := resx.New(container, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(nameField),
|
||
)
|
||
|
||
_, _, sqlErr := res.GetDBTable(testUser{}).
|
||
LeftJoin(db.T("departments").As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
|
||
Executor().ToSQL()
|
||
|
||
t.ErrorContains(sqlErr, "req.GetResource")
|
||
}
|
||
|
||
// TestExplicitColumn_OwnAliasSkipsGetResourceEntirely MaskField("User.salary")——别名不是空的,
|
||
// 而是显式写了当前资源自己的 code(DataListField 这类配置记录里,master/自身资源的字段经常这么
|
||
// 存,不是留空)——这种情况必须直接走 dp.res,完全不碰 req.GetResource,哪怕容器里根本没注册
|
||
// req.GetResource 也要能正确解析:没配置 Roles 的字段原样通过,配置了的字段才脱敏,不能因为
|
||
// "别名非空"就误当成 JOIN 字段一律走 DI、进而在 req.GetResource 不可用时把没配置权限的字段也
|
||
// 一起误脱敏
|
||
func (t *maskFieldTest) TestExplicitColumn_OwnAliasSkipsGetResourceEntirely() {
|
||
mockDB, _, err := sqlmock.New()
|
||
t.Require().NoError(err)
|
||
|
||
container := do.New()
|
||
do.Provide(container, func(i do.Injector) (engine.Engine, error) {
|
||
return engine.Mock(map[string]engine.MockDBConfig{
|
||
"default": {Driver: "mysql", Mock: mockDB},
|
||
}), nil
|
||
})
|
||
// 故意不注册 req.GetResource——如果 resolveLazyField 把 "User" 这个别名误当成需要走 DI 的
|
||
// JOIN 别名,这个测试就会失败(要么报错,要么两个字段全被误脱敏成 NULL)
|
||
|
||
res := resx.New(container, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(nameField, salaryField),
|
||
)
|
||
|
||
sql, _, sqlErr := res.GetDBTable(testUser{}).
|
||
Select(resx.MaskField("User.name").As("name"), resx.MaskField("User.salary").As("salary")).
|
||
Executor().ToSQL()
|
||
|
||
t.NoError(sqlErr)
|
||
t.Contains(sql, "`name`")
|
||
t.NotContains(sql, "NULL AS `name`")
|
||
t.Contains(sql, "-999999999 AS `salary`")
|
||
}
|
||
|
||
// -------- MaskField 嵌在 db.Func(...) 参数里 --------
|
||
|
||
// TestExplicitColumn_MaskFieldInsideFuncArgGetsMasked 顶层列是 db.Func(...),MaskField 标记的是
|
||
// 它其中一个参数(不是顶层列本身):resolveMaskedExpr 得递归进 Args() 才能找到并替换,替换后的
|
||
// mask 值不带 AS(函数参数不能单独起别名),外层函数调用整体保留原来的别名
|
||
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideFuncArgGetsMasked() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'")).As("computed")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "CONCAT(-999999999, ' extra') AS `computed`")
|
||
}
|
||
|
||
// TestExplicitColumn_MaskFieldInsideFuncArgRoleMatchNotMasked 函数参数里的 MaskField 标记,用户
|
||
// 角色命中时跟顶层列一样不脱敏,函数调用整体原样保留
|
||
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideFuncArgRoleMatchNotMasked() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}).
|
||
Select(db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'")).As("computed")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "CONCAT(`salary`, ' extra') AS `computed`")
|
||
t.NotContains(sql, "-999999999")
|
||
}
|
||
|
||
// TestExplicitColumn_MaskFieldInsideNestedFuncArgGetsMasked MaskField 嵌套两层函数调用
|
||
// (db.Func("UPPER", db.Func("CONCAT", ...)))也能递归找到并替换——resolveMaskedExpr 对
|
||
// SQLFunctionExpression 是递归调用自己,不是只展开一层
|
||
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideNestedFuncArgGetsMasked() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).
|
||
Select(db.Func("UPPER", db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'"))).As("computed")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "UPPER(CONCAT(-999999999, ' extra')) AS `computed`")
|
||
}
|
||
|
||
// TestExplicitColumn_MaskFieldInsideJoinedFuncArgGetsMasked 函数参数里的 MaskField 也支持带别名
|
||
// 的 JOIN 字段,跟顶层列的解析逻辑(resolveJoinTable 换真实表名)共用同一套
|
||
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideJoinedFuncArgGetsMasked() {
|
||
user, dept := t.newJoinedApp()
|
||
|
||
sql, _, _ := user.GetDBTable(testUser{}).
|
||
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
|
||
Select(db.Func("CONCAT", resx.MaskField("Department.budget"), db.L("' extra'")).As("computed")).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "CONCAT(-999999999, ' extra') AS `computed`")
|
||
}
|
||
|
||
// TestCustomMaskFunc_Override 容器里注册了 MaskFunc 时,脱敏值用注册的而不是内置默认值
|
||
func (t *maskFieldTest) TestCustomMaskFunc_Override() {
|
||
mockDB, _, err := sqlmock.New()
|
||
t.Require().NoError(err)
|
||
|
||
container := do.New()
|
||
do.Provide(container, func(i do.Injector) (engine.Engine, error) {
|
||
return engine.Mock(map[string]engine.MockDBConfig{
|
||
"default": {Driver: "mysql", Mock: mockDB},
|
||
}), nil
|
||
})
|
||
do.Provide(container, func(i do.Injector) (resx.MaskFunc, error) {
|
||
return func(field req.ResField) exp.LiteralExpression {
|
||
return db.V("REDACTED")
|
||
}, nil
|
||
})
|
||
|
||
customRes := resx.New(container, "User", "users",
|
||
resx.WithConn("default"),
|
||
resx.WithFields(salaryField),
|
||
)
|
||
|
||
sql, _, _ := customRes.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL()
|
||
|
||
t.Contains(sql, "REDACTED")
|
||
t.NotContains(sql, "-999999999")
|
||
}
|
||
|
||
// -------- 写权限:normalizeSaveValue 静默丢弃没有权限的字段 --------
|
||
|
||
// TestInsert_DropsFieldWithoutWriteAccess 没有权限时,INSERT 里这个字段被静默丢弃
|
||
func (t *maskFieldTest) TestInsert_DropsFieldWithoutWriteAccess() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).Insert().
|
||
Rows(db.Record{"name": "张三", "salary": 999999}).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "张三")
|
||
t.NotContains(sql, "999999", "没有写权限的字段应该被静默丢弃,不应该出现在 INSERT 里")
|
||
}
|
||
|
||
// TestInsert_KeepsFieldWithWriteAccess 有权限时,INSERT 正常写入真实值
|
||
func (t *maskFieldTest) TestInsert_KeepsFieldWithWriteAccess() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}).Insert().
|
||
Rows(db.Record{"name": "张三", "salary": 999999}).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "999999")
|
||
}
|
||
|
||
// TestUpdate_DropsFieldWithoutWriteAccess UPDATE 场景下同样会被静默丢弃
|
||
func (t *maskFieldTest) TestUpdate_DropsFieldWithoutWriteAccess() {
|
||
res, _ := t.newResource(nameField, salaryField)
|
||
|
||
sql, _, _ := res.GetDBTable(testUser{}).Update().
|
||
Set(db.Record{"name": "李四", "salary": 888888}).
|
||
Where(db.C("id").Eq(1)).
|
||
Executor().ToSQL()
|
||
|
||
t.Contains(sql, "李四")
|
||
t.NotContains(sql, "888888")
|
||
}
|