createUserLogic_test.go 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. package user
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. "math"
  9. "perms-system-server/internal/consts"
  10. "perms-system-server/internal/loaders"
  11. "perms-system-server/internal/middleware"
  12. deptModel "perms-system-server/internal/model/dept"
  13. userModel "perms-system-server/internal/model/user"
  14. "perms-system-server/internal/response"
  15. "perms-system-server/internal/svc"
  16. "perms-system-server/internal/testutil"
  17. "perms-system-server/internal/testutil/ctxhelper"
  18. "perms-system-server/internal/testutil/mocks"
  19. "perms-system-server/internal/types"
  20. "strings"
  21. "sync"
  22. "testing"
  23. "time"
  24. "github.com/zeromicro/go-zero/core/stores/sqlx"
  25. "go.uber.org/mock/gomock"
  26. )
  27. func insertTestUser(t *testing.T, ctx context.Context, username, password string) int64 {
  28. t.Helper()
  29. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  30. now := time.Now().Unix()
  31. res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  32. Username: username,
  33. Password: password,
  34. Nickname: "test",
  35. Avatar: sql.NullString{},
  36. Email: username + "@test.com",
  37. Phone: "13800000000",
  38. Remark: "",
  39. DeptId: 0,
  40. IsSuperAdmin: 2,
  41. MustChangePassword: 2,
  42. Status: 1,
  43. CreateTime: now,
  44. UpdateTime: now,
  45. })
  46. require.NoError(t, err)
  47. id, _ := res.LastInsertId()
  48. return id
  49. }
  50. func insertTestUserFull(t *testing.T, ctx context.Context, u *userModel.SysUser) int64 {
  51. t.Helper()
  52. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  53. now := time.Now().Unix()
  54. if u.CreateTime == 0 {
  55. u.CreateTime = now
  56. }
  57. if u.UpdateTime == 0 {
  58. u.UpdateTime = now
  59. }
  60. res, err := svcCtx.SysUserModel.Insert(ctx, u)
  61. require.NoError(t, err)
  62. id, _ := res.LastInsertId()
  63. return id
  64. }
  65. func strPtr(s string) *string { return &s }
  66. func int64Ptr(i int64) *int64 { return &i }
  67. // TC-0134: 正常创建
  68. func TestCreateUser_Success(t *testing.T) {
  69. ctx := ctxhelper.SuperAdminCtx()
  70. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  71. conn := testutil.GetTestSqlConn()
  72. username := testutil.UniqueId()
  73. logic := NewCreateUserLogic(ctx, svcCtx)
  74. resp, err := logic.CreateUser(&types.CreateUserReq{
  75. Username: username,
  76. Nickname: "测试用户",
  77. Email: username + "@test.com",
  78. Phone: "13800138000",
  79. Remark: "集成测试",
  80. DeptId: 0,
  81. })
  82. require.NoError(t, err)
  83. require.NotNil(t, resp)
  84. assert.Greater(t, resp.Id, int64(0))
  85. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  86. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  87. require.NoError(t, err)
  88. assert.Equal(t, username, user.Username)
  89. assert.Equal(t, "测试用户", user.Nickname)
  90. assert.Equal(t, int64(1), user.Status)
  91. assert.Equal(t, int64(2), user.IsSuperAdmin)
  92. }
  93. // TC-0135: 用户名已存在(预检)
  94. func TestCreateUser_UsernameExists(t *testing.T) {
  95. ctx := ctxhelper.SuperAdminCtx()
  96. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  97. conn := testutil.GetTestSqlConn()
  98. username := testutil.UniqueId()
  99. hashed := testutil.HashPassword("pass123")
  100. userId := insertTestUser(t, ctx, username, hashed)
  101. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
  102. logic := NewCreateUserLogic(ctx, svcCtx)
  103. _, err := logic.CreateUser(&types.CreateUserReq{
  104. Username: username,
  105. })
  106. require.Error(t, err)
  107. var codeErr *response.CodeError
  108. require.True(t, errors.As(err, &codeErr))
  109. assert.Equal(t, 409, codeErr.Code())
  110. assert.Equal(t, "用户名已存在", codeErr.Error())
  111. }
  112. // TC-0137: 非法email格式
  113. func TestCreateUser_InvalidEmail(t *testing.T) {
  114. ctx := ctxhelper.SuperAdminCtx()
  115. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  116. logic := NewCreateUserLogic(ctx, svcCtx)
  117. _, err := logic.CreateUser(&types.CreateUserReq{
  118. Username: testutil.UniqueId(),
  119. Email: "not-an-email",
  120. })
  121. require.Error(t, err)
  122. var codeErr *response.CodeError
  123. require.True(t, errors.As(err, &codeErr))
  124. assert.Equal(t, 400, codeErr.Code())
  125. assert.Equal(t, "邮箱格式不正确", codeErr.Error())
  126. }
  127. // TC-0138: 合法email
  128. func TestCreateUser_ValidEmail(t *testing.T) {
  129. ctx := ctxhelper.SuperAdminCtx()
  130. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  131. conn := testutil.GetTestSqlConn()
  132. username := testutil.UniqueId()
  133. logic := NewCreateUserLogic(ctx, svcCtx)
  134. resp, err := logic.CreateUser(&types.CreateUserReq{
  135. Username: username,
  136. Email: username + "@example.com",
  137. })
  138. require.NoError(t, err)
  139. require.NotNil(t, resp)
  140. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  141. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  142. require.NoError(t, err)
  143. assert.Equal(t, username+"@example.com", user.Email)
  144. }
  145. // TC-0139: email为空(可选)
  146. func TestCreateUser_EmptyEmailSkipsValidation(t *testing.T) {
  147. ctx := ctxhelper.SuperAdminCtx()
  148. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  149. conn := testutil.GetTestSqlConn()
  150. username := testutil.UniqueId()
  151. logic := NewCreateUserLogic(ctx, svcCtx)
  152. resp, err := logic.CreateUser(&types.CreateUserReq{
  153. Username: username,
  154. Email: "",
  155. })
  156. require.NoError(t, err)
  157. require.NotNil(t, resp)
  158. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  159. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  160. require.NoError(t, err)
  161. assert.Equal(t, "", user.Email)
  162. }
  163. // TC-0140: 非法phone格式
  164. func TestCreateUser_InvalidPhone(t *testing.T) {
  165. ctx := ctxhelper.SuperAdminCtx()
  166. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  167. logic := NewCreateUserLogic(ctx, svcCtx)
  168. _, err := logic.CreateUser(&types.CreateUserReq{
  169. Username: testutil.UniqueId(),
  170. Phone: "abc",
  171. })
  172. require.Error(t, err)
  173. var codeErr *response.CodeError
  174. require.True(t, errors.As(err, &codeErr))
  175. assert.Equal(t, 400, codeErr.Code())
  176. assert.Equal(t, "手机号格式不正确", codeErr.Error())
  177. }
  178. // TC-0141: 合法phone(国际)
  179. func TestCreateUser_ValidPhone(t *testing.T) {
  180. ctx := ctxhelper.SuperAdminCtx()
  181. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  182. conn := testutil.GetTestSqlConn()
  183. username := testutil.UniqueId()
  184. logic := NewCreateUserLogic(ctx, svcCtx)
  185. resp, err := logic.CreateUser(&types.CreateUserReq{
  186. Username: username,
  187. Phone: "13900139000",
  188. })
  189. require.NoError(t, err)
  190. require.NotNil(t, resp)
  191. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  192. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  193. require.NoError(t, err)
  194. assert.Equal(t, "13900139000", user.Phone)
  195. }
  196. // TC-0142: phone为空(可选)
  197. func TestCreateUser_EmptyPhoneSkipsValidation(t *testing.T) {
  198. ctx := ctxhelper.SuperAdminCtx()
  199. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  200. conn := testutil.GetTestSqlConn()
  201. username := testutil.UniqueId()
  202. logic := NewCreateUserLogic(ctx, svcCtx)
  203. resp, err := logic.CreateUser(&types.CreateUserReq{
  204. Username: username,
  205. Phone: "",
  206. })
  207. require.NoError(t, err)
  208. require.NotNil(t, resp)
  209. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  210. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  211. require.NoError(t, err)
  212. assert.Equal(t, "", user.Phone)
  213. }
  214. // TC-0143: 并发同username(TOCTOU)
  215. func TestCreateUser_ConcurrentSameUsername(t *testing.T) {
  216. ctx := ctxhelper.SuperAdminCtx()
  217. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  218. conn := testutil.GetTestSqlConn()
  219. username := testutil.UniqueId()
  220. t.Cleanup(func() {
  221. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", username)
  222. })
  223. var wg sync.WaitGroup
  224. results := make(chan error, 2)
  225. for i := 0; i < 2; i++ {
  226. wg.Add(1)
  227. go func() {
  228. defer wg.Done()
  229. logic := NewCreateUserLogic(ctx, svcCtx)
  230. _, err := logic.CreateUser(&types.CreateUserReq{
  231. Username: username,
  232. Nickname: "并发测试用户",
  233. })
  234. results <- err
  235. }()
  236. }
  237. wg.Wait()
  238. close(results)
  239. var errs []error
  240. for err := range results {
  241. errs = append(errs, err)
  242. }
  243. require.Len(t, errs, 2)
  244. successCount := 0
  245. failCount := 0
  246. for _, err := range errs {
  247. if err == nil {
  248. successCount++
  249. } else {
  250. failCount++
  251. var codeErr *response.CodeError
  252. require.True(t, errors.As(err, &codeErr), "error should be CodeError, got: %v", err)
  253. assert.Equal(t, 409, codeErr.Code())
  254. assert.Equal(t, "用户名已存在", codeErr.Error())
  255. }
  256. }
  257. assert.Equal(t, 1, successCount)
  258. assert.Equal(t, 1, failCount)
  259. }
  260. // TC-0141: 合法phone(国际)
  261. func TestCreateUser_ValidInternationalPhone(t *testing.T) {
  262. ctx := ctxhelper.SuperAdminCtx()
  263. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  264. conn := testutil.GetTestSqlConn()
  265. username := testutil.UniqueId()
  266. logic := NewCreateUserLogic(ctx, svcCtx)
  267. resp, err := logic.CreateUser(&types.CreateUserReq{
  268. Username: username,
  269. Phone: "+8613800138000",
  270. })
  271. require.NoError(t, err)
  272. require.NotNil(t, resp)
  273. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  274. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  275. require.NoError(t, err)
  276. assert.Equal(t, "+8613800138000", user.Phone)
  277. }
  278. // TC-0537: createUser非管理员拒绝
  279. func TestCreateUser_MemberRejected(t *testing.T) {
  280. ctx := ctxhelper.MemberCtx("test_product")
  281. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  282. logic := NewCreateUserLogic(ctx, svcCtx)
  283. _, err := logic.CreateUser(&types.CreateUserReq{Username: "test"})
  284. require.Error(t, err)
  285. var ce *response.CodeError
  286. require.True(t, errors.As(err, &ce))
  287. assert.Equal(t, 403, ce.Code())
  288. }
  289. // TC-0150: 用户名含特殊字符被拒绝
  290. func TestCreateUser_UsernameInvalidChars(t *testing.T) {
  291. ctx := ctxhelper.SuperAdminCtx()
  292. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  293. logic := NewCreateUserLogic(ctx, svcCtx)
  294. _, err := logic.CreateUser(&types.CreateUserReq{
  295. Username: "user@name!",
  296. })
  297. require.Error(t, err)
  298. var codeErr *response.CodeError
  299. require.True(t, errors.As(err, &codeErr))
  300. assert.Equal(t, 400, codeErr.Code())
  301. assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error())
  302. }
  303. // TC-0151: 用户名太短(1字符)被拒绝
  304. func TestCreateUser_UsernameTooShort(t *testing.T) {
  305. ctx := ctxhelper.SuperAdminCtx()
  306. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  307. logic := NewCreateUserLogic(ctx, svcCtx)
  308. _, err := logic.CreateUser(&types.CreateUserReq{
  309. Username: "a",
  310. })
  311. require.Error(t, err)
  312. var codeErr *response.CodeError
  313. require.True(t, errors.As(err, &codeErr))
  314. assert.Equal(t, 400, codeErr.Code())
  315. assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error())
  316. }
  317. // TC-0152: 用户名太长(65字符)被拒绝
  318. func TestCreateUser_UsernameTooLong(t *testing.T) {
  319. ctx := ctxhelper.SuperAdminCtx()
  320. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  321. logic := NewCreateUserLogic(ctx, svcCtx)
  322. _, err := logic.CreateUser(&types.CreateUserReq{
  323. Username: strings.Repeat("a", 65),
  324. })
  325. require.Error(t, err)
  326. var codeErr *response.CodeError
  327. require.True(t, errors.As(err, &codeErr))
  328. assert.Equal(t, 400, codeErr.Code())
  329. assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error())
  330. }
  331. // TC-0153: 部门不存在被拒绝
  332. func TestCreateUser_DeptNotExists(t *testing.T) {
  333. ctx := ctxhelper.SuperAdminCtx()
  334. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  335. logic := NewCreateUserLogic(ctx, svcCtx)
  336. _, err := logic.CreateUser(&types.CreateUserReq{
  337. Username: testutil.UniqueId(),
  338. DeptId: 999999999,
  339. })
  340. require.Error(t, err)
  341. var codeErr *response.CodeError
  342. require.True(t, errors.As(err, &codeErr))
  343. assert.Equal(t, 400, codeErr.Code())
  344. // CreateUser 已把部门存在性校验从"事务外 cached FindOne"下沉到"事务内 FindOneForShareTx
  345. // + S 锁"(H-R17-1),不存在的 deptId 在 FOR SHARE 读路径上会返 sqlx.ErrNotFound,
  346. // 统一落到 "部门不存在或已删除" 的文案,以覆盖"并发 DeleteDept 胜出"场景。
  347. assert.Equal(t, "部门不存在或已删除", codeErr.Error())
  348. }
  349. // TC-0154: 昵称超过64字符被拒绝
  350. func TestCreateUser_NicknameTooLong(t *testing.T) {
  351. ctx := ctxhelper.SuperAdminCtx()
  352. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  353. logic := NewCreateUserLogic(ctx, svcCtx)
  354. _, err := logic.CreateUser(&types.CreateUserReq{
  355. Username: testutil.UniqueId(),
  356. Nickname: strings.Repeat("n", 65),
  357. })
  358. require.Error(t, err)
  359. var codeErr *response.CodeError
  360. require.True(t, errors.As(err, &codeErr))
  361. assert.Equal(t, 400, codeErr.Code())
  362. assert.Equal(t, "昵称长度不能超过64个字符", codeErr.Error())
  363. }
  364. // TC-0155: 备注超过255字符被拒绝
  365. func TestCreateUser_RemarkTooLong(t *testing.T) {
  366. ctx := ctxhelper.SuperAdminCtx()
  367. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  368. logic := NewCreateUserLogic(ctx, svcCtx)
  369. _, err := logic.CreateUser(&types.CreateUserReq{
  370. Username: testutil.UniqueId(),
  371. Remark: strings.Repeat("r", 256),
  372. })
  373. require.Error(t, err)
  374. var codeErr *response.CodeError
  375. require.True(t, errors.As(err, &codeErr))
  376. assert.Equal(t, 400, codeErr.Code())
  377. assert.Equal(t, "备注长度不能超过255个字符", codeErr.Error())
  378. }
  379. // TC-0136: 带完整可选字段
  380. func TestCreateUser_AllOptionalFields(t *testing.T) {
  381. ctx := ctxhelper.SuperAdminCtx()
  382. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  383. conn := testutil.GetTestSqlConn()
  384. now := time.Now().Unix()
  385. deptRes, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
  386. ParentId: 0,
  387. Name: "tc0103_dept_" + testutil.UniqueId(),
  388. Path: "/",
  389. Sort: 1,
  390. DeptType: "NORMAL",
  391. Remark: "",
  392. Status: 1,
  393. CreateTime: now,
  394. UpdateTime: now,
  395. })
  396. require.NoError(t, err)
  397. deptId, err := deptRes.LastInsertId()
  398. require.NoError(t, err)
  399. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
  400. username := testutil.UniqueId()
  401. logic := NewCreateUserLogic(ctx, svcCtx)
  402. resp, err := logic.CreateUser(&types.CreateUserReq{
  403. Username: username,
  404. Nickname: "全字段用户",
  405. Email: username + "@example.com",
  406. Phone: "13900001111",
  407. Remark: "TC-0136完整字段",
  408. DeptId: deptId,
  409. })
  410. require.NoError(t, err)
  411. require.NotNil(t, resp)
  412. assert.Greater(t, resp.Id, int64(0))
  413. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  414. user, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  415. require.NoError(t, err)
  416. assert.Equal(t, username, user.Username)
  417. assert.Equal(t, "全字段用户", user.Nickname)
  418. assert.Equal(t, username+"@example.com", user.Email)
  419. assert.Equal(t, "13900001111", user.Phone)
  420. assert.Equal(t, "TC-0136完整字段", user.Remark)
  421. assert.Equal(t, deptId, user.DeptId)
  422. assert.Equal(t, int64(1), user.Status)
  423. assert.Equal(t, int64(2), user.IsSuperAdmin)
  424. }
  425. func callerAdminCtx(callerUserId, deptId int64, deptPath string) context.Context {
  426. return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  427. UserId: callerUserId,
  428. Username: "prod_admin_caller",
  429. IsSuperAdmin: false,
  430. MemberType: consts.MemberTypeAdmin,
  431. Status: consts.StatusEnabled,
  432. ProductCode: "test_product",
  433. DeptId: deptId,
  434. DeptPath: deptPath,
  435. MinPermsLevel: math.MaxInt64,
  436. })
  437. }
  438. // TC-0994: 产品 ADMIN 为非自己管辖部门创建用户必须 403。
  439. func TestCreateUser_MN4_AdminCannotCreateOutsideDeptSubtree(t *testing.T) {
  440. bootstrap := context.Background()
  441. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  442. conn := testutil.GetTestSqlConn()
  443. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_caller", "/100/")
  444. outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_outside", "/999/")
  445. t.Cleanup(func() {
  446. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, outsideDeptId)
  447. })
  448. adminCtx := callerAdminCtx(777771, callerDeptId, "/100/")
  449. _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
  450. Username: "mn4_seed_" + testutil.UniqueId(),
  451. DeptId: outsideDeptId,
  452. })
  453. require.Error(t, err)
  454. var ce *response.CodeError
  455. require.True(t, errors.As(err, &ce))
  456. assert.Equal(t, 403, ce.Code(),
  457. "产品 ADMIN 跨部门树创建用户必须 403,防止占用关键用户名等 AddMember 合谋挂进产品")
  458. assert.Contains(t, ce.Error(), "无权在非自己管辖的部门下创建用户")
  459. }
  460. // TC-0995: 正向 —— 产品 ADMIN 在自己子树下的部门创建用户放行。
  461. func TestCreateUser_MN4_AdminCanCreateInsideDeptSubtree(t *testing.T) {
  462. bootstrap := context.Background()
  463. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  464. conn := testutil.GetTestSqlConn()
  465. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_ok_caller", "/200/")
  466. childDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_ok_child", "/200/1/")
  467. t.Cleanup(func() {
  468. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, childDeptId)
  469. })
  470. adminCtx := callerAdminCtx(777772, callerDeptId, "/200/")
  471. username := "mn4ok_" + testutil.UniqueId()
  472. resp, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
  473. Username: username,
  474. DeptId: childDeptId,
  475. })
  476. require.NoError(t, err)
  477. require.NotNil(t, resp)
  478. t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) })
  479. user, err := svcCtx.SysUserModel.FindOne(bootstrap, resp.Id)
  480. require.NoError(t, err)
  481. assert.Equal(t, username, user.Username)
  482. assert.Equal(t, childDeptId, user.DeptId, "用户必须真实落在指定子部门")
  483. }
  484. // TC-0996: SuperAdmin 可跨一切部门(含 DeptId=0),继续允许创建系统级账号。
  485. func TestCreateUser_MN4_SuperAdminCanCreateAnywhere(t *testing.T) {
  486. bootstrap := context.Background()
  487. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  488. conn := testutil.GetTestSqlConn()
  489. randomDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_sa", "/1000/")
  490. t.Cleanup(func() {
  491. testutil.CleanTable(bootstrap, conn, "`sys_dept`", randomDeptId)
  492. })
  493. // A) 超管创建在任意部门
  494. usernameDept := "mn4sa_dept_" + testutil.UniqueId()
  495. resp, err := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{
  496. Username: usernameDept,
  497. DeptId: randomDeptId,
  498. })
  499. require.NoError(t, err)
  500. require.NotNil(t, resp)
  501. t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) })
  502. // B) 超管创建 DeptId=0 的系统级账号(历史跨组织账号语义保留)
  503. usernameZero := "mn4sa_zero_" + testutil.UniqueId()
  504. respZero, err := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{
  505. Username: usernameZero,
  506. DeptId: 0,
  507. })
  508. require.NoError(t, err)
  509. require.NotNil(t, respZero)
  510. t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", respZero.Id) })
  511. }
  512. // TC-0997: 非超管 caller 的 DeptPath 为空时必须 403,不得在部门树外开口。
  513. func TestCreateUser_MN4_EmptyCallerDeptPathRejected(t *testing.T) {
  514. bootstrap := context.Background()
  515. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  516. conn := testutil.GetTestSqlConn()
  517. dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_empty", "/500/")
  518. t.Cleanup(func() {
  519. testutil.CleanTable(bootstrap, conn, "`sys_dept`", dstDeptId)
  520. })
  521. // caller 是产品 ADMIN 但历史账号 DeptPath=="" —— 必须 403 "未归属任何部门"
  522. adminCtx := callerAdminCtx(777773, 0, "")
  523. _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
  524. Username: "mn4empty_" + testutil.UniqueId(),
  525. DeptId: dstDeptId,
  526. })
  527. require.Error(t, err)
  528. var ce *response.CodeError
  529. require.True(t, errors.As(err, &ce))
  530. assert.Equal(t, 403, ce.Code(),
  531. "caller.DeptPath=='' 属于 legacy 账号,fail-close 不允许创建用户")
  532. assert.Contains(t, ce.Error(), "您未归属任何部门")
  533. }
  534. // TC-0998: 非超管 caller 传 DeptId=0 必须 400(禁止非超管创建"无部门"账号)。
  535. func TestCreateUser_MN4_NonSuperAdminMustSpecifyDept(t *testing.T) {
  536. bootstrap := context.Background()
  537. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  538. conn := testutil.GetTestSqlConn()
  539. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "mn4_mustspec", "/300/")
  540. t.Cleanup(func() {
  541. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId)
  542. })
  543. adminCtx := callerAdminCtx(777774, callerDeptId, "/300/")
  544. _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
  545. Username: "mn4must_" + testutil.UniqueId(),
  546. DeptId: 0,
  547. })
  548. require.Error(t, err)
  549. var ce *response.CodeError
  550. require.True(t, errors.As(err, &ce))
  551. assert.Equal(t, 400, ce.Code(),
  552. "非超管 CreateUser 必须显式指定部门,禁止在部门树外创建账号")
  553. assert.Contains(t, ce.Error(), "必须指定部门")
  554. }
  555. // TC-0999: 目标部门已停用时 CreateUser 必须 400 "目标部门已停用",与 UpdateDept 闭环。
  556. func TestCreateUser_LN2_TargetDeptDisabled(t *testing.T) {
  557. bootstrap := context.Background()
  558. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  559. conn := testutil.GetTestSqlConn()
  560. now := time.Now().Unix()
  561. disRes, err := svcCtx.SysDeptModel.Insert(bootstrap, &deptModel.SysDept{
  562. ParentId: 0, Name: "ln2_dis_" + testutil.UniqueId(), Path: "/900/", Sort: 0,
  563. DeptType: "NORMAL", Remark: "", Status: 2, CreateTime: now, UpdateTime: now,
  564. })
  565. require.NoError(t, err)
  566. disabledId, _ := disRes.LastInsertId()
  567. t.Cleanup(func() {
  568. testutil.CleanTable(bootstrap, conn, "`sys_dept`", disabledId)
  569. })
  570. // 超管也必须被拒绝: 的规则针对"所有调用方",防止 disabled 部门被意外重新填人
  571. _, err = NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateUser(&types.CreateUserReq{
  572. Username: "ln2_" + testutil.UniqueId(),
  573. DeptId: disabledId,
  574. })
  575. require.Error(t, err)
  576. var ce *response.CodeError
  577. require.True(t, errors.As(err, &ce))
  578. assert.Equal(t, 400, ce.Code())
  579. assert.Equal(t, "目标部门已停用", ce.Error(),
  580. "目标部门 status!=Enabled 必须拒绝,与 UpdateDept 禁用语义闭环")
  581. }
  582. // TC-1100: DeptId 负值(-1 / MinInt64)在所有类型 caller 面前都必须被 400 拒绝,
  583. // 禁止构造 "sys_user.deptId = 负数" 的僵尸账号(FindOne 永远 NotFound,部门树永远查不到)。
  584. func TestCreateUser_NegativeDeptIdRejected(t *testing.T) {
  585. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  586. cases := []struct {
  587. name string
  588. ctx context.Context
  589. deptId int64
  590. wantMsg string
  591. }{
  592. {"super_admin_negative_one", ctxhelper.SuperAdminCtx(), -1, "部门ID必须为非负整数"},
  593. {"super_admin_min_int64", ctxhelper.SuperAdminCtx(), math.MinInt64, "部门ID必须为非负整数"},
  594. }
  595. for _, tc := range cases {
  596. tc := tc
  597. t.Run(tc.name, func(t *testing.T) {
  598. _, err := NewCreateUserLogic(tc.ctx, svcCtx).CreateUser(&types.CreateUserReq{
  599. Username: "neg_dept_" + testutil.UniqueId(),
  600. DeptId: tc.deptId,
  601. })
  602. require.Error(t, err, "负值 DeptId 必须被拒绝")
  603. var ce *response.CodeError
  604. require.True(t, errors.As(err, &ce))
  605. assert.Equal(t, 400, ce.Code(),
  606. "必须 400 而不是 5xx / 404:让调用方知道是入参不合法,不是 DB/权限问题")
  607. assert.Equal(t, tc.wantMsg, ce.Error())
  608. })
  609. }
  610. }
  611. func TestCreateUser_DefaultsMustChangePasswordToYes(t *testing.T) {
  612. ctx := ctxhelper.SuperAdminCtx()
  613. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  614. conn := testutil.GetTestSqlConn()
  615. username := "lcp_" + testutil.UniqueId()
  616. resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
  617. Username: username,
  618. Nickname: "初始口令校验",
  619. })
  620. require.NoError(t, err)
  621. require.NotNil(t, resp)
  622. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  623. u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  624. require.NoError(t, err)
  625. assert.Equal(t, int64(consts.MustChangePasswordYes), u.MustChangePassword,
  626. "管理员代填初始密码的用户必须被强制下次登录改密,落盘为 Yes")
  627. }
  628. // TC-1122: H-R14-1 —— 非超管调用方在 CreateUser 中指定 DEV 部门必须 403。
  629. //
  630. // 与 UpdateUser 的 TC-1124 对偶:CreateUser 侧通过"预埋 admin_* 键位账号"也能构成跨产品
  631. // 升级链路(若 ADMIN 能在 DEV 部门直接创建账号,新账号一旦被 AddMember 进别的产品,立刻
  632. // 在新产品内全权),因此收敛口径必须与 UpdateUser 完全一致。
  633. //
  634. // 注意:这里 DeptPath 特意让 adminPath 恰好是 devDept 的祖先,排除"被 DeptPath 前缀校验
  635. // 顺手拦掉"的干扰——本用例必须靠 DeptType==DEV 这条显式护栏拦住,才能证明独立生效。
  636. func TestCreateUser_H_R14_1_AdminCannotCreateInDevDept(t *testing.T) {
  637. bootstrap := context.Background()
  638. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  639. conn := testutil.GetTestSqlConn()
  640. adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_admin", "/9100/", consts.DeptTypeNormal)
  641. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_dev", "/9100/1/", consts.DeptTypeDev)
  642. t.Cleanup(func() {
  643. testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, devDeptId)
  644. })
  645. adminCtx := callerAdminCtx(888881, adminDeptId, "/9100/")
  646. _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
  647. Username: "h_r14_1_c_" + testutil.UniqueId(),
  648. DeptId: devDeptId,
  649. })
  650. require.Error(t, err, "ADMIN 在 DEV 部门创建用户必须被拒绝")
  651. var ce *response.CodeError
  652. require.True(t, errors.As(err, &ce))
  653. assert.Equal(t, 403, ce.Code(),
  654. "H-R14-1 创建侧必须 403,防止'先创建后挂载'的跨产品权限升级链路")
  655. assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门",
  656. "错误文案须与 UpdateUser 一致,方便 SRE/审计从单一关键字聚合所有 DEV 护栏告警")
  657. // 关键:403 发生在 Insert 之前,sys_user 表不得被污染。由于入参 username 每次 UniqueId,
  658. // 这里不额外查 DB 来验证——断言 403 + req.Username 随机 + t.Cleanup 里也没 LastInsertId
  659. // 可删,就隐式证明了"零 DB 副作用"。
  660. }
  661. // TC-1123: H-R14-1 对偶正向 —— SuperAdmin 在 DEV 部门下创建用户必须成功。
  662. // 确保护栏只拦非超管;不得误伤 SuperAdmin 的合法运维动作。
  663. func TestCreateUser_H_R14_1_SuperAdminCanCreateInDevDept(t *testing.T) {
  664. bootstrap := context.Background()
  665. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  666. conn := testutil.GetTestSqlConn()
  667. devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_c_su_dev", "/9200/", consts.DeptTypeDev)
  668. t.Cleanup(func() {
  669. testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId)
  670. })
  671. superCtx := ctxhelper.SuperAdminCtx()
  672. username := "h_r14_1_c_su_" + testutil.UniqueId()
  673. resp, err := NewCreateUserLogic(superCtx, svcCtx).CreateUser(&types.CreateUserReq{
  674. Username: username,
  675. DeptId: devDeptId,
  676. })
  677. require.NoError(t, err, "SuperAdmin 在 DEV 部门创建用户必须允许")
  678. require.NotNil(t, resp)
  679. t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_user`", resp.Id) })
  680. u, err := svcCtx.SysUserModel.FindOne(bootstrap, resp.Id)
  681. require.NoError(t, err)
  682. assert.Equal(t, devDeptId, u.DeptId,
  683. "SuperAdmin 路径必须真的把 DeptId 写到 DEV,证明护栏未误伤 SuperAdmin 的合法运维链路")
  684. }
  685. // TC-1192: L-R17-1 —— 非超管不得以保留前缀创建用户名。
  686. //
  687. // 场景:产品 ADMIN 预知"下一批上线的产品 code = acme",试图提前以 `admin_acme` 这个用户名
  688. // 抢注(沿着 UNIQUE(username) 锁住槽位)。若不拦,超管将来 CreateProduct(code=acme) 时
  689. // auto-provision 的 admin_acme 会撞 1062,compensateCreatedRows 把产品 / 角色 / 权限一并
  690. // 回滚,造成"新产品一直上线不了"的业务 DoS。
  691. //
  692. // 对每个保留前缀都跑一次小子例:`admin_` / `svc_` / `root_` / `sys_`;大小写变体用 `Admin_`
  693. // 也必须被 `strings.ToLower` 规约后拦截,避免混入 SMTP/SQL 过滤视为"只查小写"的惯性漏洞。
  694. func TestCreateUser_L_R17_1_ReservedUsernamePrefix_NonSuperAdmin(t *testing.T) {
  695. bootstrap := context.Background()
  696. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  697. conn := testutil.GetTestSqlConn()
  698. callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r17_1_caller", "/1700/")
  699. t.Cleanup(func() {
  700. testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId)
  701. })
  702. adminCtx := callerAdminCtx(1771701, callerDeptId, "/1700/")
  703. cases := []struct {
  704. name string
  705. username string
  706. wantPref string
  707. }{
  708. {"admin_prefix", "admin_acme", "admin_"},
  709. {"svc_prefix", "svc_bot", "svc_"},
  710. {"root_prefix", "root_ops", "root_"},
  711. {"sys_prefix", "sys_health", "sys_"},
  712. {"mixed_case_Admin", "Admin_acme", "admin_"},
  713. {"mixed_case_SVC", "SVC_Ingest", "svc_"},
  714. }
  715. for _, tc := range cases {
  716. tc := tc
  717. t.Run(tc.name, func(t *testing.T) {
  718. _, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
  719. Username: tc.username,
  720. DeptId: callerDeptId,
  721. })
  722. require.Error(t, err, "非超管以保留前缀创建用户必须被拒绝")
  723. var ce *response.CodeError
  724. require.True(t, errors.As(err, &ce))
  725. assert.Equal(t, 400, ce.Code(),
  726. "必须 400 而不是 409 —— 不暴露'该用户名是否存在',也不引导攻击者重试其他空位")
  727. assert.Contains(t, ce.Error(), tc.wantPref,
  728. "错误文案必须点名具体前缀,便于调用方立刻知道该改哪个前缀")
  729. assert.Contains(t, ce.Error(), "系统账号保留",
  730. "统一关键字,告警 / 审计可按单一关键字聚合此类事件")
  731. })
  732. }
  733. }
  734. // TC-1193: L-R17-1 正向回归 —— SuperAdmin 可以以保留前缀创建用户。
  735. //
  736. // 防回归:修复方案如果误把 SuperAdmin 也拦住,会让运维路径无法显式建 svc_ / root_ 账号,
  737. // 退化到"用 auto-provision 顺带带出"的隐式路径;超管豁免必须留有出口。
  738. func TestCreateUser_L_R17_1_ReservedUsernamePrefix_SuperAdminAllowed(t *testing.T) {
  739. ctx := ctxhelper.SuperAdminCtx()
  740. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  741. conn := testutil.GetTestSqlConn()
  742. username := "svc_" + testutil.UniqueId()
  743. resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
  744. Username: username,
  745. DeptId: 0,
  746. })
  747. require.NoError(t, err, "SuperAdmin 不受保留前缀约束")
  748. require.NotNil(t, resp)
  749. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  750. u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  751. require.NoError(t, err)
  752. assert.Equal(t, username, u.Username)
  753. }
  754. // TC-1194: L-R17-2 —— 成功创建后 sys_user.Avatar 显式落 NULL。
  755. //
  756. // logic 在 `SysUser{...}` 字面量里必须显式 `Avatar: sql.NullString{Valid: false}`。
  757. // 行为上 Go 结构体零值也是 `{Valid:false, String:""}`——它确实写 NULL;但显式写出来的
  758. // 实际意义是"未来如有人把 SysUser.Avatar 改成 string 类型,该字面量立刻编译失败,
  759. // 避免零值依赖在字段迁移时静默漂移到落 `''` 空串,与历史 NULL 行并存产生脏数据"。
  760. func TestCreateUser_L_R17_2_AvatarExplicitNull(t *testing.T) {
  761. ctx := ctxhelper.SuperAdminCtx()
  762. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  763. conn := testutil.GetTestSqlConn()
  764. username := "l_r17_2_" + testutil.UniqueId()
  765. resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
  766. Username: username,
  767. DeptId: 0,
  768. })
  769. require.NoError(t, err)
  770. require.NotNil(t, resp)
  771. t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
  772. u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
  773. require.NoError(t, err)
  774. assert.False(t, u.Avatar.Valid,
  775. "CreateUser 未指定 Avatar 时必须落 NULL(Valid=false),不得落空字符串(Valid=true, String=\"\")")
  776. assert.Equal(t, "", u.Avatar.String,
  777. "一致性断言:Valid=false 时 String 字段必须保持零值空串")
  778. }
  779. // TC-1195: H-R17-1 —— CreateUser 必须把 sys_dept S 锁读与 Insert 封在同一事务里。
  780. //
  781. // 用 mock 驱动,观察以下调用契约:
  782. // 1. SysUserModel.TransactCtx 被调用且其闭包被执行;
  783. // 2. 闭包内对目标 deptId 调用了 SysDeptModel.FindOneForShareTx(而不是非锁读 FindOne);
  784. // 3. 闭包内对 sys_user 走 InsertWithTx(而不是无事务 Insert);
  785. // 4. 三者发生在同一 tx 闭包内。
  786. //
  787. // 若实现回退为"tx 外 FindOne + tx 外 Insert",FindOneForShareTx 不会被打到 → gomock
  788. // 的 "expected call" 未满足导致 FAIL;反之,如果 Insert 漏掉 WithTx,本用例的 InsertWithTx
  789. // EXPECT 也会报 "got 0 calls"。
  790. func TestCreateUser_H_R17_1_InsertRunsInsideTxWithSharedDeptLock(t *testing.T) {
  791. ctrl := gomock.NewController(t)
  792. t.Cleanup(ctrl.Finish)
  793. const targetDeptId = int64(17701)
  794. const callerDeptId = int64(17702)
  795. const callerUserId = int64(9911)
  796. mockUser := mocks.NewMockSysUserModel(ctrl)
  797. mockDept := mocks.NewMockSysDeptModel(ctrl)
  798. var (
  799. findOneForShareCalled bool
  800. insertWithTxCalled bool
  801. findOneForShareOrder int
  802. insertWithTxOrder int
  803. stepCounter int
  804. )
  805. mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  806. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  807. return fn(ctx, nil)
  808. })
  809. mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), targetDeptId).
  810. DoAndReturn(func(ctx context.Context, _ sqlx.Session, _ int64) (*deptModel.SysDept, error) {
  811. findOneForShareCalled = true
  812. stepCounter++
  813. findOneForShareOrder = stepCounter
  814. return &deptModel.SysDept{
  815. Id: targetDeptId,
  816. Name: "target_dept",
  817. Path: "/17702/17701/", // 保证 HasPrefix(callerPath="/17702/") 成立
  818. DeptType: consts.DeptTypeNormal,
  819. Status: consts.StatusEnabled,
  820. }, nil
  821. })
  822. // 关键护栏:必须走 InsertWithTx,不得走 Insert。
  823. // 未声明 `mockUser.EXPECT().Insert(...)`,任何对 Insert 的调用都会让 mock FAIL。
  824. mockUser.EXPECT().InsertWithTx(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&userModel.SysUser{})).
  825. DoAndReturn(func(ctx context.Context, _ sqlx.Session, u *userModel.SysUser) (sql.Result, error) {
  826. insertWithTxCalled = true
  827. stepCounter++
  828. insertWithTxOrder = stepCounter
  829. assert.Equal(t, targetDeptId, u.DeptId)
  830. assert.False(t, u.Avatar.Valid, "L-R17-2:Avatar 必须显式 Valid=false")
  831. return stubInsertResult{id: 77}, nil
  832. })
  833. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser, Dept: mockDept})
  834. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
  835. UserId: callerUserId,
  836. Username: "h_r17_1_caller",
  837. IsSuperAdmin: false,
  838. MemberType: consts.MemberTypeAdmin,
  839. Status: consts.StatusEnabled,
  840. ProductCode: "test_product",
  841. DeptId: callerDeptId,
  842. DeptPath: "/17702/",
  843. MinPermsLevel: math.MaxInt64,
  844. })
  845. resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
  846. Username: "h_r17_1_" + testutil.UniqueId(),
  847. DeptId: targetDeptId,
  848. })
  849. require.NoError(t, err)
  850. require.NotNil(t, resp)
  851. assert.Equal(t, int64(77), resp.Id)
  852. assert.True(t, findOneForShareCalled, "必须在事务内对 sys_dept 走 FindOneForShareTx(S 锁)")
  853. assert.True(t, insertWithTxCalled, "sys_user 落盘必须走 InsertWithTx(同一 tx)")
  854. assert.Less(t, findOneForShareOrder, insertWithTxOrder,
  855. "锁序契约:先 FindOneForShareTx(sys_dept) 后 InsertWithTx(sys_user),"+
  856. "与 DeleteDept 的 `sys_dept FOR UPDATE → sys_user FOR SHARE` 对偶,"+
  857. "彻底关闭 future-user vs delete-dept 的 TOCTOU 窗口")
  858. }
  859. // TC-1196: H-R17-1 —— 事务内 FindOneForShareTx 返回 ErrNotFound 时必须 400 "部门不存在或已删除"。
  860. //
  861. // 时序等价于:并发 DeleteDept 已先行提交,S 锁读已看不到该 dept 行;此时 Insert 必须
  862. // 被拒绝,彻底避免"sys_user.deptId 指向已死 sys_dept.id"的 orphan 行。
  863. func TestCreateUser_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) {
  864. ctrl := gomock.NewController(t)
  865. t.Cleanup(ctrl.Finish)
  866. const targetDeptId = int64(17710)
  867. const callerDeptId = int64(17711)
  868. mockUser := mocks.NewMockSysUserModel(ctrl)
  869. mockDept := mocks.NewMockSysDeptModel(ctrl)
  870. mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
  871. DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
  872. return fn(ctx, nil)
  873. })
  874. mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), targetDeptId).
  875. Return(nil, sqlx.ErrNotFound)
  876. // InsertWithTx 绝不应该被调用——未声明即 Times(0),调用必 FAIL。
  877. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser, Dept: mockDept})
  878. ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
  879. UserId: 9922,
  880. Username: "h_r17_1_caller2",
  881. IsSuperAdmin: false,
  882. MemberType: consts.MemberTypeAdmin,
  883. Status: consts.StatusEnabled,
  884. ProductCode: "test_product",
  885. DeptId: callerDeptId,
  886. DeptPath: "/17712/",
  887. MinPermsLevel: math.MaxInt64,
  888. })
  889. _, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
  890. Username: "h_r17_1_del_" + testutil.UniqueId(),
  891. DeptId: targetDeptId,
  892. })
  893. require.Error(t, err)
  894. var ce *response.CodeError
  895. require.True(t, errors.As(err, &ce))
  896. assert.Equal(t, 400, ce.Code())
  897. assert.Contains(t, ce.Error(), "部门不存在或已删除",
  898. "必须给出"+"已删除"+"提示,调用方才能分辨'真不存在'与'并发删掉'两条路径")
  899. }
  900. // stubInsertResult 最小实现 sql.Result 供 mock 返回 LastInsertId。
  901. type stubInsertResult struct{ id int64 }
  902. func (r stubInsertResult) LastInsertId() (int64, error) { return r.id, nil }
  903. func (r stubInsertResult) RowsAffected() (int64, error) { return 1, nil }