1039 lines
28 KiB
Go
1039 lines
28 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"
|
|
"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.OrderSubmitted {
|
|
t.Fatalf("start response = %+v", started)
|
|
}
|
|
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.OrderSubmitted ||
|
|
heartbeat.CancelRequested {
|
|
t.Fatalf("task heartbeat response = %+v", heartbeat)
|
|
}
|
|
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
|
|
}
|
|
|
|
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)
|
|
}
|
|
deviceRoutes, err := NewDeviceRouteRegistrar(
|
|
DeviceServices{
|
|
Lifecycle: lifecycle,
|
|
Assets: assets,
|
|
},
|
|
)
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
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"`
|
|
} `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{}
|
|
)
|