From 9959fac31a3d4531100f3c02312a7752cb99d6ad Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 13:56:43 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E9=AA=8C=E8=AF=81=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=B8=8D=E5=BD=B1=E5=93=8D=20Client=20?= =?UTF-8?q?=E5=9B=9B=E6=8E=A5=E5=8F=A3=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/client_api_auth_regression_test.go | 270 +++++++++++++++++++++++ admin/repository/migrate_test.go | 63 ++++++ 2 files changed, 333 insertions(+) create mode 100644 admin/client_api_auth_regression_test.go diff --git a/admin/client_api_auth_regression_test.go b/admin/client_api_auth_regression_test.go new file mode 100644 index 0000000..3db72cf --- /dev/null +++ b/admin/client_api_auth_regression_test.go @@ -0,0 +1,270 @@ +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{"= 3 { + if err := migrateV3(db); err != nil { + t.Fatalf("构造 v%d 时执行 v3 失败: %v", version, err) + } + } + if version >= 4 { + if err := runSQLMigration(db, 4, migrationV4); err != nil { + t.Fatalf("构造 v%d 时执行 v4 失败: %v", version, err) + } + } + if version >= 5 { + if err := runSQLMigration(db, 5, migrationV5); err != nil { + t.Fatalf("构造 v%d 时执行 v5 失败: %v", version, err) + } + } + return db +} + // existingTableSet 返回库里当前存在的表名集合。 func existingTableSet(t *testing.T, db *sql.DB) map[string]bool { t.Helper()