857 lines
23 KiB
Go
857 lines
23 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/textproto"
|
|
"path/filepath"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
func TestCandidateDecisionDatasetResponseIncludesPersistentIdentity(t *testing.T) {
|
|
createdAt := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
|
response := candidateDecisionDatasetResponse(&domain.CandidateDecisionDataset{
|
|
Observations: []domain.CandidateObservation{
|
|
{
|
|
Ordinal: 1,
|
|
Identity: &domain.CandidateObservationIdentity{
|
|
CandidateKey: strings.Repeat("a", 64),
|
|
CardSignature: strings.Repeat("b", 64),
|
|
DetailSignature: strings.Repeat("c", 64),
|
|
DetailEvidenceSHA256: strings.Repeat("d", 64),
|
|
SpecificationEvidenceSHA256: strings.Repeat("e", 64),
|
|
IdentityVersion: 1,
|
|
CreatedAt: createdAt,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
observations, ok := response["observations"].([]gin.H)
|
|
if !ok || len(observations) != 1 {
|
|
t.Fatalf("observations = %#v", response["observations"])
|
|
}
|
|
identity, ok := observations[0]["identity"].(gin.H)
|
|
if !ok ||
|
|
identity["candidate_key"] != strings.Repeat("a", 64) ||
|
|
identity["identity_version"] != 1 {
|
|
t.Fatalf("identity = %#v", observations[0]["identity"])
|
|
}
|
|
}
|
|
|
|
func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
|
router := newAdminIntegrationRouter(t)
|
|
imageBody, imageContentType := referenceUpload(t, "asset-key-1")
|
|
assetResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/assets",
|
|
imageContentType,
|
|
imageBody,
|
|
"asset-key-1",
|
|
)
|
|
if assetResponse.Code != http.StatusCreated {
|
|
t.Fatalf(
|
|
"asset upload status = %d, body = %s",
|
|
assetResponse.Code,
|
|
assetResponse.Body.String(),
|
|
)
|
|
}
|
|
var asset map[string]any
|
|
decodeResponse(t, assetResponse, &asset)
|
|
assetID, _ := asset["id"].(string)
|
|
if assetID == "" || asset["media_type"] != "image/jpeg" {
|
|
t.Fatalf("asset response = %#v", asset)
|
|
}
|
|
if responseContainsKey(asset, "storage_key") ||
|
|
strings.Contains(strings.ToLower(assetResponse.Body.String()), "temp") {
|
|
t.Fatalf("asset response exposes storage details: %#v", asset)
|
|
}
|
|
|
|
replayBody, replayContentType := referenceUpload(t, "asset-key-1")
|
|
replayResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/assets",
|
|
replayContentType,
|
|
replayBody,
|
|
"asset-key-1",
|
|
)
|
|
var replayedAsset map[string]any
|
|
decodeResponse(t, replayResponse, &replayedAsset)
|
|
if replayResponse.Code != http.StatusCreated ||
|
|
replayedAsset["id"] != assetID {
|
|
t.Fatalf(
|
|
"asset replay status/body = %d / %#v",
|
|
replayResponse.Code,
|
|
replayedAsset,
|
|
)
|
|
}
|
|
|
|
taskJSON := `{
|
|
"source_ref":"external-10001",
|
|
"title":"黑色双肩包",
|
|
"sku":"BLACK-20L",
|
|
"description":"容量约20L",
|
|
"image_asset_id":"` + assetID + `",
|
|
"quantity":2,
|
|
"max_budget":"200.00"
|
|
}`
|
|
taskResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks",
|
|
"application/json",
|
|
strings.NewReader(taskJSON),
|
|
"task-key-1",
|
|
)
|
|
if taskResponse.Code != http.StatusCreated {
|
|
t.Fatalf(
|
|
"task create status = %d, body = %s",
|
|
taskResponse.Code,
|
|
taskResponse.Body.String(),
|
|
)
|
|
}
|
|
var task map[string]any
|
|
decodeResponse(t, taskResponse, &task)
|
|
taskID, _ := task["id"].(string)
|
|
if taskID == "" || task["status"] != "PENDING" ||
|
|
task["sku"] != "BLACK-20L" ||
|
|
task["max_budget"] != "200.00" {
|
|
t.Fatalf("task response = %#v", task)
|
|
}
|
|
|
|
taskReplay := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks",
|
|
"application/json",
|
|
strings.NewReader(taskJSON),
|
|
"task-key-1",
|
|
)
|
|
var replayedTask map[string]any
|
|
decodeResponse(t, taskReplay, &replayedTask)
|
|
if taskReplay.Code != http.StatusCreated ||
|
|
replayedTask["id"] != taskID {
|
|
t.Fatalf(
|
|
"task replay status/body = %d / %#v",
|
|
taskReplay.Code,
|
|
replayedTask,
|
|
)
|
|
}
|
|
|
|
listResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodGet,
|
|
"/api/v1/tasks?q=BLACK-20L&limit=20",
|
|
"",
|
|
nil,
|
|
"",
|
|
)
|
|
var list map[string]any
|
|
decodeResponse(t, listResponse, &list)
|
|
items, _ := list["items"].([]any)
|
|
if listResponse.Code != http.StatusOK || len(items) != 1 {
|
|
t.Fatalf(
|
|
"task list status/body = %d / %#v",
|
|
listResponse.Code,
|
|
list,
|
|
)
|
|
}
|
|
|
|
detailResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodGet,
|
|
"/api/v1/tasks/"+taskID,
|
|
"",
|
|
nil,
|
|
"",
|
|
)
|
|
var detail map[string]any
|
|
decodeResponse(t, detailResponse, &detail)
|
|
requirement, _ := detail["original_requirement"].(map[string]any)
|
|
if detailResponse.Code != http.StatusOK ||
|
|
requirement["sku"] != "BLACK-20L" ||
|
|
requirement["quantity"] != float64(2) {
|
|
t.Fatalf(
|
|
"task detail status/body = %d / %#v",
|
|
detailResponse.Code,
|
|
detail,
|
|
)
|
|
}
|
|
|
|
contentResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodGet,
|
|
"/api/v1/assets/"+assetID+"/content",
|
|
"",
|
|
nil,
|
|
"",
|
|
)
|
|
if contentResponse.Code != http.StatusOK ||
|
|
contentResponse.Header().Get("Content-Type") != "image/jpeg" ||
|
|
!bytes.HasPrefix(contentResponse.Body.Bytes(), []byte{0xff, 0xd8}) {
|
|
t.Fatalf(
|
|
"asset content status/headers = %d / %#v",
|
|
contentResponse.Code,
|
|
contentResponse.Header(),
|
|
)
|
|
}
|
|
|
|
cancelResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks/"+taskID+"/cancel",
|
|
"application/json",
|
|
strings.NewReader(`{"reason":"需求已撤销"}`),
|
|
"",
|
|
)
|
|
var canceled map[string]any
|
|
decodeResponse(t, cancelResponse, &canceled)
|
|
if cancelResponse.Code != http.StatusOK ||
|
|
canceled["status"] != "CANCELED" {
|
|
t.Fatalf(
|
|
"task cancel status/body = %d / %#v",
|
|
cancelResponse.Code,
|
|
canceled,
|
|
)
|
|
}
|
|
canceledDetailResponse := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodGet,
|
|
"/api/v1/tasks/"+taskID,
|
|
"",
|
|
nil,
|
|
"",
|
|
)
|
|
var canceledDetail map[string]any
|
|
decodeResponse(t, canceledDetailResponse, &canceledDetail)
|
|
events, _ := canceledDetail["events"].([]any)
|
|
if canceledDetailResponse.Code != http.StatusOK || len(events) != 2 {
|
|
t.Fatalf(
|
|
"canceled detail status/body = %d / %#v",
|
|
canceledDetailResponse.Code,
|
|
canceledDetail,
|
|
)
|
|
}
|
|
for _, value := range events {
|
|
event, _ := value.(map[string]any)
|
|
if event["actor_user_id"] !=
|
|
"00000000-0000-4000-8000-000000000099" {
|
|
t.Fatalf("event actor = %#v", event)
|
|
}
|
|
}
|
|
|
|
secondCancel := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks/"+taskID+"/cancel",
|
|
"application/json",
|
|
strings.NewReader(`{"reason":"再次取消"}`),
|
|
"",
|
|
)
|
|
if secondCancel.Code != http.StatusConflict {
|
|
t.Fatalf(
|
|
"second cancel status = %d, body = %s",
|
|
secondCancel.Code,
|
|
secondCancel.Body.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
|
fixture := newAdminIntegrationFixture(t)
|
|
taskID, executionID, taskHash, firstKey, secondKey :=
|
|
seedAdminAuthorizationTask(t, fixture)
|
|
payload := fmt.Sprintf(
|
|
`{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":2,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":null,"items":[{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""},{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""}]}`,
|
|
executionID,
|
|
taskHash,
|
|
firstKey,
|
|
firstKey,
|
|
secondKey,
|
|
)
|
|
created := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
|
"application/json",
|
|
strings.NewReader(payload),
|
|
"authorization-1",
|
|
)
|
|
if created.Code != http.StatusCreated {
|
|
t.Fatalf(
|
|
"authorization status/body = %d / %s",
|
|
created.Code,
|
|
created.Body.String(),
|
|
)
|
|
}
|
|
var createdBody struct {
|
|
Authorization struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Version int `json:"authorization_version"`
|
|
} `json:"authorization"`
|
|
Replayed bool `json:"replayed"`
|
|
}
|
|
decodeResponse(t, created, &createdBody)
|
|
if createdBody.Authorization.ID == "" ||
|
|
createdBody.Authorization.Status != "PENDING_DELIVERY" ||
|
|
createdBody.Authorization.Version != 1 ||
|
|
createdBody.Replayed {
|
|
t.Fatalf("authorization response = %+v", createdBody)
|
|
}
|
|
|
|
replayed := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
|
"application/json",
|
|
strings.NewReader(payload),
|
|
"authorization-1",
|
|
)
|
|
requireAdminStatus(t, replayed, http.StatusCreated)
|
|
if !strings.Contains(replayed.Body.String(), `"replayed":true`) ||
|
|
!strings.Contains(
|
|
replayed.Body.String(),
|
|
createdBody.Authorization.ID,
|
|
) {
|
|
t.Fatalf("authorization replay = %s", replayed.Body.String())
|
|
}
|
|
|
|
stale := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
|
"application/json",
|
|
strings.NewReader(payload),
|
|
"authorization-stale",
|
|
)
|
|
requireAdminStatus(t, stale, http.StatusConflict)
|
|
|
|
revisedPayload := fmt.Sprintf(
|
|
`{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":3,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":%q,"items":[{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""},{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"IMAGE_MATCH","reason_codes":["IMAGE_MATCH"],"note":""}]}`,
|
|
executionID,
|
|
taskHash,
|
|
secondKey,
|
|
createdBody.Authorization.ID,
|
|
firstKey,
|
|
secondKey,
|
|
)
|
|
revised := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
|
"application/json",
|
|
strings.NewReader(revisedPayload),
|
|
"authorization-2",
|
|
)
|
|
requireAdminStatus(t, revised, http.StatusCreated)
|
|
if !strings.Contains(revised.Body.String(), `"authorization_version":2`) ||
|
|
!strings.Contains(revised.Body.String(), `"candidate_key":"`+secondKey+`"`) {
|
|
t.Fatalf("revised authorization = %s", revised.Body.String())
|
|
}
|
|
|
|
detail := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodGet,
|
|
"/api/v1/tasks/"+taskID,
|
|
"",
|
|
nil,
|
|
"",
|
|
)
|
|
requireAdminStatus(t, detail, http.StatusOK)
|
|
var detailBody map[string]any
|
|
decodeResponse(t, detail, &detailBody)
|
|
authorizations, _ := detailBody["order_authorizations"].([]any)
|
|
if detailBody["version"] != float64(4) || len(authorizations) != 2 ||
|
|
!strings.Contains(detail.Body.String(), `"status":"SUPERSEDED"`) ||
|
|
!strings.Contains(detail.Body.String(), `"review_version":2`) {
|
|
t.Fatalf("authorization detail = %#v", detailBody)
|
|
}
|
|
|
|
runner, err := migration.New(fixture.db)
|
|
if err != nil {
|
|
t.Fatalf("migration.New() error = %v", err)
|
|
}
|
|
if err := runner.Down(context.Background()); err == nil {
|
|
t.Fatal("order authorization migration down succeeded with retained data")
|
|
}
|
|
}
|
|
|
|
func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
|
router := newAdminIntegrationRouter(t)
|
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
|
request.RemoteAddr = "192.0.2.10:3210"
|
|
response := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
|
}
|
|
var body map[string]any
|
|
decodeResponse(t, response, &body)
|
|
publicError, _ := body["error"].(map[string]any)
|
|
if publicError["code"] != "ADMIN_SESSION_REQUIRED" {
|
|
t.Fatalf("error response = %#v", body)
|
|
}
|
|
}
|
|
|
|
func seedAdminAuthorizationTask(
|
|
t *testing.T,
|
|
fixture *adminIntegrationFixture,
|
|
) (taskID string, executionID string, taskHash string, firstKey string, secondKey string) {
|
|
t.Helper()
|
|
imageBody, imageContentType := referenceUpload(t, "authorization-asset")
|
|
assetResponse := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodPost,
|
|
"/api/v1/assets",
|
|
imageContentType,
|
|
imageBody,
|
|
"authorization-asset",
|
|
)
|
|
requireAdminStatus(t, assetResponse, http.StatusCreated)
|
|
var asset struct {
|
|
ID string `json:"id"`
|
|
}
|
|
decodeResponse(t, assetResponse, &asset)
|
|
taskResponse := performAdminRequest(
|
|
t,
|
|
fixture.router,
|
|
http.MethodPost,
|
|
"/api/v1/tasks",
|
|
"application/json",
|
|
strings.NewReader(
|
|
`{"title":"后台授权测试商品","sku":"BLACK-L","description":"","image_asset_id":"`+
|
|
asset.ID+`","quantity":2,"max_budget":"100.00"}`,
|
|
),
|
|
"authorization-task",
|
|
)
|
|
requireAdminStatus(t, taskResponse, http.StatusCreated)
|
|
var task struct {
|
|
ID string `json:"id"`
|
|
}
|
|
decodeResponse(t, taskResponse, &task)
|
|
|
|
const (
|
|
buyerID = "00000000-0000-4000-8000-000000000901"
|
|
deviceID = "00000000-0000-4000-8000-000000000902"
|
|
)
|
|
executionID = "00000000-0000-4000-8000-000000000903"
|
|
now := time.Now().UTC()
|
|
nowText := now.Format(time.RFC3339Nano)
|
|
expiryText := now.Add(time.Hour).Format(time.RFC3339Nano)
|
|
if _, err := fixture.db.Exec(
|
|
`INSERT INTO users (
|
|
id, username, password_hash, role, is_active, created_at, updated_at
|
|
) VALUES (?, 'buyer-auth-test', 'test-only-hash', 'BUYER', 1, ?, ?)`,
|
|
buyerID,
|
|
nowText,
|
|
nowText,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization buyer: %v", err)
|
|
}
|
|
if _, err := fixture.db.Exec(
|
|
`INSERT INTO devices (
|
|
id, name, token_hash, bound_user_id, app_version,
|
|
android_version, pdd_version, last_seen_at, is_enabled,
|
|
created_at, updated_at, accessibility_enabled, pdd_installed,
|
|
readiness_reported_at
|
|
) VALUES (?, 'auth-device', ?, ?, 'test', '16', '8.17.0', ?, 1,
|
|
?, ?, 1, 1, ?)`,
|
|
deviceID,
|
|
strings.Repeat("9", 64),
|
|
buyerID,
|
|
nowText,
|
|
nowText,
|
|
nowText,
|
|
nowText,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization device: %v", err)
|
|
}
|
|
if _, err := fixture.db.Exec(
|
|
`UPDATE purchase_tasks SET
|
|
status = 'WAITING_CONFIRMATION', version = 2,
|
|
claimed_by_user_id = ?, claimed_by_device_id = ?,
|
|
claim_generation = 1, claim_token_hash = ?,
|
|
claim_issued_at = ?, claim_expires_at = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
buyerID,
|
|
deviceID,
|
|
strings.Repeat("8", 64),
|
|
nowText,
|
|
expiryText,
|
|
nowText,
|
|
task.ID,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization task: %v", err)
|
|
}
|
|
if _, err := fixture.db.Exec(
|
|
`INSERT INTO task_executions (
|
|
id, task_id, attempt_no, claim_generation, user_id, device_id,
|
|
current_step, last_heartbeat_at, order_submitted, started_at
|
|
) VALUES (?, ?, 1, 1, ?, ?, 'WAITING_ADMIN_CONFIRMATION', ?, 0, ?)`,
|
|
executionID,
|
|
task.ID,
|
|
buyerID,
|
|
deviceID,
|
|
nowText,
|
|
nowText,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization execution: %v", err)
|
|
}
|
|
store, err := repository.New(fixture.db)
|
|
if err != nil {
|
|
t.Fatalf("repository.New() error = %v", err)
|
|
}
|
|
detail, err := store.GetTaskDetail(
|
|
context.Background(),
|
|
localAdminSubject,
|
|
task.ID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
|
}
|
|
taskHash = usecase.TaskContentSHA256(detail.Task)
|
|
firstKey = strings.Repeat("a", 64)
|
|
secondKey = strings.Repeat("b", 64)
|
|
if _, err := fixture.db.Exec(
|
|
`INSERT INTO candidate_search_runs (
|
|
execution_id, task_id, task_content_sha256, execution_mode,
|
|
search_query, started_at, received_at, observation_count,
|
|
collection_complete, received_after_execution_expiry
|
|
) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 2, 1, 0)`,
|
|
executionID,
|
|
task.ID,
|
|
taskHash,
|
|
nowText,
|
|
nowText,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization search run: %v", err)
|
|
}
|
|
if _, err := fixture.db.Exec(
|
|
`INSERT INTO candidate_observations (
|
|
execution_id, task_id, ordinal, title, sku_text, price_text,
|
|
product_url, image_url, evidence_asset_ids_json,
|
|
collection_status, observed_at
|
|
) VALUES
|
|
(?, ?, 1, '候选一', 'BLACK-L', '20.00', '', '', '[]', 'COMPLETE', ?),
|
|
(?, ?, 2, '候选二', 'BLACK-L', '22.00', '', '', '[]', 'COMPLETE', ?)`,
|
|
executionID,
|
|
task.ID,
|
|
nowText,
|
|
executionID,
|
|
task.ID,
|
|
nowText,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization observations: %v", err)
|
|
}
|
|
if _, err := fixture.db.Exec(
|
|
`INSERT INTO candidate_observation_identities (
|
|
candidate_key, execution_id, candidate_ordinal, card_signature,
|
|
detail_signature, detail_evidence_sha256,
|
|
specification_evidence_sha256, identity_version, created_at
|
|
) VALUES
|
|
(?, ?, 1, ?, ?, ?, ?, 1, ?),
|
|
(?, ?, 2, ?, ?, ?, ?, 1, ?)`,
|
|
firstKey,
|
|
executionID,
|
|
strings.Repeat("c", 64),
|
|
strings.Repeat("d", 64),
|
|
strings.Repeat("e", 64),
|
|
strings.Repeat("f", 64),
|
|
nowText,
|
|
secondKey,
|
|
executionID,
|
|
strings.Repeat("1", 64),
|
|
strings.Repeat("2", 64),
|
|
strings.Repeat("3", 64),
|
|
strings.Repeat("4", 64),
|
|
nowText,
|
|
); err != nil {
|
|
t.Fatalf("seed authorization identities: %v", err)
|
|
}
|
|
return task.ID, executionID, taskHash, firstKey, secondKey
|
|
}
|
|
|
|
func requireAdminStatus(
|
|
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.String(),
|
|
want,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
|
|
router := newAdminIntegrationRouter(t)
|
|
imageBody, imageContentType := referenceUpload(t, "missing-key")
|
|
|
|
response := performAdminRequest(
|
|
t,
|
|
router,
|
|
http.MethodPost,
|
|
"/api/v1/assets",
|
|
imageContentType,
|
|
imageBody,
|
|
"",
|
|
)
|
|
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
|
}
|
|
var body map[string]any
|
|
decodeResponse(t, response, &body)
|
|
publicError, _ := body["error"].(map[string]any)
|
|
if publicError["code"] != "IDEMPOTENCY_KEY_REQUIRED" {
|
|
t.Fatalf("error response = %#v", body)
|
|
}
|
|
}
|
|
|
|
type emptyAdminWeb struct{}
|
|
|
|
func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {}
|
|
|
|
func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
return newAdminIntegrationFixture(t).router
|
|
}
|
|
|
|
type adminIntegrationFixture struct {
|
|
router http.Handler
|
|
db *sql.DB
|
|
}
|
|
|
|
func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.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)
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
if _, err := db.ExecContext(
|
|
ctx,
|
|
`INSERT INTO users (
|
|
id, username, password_hash, role, is_active, created_at, updated_at
|
|
) VALUES (?, 'admin', 'test-only-hash', 'ADMIN', 1, ?, ?)`,
|
|
"00000000-0000-4000-8000-000000000099",
|
|
now,
|
|
now,
|
|
); err != nil {
|
|
t.Fatalf("seed admin user: %v", err)
|
|
}
|
|
repositories, err := repository.New(db)
|
|
if err != nil {
|
|
t.Fatalf("repository.New() error = %v", err)
|
|
}
|
|
files, err := assetstore.New(filepath.Join(t.TempDir(), "assets"))
|
|
if err != nil {
|
|
t.Fatalf("assetstore.New() error = %v", err)
|
|
}
|
|
clock := usecase.SystemClock{}
|
|
ids := usecase.UUIDGenerator{}
|
|
assets, err := usecase.NewAssetService(repositories, files, clock, ids)
|
|
if err != nil {
|
|
t.Fatalf("usecase.NewAssetService() error = %v", err)
|
|
}
|
|
tasks, err := usecase.NewTaskService(repositories, clock, ids)
|
|
if err != nil {
|
|
t.Fatalf("usecase.NewTaskService() error = %v", err)
|
|
}
|
|
results, err := usecase.NewExecutionResultService(repositories, files, clock, ids)
|
|
if err != nil {
|
|
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
|
}
|
|
authorizations, err := usecase.NewOrderAuthorizationService(
|
|
repositories,
|
|
clock,
|
|
ids,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
|
|
}
|
|
registrar, err := NewAdminRouteRegistrar(
|
|
AdminServices{
|
|
Assets: assets,
|
|
Tasks: tasks,
|
|
Results: results,
|
|
Authorizations: authorizations,
|
|
},
|
|
emptyAdminWeb{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewAdminRouteRegistrar() error = %v", err)
|
|
}
|
|
router, err := NewRouter(RouterDependencies{
|
|
Database: db,
|
|
RegisterPublicRoutes: discardRoutes,
|
|
RegisterAdminRoutes: registrar,
|
|
RegisterDeviceRoutes: discardRoutes,
|
|
AdminSessions: allowAdminAuthenticator{},
|
|
DeviceAccess: allowAdminAuthenticator{},
|
|
LogEvent: discardEvent,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRouter() error = %v", err)
|
|
}
|
|
return &adminIntegrationFixture{router: router, db: db}
|
|
}
|
|
|
|
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
|
t.Helper()
|
|
var imageBytes bytes.Buffer
|
|
source := image.NewRGBA(image.Rect(0, 0, 8, 6))
|
|
for y := 0; y < 6; y++ {
|
|
for x := 0; x < 8; x++ {
|
|
source.Set(x, y, color.RGBA{R: uint8(x * 20), G: 80, B: 160, A: 255})
|
|
}
|
|
}
|
|
if err := jpeg.Encode(&imageBytes, source, &jpeg.Options{Quality: 85}); err != nil {
|
|
t.Fatalf("jpeg.Encode() error = %v", err)
|
|
}
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("purpose", "TASK_REFERENCE"); err != nil {
|
|
t.Fatalf("WriteField(purpose) error = %v", err)
|
|
}
|
|
if err := writer.WriteField("task_id", ""); err != nil {
|
|
t.Fatalf("WriteField(task_id) error = %v", err)
|
|
}
|
|
header := make(textproto.MIMEHeader)
|
|
header.Set("Content-Disposition", `form-data; name="file"; filename="`+key+`.jpg"`)
|
|
header.Set("Content-Type", "image/jpeg")
|
|
part, err := writer.CreatePart(header)
|
|
if err != nil {
|
|
t.Fatalf("CreatePart() error = %v", err)
|
|
}
|
|
if _, err := part.Write(imageBytes.Bytes()); err != nil {
|
|
t.Fatalf("part.Write() error = %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("multipart.Close() error = %v", err)
|
|
}
|
|
return bytes.NewReader(body.Bytes()), writer.FormDataContentType()
|
|
}
|
|
|
|
func performAdminRequest(
|
|
t *testing.T,
|
|
router http.Handler,
|
|
method string,
|
|
target string,
|
|
contentType string,
|
|
body io.Reader,
|
|
idempotencyKey string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
request := httptest.NewRequest(method, target, body)
|
|
request.AddCookie(&http.Cookie{
|
|
Name: "cmroubao_admin_session",
|
|
Value: "test-session",
|
|
})
|
|
if method != http.MethodGet && method != http.MethodHead {
|
|
const csrfToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"
|
|
request.AddCookie(&http.Cookie{
|
|
Name: "cmroubao_admin_csrf",
|
|
Value: csrfToken,
|
|
})
|
|
request.Header.Set("X-CSRF-Token", csrfToken)
|
|
}
|
|
if contentType != "" {
|
|
request.Header.Set("Content-Type", contentType)
|
|
}
|
|
if idempotencyKey != "" {
|
|
request.Header.Set("Idempotency-Key", idempotencyKey)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
func decodeResponse(
|
|
t *testing.T,
|
|
response *httptest.ResponseRecorder,
|
|
target any,
|
|
) {
|
|
t.Helper()
|
|
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
|
|
t.Fatalf(
|
|
"json.Unmarshal() error = %v, body = %s",
|
|
err,
|
|
response.Body.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func responseContainsKey(value any, key string) bool {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for candidate, child := range typed {
|
|
if candidate == key || responseContainsKey(child, key) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range typed {
|
|
if responseContainsKey(child, key) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|