userDetailLogic_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. package user
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "perms-system-server/internal/consts"
  7. "perms-system-server/internal/loaders"
  8. "perms-system-server/internal/middleware"
  9. memberModel "perms-system-server/internal/model/productmember"
  10. userModel "perms-system-server/internal/model/user"
  11. "perms-system-server/internal/model/userrole"
  12. "perms-system-server/internal/response"
  13. "perms-system-server/internal/svc"
  14. "perms-system-server/internal/testutil"
  15. "perms-system-server/internal/testutil/ctxhelper"
  16. "perms-system-server/internal/types"
  17. "testing"
  18. "time"
  19. "github.com/stretchr/testify/assert"
  20. "github.com/stretchr/testify/require"
  21. )
  22. // TC-1267: 超管不传 productCode → roleIds 含目标用户所有产品角色(全量)
  23. func TestUserDetail_SuperAdmin_NoProductCode_ReturnsAllRoles(t *testing.T) {
  24. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  25. conn := testutil.GetTestSqlConn()
  26. // 超管 ctx 中 ProductCode 为空,模拟真实超管登录态
  27. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  28. UserId: 1,
  29. Username: "superadmin",
  30. IsSuperAdmin: true,
  31. MemberType: consts.MemberTypeSuperAdmin,
  32. Status: consts.StatusEnabled,
  33. ProductCode: "",
  34. })
  35. username := testutil.UniqueId()
  36. userId := insertTestUser(t, ctxhelper.SuperAdminCtx(), username, testutil.HashPassword("pass"))
  37. roleInP1 := insertTestRole(t, svcCtx, "test_product", 1)
  38. roleInP2 := insertTestRole(t, svcCtx, "other_product", 1)
  39. now := time.Now().Unix()
  40. var roleRecordIds []int64
  41. for _, roleId := range []int64{roleInP1, roleInP2} {
  42. res, err := svcCtx.SysUserRoleModel.Insert(ctxhelper.SuperAdminCtx(), &userrole.SysUserRole{
  43. UserId: userId, RoleId: roleId, CreateTime: now, UpdateTime: now,
  44. })
  45. require.NoError(t, err)
  46. id, _ := res.LastInsertId()
  47. roleRecordIds = append(roleRecordIds, id)
  48. }
  49. t.Cleanup(func() {
  50. testutil.CleanTable(ctx, conn, "`sys_user_role`", roleRecordIds...)
  51. testutil.CleanTable(ctx, conn, "`sys_role`", roleInP1, roleInP2)
  52. testutil.CleanTable(ctx, conn, "`sys_user`", userId)
  53. })
  54. resp, err := NewUserDetailLogic(ctx, svcCtx).UserDetail(&types.UserDetailReq{Id: userId})
  55. require.NoError(t, err)
  56. assert.ElementsMatch(t, []int64{roleInP1, roleInP2}, resp.RoleIds,
  57. "超管不传 productCode 时应返回全量角色(跨产品)")
  58. }
  59. // TC-1268: 超管传 productCode → roleIds 只含该产品角色
  60. func TestUserDetail_SuperAdmin_WithProductCode_FiltersRoles(t *testing.T) {
  61. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  62. conn := testutil.GetTestSqlConn()
  63. superCtx := ctxhelper.SuperAdminCtx()
  64. username := testutil.UniqueId()
  65. userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass"))
  66. roleInCurrent1 := insertTestRole(t, svcCtx, "test_product", 1)
  67. roleInCurrent2 := insertTestRole(t, svcCtx, "test_product", 1)
  68. roleInOther := insertTestRole(t, svcCtx, "other_product", 1)
  69. now := time.Now().Unix()
  70. var roleRecordIds []int64
  71. for _, roleId := range []int64{roleInCurrent1, roleInCurrent2, roleInOther} {
  72. res, err := svcCtx.SysUserRoleModel.Insert(superCtx, &userrole.SysUserRole{
  73. UserId: userId, RoleId: roleId, CreateTime: now, UpdateTime: now,
  74. })
  75. require.NoError(t, err)
  76. id, _ := res.LastInsertId()
  77. roleRecordIds = append(roleRecordIds, id)
  78. }
  79. t.Cleanup(func() {
  80. testutil.CleanTable(superCtx, conn, "`sys_user_role`", roleRecordIds...)
  81. testutil.CleanTable(superCtx, conn, "`sys_role`", roleInCurrent1, roleInCurrent2, roleInOther)
  82. testutil.CleanTable(superCtx, conn, "`sys_user`", userId)
  83. })
  84. resp, err := NewUserDetailLogic(superCtx, svcCtx).UserDetail(&types.UserDetailReq{
  85. Id: userId,
  86. ProductCode: "test_product",
  87. })
  88. require.NoError(t, err)
  89. assert.ElementsMatch(t, []int64{roleInCurrent1, roleInCurrent2}, resp.RoleIds,
  90. "超管传 productCode 时只返回该产品下的角色")
  91. assert.NotContains(t, resp.RoleIds, roleInOther,
  92. "其他产品的角色不应出现在结果中")
  93. }
  94. // TC-1269: 非超管不传 productCode → roleIds 只含 JWT context 产品角色;req.productCode 被忽略
  95. func TestUserDetail_NonSuperAdmin_UsesCtxProductCode(t *testing.T) {
  96. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  97. conn := testutil.GetTestSqlConn()
  98. superCtx := ctxhelper.SuperAdminCtx()
  99. productCode := "test_product"
  100. username := testutil.UniqueId()
  101. userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass"))
  102. mId := insertTestMember(t, svcCtx, productCode, userId)
  103. roleInCtx := insertTestRole(t, svcCtx, productCode, 1)
  104. roleInOther := insertTestRole(t, svcCtx, "other_product", 1)
  105. now := time.Now().Unix()
  106. var roleRecordIds []int64
  107. for _, roleId := range []int64{roleInCtx, roleInOther} {
  108. res, err := svcCtx.SysUserRoleModel.Insert(superCtx, &userrole.SysUserRole{
  109. UserId: userId, RoleId: roleId, CreateTime: now, UpdateTime: now,
  110. })
  111. require.NoError(t, err)
  112. id, _ := res.LastInsertId()
  113. roleRecordIds = append(roleRecordIds, id)
  114. }
  115. t.Cleanup(func() {
  116. testutil.CleanTable(superCtx, conn, "`sys_user_role`", roleRecordIds...)
  117. testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
  118. testutil.CleanTable(superCtx, conn, "`sys_role`", roleInCtx, roleInOther)
  119. testutil.CleanTable(superCtx, conn, "`sys_user`", userId)
  120. })
  121. // ADMIN ctx productCode="test_product",req 里传 "other_product" 应被忽略
  122. ctx := ctxhelper.AdminCtx(productCode)
  123. resp, err := NewUserDetailLogic(ctx, svcCtx).UserDetail(&types.UserDetailReq{
  124. Id: userId,
  125. ProductCode: "other_product", // 非超管时此字段应被忽略
  126. })
  127. require.NoError(t, err)
  128. assert.ElementsMatch(t, []int64{roleInCtx}, resp.RoleIds,
  129. "非超管始终用 JWT context productCode,req.productCode 应被忽略")
  130. assert.NotContains(t, resp.RoleIds, roleInOther)
  131. }
  132. // TC-0182: 正常查询-含Avatar
  133. func TestUserDetail_WithAvatar(t *testing.T) {
  134. ctx := ctxhelper.SuperAdminCtx()
  135. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  136. conn := testutil.GetTestSqlConn()
  137. userId := insertTestUserFull(t, ctx, &userModel.SysUser{
  138. Username: testutil.UniqueId(),
  139. Password: testutil.HashPassword("pass"),
  140. Nickname: "avatar_user",
  141. Avatar: sql.NullString{String: "https://example.com/avatar.png", Valid: true},
  142. IsSuperAdmin: 2,
  143. MustChangePassword: 2,
  144. Status: 1,
  145. })
  146. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  147. logic := NewUserDetailLogic(ctx, svcCtx)
  148. resp, err := logic.UserDetail(&types.UserDetailReq{Id: userId})
  149. require.NoError(t, err)
  150. require.NotNil(t, resp)
  151. assert.Equal(t, "https://example.com/avatar.png", resp.Avatar)
  152. }
  153. // TC-0183: 不存在
  154. func TestUserDetail_NotFound(t *testing.T) {
  155. ctx := ctxhelper.SuperAdminCtx()
  156. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  157. logic := NewUserDetailLogic(ctx, svcCtx)
  158. _, err := logic.UserDetail(&types.UserDetailReq{Id: 999999999})
  159. require.Error(t, err)
  160. var codeErr *response.CodeError
  161. require.True(t, errors.As(err, &codeErr))
  162. assert.Equal(t, 404, codeErr.Code())
  163. assert.Equal(t, "用户不存在", codeErr.Error())
  164. }
  165. func insertH1Member(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, productCode string, u *userModel.SysUser) (int64, int64) {
  166. t.Helper()
  167. id := insertTestUserFull(t, ctx, u)
  168. now := time.Now().Unix()
  169. res, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  170. ProductCode: productCode, UserId: id, MemberType: consts.MemberTypeMember,
  171. Status: 1, CreateTime: now, UpdateTime: now,
  172. })
  173. require.NoError(t, err)
  174. mId, _ := res.LastInsertId()
  175. return id, mId
  176. }
  177. // TC-0991: 业务契约——看自己时 Email/Phone/Remark 原样返回。
  178. // 背景:PII 契约已由业务侧固定为"所有调用者(含同产品 MEMBER)原样返回联系信息",
  179. // 故不存在脱敏短路;本用例作为回归守卫,防止未来有人误加"同级脱敏"把 self-view 一起打了。
  180. func TestUserDetail_H1_ViewSelf_KeepsPII(t *testing.T) {
  181. ctx := context.Background()
  182. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  183. conn := testutil.GetTestSqlConn()
  184. productCode := "h1_self_" + testutil.UniqueId()
  185. selfId, mSelf := insertH1Member(t, ctx, svcCtx, productCode, &userModel.SysUser{
  186. Username: "self_" + testutil.UniqueId(),
  187. Password: testutil.HashPassword("pw"),
  188. Nickname: "self",
  189. Email: "[email protected]",
  190. Phone: "13900002222",
  191. Remark: "self-only note",
  192. IsSuperAdmin: 2,
  193. MustChangePassword: 2,
  194. Status: 1,
  195. DeptId: 1,
  196. })
  197. t.Cleanup(func() {
  198. testutil.CleanTable(ctx, conn, "`sys_product_member`", mSelf)
  199. testutil.CleanTable(ctx, conn, "`sys_user`", selfId)
  200. })
  201. selfCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  202. UserId: selfId, Username: "self", MemberType: consts.MemberTypeMember,
  203. Status: 1, ProductCode: productCode, DeptId: 1, DeptPath: "/1/", MinPermsLevel: 100,
  204. })
  205. resp, err := NewUserDetailLogic(selfCtx, svcCtx).UserDetail(&types.UserDetailReq{Id: selfId})
  206. require.NoError(t, err)
  207. assert.Equal(t, "[email protected]", resp.Email, "看自己必须返回 Email 原值")
  208. assert.Equal(t, "13900002222", resp.Phone, "看自己必须返回 Phone 原值")
  209. assert.Equal(t, "self-only note", resp.Remark, "看自己必须返回 Remark 原值")
  210. }
  211. // TC-0992: 超管分支 —— SuperAdmin 看任何用户必须拿到 Email/Phone/Remark 原值。
  212. // 若未来有人加脱敏逻辑却漏写 IsSuperAdmin 豁免,本用例立刻炸。
  213. func TestUserDetail_H1_SuperAdmin_KeepsPII(t *testing.T) {
  214. ctx := ctxhelper.SuperAdminCtx()
  215. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  216. conn := testutil.GetTestSqlConn()
  217. userId := insertTestUserFull(t, ctx, &userModel.SysUser{
  218. Username: "sa_view_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  219. Nickname: "n", Email: "[email protected]", Phone: "13700000000", Remark: "nb",
  220. IsSuperAdmin: 2, MustChangePassword: 2, Status: 1,
  221. })
  222. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  223. resp, err := NewUserDetailLogic(ctx, svcCtx).UserDetail(&types.UserDetailReq{Id: userId})
  224. require.NoError(t, err)
  225. assert.Equal(t, "[email protected]", resp.Email)
  226. assert.Equal(t, "13700000000", resp.Phone)
  227. assert.Equal(t, "nb", resp.Remark)
  228. }
  229. // seedPIITarget 插入一个"有完整 PII 的 target 用户"并挂到指定产品下,返回 userId / mId 供清理。
  230. // 统一 helper 以便 TC-1164~1166 共用;PII 字段值用固定常量,保证断言直接对比字符串即可。
  231. const (
  232. piiEmail = "[email protected]"
  233. piiPhone = "13911118888"
  234. piiRemark = "target remark only admin can see"
  235. )
  236. func seedPIITarget(t *testing.T, svcCtx *svc.ServiceContext, productCode string) (int64, int64) {
  237. t.Helper()
  238. bootstrap := ctxhelper.SuperAdminCtx()
  239. targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{
  240. Username: "pii_target_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
  241. Nickname: "t", Email: piiEmail, Phone: piiPhone, Remark: piiRemark,
  242. IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, DeptId: 1,
  243. })
  244. now := time.Now().Unix()
  245. mRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &memberModel.SysProductMember{
  246. ProductCode: productCode, UserId: targetId, MemberType: consts.MemberTypeMember,
  247. Status: 1, CreateTime: now, UpdateTime: now,
  248. })
  249. require.NoError(t, err)
  250. mId, _ := mRes.LastInsertId()
  251. return targetId, mId
  252. }
  253. // TC-1164: 产品 ADMIN 看同产品他人 —— PII 完整返回。
  254. // ADMIN 是产品层授权面,负责人员/角色维护,必须能看到联系方式去做"找到此人"。
  255. func TestUserDetail_M_R16_1_ProductAdmin_KeepsPII(t *testing.T) {
  256. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  257. conn := testutil.GetTestSqlConn()
  258. bootstrap := ctxhelper.SuperAdminCtx()
  259. productCode := "m_r16_admin_" + testutil.UniqueId()
  260. targetId, mId := seedPIITarget(t, svcCtx, productCode)
  261. t.Cleanup(func() {
  262. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  263. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  264. })
  265. resp, err := NewUserDetailLogic(ctxhelper.AdminCtx(productCode), svcCtx).
  266. UserDetail(&types.UserDetailReq{Id: targetId})
  267. require.NoError(t, err)
  268. assert.Equal(t, piiEmail, resp.Email, "ADMIN 必须看到 Email 原值")
  269. assert.Equal(t, piiPhone, resp.Phone, "ADMIN 必须看到 Phone 原值")
  270. assert.Equal(t, piiRemark, resp.Remark, "ADMIN 必须看到 Remark 原值")
  271. }
  272. // TC-1165: 产品 DEVELOPER 看同产品他人 —— PII 完整返回。
  273. // DEVELOPER 是全权分支,授权读取元数据里就包含"看到成员详情";不应随 M-R16-1 一起被脱敏误伤。
  274. func TestUserDetail_M_R16_1_ProductDeveloper_KeepsPII(t *testing.T) {
  275. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  276. conn := testutil.GetTestSqlConn()
  277. bootstrap := ctxhelper.SuperAdminCtx()
  278. productCode := "m_r16_dev_" + testutil.UniqueId()
  279. targetId, mId := seedPIITarget(t, svcCtx, productCode)
  280. t.Cleanup(func() {
  281. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
  282. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  283. })
  284. resp, err := NewUserDetailLogic(ctxhelper.DeveloperCtx(productCode), svcCtx).
  285. UserDetail(&types.UserDetailReq{Id: targetId})
  286. require.NoError(t, err)
  287. assert.Equal(t, piiEmail, resp.Email)
  288. assert.Equal(t, piiPhone, resp.Phone)
  289. assert.Equal(t, piiRemark, resp.Remark)
  290. }
  291. // TC-1166: 产品 MEMBER 看同产品他人 —— PII 必须被置空(不能再返回 email/phone/remark)。
  292. // 这是 M-R16-1 的核心契约:普通成员拿到的 UserItem 里仅保留 Username/Nickname/DeptId/Status
  293. // 这类"组织结构可见"的字段,拒绝把全员通讯录外泄给普通成员。
  294. func TestUserDetail_M_R16_1_ProductMember_MasksPII(t *testing.T) {
  295. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  296. conn := testutil.GetTestSqlConn()
  297. bootstrap := ctxhelper.SuperAdminCtx()
  298. productCode := "m_r16_mb_" + testutil.UniqueId()
  299. targetId, mId := seedPIITarget(t, svcCtx, productCode)
  300. // caller 本身也要挂到这个产品下,否则 FindOneByProductCodeUserId(caller.ProductCode, target.Id)
  301. // 会直接 403——那就测不到脱敏逻辑。ctxhelper.MemberCtx 里 UserId=4 与 target 不同,才能触发
  302. // "caller 看他人"的分支。
  303. callerCtx := ctxhelper.MemberCtx(productCode)
  304. callerId := int64(4)
  305. now := time.Now().Unix()
  306. callerMRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &memberModel.SysProductMember{
  307. ProductCode: productCode, UserId: callerId, MemberType: consts.MemberTypeMember,
  308. Status: 1, CreateTime: now, UpdateTime: now,
  309. })
  310. require.NoError(t, err)
  311. callerMId, _ := callerMRes.LastInsertId()
  312. t.Cleanup(func() {
  313. testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId, callerMId)
  314. testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
  315. })
  316. resp, err := NewUserDetailLogic(callerCtx, svcCtx).
  317. UserDetail(&types.UserDetailReq{Id: targetId})
  318. require.NoError(t, err, "脱敏不应该变成 403 —— 同产品可读的组织信息依然返回,只是 PII 字段置空")
  319. assert.Empty(t, resp.Email, "MEMBER 看他人:Email 必须为空字符串")
  320. assert.Empty(t, resp.Phone, "MEMBER 看他人:Phone 必须为空字符串")
  321. assert.Empty(t, resp.Remark, "MEMBER 看他人:Remark 必须为空字符串")
  322. // 未脱敏的字段必须原样回填,否则是多脱一刀(误伤 UI)。
  323. assert.Equal(t, targetId, resp.Id)
  324. assert.NotEmpty(t, resp.Username, "Username 不应被脱敏")
  325. }