fetchInitialCredentialsLogic_audit_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. package product
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "sync"
  8. "sync/atomic"
  9. "testing"
  10. "perms-system-server/internal/response"
  11. "perms-system-server/internal/svc"
  12. "perms-system-server/internal/testutil"
  13. "perms-system-server/internal/testutil/ctxhelper"
  14. "perms-system-server/internal/types"
  15. "github.com/stretchr/testify/assert"
  16. "github.com/stretchr/testify/require"
  17. )
  18. // ---------------------------------------------------------------------------
  19. // 覆盖目标:审计 M-4 —— CreateProduct 不再明文回吐 appSecret / adminPassword,
  20. // 改为发放一次性 credentialsTicket,调用方再凭 ticket 调 FetchInitialCredentials 领取。
  21. // 安全契约必须钉死:
  22. // 1) 只有超管能消费 ticket(非超管必须 403);
  23. // 2) 必须一次性消费(consumed 后再次消费必须 400);
  24. // 3) 错误/过期 ticket 必须 400;
  25. // 4) 空 ticket 必须 400;
  26. // 5) 并发消费同一 ticket 时,有且仅有一个请求能拿到明文(GetDelCtx 原子性);
  27. // 6) Redis 中落盘的 value 必须是结构化 JSON,而不是裸明文(便于未来加密 / schema 演进);
  28. // 7) FetchInitialCredentialsResp 必须暴露 appSecret / adminPassword / appKey / adminUser。
  29. // ---------------------------------------------------------------------------
  30. // TC-0901: FetchInitialCredentials 正常路径 —— 用 CreateProduct 返回的 ticket 领取凭证。
  31. func TestFetchInitialCredentials_HappyPath(t *testing.T) {
  32. ctx := ctxhelper.SuperAdminCtx()
  33. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  34. conn := testutil.GetTestSqlConn()
  35. code := testutil.UniqueId()
  36. createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  37. Code: code, Name: "tic_ok", Remark: "", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  38. })
  39. require.NoError(t, err)
  40. require.NotNil(t, createResp)
  41. require.NotEmpty(t, createResp.CredentialsTicket)
  42. t.Cleanup(func() {
  43. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  44. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  45. testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
  46. _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  47. })
  48. cred, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
  49. &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
  50. )
  51. require.NoError(t, err)
  52. require.NotNil(t, cred)
  53. assert.Equal(t, createResp.AppKey, cred.AppKey, "appKey 必须与 CreateProduct 响应一致")
  54. assert.Equal(t, createResp.AdminUser, cred.AdminUser, "adminUser 必须与 CreateProduct 响应一致")
  55. assert.NotEmpty(t, cred.AppSecret, "必须返回明文 appSecret")
  56. assert.NotEmpty(t, cred.AdminPassword, "必须返回明文 adminPassword")
  57. // 基础合理性:32 字节 hex = 64 字符;审计 L-R10-2 改为混合字符集强密码,长度固定 16。
  58. assert.Len(t, cred.AppSecret, 64, "appSecret 必须是 32 字节 hex")
  59. assert.Len(t, cred.AdminPassword, 16, "L-R10-2:adminPassword 改由 generateStrongInitialPassword(16) 生成,长度恒为 16")
  60. }
  61. // TC-0902: FetchInitialCredentials 一次性消费 —— 同一 ticket 第二次消费必须 400。
  62. func TestFetchInitialCredentials_OneShotConsumption(t *testing.T) {
  63. ctx := ctxhelper.SuperAdminCtx()
  64. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  65. conn := testutil.GetTestSqlConn()
  66. code := testutil.UniqueId()
  67. createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  68. Code: code, Name: "tic_once", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  69. })
  70. require.NoError(t, err)
  71. t.Cleanup(func() {
  72. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  73. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  74. testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
  75. _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  76. })
  77. logic := NewFetchInitialCredentialsLogic(ctx, svcCtx)
  78. first, err := logic.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket})
  79. require.NoError(t, err)
  80. require.NotNil(t, first)
  81. second, err := logic.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket})
  82. require.Error(t, err, "M-4:一次性 ticket 不能被第二次消费")
  83. assert.Nil(t, second)
  84. var ce *response.CodeError
  85. require.True(t, errors.As(err, &ce))
  86. assert.Equal(t, 400, ce.Code())
  87. assert.Contains(t, ce.Error(), "凭证票据无效或已过期")
  88. }
  89. // TC-0903: FetchInitialCredentials 未知 ticket 必须 400
  90. func TestFetchInitialCredentials_UnknownTicketRejected(t *testing.T) {
  91. ctx := ctxhelper.SuperAdminCtx()
  92. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  93. _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
  94. &types.FetchInitialCredentialsReq{Ticket: "definitely_not_a_real_ticket_" + testutil.UniqueId()},
  95. )
  96. require.Error(t, err)
  97. var ce *response.CodeError
  98. require.True(t, errors.As(err, &ce))
  99. assert.Equal(t, 400, ce.Code())
  100. }
  101. // TC-0904: 空 ticket 必须 400
  102. func TestFetchInitialCredentials_EmptyTicketRejected(t *testing.T) {
  103. ctx := ctxhelper.SuperAdminCtx()
  104. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  105. _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
  106. &types.FetchInitialCredentialsReq{Ticket: ""},
  107. )
  108. require.Error(t, err)
  109. var ce *response.CodeError
  110. require.True(t, errors.As(err, &ce))
  111. assert.Equal(t, 400, ce.Code())
  112. assert.Contains(t, ce.Error(), "ticket 不能为空")
  113. }
  114. // TC-0905: nil req 必须 400
  115. func TestFetchInitialCredentials_NilRequestRejected(t *testing.T) {
  116. ctx := ctxhelper.SuperAdminCtx()
  117. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  118. _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(nil)
  119. require.Error(t, err)
  120. var ce *response.CodeError
  121. require.True(t, errors.As(err, &ce))
  122. assert.Equal(t, 400, ce.Code())
  123. }
  124. // TC-0906: 非超管禁止消费 ticket —— 即便 ticket 偶然外泄,非超管会被直接 403,
  125. // 把攻击面进一步压缩。
  126. func TestFetchInitialCredentials_NonSuperAdminRejected(t *testing.T) {
  127. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  128. superCtx := ctxhelper.SuperAdminCtx()
  129. conn := testutil.GetTestSqlConn()
  130. code := testutil.UniqueId()
  131. createResp, err := NewCreateProductLogic(superCtx, svcCtx).CreateProduct(&types.CreateProductReq{
  132. Code: code, Name: "tic_403", AdminDeptId: seedAdminDept(t, superCtx, svcCtx),
  133. })
  134. require.NoError(t, err)
  135. t.Cleanup(func() {
  136. testutil.CleanTableByField(superCtx, conn, "`sys_product_member`", "productCode", code)
  137. testutil.CleanTableByField(superCtx, conn, "`sys_user`", "username", "admin_"+code)
  138. testutil.CleanTable(superCtx, conn, "`sys_product`", createResp.Id)
  139. _, _ = svcCtx.Redis.DelCtx(superCtx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  140. })
  141. // 用产品 ADMIN 身份尝试消费
  142. adminCtx := ctxhelper.AdminCtx(code)
  143. _, err = NewFetchInitialCredentialsLogic(adminCtx, svcCtx).FetchInitialCredentials(
  144. &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
  145. )
  146. require.Error(t, err, "非超管必须被直接 403,不进入 Redis 消费阶段")
  147. var ce *response.CodeError
  148. require.True(t, errors.As(err, &ce))
  149. assert.Equal(t, 403, ce.Code())
  150. // 同时断言 ticket 没被拒绝请求的副作用消费掉——后续超管仍可正常领取。
  151. cred, err := NewFetchInitialCredentialsLogic(superCtx, svcCtx).FetchInitialCredentials(
  152. &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
  153. )
  154. require.NoError(t, err, "M-4:非超管被拒时不得把 ticket 吞掉,否则超管会领取不到")
  155. assert.NotEmpty(t, cred.AppSecret)
  156. }
  157. // TC-0907: 未登录上下文必须 401
  158. func TestFetchInitialCredentials_UnauthenticatedRejected(t *testing.T) {
  159. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  160. _, err := NewFetchInitialCredentialsLogic(context.Background(), svcCtx).FetchInitialCredentials(
  161. &types.FetchInitialCredentialsReq{Ticket: "any"},
  162. )
  163. require.Error(t, err)
  164. var ce *response.CodeError
  165. require.True(t, errors.As(err, &ce))
  166. assert.Equal(t, 401, ce.Code())
  167. }
  168. // TC-0908: Redis 中 ticket 载荷若不可解析,必须返回 500 结构错误而非把 raw 塞给调用方。
  169. func TestFetchInitialCredentials_MalformedPayloadIn500(t *testing.T) {
  170. ctx := ctxhelper.SuperAdminCtx()
  171. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  172. ticket := "mal_" + testutil.UniqueId()
  173. key := initialCredentialsKeyPrefix + ticket
  174. require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, "{this is not valid json", 30))
  175. t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) })
  176. _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
  177. &types.FetchInitialCredentialsReq{Ticket: ticket},
  178. )
  179. require.Error(t, err)
  180. var ce *response.CodeError
  181. require.True(t, errors.As(err, &ce))
  182. assert.Equal(t, 500, ce.Code(), "损坏的载荷必须走 500 错误通道而非 panic / 静默成功")
  183. // 同时断言:损坏载荷在失败后已经被 GetDelCtx 消费掉,不会永久留在 Redis 里形成噪声。
  184. val, _ := svcCtx.Redis.GetCtx(ctx, key)
  185. assert.Empty(t, val, "损坏载荷在 GetDelCtx 过程中必须被一并删除(DEL 是原子的)")
  186. }
  187. // TC-0909: Redis 中落盘的 value 必须是结构化 JSON,包含 4 个字段。
  188. // 如果未来有人把 Marshal 换回裸字符串(又一个 M-4 回归),这个测试立刻炸。
  189. func TestFetchInitialCredentials_StoredPayloadIsStructuredJSON(t *testing.T) {
  190. ctx := ctxhelper.SuperAdminCtx()
  191. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  192. conn := testutil.GetTestSqlConn()
  193. code := testutil.UniqueId()
  194. createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  195. Code: code, Name: "tic_json", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  196. })
  197. require.NoError(t, err)
  198. t.Cleanup(func() {
  199. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  200. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  201. testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
  202. _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  203. })
  204. val, err := svcCtx.Redis.GetCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  205. require.NoError(t, err)
  206. require.NotEmpty(t, val)
  207. var payload map[string]interface{}
  208. require.NoError(t, json.Unmarshal([]byte(val), &payload), "Redis 载荷必须是结构化 JSON")
  209. for _, key := range []string{"appKey", "appSecret", "adminUser", "adminPassword"} {
  210. v, ok := payload[key]
  211. assert.True(t, ok, "Redis 载荷必须包含字段 %s", key)
  212. assert.NotEmpty(t, v, "字段 %s 不能为空", key)
  213. }
  214. }
  215. // TC-0910: Redis TTL 在合理区间(> 0 且 <= 5 分钟 = 300s)。
  216. func TestFetchInitialCredentials_TicketTTLWithinWindow(t *testing.T) {
  217. ctx := ctxhelper.SuperAdminCtx()
  218. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  219. conn := testutil.GetTestSqlConn()
  220. code := testutil.UniqueId()
  221. createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  222. Code: code, Name: "tic_ttl", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  223. })
  224. require.NoError(t, err)
  225. t.Cleanup(func() {
  226. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  227. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  228. testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
  229. _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  230. })
  231. ttl, err := svcCtx.Redis.TtlCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  232. require.NoError(t, err)
  233. assert.Greater(t, ttl, 0, "ticket 必须是短 TTL 而不是永久")
  234. assert.LessOrEqual(t, ttl, 300, "M-4:ticket TTL 不得超过 5 分钟 300s")
  235. }
  236. // TC-0911: 并发消费同一 ticket —— 有且仅有一个请求能拿到明文,其他全部 400。
  237. // 依赖 GetDelCtx 的原子 GET+DEL 语义,这是 M-4 防竞态的核心契约。
  238. func TestFetchInitialCredentials_ConcurrentConsumptionSingleWinner(t *testing.T) {
  239. ctx := ctxhelper.SuperAdminCtx()
  240. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  241. conn := testutil.GetTestSqlConn()
  242. code := testutil.UniqueId()
  243. createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  244. Code: code, Name: "tic_conc", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  245. })
  246. require.NoError(t, err)
  247. t.Cleanup(func() {
  248. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  249. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
  250. testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
  251. _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
  252. })
  253. const N = 8
  254. var wg sync.WaitGroup
  255. var successes int32
  256. var fails int32
  257. start := make(chan struct{})
  258. for i := 0; i < N; i++ {
  259. wg.Add(1)
  260. go func() {
  261. defer wg.Done()
  262. <-start
  263. _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
  264. &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
  265. )
  266. if err == nil {
  267. atomic.AddInt32(&successes, 1)
  268. } else {
  269. atomic.AddInt32(&fails, 1)
  270. }
  271. }()
  272. }
  273. close(start)
  274. wg.Wait()
  275. assert.Equal(t, int32(1), atomic.LoadInt32(&successes),
  276. "并发消费必须恰好 1 个胜出(GetDelCtx 原子 GET+DEL),实际 successes=%d fails=%d",
  277. atomic.LoadInt32(&successes), atomic.LoadInt32(&fails))
  278. assert.Equal(t, int32(N-1), atomic.LoadInt32(&fails),
  279. "其余 %d 个并发都必须拿到 400", N-1)
  280. }
  281. // TC-0912: 契约回归 —— CreateProductResp 的公开字段集合不得再包含 appSecret / adminPassword。
  282. // 这是对 M-4 的"结构体层面"回归(TC-0064 只覆盖到 JSON 序列化层面)。
  283. func TestCreateProductResp_NoLongerExposesPlaintextCredentials(t *testing.T) {
  284. resp := &types.CreateProductResp{}
  285. bs, err := json.Marshal(resp)
  286. require.NoError(t, err)
  287. // 序列化结果里出现 "appSecret" 或 "adminPassword" 都属于 M-4 回归。
  288. asStr := string(bs)
  289. assert.NotContains(t, asStr, "\"appSecret\"",
  290. "M-4:CreateProductResp 不得再含有 appSecret(哪怕是空串序列化)")
  291. assert.NotContains(t, asStr, "\"adminPassword\"",
  292. "M-4:CreateProductResp 不得再含有 adminPassword(哪怕是空串序列化)")
  293. // 必须有 ticket 相关字段
  294. assert.Contains(t, asStr, "credentialsTicket")
  295. assert.Contains(t, asStr, "credentialsExpiresAt")
  296. }
  297. // fmt 引用以避免 import 被误清理(某些工具链会 trim 没用到的 import)
  298. var _ = fmt.Sprintf