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.Equal(t, 400, body.Code, "handler 必须把 httpx.Parse 错误包成 400,实际 code=%d msg=%q", body.Code, body.Msg) assert.NotContains(t, strings.ToLower(body.Msg), "sql", "错误文案不得泄露 SQL 细节") assert.NotContains(t, strings.ToLower(body.Msg), "redis", "错误文案不得泄露 Redis 细节") assert.NotContains(t, strings.ToLower(body.Msg), "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.Equal(t, 401, resp.Code, "无登录上下文必须 401;实际 code=%d msg=%q", resp.Code, resp.Msg) assert.Contains(t, resp.Msg, "未登录") } // 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.Equal(t, 403, resp.Code, "非超管访问 FetchInitialCredentials 必须 403;实际 code=%d msg=%q", resp.Code, resp.Msg) assert.Contains(t, resp.Msg, "超级管理员") assert.NotContains(t, resp.Msg, "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.Equal(t, 400, resp.Code, "空 ticket 必须是 400 而非 401/403/500;实际 code=%d msg=%q", resp.Code, resp.Msg) assert.Contains(t, resp.Msg, "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.Equal(t, 400, resp.Code, "未知 ticket 必须 400;实际 code=%d msg=%q", resp.Code, resp.Msg) assert.Contains(t, resp.Msg, "凭证") } // 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 { Code int `json:"code"` Msg string `json:"msg"` Data types.FetchInitialCredentialsResp `json:"data"` } require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope)) assert.Equal(t, 0, envelope.Code, "业务 code 必须 0;实际=%d msg=%q", envelope.Code, envelope.Msg) 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 被删除;否则并发场景下可二次消费") }