createUserLogic_test.go 41 KB

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