Files
cmroubao/backend-api/internal/transport/httpapi/device_handlers_test.go
T

1349 lines
41 KiB
Go

package httpapi
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
const (
deviceTestAdminID = "00000000-0000-4000-8000-000000000201"
deviceTestBuyerID = "00000000-0000-4000-8000-000000000202"
deviceTestDeviceID = "00000000-0000-4000-8000-000000000203"
)
func TestDeviceRoutesRequireBuyerBearerAndSeparateRoles(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
body := `{
"app_version":"0.1.0",
"android_version":"16",
"pdd_version":"8.17.0",
"readiness":{
"accessibility_enabled":true,
"pdd_installed":true,
"active_task_id":null
}
}`
withoutBearer := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/devices/heartbeat",
contentType: "application/json",
body: strings.NewReader(body),
})
if withoutBearer.Code != http.StatusUnauthorized {
t.Fatalf(
"request without bearer status/body = %d / %s",
withoutBearer.Code,
withoutBearer.Body,
)
}
assertErrorCode(t, withoutBearer, "DEVICE_ACCESS_REQUIRED")
adminBearer := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/devices/heartbeat",
contentType: "application/json",
body: strings.NewReader(body),
bearerToken: testCSRFOpaqueToken,
})
if adminBearer.Code != http.StatusUnauthorized {
t.Fatalf(
"admin bearer status/body = %d / %s",
adminBearer.Code,
adminBearer.Body,
)
}
assertErrorCode(t, adminBearer, "DEVICE_ACCESS_REQUIRED")
adminCookieRequest := httptest.NewRequest(
http.MethodPost,
"/api/v1/devices/heartbeat",
strings.NewReader(body),
)
adminCookieRequest.Header.Set("Content-Type", "application/json")
adminCookieRequest.AddCookie(&http.Cookie{
Name: "cmroubao_admin_session",
Value: testOpaqueToken,
})
adminCookieResponse := httptest.NewRecorder()
fixture.router.ServeHTTP(adminCookieResponse, adminCookieRequest)
if adminCookieResponse.Code != http.StatusUnauthorized {
t.Fatalf(
"admin cookie on device route status/body = %d / %s",
adminCookieResponse.Code,
adminCookieResponse.Body,
)
}
assertErrorCode(
t,
adminCookieResponse,
"DEVICE_ACCESS_REQUIRED",
)
buyerOnAdmin := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodGet,
target: "/api/v1/admin-probe",
bearerToken: testOpaqueToken,
})
if buyerOnAdmin.Code != http.StatusUnauthorized {
t.Fatalf(
"buyer bearer on admin route status/body = %d / %s",
buyerOnAdmin.Code,
buyerOnAdmin.Body,
)
}
assertErrorCode(t, buyerOnAdmin, "ADMIN_SESSION_REQUIRED")
}
func TestDeviceHeartbeatUsesPrincipalDeviceAndRejectsBodyMismatch(
t *testing.T,
) {
fixture := newDeviceHTTPFixture(t)
mismatch := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/devices/heartbeat",
contentType: "application/json",
body: strings.NewReader(`{
"device_id":"00000000-0000-4000-8000-000000000299",
"app_version":"0.1.0",
"android_version":"16",
"pdd_version":"8.17.0",
"readiness":{
"accessibility_enabled":true,
"pdd_installed":true
}
}`),
bearerToken: testOpaqueToken,
})
if mismatch.Code != http.StatusForbidden {
t.Fatalf(
"device mismatch status/body = %d / %s",
mismatch.Code,
mismatch.Body,
)
}
assertErrorCode(t, mismatch, "DEVICE_ID_MISMATCH")
heartbeat := fixture.readyHeartbeat(t)
if heartbeat.Code != http.StatusOK {
t.Fatalf(
"heartbeat status/body = %d / %s",
heartbeat.Code,
heartbeat.Body,
)
}
var response struct {
DeviceID string `json:"device_id"`
Readiness struct {
AccessibilityEnabled bool `json:"accessibility_enabled"`
PDDInstalled bool `json:"pdd_installed"`
} `json:"readiness"`
ClientStateMatches bool `json:"client_state_matches"`
}
decodeResponse(t, heartbeat, &response)
if response.DeviceID != deviceTestDeviceID ||
!response.Readiness.AccessibilityEnabled ||
!response.Readiness.PDDInstalled ||
!response.ClientStateMatches {
t.Fatalf("heartbeat response = %+v", response)
}
if heartbeat.Header().Get("Cache-Control") != "no-store" {
t.Fatalf(
"heartbeat Cache-Control = %q",
heartbeat.Header().Get("Cache-Control"),
)
}
}
func TestDeviceRoutesRejectUnsupportedAndUnknownJSON(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
body := `{
"app_version":"0.1.0",
"android_version":"16",
"pdd_version":"8.17.0",
"readiness":{
"accessibility_enabled":true,
"pdd_installed":true
}
}`
unsupported := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/devices/heartbeat",
body: strings.NewReader(body),
bearerToken: testOpaqueToken,
})
if unsupported.Code != http.StatusUnsupportedMediaType {
t.Fatalf(
"unsupported media status/body = %d / %s",
unsupported.Code,
unsupported.Body,
)
}
assertErrorCode(t, unsupported, "UNSUPPORTED_MEDIA_TYPE")
unknownField := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/devices/heartbeat",
contentType: "application/json",
body: strings.NewReader(`{
"app_version":"0.1.0",
"android_version":"16",
"pdd_version":"8.17.0",
"readiness":{
"accessibility_enabled":true,
"pdd_installed":true
},
"unexpected":true
}`),
bearerToken: testOpaqueToken,
})
if unknownField.Code != http.StatusBadRequest {
t.Fatalf(
"unknown JSON status/body = %d / %s",
unknownField.Code,
unknownField.Body,
)
}
assertErrorCode(t, unknownField, "INVALID_JSON")
}
func TestDeviceClaimReturnsTaskOrNoContentWithoutClaimSecret(
t *testing.T,
) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
noTask := fixture.claimNext(t, "claim-empty", testOpaqueToken)
requireDeviceStatus(t, noTask, http.StatusNoContent)
assertNoClaimSecret(t, noTask, testOpaqueToken)
taskID := fixture.createPendingTask(t)
claimed := fixture.claimNext(t, "claim-task", testOpaqueToken)
requireDeviceStatus(t, claimed, http.StatusOK)
var response deviceLifecycleResponse
decodeResponse(t, claimed, &response)
if response.Task.ID != taskID ||
response.Task.Status != string(domain.TaskStatusClaimed) ||
response.Task.Version < 2 ||
response.Task.ClaimGeneration != 1 ||
response.Task.ReferenceImageURL == "" ||
response.Replayed {
t.Fatalf("claim response = %+v", response)
}
assertNoClaimSecret(t, claimed, testOpaqueToken)
wrongToken := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodGet,
target: response.Task.ReferenceImageURL,
bearerToken: testOpaqueToken,
claimToken: testCSRFOpaqueToken,
})
requireDeviceStatus(t, wrongToken, http.StatusForbidden)
assertErrorCode(t, wrongToken, "TASK_CLAIM_INVALID")
imageResponse := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodGet,
target: response.Task.ReferenceImageURL,
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
})
requireDeviceStatus(t, imageResponse, http.StatusOK)
if imageResponse.Header().Get("Content-Type") != "image/jpeg" ||
imageResponse.Header().Get("Cache-Control") != "private, no-store" ||
imageResponse.Body.Len() == 0 {
t.Fatalf(
"reference image headers/body = %#v / %d",
imageResponse.Header(),
imageResponse.Body.Len(),
)
}
assertNoClaimSecret(t, imageResponse, testOpaqueToken)
}
func TestDeviceConcurrentHTTPClaimKeepsOneActiveTaskPerDevice(
t *testing.T,
) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
fixture.createPendingTask(t)
fixture.createPendingTask(t)
server := httptest.NewServer(fixture.router)
t.Cleanup(server.Close)
type outcome struct {
status int
body string
err error
}
outcomes := make(chan outcome, 2)
start := make(chan struct{})
var workers sync.WaitGroup
for index, token := range []string{
testOpaqueToken,
testCSRFOpaqueToken,
} {
index := index
token := token
workers.Add(1)
go func() {
defer workers.Done()
<-start
request, err := http.NewRequest(
http.MethodPost,
server.URL+"/api/v1/tasks/claim-next",
strings.NewReader(`{}`),
)
if err != nil {
outcomes <- outcome{err: err}
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+testOpaqueToken)
request.Header.Set(claimTokenHeader, token)
request.Header.Set(
"Idempotency-Key",
fmt.Sprintf("concurrent-http-claim-%d", index),
)
response, err := server.Client().Do(request)
if err != nil {
outcomes <- outcome{err: err}
return
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
outcomes <- outcome{
status: response.StatusCode,
body: string(body),
err: err,
}
}()
}
close(start)
workers.Wait()
close(outcomes)
statuses := map[int]int{}
for result := range outcomes {
if result.err != nil {
t.Fatalf("concurrent HTTP claim error = %v", result.err)
}
statuses[result.status]++
if strings.Contains(
strings.ToLower(result.body),
"claim_token",
) {
t.Fatalf("concurrent response leaked claim field: %s", result.body)
}
}
if statuses[http.StatusOK] != 1 ||
statuses[http.StatusConflict] != 1 {
t.Fatalf("concurrent HTTP statuses = %#v", statuses)
}
var active int
if err := fixture.db.QueryRow(
`SELECT COUNT(*)
FROM purchase_tasks
WHERE claimed_by_device_id = ?
AND status = 'CLAIMED'`,
deviceTestDeviceID,
).Scan(&active); err != nil {
t.Fatalf("count active HTTP claims: %v", err)
}
if active != 1 {
t.Fatalf("active HTTP claims = %d", active)
}
}
func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
taskID := fixture.createPendingTask(t)
claim := fixture.claimNext(t, "claim-for-start", testOpaqueToken)
requireDeviceStatus(t, claim, http.StatusOK)
var claimed deviceLifecycleResponse
decodeResponse(t, claim, &claimed)
startBody := fmt.Sprintf(
`{"claim_generation":%d,"expected_version":%d}`,
claimed.Task.ClaimGeneration,
claimed.Task.Version,
)
wrongToken := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/start",
contentType: "application/json",
body: strings.NewReader(startBody),
bearerToken: testOpaqueToken,
claimToken: testCSRFOpaqueToken,
idempotencyKey: "start-wrong-token",
})
if wrongToken.Code != http.StatusForbidden {
t.Fatalf(
"wrong claim token status/body = %d / %s",
wrongToken.Code,
wrongToken.Body,
)
}
assertErrorCode(t, wrongToken, "TASK_CLAIM_INVALID")
startedResponse := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/start",
contentType: "application/json",
body: strings.NewReader(startBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "start-correct-token",
},
)
requireDeviceStatus(t, startedResponse, http.StatusOK)
var started deviceLifecycleResponse
decodeResponse(t, startedResponse, &started)
if started.Task.Status != string(domain.TaskStatusRunning) ||
started.Execution.ID == "" ||
started.Execution.CurrentStep != "PREFLIGHT" ||
started.Execution.ExpiresAt.IsZero() ||
started.Execution.OrderSubmitted {
t.Fatalf("start response = %+v", started)
}
if remaining := time.Until(started.Execution.ExpiresAt); remaining < 9*time.Minute ||
remaining > 11*time.Minute {
t.Fatalf("start execution expiry remaining = %s", remaining)
}
assertNoClaimSecret(t, startedResponse, testOpaqueToken)
heartbeatBody := fmt.Sprintf(
`{
"execution_id":%q,
"claim_generation":%d,
"step":"SEARCH_RESULTS"
}`,
started.Execution.ID,
started.Task.ClaimGeneration,
)
taskHeartbeat := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/heartbeat",
contentType: "application/json",
body: strings.NewReader(heartbeatBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
},
)
requireDeviceStatus(t, taskHeartbeat, http.StatusOK)
var heartbeat deviceLifecycleResponse
decodeResponse(t, taskHeartbeat, &heartbeat)
if heartbeat.Task.Status != string(domain.TaskStatusRunning) ||
heartbeat.Execution.ID != started.Execution.ID ||
heartbeat.Execution.CurrentStep != "SEARCH_RESULTS" ||
heartbeat.Execution.ExpiresAt.IsZero() ||
heartbeat.Execution.ExpiresAt.Before(started.Execution.ExpiresAt) ||
heartbeat.Execution.OrderSubmitted ||
heartbeat.CancelRequested {
t.Fatalf("task heartbeat response = %+v", heartbeat)
}
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
}
func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
taskID := fixture.createPendingTask(t)
claimedResponse := fixture.claimNext(t, "claim-for-results", testOpaqueToken)
requireDeviceStatus(t, claimedResponse, http.StatusOK)
var claimed deviceLifecycleResponse
decodeResponse(t, claimedResponse, &claimed)
startResponse := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/start",
contentType: "application/json",
body: strings.NewReader(fmt.Sprintf(
`{"claim_generation":%d,"expected_version":%d}`,
claimed.Task.ClaimGeneration,
claimed.Task.Version,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "start-for-results",
})
requireDeviceStatus(t, startResponse, http.StatusOK)
var started deviceLifecycleResponse
decodeResponse(t, startResponse, &started)
occurredAt := time.Now().UTC().Format(time.RFC3339Nano)
events := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/events",
contentType: "application/json",
body: strings.NewReader(fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"events":[{"event_id":"00000000-0000-4000-8000-000000000701","step":"SEARCH","type":"SEARCH_STARTED","message":"开始采集候选","occurred_at":%q}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
occurredAt,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-events-1",
})
requireDeviceStatus(t, events, http.StatusOK)
detailEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
body: deviceReferenceImage(t, 701),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-evidence-1",
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, detailEvidence, http.StatusCreated)
var evidenceResponse struct {
Evidence struct {
ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"`
}
decodeResponse(t, detailEvidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" || evidenceResponse.Evidence.SHA256 == "" {
t.Fatalf("detail evidence response = %s", detailEvidence.Body.String())
}
specificationEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
body: deviceReferenceImage(t, 702),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-evidence-2",
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, specificationEvidence, http.StatusCreated)
var specificationEvidenceResponse struct {
Evidence struct {
ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"`
}
decodeResponse(t, specificationEvidence, &specificationEvidenceResponse)
if specificationEvidenceResponse.Evidence.ID == "" ||
specificationEvidenceResponse.Evidence.SHA256 == "" {
t.Fatalf(
"specification evidence response = %s",
specificationEvidence.Body.String(),
)
}
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task for content hash: %v", err)
}
taskHash := usecase.TaskContentSHA256(detail.Task)
cardSignature := strings.Repeat("b", 64)
detailSignature := strings.Repeat("c", 64)
candidatePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
)
badCandidatePayload := strings.Replace(
candidatePayload,
evidenceResponse.Evidence.SHA256,
strings.Repeat("0", 64),
1,
)
badCandidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(badCandidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-bad-hash",
})
requireDeviceStatus(t, badCandidates, http.StatusConflict)
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(candidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidates, http.StatusOK)
candidateReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(candidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidateReplay, http.StatusOK)
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
}
humanReviewPayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
)
humanReview := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/human-reviews",
contentType: "application/json",
body: strings.NewReader(humanReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-human-review-1",
})
requireDeviceStatus(t, humanReview, http.StatusOK)
if !strings.Contains(humanReview.Body.String(), `"version":1`) {
t.Fatalf("human review response = %s", humanReview.Body.String())
}
var storedReview struct {
Review struct {
ID string `json:"id"`
} `json:"review"`
}
decodeResponse(t, humanReview, &storedReview)
if storedReview.Review.ID == "" {
t.Fatalf("human review ID missing: %+v", storedReview)
}
humanReviewReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/human-reviews",
contentType: "application/json",
body: strings.NewReader(humanReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-human-review-1",
})
requireDeviceStatus(t, humanReviewReplay, http.StatusOK)
if !strings.Contains(humanReviewReplay.Body.String(), `"replayed":true`) {
t.Fatalf("human review replay response = %s", humanReviewReplay.Body.String())
}
revisedReviewPayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_review_id":%q,"items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"IMAGE_MATCH","reason_codes":["IMAGE_MATCH"],"note":""}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
storedReview.Review.ID,
)
revisedReview := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/human-reviews",
contentType: "application/json",
body: strings.NewReader(revisedReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-human-review-2",
})
requireDeviceStatus(t, revisedReview, http.StatusOK)
if !strings.Contains(revisedReview.Body.String(), `"version":2`) {
t.Fatalf("revised human review response = %s", revisedReview.Body.String())
}
runner, err := migration.New(fixture.db)
if err != nil {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("candidate identity migration down succeeded with retained data")
}
completePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null},"order_submitted":false}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
)
complete := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/complete",
contentType: "application/json",
body: strings.NewReader(completePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-complete-1",
})
requireDeviceStatus(t, complete, http.StatusOK)
assertNoClaimSecret(t, complete, testOpaqueToken)
replayed := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/complete",
contentType: "application/json",
body: strings.NewReader(completePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-complete-1",
})
requireDeviceStatus(t, replayed, http.StatusOK)
if !strings.Contains(replayed.Body.String(), `"replayed":true`) {
t.Fatalf("terminal replay response = %s", replayed.Body.String())
}
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task result detail: %v", err)
}
if detail.Task.Status != domain.TaskStatusSucceeded ||
detail.Report == nil ||
detail.Report.Outcome == nil ||
detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 2 ||
detail.Report.CandidateBatch == nil ||
detail.Report.DecisionDataset == nil ||
len(detail.Report.DecisionDataset.Observations) != 1 ||
len(detail.Report.DecisionDataset.HumanReviews) != 2 ||
detail.Report.DecisionDataset.HumanReviews[0].Version != 1 ||
detail.Report.DecisionDataset.HumanReviews[1].Version != 2 {
t.Fatalf("execution report = %+v", detail.Report)
}
identity := detail.Report.DecisionDataset.Observations[0].Identity
if identity == nil ||
len(identity.CandidateKey) != 64 ||
identity.CardSignature != cardSignature ||
identity.DetailSignature != detailSignature ||
identity.DetailEvidenceSHA256 != evidenceResponse.Evidence.SHA256 ||
identity.SpecificationEvidenceSHA256 !=
specificationEvidenceResponse.Evidence.SHA256 {
t.Fatalf("candidate identity = %+v", identity)
}
}
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
taskID := fixture.createPendingTask(t)
claim := fixture.claimNext(t, "claim-for-release", testOpaqueToken)
requireDeviceStatus(t, claim, http.StatusOK)
var claimed deviceLifecycleResponse
decodeResponse(t, claim, &claimed)
releaseBody := fmt.Sprintf(
`{"claim_generation":%d,"expected_version":%d}`,
claimed.Task.ClaimGeneration,
claimed.Task.Version,
)
releasedResponse := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/release",
contentType: "application/json",
body: strings.NewReader(releaseBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "release-claim",
},
)
requireDeviceStatus(t, releasedResponse, http.StatusOK)
var released deviceLifecycleResponse
decodeResponse(t, releasedResponse, &released)
if released.Task.ID != taskID ||
released.Task.Status != string(domain.TaskStatusPending) ||
released.Task.Version != claimed.Task.Version+1 ||
released.Task.ClaimGeneration != claimed.Task.ClaimGeneration ||
released.Replayed {
t.Fatalf("release response = %+v", released)
}
assertNoClaimSecret(t, releasedResponse, testOpaqueToken)
}
func TestDeviceCancelAcknowledgementFollowsAdminStopRequest(
t *testing.T,
) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
taskID := fixture.createPendingTask(t)
claim := fixture.claimNext(t, "claim-for-cancel", testOpaqueToken)
requireDeviceStatus(t, claim, http.StatusOK)
var claimed deviceLifecycleResponse
decodeResponse(t, claim, &claimed)
startBody := fmt.Sprintf(
`{"claim_generation":%d,"expected_version":%d}`,
claimed.Task.ClaimGeneration,
claimed.Task.Version,
)
startResponse := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/start",
contentType: "application/json",
body: strings.NewReader(startBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "start-for-cancel",
},
)
requireDeviceStatus(t, startResponse, http.StatusOK)
var started deviceLifecycleResponse
decodeResponse(t, startResponse, &started)
cancelRequested, err := fixture.tasks.Cancel(
context.Background(),
usecase.CancelTaskCommand{
CreatorSubject: "local-admin",
ActorUserID: deviceTestAdminID,
TaskID: taskID,
Reason: "HTTP cancel acknowledgement test",
},
)
if err != nil ||
cancelRequested.Status != domain.TaskStatusRunning ||
cancelRequested.CancelRequestedAt == nil {
t.Fatalf(
"admin cancel request = %+v, error = %v",
cancelRequested,
err,
)
}
heartbeatBody := fmt.Sprintf(
`{
"execution_id":%q,
"claim_generation":%d,
"step":"STOPPING"
}`,
started.Execution.ID,
started.Task.ClaimGeneration,
)
heartbeatResponse := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/heartbeat",
contentType: "application/json",
body: strings.NewReader(heartbeatBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
},
)
requireDeviceStatus(t, heartbeatResponse, http.StatusOK)
var heartbeat deviceLifecycleResponse
decodeResponse(t, heartbeatResponse, &heartbeat)
if !heartbeat.CancelRequested {
t.Fatalf("cancel heartbeat response = %+v", heartbeat)
}
ackBody := fmt.Sprintf(
`{
"execution_id":%q,
"claim_generation":%d,
"expected_version":%d
}`,
started.Execution.ID,
heartbeat.Task.ClaimGeneration,
heartbeat.Task.Version,
)
acknowledged := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/cancel-ack",
contentType: "application/json",
body: strings.NewReader(ackBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "cancel-ack-http",
},
)
requireDeviceStatus(t, acknowledged, http.StatusOK)
var canceled deviceLifecycleResponse
decodeResponse(t, acknowledged, &canceled)
if canceled.Task.Status != string(domain.TaskStatusCanceled) ||
canceled.Replayed {
t.Fatalf("cancel acknowledgement = %+v", canceled)
}
assertNoClaimSecret(t, acknowledged, testOpaqueToken)
replayed := performDeviceRequest(
t,
fixture.router,
deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/cancel-ack",
contentType: "application/json",
body: strings.NewReader(ackBody),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "cancel-ack-http",
},
)
requireDeviceStatus(t, replayed, http.StatusOK)
var replayedBody deviceLifecycleResponse
decodeResponse(t, replayed, &replayedBody)
if !replayedBody.Replayed ||
replayedBody.Task.Status != string(domain.TaskStatusCanceled) {
t.Fatalf("cancel acknowledgement replay = %+v", replayedBody)
}
}
type deviceHTTPFixture struct {
db *sql.DB
store *repository.Store
assets *usecase.AssetService
tasks *usecase.TaskService
router http.Handler
taskSequence int
}
func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
t.Helper()
ctx := context.Background()
db, err := database.Open(
ctx,
filepath.Join(t.TempDir(), "device-handlers.db"),
)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
t.Cleanup(func() { _ = db.Close() })
runner, err := migration.New(db)
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if _, err := runner.Up(ctx); err != nil {
t.Fatalf("migration.Up() error = %v", err)
}
seedDeviceHTTPIdentity(t, db)
store, err := repository.New(db)
if err != nil {
t.Fatalf("repository.New() error = %v", err)
}
clock := usecase.SystemClock{}
ids := usecase.UUIDGenerator{}
tasks, err := usecase.NewTaskService(store, clock, ids)
if err != nil {
t.Fatalf("usecase.NewTaskService() error = %v", err)
}
files, err := assetstore.New(filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("assetstore.New() error = %v", err)
}
assets, err := usecase.NewAssetService(store, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewAssetService() error = %v", err)
}
lifecycle, err := usecase.NewLifecycleService(
store,
clock,
ids,
10*time.Minute,
10*time.Minute,
2*time.Minute,
)
if err != nil {
t.Fatalf("usecase.NewLifecycleService() error = %v", err)
}
results, err := usecase.NewExecutionResultService(store, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
}
deviceRoutes, err := NewDeviceRouteRegistrar(
DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
},
)
if err != nil {
t.Fatalf("NewDeviceRouteRegistrar() error = %v", err)
}
authenticator := deviceHTTPAuthenticator{}
router, err := NewRouter(RouterDependencies{
Database: db,
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: registerDeviceTestAdminProbe,
RegisterDeviceRoutes: deviceRoutes,
AdminSessions: authenticator,
DeviceAccess: authenticator,
LogEvent: discardEvent,
})
if err != nil {
t.Fatalf("NewRouter() error = %v", err)
}
return &deviceHTTPFixture{
db: db,
store: store,
assets: assets,
tasks: tasks,
router: router,
}
}
func seedDeviceHTTPIdentity(t *testing.T, db *sql.DB) {
t.Helper()
now := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano)
for _, user := range []struct {
id string
username string
role domain.UserRole
}{
{deviceTestAdminID, "device-http-admin", domain.UserRoleAdmin},
{deviceTestBuyerID, "device-http-buyer", domain.UserRoleBuyer},
} {
if _, err := db.ExecContext(
context.Background(),
`INSERT INTO users (
id, username, password_hash, role, is_active,
created_at, updated_at
) VALUES (?, ?, 'test-only-password-hash', ?, 1, ?, ?)`,
user.id,
user.username,
user.role,
now,
now,
); err != nil {
t.Fatalf("seed user %s: %v", user.username, err)
}
}
if _, err := db.ExecContext(
context.Background(),
`INSERT INTO devices (
id, name, token_hash, bound_user_id, is_enabled,
created_at, updated_at
) VALUES (?, 'device-http-test', ?, ?, 1, ?, ?)`,
deviceTestDeviceID,
deviceTestHash("device-registration-token"),
deviceTestBuyerID,
now,
now,
); err != nil {
t.Fatalf("seed device: %v", err)
}
}
func (fixture *deviceHTTPFixture) readyHeartbeat(
t *testing.T,
) *httptest.ResponseRecorder {
t.Helper()
return performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/devices/heartbeat",
contentType: "application/json",
body: strings.NewReader(`{
"app_version":"0.1.0",
"android_version":"16",
"pdd_version":"8.17.0",
"readiness":{
"accessibility_enabled":true,
"pdd_installed":true,
"active_task_id":null
}
}`),
bearerToken: testOpaqueToken,
})
}
func (fixture *deviceHTTPFixture) claimNext(
t *testing.T,
idempotencyKey string,
claimToken string,
) *httptest.ResponseRecorder {
t.Helper()
return performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/claim-next",
contentType: "application/json",
body: strings.NewReader(`{}`),
bearerToken: testOpaqueToken,
claimToken: claimToken,
idempotencyKey: idempotencyKey,
})
}
func (fixture *deviceHTTPFixture) createPendingTask(t *testing.T) string {
t.Helper()
fixture.taskSequence++
index := fixture.taskSequence
uploaded, err := fixture.assets.UploadTaskReference(
context.Background(),
usecase.UploadTaskReferenceCommand{
CreatorSubject: "local-admin",
IdempotencyKey: fmt.Sprintf("device-http-asset-%d", index),
DeclaredMediaType: "image/jpeg",
Content: deviceReferenceImage(t, index),
},
)
if err != nil || uploaded.Asset.ID == "" {
t.Fatalf("upload asset = %+v, error = %v", uploaded, err)
}
sourceRef := fmt.Sprintf("device-http-source-%d", index)
result, err := fixture.tasks.Create(
context.Background(),
usecase.CreateTaskCommand{
CreatorSubject: "local-admin",
ActorUserID: deviceTestAdminID,
IdempotencyKey: fmt.Sprintf("device-http-task-%d", index),
SourceRef: &sourceRef,
Title: "测试采购商品",
Description: "仅用于 HTTP 合同测试",
SKU: fmt.Sprintf("TEST-SKU-%d", index),
ImageAssetID: uploaded.Asset.ID,
Quantity: 2,
},
)
if err != nil {
t.Fatalf("create task: %v", err)
}
return result.Task.ID
}
func deviceReferenceImage(t *testing.T, index int) io.Reader {
t.Helper()
var encoded bytes.Buffer
source := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
source.Set(x, y, color.RGBA{
R: uint8(40 + index),
G: uint8(80 + x),
B: uint8(120 + y),
A: 255,
})
}
}
if err := jpeg.Encode(&encoded, source, nil); err != nil {
t.Fatalf("encode device reference image: %v", err)
}
return bytes.NewReader(encoded.Bytes())
}
type deviceRequest struct {
method string
target string
contentType string
body io.Reader
bearerToken string
claimToken string
idempotencyKey string
executionID string
claimGeneration int64
}
func performDeviceRequest(
t *testing.T,
router http.Handler,
spec deviceRequest,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(spec.method, spec.target, spec.body)
if spec.contentType != "" {
request.Header.Set("Content-Type", spec.contentType)
}
if spec.bearerToken != "" {
request.Header.Set(
"Authorization",
"Bearer "+spec.bearerToken,
)
}
if spec.claimToken != "" {
request.Header.Set(claimTokenHeader, spec.claimToken)
}
if spec.idempotencyKey != "" {
request.Header.Set("Idempotency-Key", spec.idempotencyKey)
}
if spec.executionID != "" {
request.Header.Set("X-Execution-ID", spec.executionID)
request.Header.Set(
"X-Claim-Generation",
strconv.FormatInt(spec.claimGeneration, 10),
)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
type deviceLifecycleResponse struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
Version int64 `json:"version"`
ClaimGeneration int64 `json:"claim_generation"`
ReferenceImageURL string `json:"reference_image_url"`
} `json:"task"`
Execution struct {
ID string `json:"id"`
CurrentStep string `json:"current_step"`
OrderSubmitted bool `json:"order_submitted"`
ExpiresAt time.Time `json:"execution_expires_at"`
} `json:"execution"`
Replayed bool `json:"replayed"`
CancelRequested bool `json:"cancel_requested"`
}
func requireDeviceStatus(
t *testing.T,
response *httptest.ResponseRecorder,
want int,
) {
t.Helper()
if response.Code != want {
t.Fatalf(
"status/body = %d / %s, want %d",
response.Code,
response.Body,
want,
)
}
}
func assertNoClaimSecret(
t *testing.T,
response *httptest.ResponseRecorder,
claimToken string,
) {
t.Helper()
responseText := strings.ToLower(response.Body.String())
claimHash := deviceTestHash(claimToken)
for _, forbidden := range []string{
strings.ToLower(claimToken),
claimHash,
"x-claim-token",
"claim_token",
"claim_token_hash",
} {
if strings.Contains(responseText, forbidden) {
t.Fatalf(
"response exposes claim secret %q: %s",
forbidden,
response.Body,
)
}
}
if response.Header().Get(claimTokenHeader) != "" {
t.Fatalf(
"%s response header must be empty",
claimTokenHeader,
)
}
}
func deviceTestHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
type deviceHTTPAuthenticator struct{}
func (deviceHTTPAuthenticator) AuthenticateAdmin(
context.Context,
string,
) (domain.AuthPrincipal, error) {
return domain.AuthPrincipal{
UserID: deviceTestAdminID,
Username: "device-http-admin",
Role: domain.UserRoleAdmin,
SessionID: "device-http-admin-session",
ExpiresAt: time.Now().Add(time.Hour),
}, nil
}
func (deviceHTTPAuthenticator) AuthenticateAccessToken(
_ context.Context,
token string,
) (domain.AuthPrincipal, error) {
switch token {
case testOpaqueToken:
return domain.AuthPrincipal{
UserID: deviceTestBuyerID,
Username: "device-http-buyer",
Role: domain.UserRoleBuyer,
DeviceID: deviceTestDeviceID,
ExpiresAt: time.Now().Add(
time.Hour,
),
}, nil
case testCSRFOpaqueToken:
return domain.AuthPrincipal{
UserID: deviceTestAdminID,
Username: "device-http-admin",
Role: domain.UserRoleAdmin,
SessionID: "device-http-admin-session",
ExpiresAt: time.Now().Add(
time.Hour,
),
}, nil
default:
return domain.AuthPrincipal{}, errors.New(
"test access token is invalid",
)
}
}
func registerDeviceTestAdminProbe(routes gin.IRoutes) error {
routes.GET("/api/v1/admin-probe", func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
return nil
}
var (
_ AdminAuthenticator = deviceHTTPAuthenticator{}
_ DeviceAuthenticator = deviceHTTPAuthenticator{}
)