fetchInitialCredentialsHandler_test.go 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. package product
  2. import (
  3. "context"
  4. "encoding/json"
  5. "math"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "perms-system-server/internal/consts"
  11. "perms-system-server/internal/loaders"
  12. "perms-system-server/internal/middleware"
  13. "perms-system-server/internal/response"
  14. "perms-system-server/internal/svc"
  15. "perms-system-server/internal/testutil"
  16. "perms-system-server/internal/types"
  17. "github.com/stretchr/testify/assert"
  18. "github.com/stretchr/testify/require"
  19. )
  20. func init() { response.Setup() }
  21. // ---------------------------------------------------------------------------
  22. // 覆盖目标:的 handler 薄层契约 —— `/api/product/fetchInitialCredentials`
  23. //
  24. // 此前 TC-0901 ~ TC-0912 已在 logic 层全量覆盖一次性凭据取回语义;test-report.md §10.4
  25. // 留了一条明确的"未测场景":handler 薄层 + 路由 wiring(JwtAuth 中间件绑定、
  26. // httpx.Parse 错误透传、RequireSuperAdmin 权限透传)。本文件把这些空白全部填上。
  27. //
  28. // 所有测试都遵循"真实 ServiceContext + Redis + 最小化 ctx 注入"模式:不 mock 任何业务
  29. // 路径,保证任何未来对 handler 层做"简化"/"下沉到中间件"的重构都会被立即发现。
  30. // ---------------------------------------------------------------------------
  31. // initialCredentialsKeyPrefix 与 internal/logic/product/createProductLogic.go 中未导出的同名常量一致。
  32. // 这里显式在测试里拷贝一份 —— 一旦生产代码改了前缀,handler 链路会立即失灵,对应 happy-path 用例会红。
  33. // 我们不想导出它( 语义要求尽量收敛可见面),所以此处 string-literal 锚点。
  34. const fetchInitialCredentialsKeyPrefix = "pm:initcred:"
  35. func superAdminReqCtx(r *http.Request) *http.Request {
  36. return r.WithContext(middleware.WithUserDetails(r.Context(), &loaders.UserDetails{
  37. UserId: 1,
  38. Username: "superadmin",
  39. IsSuperAdmin: true,
  40. MemberType: consts.MemberTypeSuperAdmin,
  41. Status: consts.StatusEnabled,
  42. MinPermsLevel: math.MaxInt64,
  43. }))
  44. }
  45. func adminReqCtx(r *http.Request) *http.Request {
  46. return r.WithContext(middleware.WithUserDetails(r.Context(), &loaders.UserDetails{
  47. UserId: 2,
  48. Username: "admin_h",
  49. IsSuperAdmin: false,
  50. MemberType: consts.MemberTypeAdmin,
  51. Status: consts.StatusEnabled,
  52. ProductCode: "p_handler",
  53. }))
  54. }
  55. // TC-0961: handler 薄层契约 —— body 非法 JSON 必须被 httpx.Parse 捕获并回 400,
  56. // 且错误文案应定位到解析失败而不是业务层语义(防止 handler 把 500 吞成 200 或透传 SQL 错误)。
  57. func TestFetchInitialCredentialsHandler_MalformedBodyReturns400(t *testing.T) {
  58. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  59. handler := FetchInitialCredentialsHandler(svcCtx)
  60. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  61. strings.NewReader("{not-valid-json"))
  62. req.Header.Set("Content-Type", "application/json")
  63. req = superAdminReqCtx(req)
  64. rr := httptest.NewRecorder()
  65. handler.ServeHTTP(rr, req)
  66. var body response.Body
  67. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
  68. assert.False(t, body.Success)
  69. assert.Equal(t, 400, body.ErrorCode,
  70. "handler 必须把 httpx.Parse 错误包成 400,实际 code=%d msg=%q", body.ErrorCode, body.ErrorMessage)
  71. assert.NotContains(t, strings.ToLower(body.ErrorMessage), "sql", "错误文案不得泄露 SQL 细节")
  72. assert.NotContains(t, strings.ToLower(body.ErrorMessage), "redis", "错误文案不得泄露 Redis 细节")
  73. assert.NotContains(t, strings.ToLower(body.ErrorMessage), "ticket", "解析失败阶段不得泄露 ticket 字段存在与否")
  74. }
  75. // TC-0962: handler 薄层契约 —— 无用户上下文必须 401,而不是 200 / 500 / panic。
  76. // 虽然生产上 /api/product/* 整体挂了 JwtAuth 中间件(见路由 wiring 测试),但 handler
  77. // 自身也必须独立具备 fail-close 能力,防止未来某人把路由误移到无鉴权块。
  78. func TestFetchInitialCredentialsHandler_NoUserCtxReturns401(t *testing.T) {
  79. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  80. handler := FetchInitialCredentialsHandler(svcCtx)
  81. body := `{"ticket":"any"}`
  82. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  83. strings.NewReader(body))
  84. req.Header.Set("Content-Type", "application/json")
  85. rr := httptest.NewRecorder()
  86. handler.ServeHTTP(rr, req)
  87. var resp response.Body
  88. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  89. assert.False(t, resp.Success)
  90. assert.Equal(t, 401, resp.ErrorCode,
  91. "无登录上下文必须 401;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
  92. assert.Contains(t, resp.ErrorMessage, "未登录")
  93. }
  94. // TC-0963: handler 薄层契约 —— 非超管必须 403,且响应体不得泄露 ticket 存在性或业务细节。
  95. // 这条契约保证了 的"即便 ticket 泄漏到日志,非超管也无法消费"防线在 handler 层被钉死。
  96. func TestFetchInitialCredentialsHandler_NonSuperAdminReturns403(t *testing.T) {
  97. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  98. handler := FetchInitialCredentialsHandler(svcCtx)
  99. body := `{"ticket":"should_not_be_consumed"}`
  100. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  101. strings.NewReader(body))
  102. req.Header.Set("Content-Type", "application/json")
  103. req = adminReqCtx(req)
  104. rr := httptest.NewRecorder()
  105. handler.ServeHTTP(rr, req)
  106. var resp response.Body
  107. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  108. assert.False(t, resp.Success)
  109. assert.Equal(t, 403, resp.ErrorCode,
  110. "非超管访问 FetchInitialCredentials 必须 403;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
  111. assert.Contains(t, resp.ErrorMessage, "超级管理员")
  112. assert.NotContains(t, resp.ErrorMessage, "ticket",
  113. "403 文案不得提及 ticket,以防通过错误差异化探测 ticket 合法性")
  114. }
  115. // TC-0964: handler 薄层契约 —— 超管 + 空 ticket 必须 400,文案精确到字段名。
  116. func TestFetchInitialCredentialsHandler_EmptyTicketReturns400(t *testing.T) {
  117. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  118. handler := FetchInitialCredentialsHandler(svcCtx)
  119. body := `{"ticket":""}`
  120. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  121. strings.NewReader(body))
  122. req.Header.Set("Content-Type", "application/json")
  123. req = superAdminReqCtx(req)
  124. rr := httptest.NewRecorder()
  125. handler.ServeHTTP(rr, req)
  126. var resp response.Body
  127. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  128. assert.False(t, resp.Success)
  129. assert.Equal(t, 400, resp.ErrorCode,
  130. "空 ticket 必须是 400 而非 401/403/500;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
  131. assert.Contains(t, resp.ErrorMessage, "ticket")
  132. }
  133. // TC-0965: handler 薄层契约 —— 超管 + 未知 ticket 必须 400 "凭证票据无效或已过期"。
  134. // 这里刻意不去区分"不存在"与"已过期",避免泄露存在性 oracle(与 logic TC-0904 同语义)。
  135. func TestFetchInitialCredentialsHandler_UnknownTicketReturns400(t *testing.T) {
  136. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  137. handler := FetchInitialCredentialsHandler(svcCtx)
  138. body := `{"ticket":"definitely-not-a-real-ticket-` + testutil.UniqueId() + `"}`
  139. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  140. strings.NewReader(body))
  141. req.Header.Set("Content-Type", "application/json")
  142. req = superAdminReqCtx(req)
  143. rr := httptest.NewRecorder()
  144. handler.ServeHTTP(rr, req)
  145. var resp response.Body
  146. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  147. assert.False(t, resp.Success)
  148. assert.Equal(t, 400, resp.ErrorCode,
  149. "未知 ticket 必须 400;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
  150. assert.Contains(t, resp.ErrorMessage, "凭证")
  151. }
  152. // TC-0966: handler 薄层契约 —— 超管 + 已落地 ticket 必须 200 + 正确字段,且 Redis key
  153. // 被消费(GetDel 原子性);此处直接往 Redis 写一份符合 initialCredentialsPayload 结构的 JSON。
  154. // 如果未来 handler 误把 logic 结果吞掉或字段映射错,这条 TC 会立刻捕捉。
  155. func TestFetchInitialCredentialsHandler_HappyPath200WithFields(t *testing.T) {
  156. ctx := context.Background()
  157. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  158. ticket := "handler_h_" + testutil.UniqueId()
  159. key := fetchInitialCredentialsKeyPrefix + ticket
  160. payload := map[string]string{
  161. "appKey": "APPKEY_H_" + ticket,
  162. "appSecret": "SECRET_H_" + ticket,
  163. "adminUser": "admin_h_" + ticket,
  164. "adminPassword": "PWD_H_" + ticket,
  165. }
  166. buf, err := json.Marshal(payload)
  167. require.NoError(t, err)
  168. require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, string(buf), 60))
  169. t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) })
  170. handler := FetchInitialCredentialsHandler(svcCtx)
  171. body := `{"ticket":"` + ticket + `"}`
  172. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  173. strings.NewReader(body))
  174. req.Header.Set("Content-Type", "application/json")
  175. req = superAdminReqCtx(req)
  176. rr := httptest.NewRecorder()
  177. handler.ServeHTTP(rr, req)
  178. require.Equal(t, http.StatusOK, rr.Code, "happy path 必须 HTTP 200;body=%s", rr.Body.String())
  179. var envelope struct {
  180. Success bool `json:"success"`
  181. ErrorCode int `json:"errorCode"`
  182. ErrorMessage string `json:"errorMessage"`
  183. Data types.FetchInitialCredentialsResp `json:"data"`
  184. }
  185. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
  186. assert.True(t, envelope.Success)
  187. assert.Equal(t, 0, envelope.ErrorCode, "业务 code 必须 0;实际=%d msg=%q", envelope.ErrorCode, envelope.ErrorMessage)
  188. assert.Equal(t, payload["appKey"], envelope.Data.AppKey)
  189. assert.Equal(t, payload["appSecret"], envelope.Data.AppSecret)
  190. assert.Equal(t, payload["adminUser"], envelope.Data.AdminUser)
  191. assert.Equal(t, payload["adminPassword"], envelope.Data.AdminPassword)
  192. // 一次性消费语义:Redis key 必须已被 GetDel 清除。
  193. remain, err := svcCtx.Redis.GetCtx(ctx, key)
  194. require.NoError(t, err)
  195. assert.Empty(t, remain, "handler 成功返回后 ticket 必须从 Redis 被删除;否则并发场景下可二次消费")
  196. }