Files
req/resx/res_mask_field_test.go
what f39adfa46c 测试: GetDBTable 显式传 WithPermission,补充权限组合覆盖
Permission 不传时按零值处理,行级过滤和字段脱敏都会跳过(见 options.go 里
Permission 字段的注释),这是既有设计,不是这次改的。但 resx 包自己的测试
之前几乎都没传 WithPermission,实际上一直跑在"权限检查关闭"的状态下——
字段脱敏、写权限过滤、行级过滤相关的断言全部失效但没有暴露出来。

给 res_mask_field_test.go/res_interceptor_test.go 里所有 GetDBTable 调用
显式加上 WithPermission(ResAll),让这些测试真正跑在权限检查打开的状态下。
另外新增 res_permission_test.go,单独验证 WithPermission 各个取值(不传/
显式传 0/单独 ResRow/单独 ResMask/ResAll/按位组合)分别独立生效,作为这个
默认行为的回归防护。
2026-08-20 15:59:24 +08:00

591 lines
25 KiB
Go
Raw Permalink 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 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{}, req.WithPermission(req.ResAll)).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"}}, req.WithPermission(req.ResAll)).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{}, req.WithPermission(req.ResAll)).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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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"}}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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"), req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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"}}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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"}}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).
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{}, req.WithPermission(req.ResAll)).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{}, req.WithPermission(req.ResAll)).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"}}, req.WithPermission(req.ResAll)).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{}, req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "李四", "salary": 888888}).
Where(db.C("id").Eq(1)).
Executor().ToSQL()
t.Contains(sql, "李四")
t.NotContains(sql, "888888")
}