access_test.go 66 KB

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