access_test.go 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708
  1. package auth
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. "github.com/zeromicro/go-zero/core/stores/sqlx"
  9. "go.uber.org/mock/gomock"
  10. "math"
  11. "math/rand"
  12. "os"
  13. "perms-system-server/internal/consts"
  14. "perms-system-server/internal/loaders"
  15. "perms-system-server/internal/middleware"
  16. deptModel "perms-system-server/internal/model/dept"
  17. "perms-system-server/internal/model/productmember"
  18. userModel "perms-system-server/internal/model/user"
  19. "perms-system-server/internal/response"
  20. "perms-system-server/internal/svc"
  21. "perms-system-server/internal/testutil"
  22. "perms-system-server/internal/testutil/ctxhelper"
  23. "perms-system-server/internal/testutil/mocks"
  24. "testing"
  25. "time"
  26. )
  27. // =====================================================================
  28. // RequireSuperAdmin
  29. // =====================================================================
  30. // TC-0481: 超管通过
  31. func TestRequireSuperAdmin_SuperAdmin(t *testing.T) {
  32. err := RequireSuperAdmin(ctxhelper.SuperAdminCtx())
  33. assert.NoError(t, err)
  34. }
  35. // TC-0482: ADMIN → 403 "仅超级管理员"
  36. func TestRequireSuperAdmin_Admin(t *testing.T) {
  37. err := RequireSuperAdmin(ctxhelper.AdminCtx("p1"))
  38. require.Error(t, err)
  39. var ce *response.CodeError
  40. require.True(t, errors.As(err, &ce))
  41. assert.Equal(t, 403, ce.Code())
  42. assert.Contains(t, ce.Error(), "仅超级管理员")
  43. }
  44. // TC-0483: MEMBER → 403
  45. func TestRequireSuperAdmin_Member(t *testing.T) {
  46. err := RequireSuperAdmin(ctxhelper.MemberCtx("p1"))
  47. require.Error(t, err)
  48. var ce *response.CodeError
  49. require.True(t, errors.As(err, &ce))
  50. assert.Equal(t, 403, ce.Code())
  51. }
  52. // TC-0484: 无 UserDetails → 401 "未登录"
  53. func TestRequireSuperAdmin_NoUserDetails(t *testing.T) {
  54. err := RequireSuperAdmin(context.Background())
  55. require.Error(t, err)
  56. var ce *response.CodeError
  57. require.True(t, errors.As(err, &ce))
  58. assert.Equal(t, 401, ce.Code())
  59. assert.Contains(t, ce.Error(), "未登录")
  60. }
  61. // =====================================================================
  62. // RequireProductAdmin
  63. // =====================================================================
  64. // TC-0485: SuperAdmin → nil
  65. func TestRequireProductAdminFor_SuperAdmin(t *testing.T) {
  66. err := RequireProductAdminFor(ctxhelper.SuperAdminCtx(), "p1")
  67. assert.NoError(t, err)
  68. }
  69. // TC-0486: ADMIN → nil (same product)
  70. func TestRequireProductAdminFor_Admin(t *testing.T) {
  71. err := RequireProductAdminFor(ctxhelper.AdminCtx("p1"), "p1")
  72. assert.NoError(t, err)
  73. }
  74. // TC-0487: DEVELOPER → 403
  75. func TestRequireProductAdminFor_Developer(t *testing.T) {
  76. err := RequireProductAdminFor(ctxhelper.DeveloperCtx("p1"), "p1")
  77. require.Error(t, err)
  78. var ce *response.CodeError
  79. require.True(t, errors.As(err, &ce))
  80. assert.Equal(t, 403, ce.Code())
  81. }
  82. // TC-0488: MEMBER → 403
  83. func TestRequireProductAdminFor_Member(t *testing.T) {
  84. err := RequireProductAdminFor(ctxhelper.MemberCtx("p1"), "p1")
  85. require.Error(t, err)
  86. var ce *response.CodeError
  87. require.True(t, errors.As(err, &ce))
  88. assert.Equal(t, 403, ce.Code())
  89. }
  90. // TC-0489: 无 UserDetails → 401
  91. func TestRequireProductAdminFor_NoUserDetails(t *testing.T) {
  92. err := RequireProductAdminFor(context.Background(), "p1")
  93. require.Error(t, err)
  94. var ce *response.CodeError
  95. require.True(t, errors.As(err, &ce))
  96. assert.Equal(t, 401, ce.Code())
  97. assert.Contains(t, ce.Error(), "未登录")
  98. }
  99. // TC-0490: ADMIN 跨产品被拒绝
  100. func TestRequireProductAdminFor_AdminCrossProduct(t *testing.T) {
  101. err := RequireProductAdminFor(ctxhelper.AdminCtx("p1"), "other_product")
  102. require.Error(t, err)
  103. var ce *response.CodeError
  104. require.True(t, errors.As(err, &ce))
  105. assert.Equal(t, 403, ce.Code())
  106. }
  107. // =====================================================================
  108. // CheckMemberTypeAssignment
  109. // =====================================================================
  110. // TC-0491: 超管分配 ADMIN → nil
  111. func TestCheckMemberTypeAssignment_SuperAdminAssignsAdmin(t *testing.T) {
  112. err := CheckMemberTypeAssignment(ctxhelper.SuperAdminCtx(), consts.MemberTypeAdmin)
  113. assert.NoError(t, err)
  114. }
  115. // TC-0492: ADMIN 分配 DEVELOPER → nil
  116. func TestCheckMemberTypeAssignment_AdminAssignsDeveloper(t *testing.T) {
  117. err := CheckMemberTypeAssignment(ctxhelper.AdminCtx("p1"), consts.MemberTypeDeveloper)
  118. assert.NoError(t, err)
  119. }
  120. // TC-0493: ADMIN 分配 ADMIN(同级)→ 403
  121. func TestCheckMemberTypeAssignment_AdminAssignsAdmin(t *testing.T) {
  122. err := CheckMemberTypeAssignment(ctxhelper.AdminCtx("p1"), consts.MemberTypeAdmin)
  123. require.Error(t, err)
  124. var ce *response.CodeError
  125. require.True(t, errors.As(err, &ce))
  126. assert.Equal(t, 403, ce.Code())
  127. }
  128. // TC-0494: DEVELOPER 分配 ADMIN(更高级)→ 403
  129. func TestCheckMemberTypeAssignment_DeveloperAssignsAdmin(t *testing.T) {
  130. err := CheckMemberTypeAssignment(ctxhelper.DeveloperCtx("p1"), consts.MemberTypeAdmin)
  131. require.Error(t, err)
  132. var ce *response.CodeError
  133. require.True(t, errors.As(err, &ce))
  134. assert.Equal(t, 403, ce.Code())
  135. }
  136. // TC-0495: MEMBER 分配 MEMBER(同级)→ 403
  137. func TestCheckMemberTypeAssignment_MemberAssignsMember(t *testing.T) {
  138. err := CheckMemberTypeAssignment(ctxhelper.MemberCtx("p1"), consts.MemberTypeMember)
  139. require.Error(t, err)
  140. var ce *response.CodeError
  141. require.True(t, errors.As(err, &ce))
  142. assert.Equal(t, 403, ce.Code())
  143. }
  144. // TC-0496: 无 UserDetails → 401
  145. func TestCheckMemberTypeAssignment_NoUserDetails(t *testing.T) {
  146. err := CheckMemberTypeAssignment(context.Background(), consts.MemberTypeMember)
  147. require.Error(t, err)
  148. var ce *response.CodeError
  149. require.True(t, errors.As(err, &ce))
  150. assert.Equal(t, 401, ce.Code())
  151. assert.Contains(t, ce.Error(), "未登录")
  152. }
  153. // =====================================================================
  154. // memberTypePriority (未导出,同包可测)
  155. // =====================================================================
  156. // TC-0505: 所有成员类型返回正确优先级
  157. func TestMemberTypePriority(t *testing.T) {
  158. tests := []struct {
  159. name string
  160. memberType string
  161. want int
  162. }{
  163. {"SUPER_ADMIN=0", consts.MemberTypeSuperAdmin, 0},
  164. {"ADMIN=1", consts.MemberTypeAdmin, 1},
  165. {"DEVELOPER=2", consts.MemberTypeDeveloper, 2},
  166. {"MEMBER=3", consts.MemberTypeMember, 3},
  167. {"unknown=MaxInt32", "UNKNOWN_TYPE", math.MaxInt32},
  168. {"empty=MaxInt32", "", math.MaxInt32},
  169. }
  170. for _, tt := range tests {
  171. t.Run(tt.name, func(t *testing.T) {
  172. got := memberTypePriority(tt.memberType)
  173. assert.Equal(t, tt.want, got)
  174. })
  175. }
  176. }
  177. // =====================================================================
  178. // CheckManageAccess (集成测试,需要真实 DB)
  179. // =====================================================================
  180. func newIntegrationSvcCtx() *svc.ServiceContext {
  181. return svc.NewServiceContext(testutil.GetTestConfig())
  182. }
  183. func createTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, conn sqlx.SqlConn, opts struct {
  184. username string
  185. deptId int64
  186. isSuperAdmin int64
  187. }) int64 {
  188. t.Helper()
  189. now := time.Now().Unix()
  190. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  191. Username: opts.username,
  192. Password: testutil.HashPassword("test123"),
  193. Nickname: opts.username,
  194. Email: opts.username + "@test.com",
  195. Phone: "",
  196. Remark: "",
  197. DeptId: opts.deptId,
  198. IsSuperAdmin: opts.isSuperAdmin,
  199. MustChangePassword: consts.MustChangePasswordNo,
  200. Status: consts.StatusEnabled,
  201. CreateTime: now,
  202. UpdateTime: now,
  203. })
  204. require.NoError(t, err)
  205. id, _ := res.LastInsertId()
  206. return id
  207. }
  208. // TC-0497: SuperAdmin 跳过所有检查
  209. func TestCheckManageAccess_SuperAdminBypasses(t *testing.T) {
  210. ctx := context.Background()
  211. svcCtx := newIntegrationSvcCtx()
  212. conn := testutil.GetTestSqlConn()
  213. targetId := createTestUser(t, ctx, svcCtx, conn, struct {
  214. username string
  215. deptId int64
  216. isSuperAdmin int64
  217. }{
  218. username: fmt.Sprintf("target_sa_%d", rand.Intn(100000)),
  219. deptId: 0,
  220. isSuperAdmin: consts.IsSuperAdminNo,
  221. })
  222. t.Cleanup(func() {
  223. testutil.CleanTable(ctx, conn, "`sys_user`", targetId)
  224. })
  225. err := CheckManageAccess(ctxhelper.SuperAdminCtx(), svcCtx, targetId, "p1")
  226. assert.NoError(t, err)
  227. }
  228. // TC-0498: 操作自己豁免
  229. func TestCheckManageAccess_SelfManagement(t *testing.T) {
  230. selfCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
  231. UserId: 100,
  232. Username: "self_user",
  233. IsSuperAdmin: false,
  234. MemberType: consts.MemberTypeMember,
  235. Status: consts.StatusEnabled,
  236. ProductCode: "p1",
  237. DeptId: 1,
  238. DeptPath: "/1/",
  239. })
  240. svcCtx := newIntegrationSvcCtx()
  241. err := CheckManageAccess(selfCtx, svcCtx, 100, "p1")
  242. assert.NoError(t, err)
  243. }
  244. // TC-0499: ADMIN 跳过部门层级检查
  245. func TestCheckManageAccess_AdminSkipsDeptCheck(t *testing.T) {
  246. ctx := context.Background()
  247. svcCtx := newIntegrationSvcCtx()
  248. conn := testutil.GetTestSqlConn()
  249. now := time.Now().Unix()
  250. pc := fmt.Sprintf("tp_access_%d", rand.Intn(100000))
  251. targetId := createTestUser(t, ctx, svcCtx, conn, struct {
  252. username string
  253. deptId int64
  254. isSuperAdmin int64
  255. }{
  256. username: fmt.Sprintf("target_admin_%d", rand.Intn(100000)),
  257. deptId: 0,
  258. isSuperAdmin: consts.IsSuperAdminNo,
  259. })
  260. pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
  261. ProductCode: pc, UserId: targetId, MemberType: consts.MemberTypeMember,
  262. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  263. })
  264. require.NoError(t, err)
  265. pmId, _ := pmRes.LastInsertId()
  266. t.Cleanup(func() {
  267. testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
  268. testutil.CleanTable(ctx, conn, "`sys_user`", targetId)
  269. })
  270. adminCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
  271. UserId: 2,
  272. Username: "admin_test",
  273. IsSuperAdmin: false,
  274. MemberType: consts.MemberTypeAdmin,
  275. Status: consts.StatusEnabled,
  276. ProductCode: pc,
  277. DeptId: 999,
  278. DeptPath: "/999/",
  279. MinPermsLevel: math.MaxInt64,
  280. })
  281. err = CheckManageAccess(adminCtx, svcCtx, targetId, pc)
  282. assert.NoError(t, err)
  283. }
  284. // TC-0500: 无 UserDetails → 401
  285. func TestCheckManageAccess_NoUserDetails(t *testing.T) {
  286. svcCtx := newIntegrationSvcCtx()
  287. err := CheckManageAccess(context.Background(), svcCtx, 1, "p1")
  288. require.Error(t, err)
  289. var ce *response.CodeError
  290. require.True(t, errors.As(err, &ce))
  291. assert.Equal(t, 401, ce.Code())
  292. }
  293. // TC-0501: DEVELOPER 无部门归属 → 403
  294. func TestCheckManageAccess_NoDept(t *testing.T) {
  295. svcCtx := newIntegrationSvcCtx()
  296. noDeptCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
  297. UserId: 3,
  298. Username: "dev_nodept",
  299. IsSuperAdmin: false,
  300. MemberType: consts.MemberTypeDeveloper,
  301. Status: consts.StatusEnabled,
  302. ProductCode: "p1",
  303. DeptId: 0,
  304. DeptPath: "",
  305. MinPermsLevel: math.MaxInt64,
  306. })
  307. err := CheckManageAccess(noDeptCtx, svcCtx, 99999, "p1")
  308. require.Error(t, err)
  309. var ce *response.CodeError
  310. require.True(t, errors.As(err, &ce))
  311. assert.Equal(t, 403, ce.Code())
  312. assert.Contains(t, ce.Error(), "未归属任何部门")
  313. }
  314. // TC-0502: DEVELOPER 操作不同部门的用户 → 403
  315. func TestCheckManageAccess_CrossDeptForbidden(t *testing.T) {
  316. ctx := context.Background()
  317. svcCtx := newIntegrationSvcCtx()
  318. conn := testutil.GetTestSqlConn()
  319. now := time.Now().Unix()
  320. dept1Res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  321. ParentId: 0, Name: fmt.Sprintf("dept_a_%d", rand.Intn(100000)),
  322. Path: fmt.Sprintf("/%d/", rand.Intn(100000)),
  323. Sort: 1, DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled,
  324. CreateTime: now, UpdateTime: now,
  325. })
  326. require.NoError(t, err)
  327. dept1Id, _ := dept1Res.LastInsertId()
  328. dept2Res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  329. ParentId: 0, Name: fmt.Sprintf("dept_b_%d", rand.Intn(100000)),
  330. Path: fmt.Sprintf("/%d/", rand.Intn(100000)),
  331. Sort: 1, DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled,
  332. CreateTime: now, UpdateTime: now,
  333. })
  334. require.NoError(t, err)
  335. dept2Id, _ := dept2Res.LastInsertId()
  336. targetId := createTestUser(t, ctx, svcCtx, conn, struct {
  337. username string
  338. deptId int64
  339. isSuperAdmin int64
  340. }{
  341. username: fmt.Sprintf("target_crossdept_%d", rand.Intn(100000)),
  342. deptId: dept2Id,
  343. isSuperAdmin: consts.IsSuperAdminNo,
  344. })
  345. dept2, err := svcCtx.SysDeptModel.FindOne(ctx, dept2Id)
  346. require.NoError(t, err)
  347. dept1, err := svcCtx.SysDeptModel.FindOne(ctx, dept1Id)
  348. require.NoError(t, err)
  349. t.Cleanup(func() {
  350. testutil.CleanTable(ctx, conn, "`sys_user`", targetId)
  351. testutil.CleanTable(ctx, conn, "`sys_dept`", dept1Id, dept2Id)
  352. })
  353. callerCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
  354. UserId: 99998,
  355. Username: "dev_crossdept",
  356. IsSuperAdmin: false,
  357. MemberType: consts.MemberTypeDeveloper,
  358. Status: consts.StatusEnabled,
  359. ProductCode: "p1",
  360. DeptId: dept1Id,
  361. DeptPath: dept1.Path,
  362. MinPermsLevel: math.MaxInt64,
  363. })
  364. _ = dept2
  365. err = CheckManageAccess(callerCtx, svcCtx, targetId, "p1")
  366. require.Error(t, err)
  367. var ce *response.CodeError
  368. require.True(t, errors.As(err, &ce))
  369. assert.Equal(t, 403, ce.Code())
  370. }
  371. // TC-0504: caller.DeptPath为空时拒绝
  372. func TestCheckManageAccess_EmptyDeptPath(t *testing.T) {
  373. svcCtx := newIntegrationSvcCtx()
  374. emptyPathCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
  375. UserId: 88888,
  376. Username: "dev_emptypath",
  377. IsSuperAdmin: false,
  378. MemberType: consts.MemberTypeDeveloper,
  379. Status: consts.StatusEnabled,
  380. ProductCode: "p1",
  381. DeptId: 1,
  382. DeptPath: "",
  383. MinPermsLevel: math.MaxInt64,
  384. })
  385. err := CheckManageAccess(emptyPathCtx, svcCtx, 99999, "p1")
  386. require.Error(t, err)
  387. var ce *response.CodeError
  388. require.True(t, errors.As(err, &ce))
  389. assert.Equal(t, 403, ce.Code())
  390. assert.Contains(t, ce.Error(), "部门信息异常")
  391. }
  392. // TC-0503: DEVELOPER 操作同部门的 MEMBER 且权限级别更高 → nil
  393. func TestCheckManageAccess_SameDeptLowerLevel(t *testing.T) {
  394. ctx := context.Background()
  395. svcCtx := newIntegrationSvcCtx()
  396. conn := testutil.GetTestSqlConn()
  397. now := time.Now().Unix()
  398. pc := fmt.Sprintf("tp_samedept_%d", rand.Intn(100000))
  399. deptRes, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  400. ParentId: 0, Name: fmt.Sprintf("dept_same_%d", rand.Intn(100000)),
  401. Path: "/", Sort: 1, DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled,
  402. CreateTime: now, UpdateTime: now,
  403. })
  404. require.NoError(t, err)
  405. deptId, _ := deptRes.LastInsertId()
  406. deptObj, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
  407. require.NoError(t, err)
  408. targetId := createTestUser(t, ctx, svcCtx, conn, struct {
  409. username string
  410. deptId int64
  411. isSuperAdmin int64
  412. }{
  413. username: fmt.Sprintf("target_samedept_%d", rand.Intn(100000)),
  414. deptId: deptId,
  415. isSuperAdmin: consts.IsSuperAdminNo,
  416. })
  417. pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
  418. ProductCode: pc, UserId: targetId, MemberType: consts.MemberTypeMember,
  419. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  420. })
  421. require.NoError(t, err)
  422. pmId, _ := pmRes.LastInsertId()
  423. t.Cleanup(func() {
  424. testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
  425. testutil.CleanTable(ctx, conn, "`sys_user`", targetId)
  426. testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
  427. })
  428. callerCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
  429. UserId: 99997,
  430. Username: "dev_samedept",
  431. IsSuperAdmin: false,
  432. MemberType: consts.MemberTypeDeveloper,
  433. Status: consts.StatusEnabled,
  434. ProductCode: pc,
  435. DeptId: deptId,
  436. DeptPath: deptObj.Path,
  437. MinPermsLevel: 1,
  438. })
  439. err = CheckManageAccess(callerCtx, svcCtx, targetId, pc)
  440. assert.NoError(t, err)
  441. }
  442. // suppress unused import
  443. var _ = sqlx.ErrNotFound
  444. // ---------------------------------------------------------------------------
  445. // 覆盖目标:CheckAddMemberAccess 专门为 AddMember 前置流程设计,
  446. // 用以堵住"产品 ADMIN 从部门树外把人强拉进自己产品"的漏洞。
  447. // 对比 CheckManageAccess:
  448. // 1) 不做 memberType / permsLevel 比对;
  449. // 2) 对产品 ADMIN 不走 checkDeptHierarchy 的 bypass,强制做部门链校验;
  450. // 3) SuperAdmin 仍完全豁免;
  451. // 4) target 为空 / 未归属部门等情况 fail-close。
  452. // ---------------------------------------------------------------------------
  453. func callerProductAdmin(deptId int64, deptPath string) *loaders.UserDetails {
  454. return &loaders.UserDetails{
  455. UserId: 2,
  456. Username: "pa",
  457. IsSuperAdmin: false,
  458. MemberType: consts.MemberTypeAdmin,
  459. Status: consts.StatusEnabled,
  460. ProductCode: "pc_h3",
  461. DeptId: deptId,
  462. DeptPath: deptPath,
  463. }
  464. }
  465. func callerMember(deptId int64, deptPath string) *loaders.UserDetails {
  466. return &loaders.UserDetails{
  467. UserId: 3,
  468. Username: "mbr",
  469. IsSuperAdmin: false,
  470. MemberType: consts.MemberTypeMember,
  471. Status: consts.StatusEnabled,
  472. ProductCode: "pc_h3",
  473. DeptId: deptId,
  474. DeptPath: deptPath,
  475. }
  476. }
  477. // TC-0940: 产品 ADMIN 将部门树**外**的 target 拉进产品时必须 403,
  478. // 不得因其 MemberType=ADMIN 享受 checkDeptHierarchy 的 bypass。
  479. func TestCheckAddMemberAccess_ProductAdmin_CrossDept_Rejected(t *testing.T) {
  480. ctrl := gomock.NewController(t)
  481. t.Cleanup(ctrl.Finish)
  482. // target 所在部门 path = /200/201/,与 caller 部门 path=/100/ 不在同一子树
  483. deptMock := mocks.NewMockSysDeptModel(ctrl)
  484. deptMock.EXPECT().FindOne(gomock.Any(), int64(201)).
  485. Return(&deptModel.SysDept{Id: 201, Path: "/200/201/"}, nil).Times(1)
  486. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  487. ctx := middleware.WithUserDetails(context.Background(), callerProductAdmin(100, "/100/"))
  488. target := &userModel.SysUser{Id: 42, DeptId: 201}
  489. err := CheckAddMemberAccess(ctx, svcCtx, target)
  490. require.Error(t, err, "产品 ADMIN 不能把部门树外的人拉进自己产品")
  491. var ce *response.CodeError
  492. require.True(t, errors.As(err, &ce))
  493. assert.Equal(t, 403, ce.Code())
  494. assert.Contains(t, ce.Error(), "其他部门")
  495. }
  496. // TC-0941: 产品 ADMIN 将部门树**内**的 target 拉进产品允许通过。
  497. func TestCheckAddMemberAccess_ProductAdmin_SameSubtree_Allowed(t *testing.T) {
  498. ctrl := gomock.NewController(t)
  499. t.Cleanup(ctrl.Finish)
  500. deptMock := mocks.NewMockSysDeptModel(ctrl)
  501. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  502. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil).Times(1)
  503. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  504. ctx := middleware.WithUserDetails(context.Background(), callerProductAdmin(100, "/100/"))
  505. target := &userModel.SysUser{Id: 42, DeptId: 101}
  506. err := CheckAddMemberAccess(ctx, svcCtx, target)
  507. require.NoError(t, err, "target 在 caller 部门子树内应允许添加")
  508. }
  509. // TC-0942: SuperAdmin 完全豁免,不触发 SysDeptModel.FindOne。
  510. func TestCheckAddMemberAccess_SuperAdmin_BypassNoDBCall(t *testing.T) {
  511. ctrl := gomock.NewController(t)
  512. t.Cleanup(ctrl.Finish)
  513. deptMock := mocks.NewMockSysDeptModel(ctrl)
  514. deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
  515. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  516. su := &loaders.UserDetails{
  517. UserId: 1, Username: "su", IsSuperAdmin: true,
  518. MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled,
  519. }
  520. ctx := middleware.WithUserDetails(context.Background(), su)
  521. target := &userModel.SysUser{Id: 42, DeptId: 999} // 任意部门
  522. err := CheckAddMemberAccess(ctx, svcCtx, target)
  523. require.NoError(t, err)
  524. }
  525. // TC-0943: caller 自加自 (target.Id == caller.UserId) 豁免部门校验,
  526. // 避免阻塞"ADMIN 把自己添加进新产品"这类合法运维路径。
  527. func TestCheckAddMemberAccess_SelfAdd_Allowed(t *testing.T) {
  528. ctrl := gomock.NewController(t)
  529. t.Cleanup(ctrl.Finish)
  530. deptMock := mocks.NewMockSysDeptModel(ctrl)
  531. deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
  532. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  533. caller := callerProductAdmin(100, "/100/")
  534. ctx := middleware.WithUserDetails(context.Background(), caller)
  535. target := &userModel.SysUser{Id: caller.UserId, DeptId: 999}
  536. err := CheckAddMemberAccess(ctx, svcCtx, target)
  537. require.NoError(t, err)
  538. }
  539. // TC-0944: caller 自身 DeptId=0(幽灵账号)时必须 403,
  540. // 不得让"无部门归属但拥有 product ADMIN"的账号绕过整个部门链校验。
  541. func TestCheckAddMemberAccess_CallerWithoutDept_Rejected(t *testing.T) {
  542. ctrl := gomock.NewController(t)
  543. t.Cleanup(ctrl.Finish)
  544. deptMock := mocks.NewMockSysDeptModel(ctrl)
  545. deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
  546. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  547. caller := callerProductAdmin(0, "")
  548. ctx := middleware.WithUserDetails(context.Background(), caller)
  549. target := &userModel.SysUser{Id: 42, DeptId: 101}
  550. err := CheckAddMemberAccess(ctx, svcCtx, target)
  551. require.Error(t, err)
  552. var ce *response.CodeError
  553. require.True(t, errors.As(err, &ce))
  554. assert.Equal(t, 403, ce.Code())
  555. assert.Contains(t, ce.Error(), "未归属任何部门")
  556. }
  557. // TC-0945: target 未归属部门时必须 403(仅超管可破例),
  558. // 避免"空 deptId 的 user 被部门前缀匹配逻辑误判"通过。
  559. func TestCheckAddMemberAccess_TargetWithoutDept_Rejected(t *testing.T) {
  560. ctrl := gomock.NewController(t)
  561. t.Cleanup(ctrl.Finish)
  562. deptMock := mocks.NewMockSysDeptModel(ctrl)
  563. deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
  564. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  565. caller := callerProductAdmin(100, "/100/")
  566. ctx := middleware.WithUserDetails(context.Background(), caller)
  567. target := &userModel.SysUser{Id: 42, DeptId: 0}
  568. err := CheckAddMemberAccess(ctx, svcCtx, target)
  569. require.Error(t, err)
  570. var ce *response.CodeError
  571. require.True(t, errors.As(err, &ce))
  572. assert.Equal(t, 403, ce.Code())
  573. assert.Contains(t, ce.Error(), "未归属部门")
  574. }
  575. // TC-0946: 未登录 / 缺少 UserDetails 上下文时返回 401,
  576. // 而不是 silently 放行或 panic。
  577. func TestCheckAddMemberAccess_NoCallerCtx_Unauthorized(t *testing.T) {
  578. ctrl := gomock.NewController(t)
  579. t.Cleanup(ctrl.Finish)
  580. deptMock := mocks.NewMockSysDeptModel(ctrl)
  581. deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
  582. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  583. target := &userModel.SysUser{Id: 42, DeptId: 101}
  584. err := CheckAddMemberAccess(context.Background(), svcCtx, target)
  585. require.Error(t, err)
  586. var ce *response.CodeError
  587. require.True(t, errors.As(err, &ce))
  588. assert.Equal(t, 401, ce.Code())
  589. }
  590. // TC-0947: SysDeptModel.FindOne 报错时必须 fail-close 返回 403(无法校验),
  591. // 不得静默放行。消息避免暴露底层 DB 细节。
  592. func TestCheckAddMemberAccess_DeptFindOneError_FailClose(t *testing.T) {
  593. ctrl := gomock.NewController(t)
  594. t.Cleanup(ctrl.Finish)
  595. deptMock := mocks.NewMockSysDeptModel(ctrl)
  596. deptMock.EXPECT().FindOne(gomock.Any(), int64(777)).
  597. Return(nil, errors.New("db: connection refused")).Times(1)
  598. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  599. caller := callerProductAdmin(100, "/100/")
  600. ctx := middleware.WithUserDetails(context.Background(), caller)
  601. target := &userModel.SysUser{Id: 42, DeptId: 777}
  602. err := CheckAddMemberAccess(ctx, svcCtx, target)
  603. require.Error(t, err)
  604. var ce *response.CodeError
  605. require.True(t, errors.As(err, &ce))
  606. assert.Equal(t, 403, ce.Code())
  607. assert.NotContains(t, ce.Error(), "db:",
  608. "错误消息不得泄漏底层 DB 细节")
  609. }
  610. // TC-0948: 非 ADMIN 的普通 MEMBER 作 caller 时同样走 CheckAddMemberAccess 的部门链判定
  611. // (虽然 AddMember 的 RequireProductAdminFor 会更早拒绝,但防御深度仍需保证此函数独立正确)。
  612. func TestCheckAddMemberAccess_Member_CrossDept_Rejected(t *testing.T) {
  613. ctrl := gomock.NewController(t)
  614. t.Cleanup(ctrl.Finish)
  615. deptMock := mocks.NewMockSysDeptModel(ctrl)
  616. deptMock.EXPECT().FindOne(gomock.Any(), int64(201)).
  617. Return(&deptModel.SysDept{Id: 201, Path: "/200/201/"}, nil).Times(1)
  618. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  619. caller := callerMember(100, "/100/")
  620. ctx := middleware.WithUserDetails(context.Background(), caller)
  621. target := &userModel.SysUser{Id: 42, DeptId: 201}
  622. err := CheckAddMemberAccess(ctx, svcCtx, target)
  623. require.Error(t, err)
  624. var ce *response.CodeError
  625. require.True(t, errors.As(err, &ce))
  626. assert.Equal(t, 403, ce.Code())
  627. }
  628. // TC-0949: target 为 nil 时必须 400,而不是 panic。
  629. func TestCheckAddMemberAccess_NilTarget_BadRequest(t *testing.T) {
  630. ctrl := gomock.NewController(t)
  631. t.Cleanup(ctrl.Finish)
  632. deptMock := mocks.NewMockSysDeptModel(ctrl)
  633. deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
  634. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  635. caller := callerProductAdmin(100, "/100/")
  636. ctx := middleware.WithUserDetails(context.Background(), caller)
  637. err := CheckAddMemberAccess(ctx, svcCtx, nil)
  638. require.Error(t, err)
  639. var ce *response.CodeError
  640. require.True(t, errors.As(err, &ce))
  641. assert.Equal(t, 400, ce.Code())
  642. }
  643. // ---------------------------------------------------------------------------
  644. // checkDeptHierarchy 对 caller.DeptId=0 / DeptPath=""
  645. // 的历史 MEMBER / DEVELOPER 账号直接 403。
  646. //
  647. // 契约期望(fix 后):历史账号任意一次管理动作时,CheckManageAccess 要么走
  648. // (a) 明确的"未归属部门,拒绝管理他人"403(当前行为,方向正确但文案 / 缺失)
  649. // (b) 自动把缺失部门挪到默认部门 → 正常走部门链校验
  650. // 无论走 (a) 还是 (b),都需要有 **response.CodeError 结构** 而不是普通 string error,
  651. // 否则前端做不到"按错误码触发数据迁移工单"。
  652. //
  653. // 本测试用 skipPending 标签,方便 report 识别未落地项;fix 落地(或数据迁移脚本
  654. // 跑完)后把 AUDIT_RUN_PENDING=1 打开并调整断言即可切换成真正的回归保护。
  655. // ---------------------------------------------------------------------------
  656. const auditPendingEnv = "AUDIT_RUN_PENDING"
  657. func skipPending(t *testing.T, marker, reason string) {
  658. t.Helper()
  659. if os.Getenv(auditPendingEnv) != "" {
  660. return
  661. }
  662. t.Skipf("AUDIT_PENDING %s (Round 8 fix 未落地) —— %s", marker, reason)
  663. }
  664. // TC-0993: 历史 DEVELOPER(DeptId=0)对合法目标的管理操作 —— fix 后必须是
  665. // 可识别的 response.CodeError,且带有迁移提示("您未归属任何部门"),让运维据此跑数据迁移。
  666. func TestCheckManageAccess_L3_LegacyDeveloperWithDeptZero_MustReturnCodedError(t *testing.T) {
  667. skipPending(t, "L-3",
  668. "当前返回 403 但文案分叉('您未归属任何部门' / '您的部门信息异常'),建议"+
  669. "合一为 '您未归属任何部门' 且带 CodeError.Code=403;fix 落地后移除 Skip")
  670. ctx := context.Background()
  671. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  672. // caller 是 legacy developer,DeptId=0 / DeptPath=""。
  673. callerCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{
  674. UserId: 999001, Username: "legacy_dev", IsSuperAdmin: false,
  675. MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled,
  676. ProductCode: "test_product",
  677. // DeptId=0, DeptPath="" —— legacy 账号
  678. })
  679. err := CheckManageAccess(callerCtx, svcCtx, 999002 /* target */, "test_product")
  680. require.Error(t, err, "legacy caller 必须被拒绝")
  681. var ce *response.CodeError
  682. require.ErrorAs(t, err, &ce, "必须是 response.CodeError,不得为裸 error(前端无法据此触发迁移)")
  683. assert.Equal(t, 403, ce.Code(), "必须是 403 以便前端分类")
  684. assert.Contains(t, ce.Error(), "未归属",
  685. "文案必须显式提示'未归属任何部门',便于人工判定是否需要跑数据迁移")
  686. }
  687. // ---------------------------------------------------------------------------
  688. // 覆盖目标:CheckManageAccess(WithPrefetchedTarget(...))
  689. // 允许调用方透传已经 FindOne 到的 target,避免单次请求内重复 FindOne(targetUserId)。
  690. //
  691. // 修复前:UpdateUserStatus / UpdateUser 一次请求会先做 ValidateStatusChange 里的 FindOne,
  692. // 紧接着 checkDeptHierarchy 里又 FindOne 一次,DB/缓存都白打一次 RTT。
  693. //
  694. // 修复后的契约:
  695. // * Option 与参数一致(target.Id == targetUserId)时,FindOne 必须被跳过;
  696. // * 不一致时 option 失效(defensive ignore),checkDeptHierarchy 回退到原有 FindOne 路径。
  697. // ---------------------------------------------------------------------------
  698. func buildMemberCallerCtx() context.Context {
  699. caller := &loaders.UserDetails{
  700. UserId: 1,
  701. Username: "op",
  702. IsSuperAdmin: false,
  703. MemberType: consts.MemberTypeMember,
  704. Status: consts.StatusEnabled,
  705. ProductCode: "pc_m5",
  706. DeptId: 100,
  707. DeptPath: "/100/",
  708. MinPermsLevel: 50,
  709. }
  710. return middleware.WithUserDetails(context.Background(), caller)
  711. }
  712. // TC-0860: 透传的 prefetched.Id 与 targetUserId 一致 → SysUserModel.FindOne 必须一次都不被调用。
  713. func TestCheckManageAccess_PrefetchedTarget_SkipsFindOne(t *testing.T) {
  714. ctrl := gomock.NewController(t)
  715. t.Cleanup(ctrl.Finish)
  716. userMock := mocks.NewMockSysUserModel(ctrl)
  717. // 关键断言:FindOne 次数为 0。gomock 默认不允许未声明的调用;省略 EXPECT 即相当于 0 次。
  718. deptMock := mocks.NewMockSysDeptModel(ctrl)
  719. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  720. roleMock := mocks.NewMockSysRoleModel(ctrl)
  721. // 目标用户所在部门的 Path 需满足 HasPrefix caller.DeptPath="/100/"
  722. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  723. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  724. // 目标产品成员存在,MemberType=MEMBER 与 caller 同级 → 走 permsLevel 比较分支。
  725. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  726. Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  727. // 目标的 permsLevel 高于 caller(数值更大 → 权限更低),校验放行。
  728. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  729. Return(int64(100), nil)
  730. // checkPermLevel 现在会对 caller 也做一次 DB fresh read。
  731. // caller.UserId=1,permsLevel=50(比 target=100 严格高权)→ 放行。
  732. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5").
  733. Return(int64(50), nil)
  734. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  735. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  736. })
  737. prefetched := &userModel.SysUser{Id: 42, DeptId: 101}
  738. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(prefetched))
  739. assert.NoError(t, err,
  740. "prefetched 与 targetUserId 一致且业务级校验全部通过时应放行")
  741. // ctrl.Finish() 里会自动校验 userMock.FindOne 调用次数为 0(未显式 EXPECT),
  742. // 若源码回退到 FindOne 路径测试会抛 "unexpected call to FindOne" 直接 FAIL。
  743. }
  744. // TC-0861: 透传的 prefetched.Id 与 targetUserId 不一致 → option 被 defensive 忽略,
  745. // 必须真实调用 SysUserModel.FindOne(ctx, targetUserId) 一次。
  746. // 这是一条 "调用方把错 id 传进来时不能被当做合法 prefetched" 的安全断言:
  747. // 如果源码直接信任 prefetched 而不校验 Id,就会出现 "用 A 的 userDetails 去放行对 B 的管理"。
  748. func TestCheckManageAccess_PrefetchedIdMismatch_IgnoredAndFallsBackToFindOne(t *testing.T) {
  749. ctrl := gomock.NewController(t)
  750. t.Cleanup(ctrl.Finish)
  751. userMock := mocks.NewMockSysUserModel(ctrl)
  752. deptMock := mocks.NewMockSysDeptModel(ctrl)
  753. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  754. roleMock := mocks.NewMockSysRoleModel(ctrl)
  755. // 关键断言:FindOne(targetUserId=42) 必须真实被调用一次,说明 prefetched 没被盲信。
  756. // 我们返回的真实对象 DeptId=101(与乱传的 prefetched 一致),好让流程继续走通。
  757. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  758. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
  759. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  760. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  761. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  762. Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  763. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  764. Return(int64(100), nil)
  765. // caller 侧 fresh read 仍需要。
  766. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5").
  767. Return(int64(50), nil)
  768. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  769. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  770. })
  771. // 故意传 Id=999,与 targetUserId=42 不一致。
  772. wrong := &userModel.SysUser{Id: 999, DeptId: 101}
  773. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(wrong))
  774. assert.NoError(t, err,
  775. "prefetched.Id 不匹配时回退 FindOne 后,本场景仍应通过业务级校验")
  776. }
  777. // 正向防御:prefetched 为 nil 时也不应 panic,且必须走 FindOne 一次(不传 option 的等价路径)。
  778. func TestCheckManageAccess_NilPrefetched_FallsBackToFindOne(t *testing.T) {
  779. ctrl := gomock.NewController(t)
  780. t.Cleanup(ctrl.Finish)
  781. userMock := mocks.NewMockSysUserModel(ctrl)
  782. deptMock := mocks.NewMockSysDeptModel(ctrl)
  783. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  784. roleMock := mocks.NewMockSysRoleModel(ctrl)
  785. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  786. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
  787. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  788. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  789. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  790. Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  791. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  792. Return(int64(math.MaxInt64), nil)
  793. // caller 侧 fresh read。
  794. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5").
  795. Return(int64(50), nil)
  796. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  797. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  798. })
  799. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(nil))
  800. assert.NoError(t, err)
  801. }
  802. // ---------------------------------------------------------------------------
  803. // 覆盖目标:checkPermLevel 在 DB 非 ErrNotFound 错误时必须 fail-close 返回 500,
  804. // 而不是被默默降级为"目标无角色 → 权限最低 → 放行"。
  805. // 该测试用 gomock 伪造 SysRoleModel.FindMinPermsLevelByUserIdAndProductCode 返回一个通用 DB 错误,
  806. // 验证 CheckManageAccess 的响应是 500 CodeError(非 403)。
  807. // ---------------------------------------------------------------------------
  808. // TC-0819: checkPermLevel 遇到非 ErrNotFound 的 DB 错误时必须 500。
  809. func TestCheckManageAccess_DBError_FailCloseWith500(t *testing.T) {
  810. ctrl := gomock.NewController(t)
  811. t.Cleanup(ctrl.Finish)
  812. const targetUserId = int64(42)
  813. const callerDeptId = int64(1)
  814. const targetDeptId = int64(2)
  815. const productCode = "test_product"
  816. // 让 checkDeptHierarchy 顺利放行:target 在 caller 子部门下(path 前缀 /1/)。
  817. mockUser := mocks.NewMockSysUserModel(ctrl)
  818. mockUser.EXPECT().FindOne(gomock.Any(), int64(targetUserId)).
  819. Return(&userModel.SysUser{Id: targetUserId, DeptId: targetDeptId}, nil).AnyTimes()
  820. mockDept := mocks.NewMockSysDeptModel(ctrl)
  821. mockDept.EXPECT().FindOne(gomock.Any(), targetDeptId).
  822. Return(&deptModel.SysDept{Id: targetDeptId, Path: "/1/2/"}, nil).AnyTimes()
  823. // 让 permsLevel 判定路径进入:"target 也是 MEMBER,同级 → 需要 DB 查 permsLevel"。
  824. mockPM := mocks.NewMockSysProductMemberModel(ctrl)
  825. mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), productCode, int64(targetUserId)).
  826. Return(&productmember.SysProductMember{
  827. UserId: targetUserId, ProductCode: productCode,
  828. MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
  829. }, nil).AnyTimes()
  830. // 关键:SysRoleModel 返回非 ErrNotFound 的 DB 错误。
  831. dbErr := errors.New("driver: bad connection")
  832. mockRole := mocks.NewMockSysRoleModel(ctrl)
  833. mockRole.EXPECT().
  834. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(targetUserId), productCode).
  835. Return(int64(0), dbErr).AnyTimes()
  836. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  837. User: mockUser,
  838. Dept: mockDept,
  839. Role: mockRole,
  840. ProductMember: mockPM,
  841. })
  842. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  843. UserId: 100,
  844. Username: "l4_member_caller",
  845. IsSuperAdmin: false,
  846. MemberType: consts.MemberTypeMember,
  847. Status: consts.StatusEnabled,
  848. ProductCode: productCode,
  849. DeptId: callerDeptId,
  850. DeptPath: "/1/",
  851. MinPermsLevel: 100,
  852. })
  853. err := CheckManageAccess(ctx, svcCtx, targetUserId, productCode)
  854. require.Error(t, err, "DB 错误时必须 fail-close")
  855. var ce *response.CodeError
  856. require.True(t, errors.As(err, &ce), "必须是结构化 CodeError")
  857. assert.Equal(t, 500, ce.Code(),
  858. "DB 非 ErrNotFound 错误绝不能被伪装成'无角色'从而降级为 403/放行;必须是 500")
  859. assert.NotContains(t, ce.Error(), "无权管理",
  860. "错误消息不得看起来像权限判定成功后做出的业务决策(避免误导运维)")
  861. }
  862. // TC-0820: 对照组 —— ErrNotFound 仍应被视作"无角色",即按最低权限处理(由 caller.MinPermsLevel 决定放行还是 403)。
  863. // 这里构造 caller 的 MinPermsLevel=MaxInt64(sentinel),target 无角色(ErrNotFound) →
  864. // caller.MinPermsLevel(=MaxInt64) >= targetLevel(=MaxInt64) → 返回 403。这个分支不是本次回归重点,
  865. // 只是用来证明 ErrNotFound 路径没有被修复误伤为 500。
  866. func TestCheckManageAccess_ErrNotFound_StillTreatedAsNoRole(t *testing.T) {
  867. ctrl := gomock.NewController(t)
  868. t.Cleanup(ctrl.Finish)
  869. const targetUserId = int64(43)
  870. const callerDeptId = int64(1)
  871. const targetDeptId = int64(2)
  872. const productCode = "test_product"
  873. mockUser := mocks.NewMockSysUserModel(ctrl)
  874. mockUser.EXPECT().FindOne(gomock.Any(), int64(targetUserId)).
  875. Return(&userModel.SysUser{Id: targetUserId, DeptId: targetDeptId}, nil).AnyTimes()
  876. mockDept := mocks.NewMockSysDeptModel(ctrl)
  877. mockDept.EXPECT().FindOne(gomock.Any(), targetDeptId).
  878. Return(&deptModel.SysDept{Id: targetDeptId, Path: "/1/2/"}, nil).AnyTimes()
  879. mockPM := mocks.NewMockSysProductMemberModel(ctrl)
  880. mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), productCode, int64(targetUserId)).
  881. Return(&productmember.SysProductMember{
  882. UserId: targetUserId, ProductCode: productCode,
  883. MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
  884. }, nil).AnyTimes()
  885. mockRole := mocks.NewMockSysRoleModel(ctrl)
  886. mockRole.EXPECT().
  887. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(targetUserId), productCode).
  888. Return(int64(0), sqlx.ErrNotFound).AnyTimes()
  889. // checkPermLevel 现在也会对 caller 做 fresh read。
  890. // 这里构造"caller 同样无角色 → callerNoRole=true → >= 比较由 callerNoRole 决定,结果仍 403"。
  891. mockRole.EXPECT().
  892. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(101), productCode).
  893. Return(int64(0), sqlx.ErrNotFound).AnyTimes()
  894. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  895. User: mockUser, Dept: mockDept, Role: mockRole, ProductMember: mockPM,
  896. })
  897. ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
  898. UserId: 101,
  899. Username: "l4_caller_no_role",
  900. IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
  901. ProductCode: productCode, DeptId: callerDeptId, DeptPath: "/1/",
  902. // sentinel:自己也没有任何角色。
  903. MinPermsLevel: math.MaxInt64,
  904. })
  905. err := CheckManageAccess(ctx, svcCtx, targetUserId, productCode)
  906. require.Error(t, err, "caller 与 target 都 sentinel → >= 比较应拦截")
  907. var ce *response.CodeError
  908. require.True(t, errors.As(err, &ce))
  909. assert.Equal(t, 403, ce.Code(),
  910. "ErrNotFound 正常降级为 sentinel;结果应是业务 403 而非基础设施 500")
  911. }
  912. // ---------------------------------------------------------------------------
  913. // 覆盖目标:checkPermLevel 必须对 caller.MinPermsLevel 做 DB fresh read。
  914. //
  915. // 修复前:caller.MinPermsLevel 来自 UserDetailsLoader 5min TTL 缓存。超管刚把 caller 降级后
  916. // 缓存里仍是旧(更高权)级别,降级 admin 在 5min 内仍可跨级管辖目标用户 —— 这是 的
  917. // GuardRoleLevelAssignable 修复所没有覆盖的"直接管人"分支。
  918. //
  919. // 修复后契约:
  920. // 1. caller 刚被降级,缓存 MinPermsLevel=低(高权)但 DB 实值=高(低权)→ 走 DB 值,仍拒 403。
  921. // 2. caller 在 DB 里已无角色(sqlx.ErrNotFound)→ callerNoRole=true → 同级管辖拒。
  922. // 3. caller 侧 DB 抖动(非 ErrNotFound)→ 500 fail-close,不得降级为"无角色放行"。
  923. // 4. SuperAdmin caller 短路,永不触发 caller 侧 DB 读。
  924. // 5. caller 管自己时短路在 CheckManageAccess 顶部,根本不到 checkPermLevel。
  925. //
  926. // 命名规则:TC-0969 ~ TC-0975
  927. // ---------------------------------------------------------------------------
  928. const h2TestProductCode = "pc_h2"
  929. // h2MockTargetMemberAndDept 为所有 测试共享的 target 侧 mock:
  930. // - target.MemberType=MEMBER(与 MEMBER caller 同级 → 必然进入 permsLevel 对比路径)
  931. // - target.DeptPath=/100/101/ 使 caller.DeptPath=/100/ 顺利覆盖
  932. func h2MockTargetMemberAndDept(ctrl *gomock.Controller) (
  933. *mocks.MockSysUserModel,
  934. *mocks.MockSysDeptModel,
  935. *mocks.MockSysProductMemberModel,
  936. ) {
  937. userMock := mocks.NewMockSysUserModel(ctrl)
  938. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  939. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).AnyTimes()
  940. deptMock := mocks.NewMockSysDeptModel(ctrl)
  941. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  942. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil).AnyTimes()
  943. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  944. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), h2TestProductCode, int64(42)).
  945. Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil).AnyTimes()
  946. return userMock, deptMock, pmMock
  947. }
  948. func h2DowngradedCallerCtx(cachedLevel int64) *loaders.UserDetails {
  949. return &loaders.UserDetails{
  950. UserId: 1,
  951. Username: "h2_caller",
  952. IsSuperAdmin: false,
  953. MemberType: consts.MemberTypeMember,
  954. Status: consts.StatusEnabled,
  955. ProductCode: h2TestProductCode,
  956. DeptId: 100,
  957. DeptPath: "/100/",
  958. MinPermsLevel: cachedLevel,
  959. }
  960. }
  961. // TC-0969: caller 缓存里还是高权(level=10),但 DB 已被降级到低权(level=100)
  962. // target level=50;按缓存 "10 < 50" 应放行,按 DB fresh read "100 >= 50" 应拒绝。
  963. // 修复后必须以 DB 为准 → 403。
  964. func TestCheckPermLevel_StaleCacheHighPriv_FreshReadLowPriv_Forbids(t *testing.T) {
  965. ctrl := gomock.NewController(t)
  966. t.Cleanup(ctrl.Finish)
  967. userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
  968. roleMock := mocks.NewMockSysRoleModel(ctrl)
  969. // target fresh read:level=50
  970. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
  971. Return(int64(50), nil).Times(1)
  972. // caller fresh read:DB 真实已降级到 100(低权),比 target 的 50 更低权 → 应拒
  973. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
  974. Return(int64(100), nil).Times(1)
  975. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  976. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  977. })
  978. // 关键:缓存里还是 10(降级前的高权级别)—— 源码如果信任 caller.MinPermsLevel 就会放行。
  979. ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
  980. err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
  981. require.Error(t, err, "降级后缓存仍高权的 caller 必须被 DB 实值拦截")
  982. var ce *response.CodeError
  983. require.True(t, errors.As(err, &ce))
  984. assert.Equal(t, 403, ce.Code(),
  985. "TOCTOU 修复后必须走 DB,结果为 403;若测试看到 200/nil err 说明仍读的是 caller.MinPermsLevel 缓存")
  986. assert.Contains(t, ce.Error(), "无权管理权限级别高于或等于您的用户")
  987. }
  988. // TC-0970: caller 在 DB 里已无任何角色(ErrNotFound)→ 即便缓存仍显示 MinPermsLevel=10,也必须拒。
  989. // 对称验证 里 GuardRoleLevelAssignable 对"caller 被清角色"的处理方式。
  990. func TestCheckPermLevel_CallerFreshRead_NotFound_ForbidsEvenWithCachedLevel(t *testing.T) {
  991. ctrl := gomock.NewController(t)
  992. t.Cleanup(ctrl.Finish)
  993. userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
  994. roleMock := mocks.NewMockSysRoleModel(ctrl)
  995. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
  996. Return(int64(50), nil).Times(1)
  997. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
  998. Return(int64(0), sqlx.ErrNotFound).Times(1)
  999. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  1000. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  1001. })
  1002. ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
  1003. err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
  1004. require.Error(t, err, "caller 在 DB 已无角色时一律拒绝同级/跨级管辖")
  1005. var ce *response.CodeError
  1006. require.True(t, errors.As(err, &ce))
  1007. assert.Equal(t, 403, ce.Code())
  1008. }
  1009. // TC-0971: 正向通过路径 —— caller DB level=10(高权),target level=50 → 严格高权,放行。
  1010. // 用来证明修复没有误伤合法管理路径。
  1011. func TestCheckPermLevel_FreshRead_HigherPriv_Passes(t *testing.T) {
  1012. ctrl := gomock.NewController(t)
  1013. t.Cleanup(ctrl.Finish)
  1014. userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
  1015. roleMock := mocks.NewMockSysRoleModel(ctrl)
  1016. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
  1017. Return(int64(50), nil).Times(1)
  1018. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
  1019. Return(int64(10), nil).Times(1)
  1020. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  1021. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  1022. })
  1023. ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
  1024. err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
  1025. assert.NoError(t, err, "合法严格高权管理路径不得被修复误伤")
  1026. }
  1027. // TC-0972: caller 侧 DB 非 ErrNotFound 错误 → 500 fail-close。
  1028. // 对称于 checkPermLevel 里 target 侧的 fail-close,把 caller 侧也钉死,避免"DB 抖动被伪装成无角色"。
  1029. func TestCheckPermLevel_CallerFreshRead_GenericDBErr_FailsClosedWith500(t *testing.T) {
  1030. ctrl := gomock.NewController(t)
  1031. t.Cleanup(ctrl.Finish)
  1032. userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
  1033. roleMock := mocks.NewMockSysRoleModel(ctrl)
  1034. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
  1035. Return(int64(50), nil).Times(1)
  1036. // caller 侧 DB 抖动
  1037. dbErr := errors.New("driver: bad connection")
  1038. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
  1039. Return(int64(0), dbErr).Times(1)
  1040. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  1041. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  1042. })
  1043. ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
  1044. err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
  1045. require.Error(t, err)
  1046. var ce *response.CodeError
  1047. require.True(t, errors.As(err, &ce))
  1048. assert.Equal(t, 500, ce.Code(),
  1049. "caller 侧 DB 抖动必须 fail-close 500,绝不能伪装成'无角色→放行'")
  1050. // 不得透传驱动细节
  1051. assert.NotContains(t, ce.Error(), "driver: bad connection")
  1052. }
  1053. // TC-0973: SuperAdmin caller 短路 —— 不应触发任何 caller 侧 DB 读。
  1054. // 如果修复把 fresh read 放错位置,SuperAdmin 也会被多打一次 DB,测试会因 Role mock 收到未预期调用而挂。
  1055. func TestCheckPermLevel_SuperAdmin_ShortCircuits_NoCallerFreshRead(t *testing.T) {
  1056. ctrl := gomock.NewController(t)
  1057. t.Cleanup(ctrl.Finish)
  1058. // 故意不设任何 mock EXPECT —— 任何 DB 调用都会 fail。
  1059. userMock := mocks.NewMockSysUserModel(ctrl)
  1060. deptMock := mocks.NewMockSysDeptModel(ctrl)
  1061. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  1062. roleMock := mocks.NewMockSysRoleModel(ctrl)
  1063. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  1064. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  1065. })
  1066. super := &loaders.UserDetails{
  1067. UserId: 999, Username: "super_h2", IsSuperAdmin: true,
  1068. MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled,
  1069. ProductCode: h2TestProductCode,
  1070. }
  1071. err := CheckManageAccess(ctxhelper.CustomCtx(super), svcCtx, 42, h2TestProductCode)
  1072. assert.NoError(t, err, "SuperAdmin 必须在 CheckManageAccess 顶部短路,不得触发 fresh read")
  1073. }
  1074. // TC-0974: caller 管理自己时必须在 CheckManageAccess 顶部短路( 已钉);
  1075. // 修复不得把这条路径带偏到 fresh read。
  1076. func TestCheckPermLevel_ManageSelf_ShortCircuits_NoFreshRead(t *testing.T) {
  1077. ctrl := gomock.NewController(t)
  1078. t.Cleanup(ctrl.Finish)
  1079. // 故意不设 mock EXPECT,caller 管自己应该一次 DB 都不打。
  1080. userMock := mocks.NewMockSysUserModel(ctrl)
  1081. deptMock := mocks.NewMockSysDeptModel(ctrl)
  1082. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  1083. roleMock := mocks.NewMockSysRoleModel(ctrl)
  1084. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  1085. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  1086. })
  1087. ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(100))
  1088. err := CheckManageAccess(ctx, svcCtx, 1 /* 就是 caller.UserId */, h2TestProductCode)
  1089. assert.NoError(t, err, "caller 管自己永远放行,且不触发 DB fresh read")
  1090. }
  1091. // TC-0975: 与 共享 loadFreshMinPermsLevel helper(契约自洽性)。
  1092. // 既然 checkPermLevel 与 GuardRoleLevelAssignable 两个授权决策点的 caller 侧都走同一个 helper,
  1093. // 任意通用 DB 错误都必须映射为同一文案的 500 CodeError,任意 ErrNotFound 都映射为 notFound=true。
  1094. // 这里覆盖 helper 的两个对称分支。
  1095. func TestLoadFreshMinPermsLevel_ContractParity(t *testing.T) {
  1096. t.Run("generic DB error → 500 fail-close", func(t *testing.T) {
  1097. ctrl := gomock.NewController(t)
  1098. t.Cleanup(ctrl.Finish)
  1099. roleMock := mocks.NewMockSysRoleModel(ctrl)
  1100. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(7), h2TestProductCode).
  1101. Return(int64(0), errors.New("i/o timeout")).Times(1)
  1102. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: roleMock})
  1103. level, notFound, err := loadFreshMinPermsLevel(ctxhelper.CustomCtx(nil), svcCtx, 7, h2TestProductCode)
  1104. assert.Equal(t, int64(0), level)
  1105. assert.False(t, notFound)
  1106. require.Error(t, err)
  1107. var ce *response.CodeError
  1108. require.True(t, errors.As(err, &ce))
  1109. assert.Equal(t, 500, ce.Code())
  1110. })
  1111. t.Run("ErrNotFound → notFound=true, err=nil", func(t *testing.T) {
  1112. ctrl := gomock.NewController(t)
  1113. t.Cleanup(ctrl.Finish)
  1114. roleMock := mocks.NewMockSysRoleModel(ctrl)
  1115. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(8), h2TestProductCode).
  1116. Return(int64(0), sqlx.ErrNotFound).Times(1)
  1117. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: roleMock})
  1118. level, notFound, err := loadFreshMinPermsLevel(ctxhelper.CustomCtx(nil), svcCtx, 8, h2TestProductCode)
  1119. assert.NoError(t, err)
  1120. assert.True(t, notFound)
  1121. assert.Equal(t, int64(0), level)
  1122. })
  1123. }
  1124. // ---------------------------------------------------------------------------
  1125. // 覆盖目标:GuardRoleLevelAssignable 必须每次走 DB 强一致查询,
  1126. // 绝不能信任 caller(loaders.UserDetails)里可能已经 stale 的 MinPermsLevel 缓存。
  1127. //
  1128. // TOCTOU 场景:
  1129. // 1. caller 原先是 permsLevel=5 的高阶成员。
  1130. // 2. 超管把 caller 的高阶角色摘掉,现在 DB 里 MinPermsLevel=100(低阶)。
  1131. // 3. UD 缓存还没被 Clean(Redis 抖动 / TTL 窗口内),caller.MinPermsLevel=5 是旧值。
  1132. // 4. caller 此刻尝试分配 permsLevel=50 的角色 —— 若信缓存(5 vs 50)会**误放行**;
  1133. // 修复后走 DB(100 vs 50),必须 403 拦截。
  1134. // ---------------------------------------------------------------------------
  1135. // TC-0930: stale caller.MinPermsLevel 不得影响判定,rolePermsLevel <= freshLevel 必须 403。
  1136. func TestGuardRoleLevelAssignable_StaleCallerCache_FreshDBRejects(t *testing.T) {
  1137. ctrl := gomock.NewController(t)
  1138. t.Cleanup(ctrl.Finish)
  1139. const productCode = "m3_pc_stale"
  1140. const callerId = int64(1001)
  1141. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1142. // 关键:DB 强一致返回 100(被降级后的真实等级)。
  1143. mockRole.EXPECT().
  1144. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
  1145. Return(int64(100), nil).
  1146. Times(1)
  1147. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1148. caller := &loaders.UserDetails{
  1149. UserId: callerId,
  1150. Username: "m3_stale_caller",
  1151. MemberType: consts.MemberTypeMember,
  1152. ProductCode: productCode,
  1153. Status: consts.StatusEnabled,
  1154. MinPermsLevel: 5,
  1155. }
  1156. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 50)
  1157. require.Error(t, err, "stale 缓存(5) 下试图分配 permsLevel=50,信缓存会放行;走 DB(100) 必须 403")
  1158. var ce *response.CodeError
  1159. require.True(t, errors.As(err, &ce))
  1160. assert.Equal(t, 403, ce.Code(), "拒绝分配高于自身 fresh 等级的角色 → 403")
  1161. }
  1162. // TC-0931: 同级(rolePermsLevel == freshLevel)也要拦截,保持与 checkPermLevel 的 ">=" 对齐。
  1163. func TestGuardRoleLevelAssignable_SameLevel_Rejected(t *testing.T) {
  1164. ctrl := gomock.NewController(t)
  1165. t.Cleanup(ctrl.Finish)
  1166. const productCode = "m3_pc_same"
  1167. const callerId = int64(1002)
  1168. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1169. mockRole.EXPECT().
  1170. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
  1171. Return(int64(50), nil).
  1172. Times(1)
  1173. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1174. caller := &loaders.UserDetails{
  1175. UserId: callerId,
  1176. Username: "m3_same_caller",
  1177. MemberType: consts.MemberTypeMember,
  1178. ProductCode: productCode,
  1179. Status: consts.StatusEnabled,
  1180. }
  1181. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 50)
  1182. require.Error(t, err, "与自身同级不允许分配,否则会让下属获得与上级等效的权力")
  1183. var ce *response.CodeError
  1184. require.True(t, errors.As(err, &ce))
  1185. assert.Equal(t, 403, ce.Code())
  1186. assert.Contains(t, ce.Error(), "同级")
  1187. }
  1188. // TC-0932: rolePermsLevel 严格低于 freshLevel(数值更大)时放行。
  1189. func TestGuardRoleLevelAssignable_StrictlyLower_Allowed(t *testing.T) {
  1190. ctrl := gomock.NewController(t)
  1191. t.Cleanup(ctrl.Finish)
  1192. const productCode = "m3_pc_ok"
  1193. const callerId = int64(1003)
  1194. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1195. mockRole.EXPECT().
  1196. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
  1197. Return(int64(50), nil).
  1198. Times(1)
  1199. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1200. caller := &loaders.UserDetails{
  1201. UserId: callerId,
  1202. Username: "m3_ok_caller",
  1203. MemberType: consts.MemberTypeMember,
  1204. ProductCode: productCode,
  1205. Status: consts.StatusEnabled,
  1206. }
  1207. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 80)
  1208. require.NoError(t, err, "permsLevel=80 严格低于 freshLevel=50(数值更大 = 更低权限)应放行")
  1209. }
  1210. // TC-0933: SuperAdmin 完全豁免,不触发任何 DB 查询。
  1211. func TestGuardRoleLevelAssignable_SuperAdmin_BypassNoDBCall(t *testing.T) {
  1212. ctrl := gomock.NewController(t)
  1213. t.Cleanup(ctrl.Finish)
  1214. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1215. // 预期 0 次调用:SuperAdmin 必须短路返回,不能浪费 DB RTT。
  1216. mockRole.EXPECT().
  1217. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
  1218. Times(0)
  1219. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1220. caller := &loaders.UserDetails{
  1221. UserId: 1, Username: "root", IsSuperAdmin: true, Status: consts.StatusEnabled,
  1222. }
  1223. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
  1224. require.NoError(t, err, "SuperAdmin 必须放行任何 permsLevel")
  1225. }
  1226. // TC-0934: 产品 ADMIN 拥有全权,豁免 DB 查询。
  1227. func TestGuardRoleLevelAssignable_ProductAdmin_BypassNoDBCall(t *testing.T) {
  1228. ctrl := gomock.NewController(t)
  1229. t.Cleanup(ctrl.Finish)
  1230. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1231. mockRole.EXPECT().
  1232. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
  1233. Times(0)
  1234. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1235. caller := &loaders.UserDetails{
  1236. UserId: 2, Username: "pa", MemberType: consts.MemberTypeAdmin,
  1237. ProductCode: "p1", Status: consts.StatusEnabled,
  1238. }
  1239. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
  1240. require.NoError(t, err, "产品 ADMIN 属于全权角色,必须豁免等级校验")
  1241. }
  1242. // TC-0935: DEVELOPER 同样享有全权豁免。
  1243. func TestGuardRoleLevelAssignable_Developer_BypassNoDBCall(t *testing.T) {
  1244. ctrl := gomock.NewController(t)
  1245. t.Cleanup(ctrl.Finish)
  1246. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1247. mockRole.EXPECT().
  1248. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
  1249. Times(0)
  1250. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1251. caller := &loaders.UserDetails{
  1252. UserId: 3, Username: "dev", MemberType: consts.MemberTypeDeveloper,
  1253. ProductCode: "p1", Status: consts.StatusEnabled,
  1254. }
  1255. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
  1256. require.NoError(t, err)
  1257. }
  1258. // TC-0936: caller 在 DB 里**无任何角色**(ErrNotFound),必须 403,不能默认为 MaxInt64 放行。
  1259. // 这里的语义是"没有可分配的角色等级":一个 MEMBER 连自己都没角色,自然不能分配角色给别人。
  1260. func TestGuardRoleLevelAssignable_CallerHasNoRole_Rejected(t *testing.T) {
  1261. ctrl := gomock.NewController(t)
  1262. t.Cleanup(ctrl.Finish)
  1263. const productCode = "m3_pc_noRole"
  1264. const callerId = int64(1004)
  1265. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1266. mockRole.EXPECT().
  1267. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
  1268. Return(int64(0), sqlx.ErrNotFound).
  1269. Times(1)
  1270. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1271. caller := &loaders.UserDetails{
  1272. UserId: callerId, Username: "m3_no_role", MemberType: consts.MemberTypeMember,
  1273. ProductCode: productCode, Status: consts.StatusEnabled,
  1274. }
  1275. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 99)
  1276. require.Error(t, err, "caller 无任何角色时必须拒绝,否则会被误判为 MaxInt64 最低级从而放行任何 permsLevel")
  1277. var ce *response.CodeError
  1278. require.True(t, errors.As(err, &ce))
  1279. assert.Equal(t, 403, ce.Code())
  1280. assert.Contains(t, ce.Error(), "没有可分配的角色等级")
  1281. }
  1282. // TC-0937: DB 抖动(非 ErrNotFound)必须 fail-close 返回 500,不得降级为"无角色 → 放行"。
  1283. func TestGuardRoleLevelAssignable_DBError_FailCloseWith500(t *testing.T) {
  1284. ctrl := gomock.NewController(t)
  1285. t.Cleanup(ctrl.Finish)
  1286. const productCode = "m3_pc_dbErr"
  1287. const callerId = int64(1005)
  1288. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1289. mockRole.EXPECT().
  1290. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
  1291. Return(int64(0), errors.New("driver: bad connection")).
  1292. Times(1)
  1293. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1294. caller := &loaders.UserDetails{
  1295. UserId: callerId, Username: "m3_db_err", MemberType: consts.MemberTypeMember,
  1296. ProductCode: productCode, Status: consts.StatusEnabled,
  1297. }
  1298. err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 10)
  1299. require.Error(t, err)
  1300. var ce *response.CodeError
  1301. require.True(t, errors.As(err, &ce))
  1302. assert.Equal(t, 500, ce.Code(),
  1303. "DB 非 ErrNotFound 错误必须 fail-close 500,不能被伪装成 ErrNotFound → 放行超权分配")
  1304. }
  1305. // TC-0938: nil caller 防御:理论上无登录上下文绝不该进入此函数,防御性路径必须 403 而非 panic。
  1306. func TestGuardRoleLevelAssignable_NilCaller_Rejected(t *testing.T) {
  1307. ctrl := gomock.NewController(t)
  1308. t.Cleanup(ctrl.Finish)
  1309. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1310. mockRole.EXPECT().
  1311. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
  1312. Times(0)
  1313. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1314. err := GuardRoleLevelAssignable(context.Background(), svcCtx, nil, 10)
  1315. require.Error(t, err, "nil caller 必须被拦截,杜绝隐式放行")
  1316. var ce *response.CodeError
  1317. require.True(t, errors.As(err, &ce))
  1318. assert.Equal(t, 403, ce.Code())
  1319. }
  1320. // ---------------------------------------------------------------------------
  1321. // 覆盖目标:LoadCallerAssignableLevel 在一次请求内对同一 caller 只做
  1322. // 一次 DB 读;CheckRoleLevelAgainst 不再访问 DB,给 BindRoles 这种"批量覆盖"的接口把
  1323. // N 次 loadFreshMinPermsLevel 合并为 1 次。
  1324. //
  1325. // 核心断言口径:
  1326. // 1. SuperAdmin / ADMIN / DEVELOPER 等全权调用者不打 DB(HasFullPerms=true 短路);
  1327. // 2. MEMBER caller 打 1 次 FindMinPermsLevelByUserIdAndProductCode;
  1328. // 3. caller.ErrNotFound → NoRole=true(不打翻 500);
  1329. // 4. caller 其他 DB 错误 → fail-close 500(保持与 loadFreshMinPermsLevel 一致的口径,
  1330. // 避免降级为"无角色 = 最低级"放行)。
  1331. // 5. CheckRoleLevelAgainst 是纯函数,不访问 svcCtx。
  1332. // ---------------------------------------------------------------------------
  1333. // TC-1017: SuperAdmin / ADMIN / DEVELOPER 走 HasFullPerms 短路,不触碰 DB。
  1334. func TestLoadCallerAssignableLevel_FullPermsShortCircuit_NoDB(t *testing.T) {
  1335. ctrl := gomock.NewController(t)
  1336. t.Cleanup(ctrl.Finish)
  1337. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1338. // 关键:没有 EXPECT.FindMinPermsLevelByUserIdAndProductCode —— 一旦被调用会 fail。
  1339. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1340. cases := []struct {
  1341. name string
  1342. caller *loaders.UserDetails
  1343. }{
  1344. {
  1345. name: "SuperAdmin",
  1346. caller: &loaders.UserDetails{UserId: 1, IsSuperAdmin: true, ProductCode: "p"},
  1347. },
  1348. {
  1349. name: "ADMIN",
  1350. caller: &loaders.UserDetails{UserId: 2, MemberType: consts.MemberTypeAdmin, ProductCode: "p"},
  1351. },
  1352. {
  1353. name: "DEVELOPER",
  1354. caller: &loaders.UserDetails{UserId: 3, MemberType: consts.MemberTypeDeveloper, ProductCode: "p"},
  1355. },
  1356. }
  1357. for _, c := range cases {
  1358. t.Run(c.name, func(t *testing.T) {
  1359. snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, c.caller)
  1360. require.NoError(t, err)
  1361. assert.True(t, snap.HasFullPerms, "全权调用者必须落 HasFullPerms 分支")
  1362. assert.False(t, snap.NoRole)
  1363. })
  1364. }
  1365. }
  1366. // TC-1018: MEMBER caller 仅打 1 次 FindMinPermsLevelByUserIdAndProductCode;
  1367. // 循环内对 N 个角色走 CheckRoleLevelAgainst 不再打 DB。
  1368. func TestLoadCallerAssignableLevel_Member_ReadsDBOnce_ThenConstantTime(t *testing.T) {
  1369. ctrl := gomock.NewController(t)
  1370. t.Cleanup(ctrl.Finish)
  1371. const callerId = int64(1001)
  1372. const productCode = "pc_m_r10_3"
  1373. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1374. // 关键断言:Times(1) 保证 N 个角色场景不会退化为 N 次 DB 读。
  1375. mockRole.EXPECT().
  1376. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
  1377. Return(int64(100), nil).
  1378. Times(1)
  1379. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1380. caller := &loaders.UserDetails{
  1381. UserId: callerId,
  1382. MemberType: consts.MemberTypeMember,
  1383. ProductCode: productCode,
  1384. }
  1385. snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
  1386. require.NoError(t, err)
  1387. assert.False(t, snap.HasFullPerms)
  1388. assert.False(t, snap.NoRole)
  1389. assert.Equal(t, int64(100), snap.Level)
  1390. // 模拟 BindRoles 批量覆盖的循环:5 个角色,全部走 CheckRoleLevelAgainst 的纯比较,
  1391. // 任何一个角色额外打 DB 都会命中 gomock 的 "unexpected call" 断言。
  1392. roleLevels := []int64{200, 150, 300, 120, 999}
  1393. for _, rl := range roleLevels {
  1394. if err := CheckRoleLevelAgainst(snap, rl); err != nil {
  1395. t.Fatalf("role level %d should be assignable against caller level %d: %v", rl, snap.Level, err)
  1396. }
  1397. }
  1398. // 同级与更高级一律拒绝(与 GuardRoleLevelAssignable 对称):
  1399. for _, rl := range []int64{100, 50, 1} {
  1400. err := CheckRoleLevelAgainst(snap, rl)
  1401. var codeErr *response.CodeError
  1402. require.True(t, errors.As(err, &codeErr), "同级或更高级必须返回 CodeError")
  1403. assert.Equal(t, 403, codeErr.Code())
  1404. }
  1405. }
  1406. // TC-1019: caller 在该产品下无角色 → NoRole=true,不回滚 500。
  1407. func TestLoadCallerAssignableLevel_Member_ErrNotFound_MapsToNoRole(t *testing.T) {
  1408. ctrl := gomock.NewController(t)
  1409. t.Cleanup(ctrl.Finish)
  1410. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1411. mockRole.EXPECT().
  1412. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc").
  1413. Return(int64(0), sqlx.ErrNotFound).
  1414. Times(1)
  1415. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1416. caller := &loaders.UserDetails{
  1417. UserId: 42,
  1418. MemberType: consts.MemberTypeMember,
  1419. ProductCode: "pc",
  1420. }
  1421. snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
  1422. require.NoError(t, err, "ErrNotFound 必须被归一为 NoRole=true,不得外泄为 500")
  1423. assert.False(t, snap.HasFullPerms)
  1424. assert.True(t, snap.NoRole)
  1425. // 验证 NoRole 的 caller 连最低级角色也无法分配(与修复前保持的业务语义一致)。
  1426. err = CheckRoleLevelAgainst(snap, 999)
  1427. var codeErr *response.CodeError
  1428. require.True(t, errors.As(err, &codeErr))
  1429. assert.Equal(t, 403, codeErr.Code())
  1430. assert.Contains(t, codeErr.Error(), "没有可分配的角色等级")
  1431. }
  1432. // TC-1020: caller 其他 DB 错误必须 fail-close 500,不得降级为 NoRole 放行。
  1433. // 保证修复没有把"DB 抖动"悄悄压成"无角色 → 最低级 → 403"这种语义欺骗。
  1434. func TestLoadCallerAssignableLevel_Member_DBError_FailClose500(t *testing.T) {
  1435. ctrl := gomock.NewController(t)
  1436. t.Cleanup(ctrl.Finish)
  1437. dbErr := errors.New("driver: bad connection")
  1438. mockRole := mocks.NewMockSysRoleModel(ctrl)
  1439. mockRole.EXPECT().
  1440. FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(7), "pc").
  1441. Return(int64(0), dbErr).
  1442. Times(1)
  1443. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
  1444. caller := &loaders.UserDetails{
  1445. UserId: 7,
  1446. MemberType: consts.MemberTypeMember,
  1447. ProductCode: "pc",
  1448. }
  1449. _, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
  1450. var codeErr *response.CodeError
  1451. require.True(t, errors.As(err, &codeErr), "DB 抖动必须 fail-close 为 CodeError 而非 nil")
  1452. assert.Equal(t, 500, codeErr.Code())
  1453. }