feat(t215): add admin order authorization
This commit is contained in:
@@ -3,7 +3,9 @@ package httpapi
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
@@ -288,6 +290,131 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -307,6 +434,201 @@ func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -337,6 +659,16 @@ 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"))
|
||||
@@ -385,8 +717,21 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
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},
|
||||
AdminServices{
|
||||
Assets: assets,
|
||||
Tasks: tasks,
|
||||
Results: results,
|
||||
Authorizations: authorizations,
|
||||
},
|
||||
emptyAdminWeb{},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -404,7 +749,7 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
return router
|
||||
return &adminIntegrationFixture{router: router, db: db}
|
||||
}
|
||||
|
||||
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
||||
|
||||
Reference in New Issue
Block a user