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") }