Files
cmautobuy/admin/client_api_auth_regression_test.go
T

271 lines
10 KiB
Go
Raw Normal View History

package main
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"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()
db, err := repository.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err := repository.Migrate(db); err != nil {
t.Fatal(err)
}
return db
}
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)
}
}