fetchInitialCredentialsHandler_test.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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.Equal(t, 400, body.Code,
  69. "handler 必须把 httpx.Parse 错误包成 400,实际 code=%d msg=%q", body.Code, body.Msg)
  70. assert.NotContains(t, strings.ToLower(body.Msg), "sql", "错误文案不得泄露 SQL 细节")
  71. assert.NotContains(t, strings.ToLower(body.Msg), "redis", "错误文案不得泄露 Redis 细节")
  72. assert.NotContains(t, strings.ToLower(body.Msg), "ticket", "解析失败阶段不得泄露 ticket 字段存在与否")
  73. }
  74. // TC-0962: handler 薄层契约 —— 无用户上下文必须 401,而不是 200 / 500 / panic。
  75. // 虽然生产上 /api/product/* 整体挂了 JwtAuth 中间件(见路由 wiring 测试),但 handler
  76. // 自身也必须独立具备 fail-close 能力,防止未来某人把路由误移到无鉴权块。
  77. func TestFetchInitialCredentialsHandler_NoUserCtxReturns401(t *testing.T) {
  78. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  79. handler := FetchInitialCredentialsHandler(svcCtx)
  80. body := `{"ticket":"any"}`
  81. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  82. strings.NewReader(body))
  83. req.Header.Set("Content-Type", "application/json")
  84. rr := httptest.NewRecorder()
  85. handler.ServeHTTP(rr, req)
  86. var resp response.Body
  87. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  88. assert.Equal(t, 401, resp.Code,
  89. "无登录上下文必须 401;实际 code=%d msg=%q", resp.Code, resp.Msg)
  90. assert.Contains(t, resp.Msg, "未登录")
  91. }
  92. // TC-0963: handler 薄层契约 —— 非超管必须 403,且响应体不得泄露 ticket 存在性或业务细节。
  93. // 这条契约保证了 的"即便 ticket 泄漏到日志,非超管也无法消费"防线在 handler 层被钉死。
  94. func TestFetchInitialCredentialsHandler_NonSuperAdminReturns403(t *testing.T) {
  95. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  96. handler := FetchInitialCredentialsHandler(svcCtx)
  97. body := `{"ticket":"should_not_be_consumed"}`
  98. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  99. strings.NewReader(body))
  100. req.Header.Set("Content-Type", "application/json")
  101. req = adminReqCtx(req)
  102. rr := httptest.NewRecorder()
  103. handler.ServeHTTP(rr, req)
  104. var resp response.Body
  105. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  106. assert.Equal(t, 403, resp.Code,
  107. "非超管访问 FetchInitialCredentials 必须 403;实际 code=%d msg=%q", resp.Code, resp.Msg)
  108. assert.Contains(t, resp.Msg, "超级管理员")
  109. assert.NotContains(t, resp.Msg, "ticket",
  110. "403 文案不得提及 ticket,以防通过错误差异化探测 ticket 合法性")
  111. }
  112. // TC-0964: handler 薄层契约 —— 超管 + 空 ticket 必须 400,文案精确到字段名。
  113. func TestFetchInitialCredentialsHandler_EmptyTicketReturns400(t *testing.T) {
  114. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  115. handler := FetchInitialCredentialsHandler(svcCtx)
  116. body := `{"ticket":""}`
  117. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  118. strings.NewReader(body))
  119. req.Header.Set("Content-Type", "application/json")
  120. req = superAdminReqCtx(req)
  121. rr := httptest.NewRecorder()
  122. handler.ServeHTTP(rr, req)
  123. var resp response.Body
  124. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  125. assert.Equal(t, 400, resp.Code,
  126. "空 ticket 必须是 400 而非 401/403/500;实际 code=%d msg=%q", resp.Code, resp.Msg)
  127. assert.Contains(t, resp.Msg, "ticket")
  128. }
  129. // TC-0965: handler 薄层契约 —— 超管 + 未知 ticket 必须 400 "凭证票据无效或已过期"。
  130. // 这里刻意不去区分"不存在"与"已过期",避免泄露存在性 oracle(与 logic TC-0904 同语义)。
  131. func TestFetchInitialCredentialsHandler_UnknownTicketReturns400(t *testing.T) {
  132. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  133. handler := FetchInitialCredentialsHandler(svcCtx)
  134. body := `{"ticket":"definitely-not-a-real-ticket-` + testutil.UniqueId() + `"}`
  135. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  136. strings.NewReader(body))
  137. req.Header.Set("Content-Type", "application/json")
  138. req = superAdminReqCtx(req)
  139. rr := httptest.NewRecorder()
  140. handler.ServeHTTP(rr, req)
  141. var resp response.Body
  142. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
  143. assert.Equal(t, 400, resp.Code,
  144. "未知 ticket 必须 400;实际 code=%d msg=%q", resp.Code, resp.Msg)
  145. assert.Contains(t, resp.Msg, "凭证")
  146. }
  147. // TC-0966: handler 薄层契约 —— 超管 + 已落地 ticket 必须 200 + 正确字段,且 Redis key
  148. // 被消费(GetDel 原子性);此处直接往 Redis 写一份符合 initialCredentialsPayload 结构的 JSON。
  149. // 如果未来 handler 误把 logic 结果吞掉或字段映射错,这条 TC 会立刻捕捉。
  150. func TestFetchInitialCredentialsHandler_HappyPath200WithFields(t *testing.T) {
  151. ctx := context.Background()
  152. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  153. ticket := "handler_h_" + testutil.UniqueId()
  154. key := fetchInitialCredentialsKeyPrefix + ticket
  155. payload := map[string]string{
  156. "appKey": "APPKEY_H_" + ticket,
  157. "appSecret": "SECRET_H_" + ticket,
  158. "adminUser": "admin_h_" + ticket,
  159. "adminPassword": "PWD_H_" + ticket,
  160. }
  161. buf, err := json.Marshal(payload)
  162. require.NoError(t, err)
  163. require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, string(buf), 60))
  164. t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) })
  165. handler := FetchInitialCredentialsHandler(svcCtx)
  166. body := `{"ticket":"` + ticket + `"}`
  167. req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
  168. strings.NewReader(body))
  169. req.Header.Set("Content-Type", "application/json")
  170. req = superAdminReqCtx(req)
  171. rr := httptest.NewRecorder()
  172. handler.ServeHTTP(rr, req)
  173. require.Equal(t, http.StatusOK, rr.Code, "happy path 必须 HTTP 200;body=%s", rr.Body.String())
  174. var envelope struct {
  175. Code int `json:"code"`
  176. Msg string `json:"msg"`
  177. Data types.FetchInitialCredentialsResp `json:"data"`
  178. }
  179. require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
  180. assert.Equal(t, 0, envelope.Code, "业务 code 必须 0;实际=%d msg=%q", envelope.Code, envelope.Msg)
  181. assert.Equal(t, payload["appKey"], envelope.Data.AppKey)
  182. assert.Equal(t, payload["appSecret"], envelope.Data.AppSecret)
  183. assert.Equal(t, payload["adminUser"], envelope.Data.AdminUser)
  184. assert.Equal(t, payload["adminPassword"], envelope.Data.AdminPassword)
  185. // 一次性消费语义:Redis key 必须已被 GetDel 清除。
  186. remain, err := svcCtx.Redis.GetCtx(ctx, key)
  187. require.NoError(t, err)
  188. assert.Empty(t, remain, "handler 成功返回后 ticket 必须从 Redis 被删除;否则并发场景下可二次消费")
  189. }