| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- package product
- import (
- "context"
- "encoding/json"
- "math"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "perms-system-server/internal/consts"
- "perms-system-server/internal/loaders"
- "perms-system-server/internal/middleware"
- "perms-system-server/internal/response"
- "perms-system-server/internal/svc"
- "perms-system-server/internal/testutil"
- "perms-system-server/internal/types"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- func init() { response.Setup() }
- // ---------------------------------------------------------------------------
- // 覆盖目标:的 handler 薄层契约 —— `/api/product/fetchInitialCredentials`
- //
- // 此前 TC-0901 ~ TC-0912 已在 logic 层全量覆盖一次性凭据取回语义;test-report.md §10.4
- // 留了一条明确的"未测场景":handler 薄层 + 路由 wiring(JwtAuth 中间件绑定、
- // httpx.Parse 错误透传、RequireSuperAdmin 权限透传)。本文件把这些空白全部填上。
- //
- // 所有测试都遵循"真实 ServiceContext + Redis + 最小化 ctx 注入"模式:不 mock 任何业务
- // 路径,保证任何未来对 handler 层做"简化"/"下沉到中间件"的重构都会被立即发现。
- // ---------------------------------------------------------------------------
- // initialCredentialsKeyPrefix 与 internal/logic/product/createProductLogic.go 中未导出的同名常量一致。
- // 这里显式在测试里拷贝一份 —— 一旦生产代码改了前缀,handler 链路会立即失灵,对应 happy-path 用例会红。
- // 我们不想导出它( 语义要求尽量收敛可见面),所以此处 string-literal 锚点。
- const fetchInitialCredentialsKeyPrefix = "pm:initcred:"
- func superAdminReqCtx(r *http.Request) *http.Request {
- return r.WithContext(middleware.WithUserDetails(r.Context(), &loaders.UserDetails{
- UserId: 1,
- Username: "superadmin",
- IsSuperAdmin: true,
- MemberType: consts.MemberTypeSuperAdmin,
- Status: consts.StatusEnabled,
- MinPermsLevel: math.MaxInt64,
- }))
- }
- func adminReqCtx(r *http.Request) *http.Request {
- return r.WithContext(middleware.WithUserDetails(r.Context(), &loaders.UserDetails{
- UserId: 2,
- Username: "admin_h",
- IsSuperAdmin: false,
- MemberType: consts.MemberTypeAdmin,
- Status: consts.StatusEnabled,
- ProductCode: "p_handler",
- }))
- }
- // TC-0961: handler 薄层契约 —— body 非法 JSON 必须被 httpx.Parse 捕获并回 400,
- // 且错误文案应定位到解析失败而不是业务层语义(防止 handler 把 500 吞成 200 或透传 SQL 错误)。
- func TestFetchInitialCredentialsHandler_MalformedBodyReturns400(t *testing.T) {
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- handler := FetchInitialCredentialsHandler(svcCtx)
- req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
- strings.NewReader("{not-valid-json"))
- req.Header.Set("Content-Type", "application/json")
- req = superAdminReqCtx(req)
- rr := httptest.NewRecorder()
- handler.ServeHTTP(rr, req)
- var body response.Body
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
- assert.False(t, body.Success)
- assert.Equal(t, 400, body.ErrorCode,
- "handler 必须把 httpx.Parse 错误包成 400,实际 code=%d msg=%q", body.ErrorCode, body.ErrorMessage)
- assert.NotContains(t, strings.ToLower(body.ErrorMessage), "sql", "错误文案不得泄露 SQL 细节")
- assert.NotContains(t, strings.ToLower(body.ErrorMessage), "redis", "错误文案不得泄露 Redis 细节")
- assert.NotContains(t, strings.ToLower(body.ErrorMessage), "ticket", "解析失败阶段不得泄露 ticket 字段存在与否")
- }
- // TC-0962: handler 薄层契约 —— 无用户上下文必须 401,而不是 200 / 500 / panic。
- // 虽然生产上 /api/product/* 整体挂了 JwtAuth 中间件(见路由 wiring 测试),但 handler
- // 自身也必须独立具备 fail-close 能力,防止未来某人把路由误移到无鉴权块。
- func TestFetchInitialCredentialsHandler_NoUserCtxReturns401(t *testing.T) {
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- handler := FetchInitialCredentialsHandler(svcCtx)
- body := `{"ticket":"any"}`
- req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
- strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- rr := httptest.NewRecorder()
- handler.ServeHTTP(rr, req)
- var resp response.Body
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.False(t, resp.Success)
- assert.Equal(t, 401, resp.ErrorCode,
- "无登录上下文必须 401;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
- assert.Contains(t, resp.ErrorMessage, "未登录")
- }
- // TC-0963: handler 薄层契约 —— 非超管必须 403,且响应体不得泄露 ticket 存在性或业务细节。
- // 这条契约保证了 的"即便 ticket 泄漏到日志,非超管也无法消费"防线在 handler 层被钉死。
- func TestFetchInitialCredentialsHandler_NonSuperAdminReturns403(t *testing.T) {
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- handler := FetchInitialCredentialsHandler(svcCtx)
- body := `{"ticket":"should_not_be_consumed"}`
- req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
- strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- req = adminReqCtx(req)
- rr := httptest.NewRecorder()
- handler.ServeHTTP(rr, req)
- var resp response.Body
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.False(t, resp.Success)
- assert.Equal(t, 403, resp.ErrorCode,
- "非超管访问 FetchInitialCredentials 必须 403;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
- assert.Contains(t, resp.ErrorMessage, "超级管理员")
- assert.NotContains(t, resp.ErrorMessage, "ticket",
- "403 文案不得提及 ticket,以防通过错误差异化探测 ticket 合法性")
- }
- // TC-0964: handler 薄层契约 —— 超管 + 空 ticket 必须 400,文案精确到字段名。
- func TestFetchInitialCredentialsHandler_EmptyTicketReturns400(t *testing.T) {
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- handler := FetchInitialCredentialsHandler(svcCtx)
- body := `{"ticket":""}`
- req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
- strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- req = superAdminReqCtx(req)
- rr := httptest.NewRecorder()
- handler.ServeHTTP(rr, req)
- var resp response.Body
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.False(t, resp.Success)
- assert.Equal(t, 400, resp.ErrorCode,
- "空 ticket 必须是 400 而非 401/403/500;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
- assert.Contains(t, resp.ErrorMessage, "ticket")
- }
- // TC-0965: handler 薄层契约 —— 超管 + 未知 ticket 必须 400 "凭证票据无效或已过期"。
- // 这里刻意不去区分"不存在"与"已过期",避免泄露存在性 oracle(与 logic TC-0904 同语义)。
- func TestFetchInitialCredentialsHandler_UnknownTicketReturns400(t *testing.T) {
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- handler := FetchInitialCredentialsHandler(svcCtx)
- body := `{"ticket":"definitely-not-a-real-ticket-` + testutil.UniqueId() + `"}`
- req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
- strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- req = superAdminReqCtx(req)
- rr := httptest.NewRecorder()
- handler.ServeHTTP(rr, req)
- var resp response.Body
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.False(t, resp.Success)
- assert.Equal(t, 400, resp.ErrorCode,
- "未知 ticket 必须 400;实际 code=%d msg=%q", resp.ErrorCode, resp.ErrorMessage)
- assert.Contains(t, resp.ErrorMessage, "凭证")
- }
- // TC-0966: handler 薄层契约 —— 超管 + 已落地 ticket 必须 200 + 正确字段,且 Redis key
- // 被消费(GetDel 原子性);此处直接往 Redis 写一份符合 initialCredentialsPayload 结构的 JSON。
- // 如果未来 handler 误把 logic 结果吞掉或字段映射错,这条 TC 会立刻捕捉。
- func TestFetchInitialCredentialsHandler_HappyPath200WithFields(t *testing.T) {
- ctx := context.Background()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- ticket := "handler_h_" + testutil.UniqueId()
- key := fetchInitialCredentialsKeyPrefix + ticket
- payload := map[string]string{
- "appKey": "APPKEY_H_" + ticket,
- "appSecret": "SECRET_H_" + ticket,
- "adminUser": "admin_h_" + ticket,
- "adminPassword": "PWD_H_" + ticket,
- }
- buf, err := json.Marshal(payload)
- require.NoError(t, err)
- require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, string(buf), 60))
- t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) })
- handler := FetchInitialCredentialsHandler(svcCtx)
- body := `{"ticket":"` + ticket + `"}`
- req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
- strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- req = superAdminReqCtx(req)
- rr := httptest.NewRecorder()
- handler.ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code, "happy path 必须 HTTP 200;body=%s", rr.Body.String())
- var envelope struct {
- Success bool `json:"success"`
- ErrorCode int `json:"errorCode"`
- ErrorMessage string `json:"errorMessage"`
- Data types.FetchInitialCredentialsResp `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
- assert.True(t, envelope.Success)
- assert.Equal(t, 0, envelope.ErrorCode, "业务 code 必须 0;实际=%d msg=%q", envelope.ErrorCode, envelope.ErrorMessage)
- assert.Equal(t, payload["appKey"], envelope.Data.AppKey)
- assert.Equal(t, payload["appSecret"], envelope.Data.AppSecret)
- assert.Equal(t, payload["adminUser"], envelope.Data.AdminUser)
- assert.Equal(t, payload["adminPassword"], envelope.Data.AdminPassword)
- // 一次性消费语义:Redis key 必须已被 GetDel 清除。
- remain, err := svcCtx.Redis.GetCtx(ctx, key)
- require.NoError(t, err)
- assert.Empty(t, remain, "handler 成功返回后 ticket 必须从 Redis 被删除;否则并发场景下可二次消费")
- }
|