Files
cmautobuy/admin/client_api_auth_regression_test.go
T

262 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/service"
)
const clientProfileJSON = `{
"client":{"name":"路由回归客户端"},
"supported_types":["collect","purchase"],
"device":{"address":"192.168.0.173:5555","platform":"android","pdd_package":"com.xunmeng.pinduoduo"},
"capabilities":{"purchase_mode":"dry_run","schema_versions":[1]}
}`
const apiResultJSON = `{
"task_version":1,"attempt_id":"http-result-1","result_type":"purchase",
"completed_at":"2026-08-09T06:00:00Z",
"pdd_data":{"schema_version":1,"goods":{"goods_id":"1"}}
}`
const apiFailureJSON = `{
"task_version":1,"attempt_id":"http-failure-1","status":"failed",
"error":{"code":"PDD_PAGE_TIMEOUT","message":"页面超时","retryable":true}
}`
func TestClient四接口不受Web认证状态影响(t *testing.T) {
states := []struct {
name string
setupUsers bool
loggedIn bool
}{
{"Admin未初始化", false, false},
{"已有管理员但Web未登录", true, false},
{"管理员Web已登录", true, true},
}
for _, state := range states {
t.Run(state.name, func(t *testing.T) {
db := newAPIRouteTestDB(t)
var webToken string
if state.setupUsers {
if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", time.Now()); err != nil {
t.Fatal(err)
}
if state.loggedIn {
var err error
webToken, _, _, err = service.Login(db, "admin", "admin-password", time.Now())
if err != nil {
t.Fatal(err)
}
}
}
insertAPIRouteTask(t, db, "TASK-RESULT", "client-route", time.Now())
insertAPIRouteTask(t, db, "TASK-FAILURE", "client-route", time.Now().Add(time.Second))
router, err := newRouter(db)
if err != nil {
t.Fatal(err)
}
registration := callClientAPI(router, http.MethodPut, "/api/v1/client/registration",
clientProfileJSON, "client-route", "", webToken)
assertClientAPIResponse(t, registration, http.StatusOK)
assertJSONField(t, registration.Body.Bytes(), "registered", true)
firstClaim := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
clientProfileJSON, "client-route", "", webToken)
assertClientAPIResponse(t, firstClaim, http.StatusOK)
firstTaskID := claimedTaskID(t, firstClaim.Body.Bytes())
secondClaim := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
clientProfileJSON, "client-route", "", webToken)
assertClientAPIResponse(t, secondClaim, http.StatusOK)
secondTaskID := claimedTaskID(t, secondClaim.Body.Bytes())
if firstTaskID == secondTaskID {
t.Fatalf("两次领取返回同一任务 %s", firstTaskID)
}
result := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/"+firstTaskID+"/result", apiResultJSON,
"client-route", "idem-result", webToken)
assertClientAPIResponse(t, result, http.StatusOK)
assertJSONField(t, result.Body.Bytes(), "accepted", true)
failure := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/"+secondTaskID+"/failure", apiFailureJSON,
"client-route", "idem-failure", webToken)
assertClientAPIResponse(t, failure, http.StatusOK)
assertJSONField(t, failure.Body.Bytes(), "accepted", true)
emptyClaim := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
clientProfileJSON, "client-route", "", webToken)
assertClientAPIResponse(t, emptyClaim, http.StatusNoContent)
if emptyClaim.Body.Len() != 0 {
t.Fatalf("204 响应体应为空,实际 %q", emptyClaim.Body.String())
}
})
}
}
func TestClient提交HTTP契约_取消重派与幂等不受网页登录影响(t *testing.T) {
db := newAPIRouteTestDB(t)
insertAPIRouteTask(t, db, "TASK-CANCELLED", "client-original", time.Now())
insertAPIRouteTask(t, db, "TASK-REASSIGNED", "client-original", time.Now().Add(time.Second))
insertAPIRouteTask(t, db, "TASK-NEVER-CLAIMED", "client-other", time.Now().Add(2*time.Second))
router, err := newRouter(db)
if err != nil {
t.Fatal(err)
}
first := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
clientProfileJSON, "client-original", "", "")
assertClientAPIResponse(t, first, http.StatusOK)
firstID := claimedTaskID(t, first.Body.Bytes())
second := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
clientProfileJSON, "client-original", "", "")
assertClientAPIResponse(t, second, http.StatusOK)
secondID := claimedTaskID(t, second.Body.Bytes())
if _, err := db.Exec(`UPDATE tasks SET status = 'cancelled' WHERE task_id = ?`, firstID); err != nil {
t.Fatal(err)
}
cancelled := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/"+firstID+"/result", apiResultJSON,
"client-original", "cancelled-result-key", "")
assertClientAPIResponse(t, cancelled, http.StatusOK)
assertJSONField(t, cancelled.Body.Bytes(), "accepted", true)
// 相同键和内容必须返回完全相同的业务响应;相同键不同内容返回稳定 JSON 409。
repeated := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/"+firstID+"/result", apiResultJSON,
"client-original", "cancelled-result-key", "")
assertClientAPIResponse(t, repeated, http.StatusOK)
if repeated.Body.String() != cancelled.Body.String() {
t.Fatalf("幂等重试响应不一致:\n首次 %s\n重试 %s", cancelled.Body.String(), repeated.Body.String())
}
conflict := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/"+firstID+"/result", strings.Replace(apiResultJSON, "http-result-1", "http-result-2", 1),
"client-original", "cancelled-result-key", "")
assertClientAPIResponse(t, conflict, http.StatusConflict)
assertAPIErrorCode(t, conflict.Body.Bytes(), "IDEMPOTENCY_CONFLICT")
if _, err := db.Exec(`UPDATE tasks SET assigned_client = 'client-new', status = 'assigned' WHERE task_id = ?`, secondID); err != nil {
t.Fatal(err)
}
reassigned := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/"+secondID+"/failure", apiFailureJSON,
"client-original", "reassigned-failure-key", "")
assertClientAPIResponse(t, reassigned, http.StatusOK)
assertJSONField(t, reassigned.Body.Bytes(), "accepted", true)
neverClaimed := callClientAPI(router, http.MethodPost,
"/api/v1/client/tasks/TASK-NEVER-CLAIMED/result", apiResultJSON,
"client-original", "never-claimed-key", "")
assertClientAPIResponse(t, neverClaimed, http.StatusForbidden)
assertAPIErrorCode(t, neverClaimed.Body.Bytes(), "TASK_NOT_ASSIGNED")
}
func newAPIRouteTestDB(t *testing.T) *sql.DB {
t.Helper()
return newMySQLTestDB(t)
}
func insertAPIRouteTask(t *testing.T, db *sql.DB, taskID, clientID string, createdAt time.Time) {
t.Helper()
at := createdAt.UTC().Format(model.TimeLayout)
_, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client,
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, created_at, updated_at)
VALUES (?, 'purchase', 'assigned', ?, 'TEST-ORDER',
'https://mobile.yangkeduo.com/goods.html?goods_id=1', '1',
'{"color":"黑色","size":"M码"}', 2, 4200, ?, ?)`,
taskID, clientID, at, at)
if err != nil {
t.Fatalf("插入 API 路由测试任务失败: %v", err)
}
}
func callClientAPI(handler http.Handler, method, path, body, clientID, idemKey, webToken string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer reserved-but-not-validated")
request.Header.Set("X-Request-Id", "route-regression-request")
if clientID != "" {
request.Header.Set("X-Client-Id", clientID)
}
if idemKey != "" {
request.Header.Set("Idempotency-Key", idemKey)
}
// 故意带一个无效的 Web CSRF Cookie;Client API 不经过 Web CSRF,不能被它影响。
request.AddCookie(&http.Cookie{Name: "cmautobuy_csrf", Value: "invalid-web-csrf"})
if webToken != "" {
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: webToken})
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func assertClientAPIResponse(t *testing.T, response *httptest.ResponseRecorder, wantStatus int) {
t.Helper()
if response.Code != wantStatus {
t.Fatalf("Client API 状态码 = %d,期望 %d,响应:%s", response.Code, wantStatus, response.Body.String())
}
body := response.Body.String()
for _, forbidden := range []string{"<!DOCTYPE", "登录 Admin", "CSRF token"} {
if strings.Contains(body, forbidden) {
t.Fatalf("Client API 混入 Web 响应 %q:%s", forbidden, body)
}
}
if wantStatus != http.StatusNoContent {
contentType := response.Header().Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
t.Fatalf("Client API Content-Type = %q,不是 JSON,响应:%s", contentType, body)
}
}
}
func claimedTaskID(t *testing.T, body []byte) string {
t.Helper()
var payload struct {
Task struct {
ID string `json:"id"`
} `json:"task"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.Task.ID == "" {
t.Fatalf("领取响应缺少任务编号: body=%s err=%v", body, err)
}
return payload.Task.ID
}
func assertJSONField(t *testing.T, body []byte, field string, want any) {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("响应不是 JSON: %s err=%v", body, err)
}
if fmt.Sprint(payload[field]) != fmt.Sprint(want) {
t.Fatalf("JSON 字段 %s = %v,期望 %v;响应:%s", field, payload[field], want, body)
}
}
func assertAPIErrorCode(t *testing.T, body []byte, want string) {
t.Helper()
var payload struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.Error.Code != want {
t.Fatalf("错误代码 = %q,期望 %q;响应:%s err=%v", payload.Error.Code, want, body, err)
}
}