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{} )