feat(t217): verify authorized order dry runs
This commit is contained in:
@@ -410,6 +410,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order dry-run migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ type DeviceServices struct {
|
||||
Assets *usecase.AssetService
|
||||
Results *usecase.ExecutionResultService
|
||||
Commands *usecase.DeviceOrderCommandService
|
||||
DryRuns *usecase.OrderDryRunService
|
||||
}
|
||||
|
||||
func (services DeviceServices) validate() error {
|
||||
if services.Lifecycle == nil || services.Assets == nil ||
|
||||
services.Results == nil || services.Commands == nil {
|
||||
services.Results == nil || services.Commands == nil ||
|
||||
services.DryRuns == nil {
|
||||
return errors.New("device services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -83,12 +85,141 @@ func NewDeviceRouteRegistrar(
|
||||
"/api/v1/tasks/:id/commands/:command_id/ack",
|
||||
handler.acknowledgeOrderCommand,
|
||||
)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/order-dry-runs/start",
|
||||
handler.startOrderDryRun,
|
||||
)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/order-dry-runs/:command_id/ready",
|
||||
handler.readyOrderDryRun,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
CommandID string `json:"command_id"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
result, err := handler.services.DryRuns.Start(
|
||||
ctx.Request.Context(),
|
||||
usecase.StartOrderDryRunCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
AuthorizationID: request.CommandID,
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
CommandSHA256: request.CommandSHA256,
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"dry_run": orderDryRunResponse(result.DryRun),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) readyOrderDryRun(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
ObservedTitle string `json:"observed_title"`
|
||||
SelectedSKU string `json:"selected_sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||
TotalPriceCents int64 `json:"total_price_cents"`
|
||||
EvidenceAssetID string `json:"evidence_asset_id"`
|
||||
EvidenceSHA256 string `json:"evidence_sha256"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
result, err := handler.services.DryRuns.Ready(
|
||||
ctx.Request.Context(),
|
||||
usecase.ReadyOrderDryRunCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
AuthorizationID: ctx.Param("command_id"),
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
CommandSHA256: request.CommandSHA256,
|
||||
CardSignature: request.CardSignature,
|
||||
DetailSignature: request.DetailSignature,
|
||||
ObservedTitle: request.ObservedTitle,
|
||||
SelectedSKU: request.SelectedSKU,
|
||||
Quantity: request.Quantity,
|
||||
UnitPriceCents: request.UnitPriceCents,
|
||||
TotalPriceCents: request.TotalPriceCents,
|
||||
EvidenceAssetID: request.EvidenceAssetID,
|
||||
EvidenceSHA256: request.EvidenceSHA256,
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"dry_run": orderDryRunResponse(result.DryRun),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func orderDryRunResponse(dryRun domain.OrderDryRun) gin.H {
|
||||
return gin.H{
|
||||
"id": dryRun.ID,
|
||||
"command_id": dryRun.AuthorizationID,
|
||||
"task_id": dryRun.TaskID,
|
||||
"execution_id": dryRun.ExecutionID,
|
||||
"command_sha256": dryRun.CommandSHA256,
|
||||
"status": dryRun.Status,
|
||||
"card_signature": dryRun.CardSignature,
|
||||
"detail_signature": dryRun.DetailSignature,
|
||||
"observed_title": dryRun.ObservedTitle,
|
||||
"selected_sku": dryRun.SelectedSKU,
|
||||
"quantity": dryRun.Quantity,
|
||||
"unit_price_cents": dryRun.UnitPriceCents,
|
||||
"total_price_cents": dryRun.TotalPriceCents,
|
||||
"evidence_asset_id": dryRun.EvidenceAssetID,
|
||||
"evidence_sha256": dryRun.EvidenceSHA256,
|
||||
"started_at": formatTime(dryRun.StartedAt),
|
||||
"ready_at": formatOptionalTime(dryRun.ReadyAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) pullOrderCommand(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order dry-run migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
@@ -721,8 +724,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 1 {
|
||||
t.Fatalf("restored migrations = %d, want 1", applied)
|
||||
} else if applied != 2 {
|
||||
t.Fatalf("restored migrations = %d, want 2", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -885,6 +888,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
OriginalSKU string `json:"original_sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
AuthorizationStatus string `json:"authorization_status"`
|
||||
@@ -905,6 +909,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
command.SchemaVersion != 1 ||
|
||||
command.TaskID != taskID ||
|
||||
command.ExecutionID != started.Execution.ID ||
|
||||
command.OriginalSKU == "" ||
|
||||
command.Quantity != 2 ||
|
||||
len(command.CommandSHA256) != 64 ||
|
||||
command.AuthorizationStatus != "DELIVERED" ||
|
||||
@@ -997,10 +1002,149 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
) {
|
||||
t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents int
|
||||
startDryRunPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.ID,
|
||||
command.CommandSHA256,
|
||||
)
|
||||
startDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-start",
|
||||
})
|
||||
requireDeviceStatus(t, startDryRun, http.StatusOK)
|
||||
if !strings.Contains(startDryRun.Body.String(), `"status":"PREPARING"`) {
|
||||
t.Fatalf("dry-run start = %s", startDryRun.Body.String())
|
||||
}
|
||||
startDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-start",
|
||||
})
|
||||
requireDeviceStatus(t, startDryRunReplay, http.StatusOK)
|
||||
if !strings.Contains(startDryRunReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("dry-run start replay = %s", startDryRunReplay.Body.String())
|
||||
}
|
||||
executingPull := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
})
|
||||
requireDeviceStatus(t, executingPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
executingPull.Body.String(),
|
||||
`"authorization_status":"EXECUTING"`,
|
||||
) {
|
||||
t.Fatalf("executing pull = %s", executingPull.Body.String())
|
||||
}
|
||||
const dryRunEvidenceID = "00000000-0000-4000-8000-000000000077"
|
||||
dryRunEvidenceSHA := strings.Repeat("e", 64)
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO execution_evidence_assets (
|
||||
id, task_id, execution_id, media_type, size_bytes, sha256,
|
||||
storage_key, created_at, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`,
|
||||
dryRunEvidenceID,
|
||||
taskID,
|
||||
started.Execution.ID,
|
||||
dryRunEvidenceSHA,
|
||||
"dry-run/order-confirmation.jpg",
|
||||
time.Now().UTC().Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
t.Fatalf("seed dry-run evidence: %v", err)
|
||||
}
|
||||
readyDryRunPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q,"card_signature":%q,"detail_signature":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
command.CommandSHA256,
|
||||
command.Candidate.CardSignature,
|
||||
strings.Repeat("f", 64),
|
||||
command.Candidate.Title,
|
||||
command.OriginalSKU,
|
||||
dryRunEvidenceID,
|
||||
dryRunEvidenceSHA,
|
||||
)
|
||||
badSKUDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
"/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(strings.Replace(
|
||||
readyDryRunPayload,
|
||||
fmt.Sprintf(`"selected_sku":%q`, command.OriginalSKU),
|
||||
`"selected_sku":"wrong-sku"`,
|
||||
1,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready-bad-sku",
|
||||
})
|
||||
requireDeviceStatus(t, badSKUDryRun, http.StatusConflict)
|
||||
badTotalDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
"/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(strings.Replace(
|
||||
readyDryRunPayload,
|
||||
`"total_price_cents":4300`,
|
||||
`"total_price_cents":4301`,
|
||||
1,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready-bad-total",
|
||||
})
|
||||
requireDeviceStatus(t, badTotalDryRun, http.StatusConflict)
|
||||
readyDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(readyDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready",
|
||||
})
|
||||
requireDeviceStatus(t, readyDryRun, http.StatusOK)
|
||||
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
|
||||
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
|
||||
}
|
||||
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID +
|
||||
"/order-dry-runs/" + command.ID + "/ready",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(readyDryRunPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-dry-run-ready",
|
||||
})
|
||||
requireDeviceStatus(t, readyDryRunReplay, http.StatusOK)
|
||||
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int
|
||||
for eventType, target := range map[string]*int{
|
||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
|
||||
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
|
||||
} {
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM task_events
|
||||
@@ -1011,11 +1155,14 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t.Fatalf("count %s events: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 {
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
|
||||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 {
|
||||
t.Fatalf(
|
||||
"delivery/ack events = %d/%d",
|
||||
"delivery/ack/dry-run events = %d/%d/%d/%d",
|
||||
deliveredEvents,
|
||||
acknowledgedEvents,
|
||||
dryRunStartedEvents,
|
||||
dryRunReadyEvents,
|
||||
)
|
||||
}
|
||||
runner, err := migration.New(fixture.db)
|
||||
@@ -1023,7 +1170,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("device command migration down succeeded with command data")
|
||||
t.Fatal("order dry-run migration down succeeded with dry-run data")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1352,12 +1499,17 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
|
||||
}
|
||||
dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
DryRuns: dryRuns,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user