Skip to main content

fakecloud_stepfunctions/service/
mod.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use chrono::Utc;
6use http::StatusCode;
7use serde_json::{json, Value};
8use tokio::sync::Mutex as AsyncMutex;
9
10use fakecloud_core::delivery::DeliveryBus;
11use fakecloud_core::pagination::paginate_checked;
12use fakecloud_core::registry::ServiceRegistry;
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_core::validation::*;
15use fakecloud_dynamodb::SharedDynamoDbState;
16use fakecloud_persistence::SnapshotStore;
17
18use crate::interpreter;
19use crate::state::{
20    Execution, ExecutionStatus, SharedStepFunctionsState, StateMachine, StateMachineStatus,
21    StateMachineType, StepFunctionsSnapshot, StepFunctionsState,
22    STEPFUNCTIONS_SNAPSHOT_SCHEMA_VERSION,
23};
24
25const SUPPORTED: &[&str] = &[
26    "CreateStateMachine",
27    "DescribeStateMachine",
28    "ListStateMachines",
29    "DeleteStateMachine",
30    "UpdateStateMachine",
31    "TagResource",
32    "UntagResource",
33    "ListTagsForResource",
34    "StartExecution",
35    "StopExecution",
36    "DescribeExecution",
37    "ListExecutions",
38    "GetExecutionHistory",
39    "DescribeStateMachineForExecution",
40    "CreateActivity",
41    "DeleteActivity",
42    "DescribeActivity",
43    "ListActivities",
44    "GetActivityTask",
45    "SendTaskFailure",
46    "SendTaskHeartbeat",
47    "SendTaskSuccess",
48    "PublishStateMachineVersion",
49    "DeleteStateMachineVersion",
50    "ListStateMachineVersions",
51    "CreateStateMachineAlias",
52    "DeleteStateMachineAlias",
53    "DescribeStateMachineAlias",
54    "ListStateMachineAliases",
55    "UpdateStateMachineAlias",
56    "DescribeMapRun",
57    "ListMapRuns",
58    "UpdateMapRun",
59    "RedriveExecution",
60    "StartSyncExecution",
61    "TestState",
62    "ValidateStateMachineDefinition",
63];
64
65/// Handle to the central service registry, set by `main.rs` after every service
66/// has been registered. Wrapped in `OnceLock` so `StepFunctionsService` can be
67/// constructed (and registered into the very registry it later reads back) before
68/// the registry itself is finalized. The interpreter snapshots the inner `Arc`
69/// when it needs to dispatch generic `aws-sdk:*` Task integrations.
70pub type SharedServiceRegistry = Arc<std::sync::OnceLock<Arc<ServiceRegistry>>>;
71
72pub struct StepFunctionsService {
73    state: SharedStepFunctionsState,
74    delivery: Option<Arc<DeliveryBus>>,
75    dynamodb_state: Option<SharedDynamoDbState>,
76    registry: Option<SharedServiceRegistry>,
77    snapshot_store: Option<Arc<dyn SnapshotStore>>,
78    snapshot_lock: Arc<AsyncMutex<()>>,
79}
80
81mod activities;
82mod executions;
83mod map_runs;
84mod state_machines;
85mod tags;
86mod tasks;
87mod validation;
88
89impl StepFunctionsService {
90    pub fn new(state: SharedStepFunctionsState) -> Self {
91        Self {
92            state,
93            delivery: None,
94            dynamodb_state: None,
95            registry: None,
96            snapshot_store: None,
97            snapshot_lock: Arc::new(AsyncMutex::new(())),
98        }
99    }
100
101    pub fn with_delivery(mut self, delivery: Arc<DeliveryBus>) -> Self {
102        self.delivery = Some(delivery);
103        self
104    }
105
106    pub fn with_dynamodb(mut self, dynamodb_state: SharedDynamoDbState) -> Self {
107        self.dynamodb_state = Some(dynamodb_state);
108        self
109    }
110
111    /// Hand the service a deferred-fill handle to the central [`ServiceRegistry`].
112    /// `main.rs` calls [`OnceLock::set`] on the inner cell after every service
113    /// has been registered; until then the interpreter falls back to its
114    /// hand-coded SDK integrations (lambda invoke, sqs sendMessage, …).
115    pub fn with_registry(mut self, registry: SharedServiceRegistry) -> Self {
116        self.registry = Some(registry);
117        self
118    }
119
120    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
121        self.snapshot_store = Some(store);
122        self
123    }
124
125    async fn save_snapshot(&self) {
126        save_stepfunctions_snapshot(
127            &self.state,
128            self.snapshot_store.clone(),
129            &self.snapshot_lock,
130        )
131        .await;
132    }
133
134    /// Build a hook that persists the current Step Functions state when invoked,
135    /// or `None` in memory mode (no snapshot store). The CloudFormation
136    /// provisioner mutates `state` directly and uses this to write a
137    /// CFN-provisioned resource through to disk, the same way a direct mutating
138    /// API call would.
139    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
140        let store = self.snapshot_store.clone()?;
141        let state = self.state.clone();
142        let lock = self.snapshot_lock.clone();
143        Some(Arc::new(move || {
144            let state = state.clone();
145            let store = store.clone();
146            let lock = lock.clone();
147            Box::pin(async move {
148                save_stepfunctions_snapshot(&state, Some(store), &lock).await;
149            })
150        }))
151    }
152}
153
154/// Persist the current Step Functions state as a snapshot. Offloads the serde +
155/// blocking file write to the Tokio blocking pool. Noop when `store` is `None`
156/// (memory mode). Shared by `StepFunctionsService::save_snapshot` and the
157/// CloudFormation provisioner's post-provision persist hook so both route
158/// through the same serialize-and-write path.
159pub async fn save_stepfunctions_snapshot(
160    state: &SharedStepFunctionsState,
161    store: Option<Arc<dyn SnapshotStore>>,
162    lock: &AsyncMutex<()>,
163) {
164    let Some(store) = store else {
165        return;
166    };
167    let _guard = lock.lock().await;
168    let snapshot =
169        StepFunctionsSnapshot::of(STEPFUNCTIONS_SNAPSHOT_SCHEMA_VERSION, state.read().clone());
170    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
171        let bytes = serde_json::to_vec(&snapshot)
172            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
173        store.save(&bytes)
174    })
175    .await;
176    match join {
177        Ok(Ok(())) => {}
178        Ok(Err(err)) => tracing::error!(%err, "failed to write stepfunctions snapshot"),
179        Err(err) => tracing::error!(%err, "stepfunctions snapshot task panicked"),
180    }
181}
182
183/// Abort executions that were RUNNING (or PENDING_REDRIVE) when the server
184/// stopped, called once after a persistence snapshot is loaded on startup.
185///
186/// The in-memory interpreter that was driving each one is gone after a restart,
187/// so the execution can never advance. Real Step Functions resumes from durable
188/// interpreter state; fakecloud can't, so leaving them RUNNING would strand them
189/// forever (DescribeExecution would report RUNNING with no way to ever finish).
190/// Aborting with a terminal stop date + error/cause is the honest outcome
191/// (bug-audit 2026-06-20, 0.A2). Returns the number reconciled.
192pub fn reconcile_interrupted_executions(state: &SharedStepFunctionsState) -> usize {
193    let now = Utc::now();
194    let mut count = 0;
195    let mut accounts = state.write();
196    for (_, _, s) in accounts.iter_regional_mut() {
197        for exec in s.executions.values_mut() {
198            if matches!(
199                exec.status,
200                ExecutionStatus::Running | ExecutionStatus::PendingRedrive
201            ) {
202                exec.status = ExecutionStatus::Aborted;
203                exec.stop_date = Some(now);
204                exec.error = Some("Fakecloud.Restart".to_string());
205                exec.cause = Some(
206                    "Execution was interrupted by a fakecloud restart and cannot be resumed"
207                        .to_string(),
208                );
209                count += 1;
210            }
211        }
212    }
213    count
214}
215
216fn is_mutating_action(action: &str) -> bool {
217    matches!(
218        action,
219        "CreateStateMachine"
220            | "DeleteStateMachine"
221            | "UpdateStateMachine"
222            | "TagResource"
223            | "UntagResource"
224            | "StartExecution"
225            | "StopExecution"
226            | "CreateActivity"
227            | "DeleteActivity"
228            | "GetActivityTask"
229            | "SendTaskFailure"
230            | "SendTaskHeartbeat"
231            | "SendTaskSuccess"
232            | "PublishStateMachineVersion"
233            | "DeleteStateMachineVersion"
234            | "CreateStateMachineAlias"
235            | "DeleteStateMachineAlias"
236            | "UpdateStateMachineAlias"
237            | "UpdateMapRun"
238            | "RedriveExecution"
239            | "StartSyncExecution"
240    )
241}
242
243#[async_trait]
244impl AwsService for StepFunctionsService {
245    fn service_name(&self) -> &str {
246        "states"
247    }
248
249    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
250        let mutates = is_mutating_action(req.action.as_str());
251        let result = match req.action.as_str() {
252            "CreateStateMachine" => self.create_state_machine(&req),
253            "DescribeStateMachine" => self.describe_state_machine(&req),
254            "ListStateMachines" => self.list_state_machines(&req),
255            "DeleteStateMachine" => self.delete_state_machine(&req),
256            "UpdateStateMachine" => self.update_state_machine(&req),
257            "TagResource" => self.tag_resource(&req),
258            "UntagResource" => self.untag_resource(&req),
259            "ListTagsForResource" => self.list_tags_for_resource(&req),
260            "StartExecution" => self.start_execution(&req),
261            "StopExecution" => self.stop_execution(&req),
262            "DescribeExecution" => self.describe_execution(&req),
263            "ListExecutions" => self.list_executions(&req),
264            "GetExecutionHistory" => self.get_execution_history(&req),
265            "DescribeStateMachineForExecution" => self.describe_state_machine_for_execution(&req),
266            "CreateActivity" => self.create_activity(&req),
267            "DeleteActivity" => self.delete_activity(&req),
268            "DescribeActivity" => self.describe_activity(&req),
269            "ListActivities" => self.list_activities(&req),
270            "GetActivityTask" => self.get_activity_task(&req).await,
271            "SendTaskFailure" => self.send_task_failure(&req),
272            "SendTaskHeartbeat" => self.send_task_heartbeat(&req),
273            "SendTaskSuccess" => self.send_task_success(&req),
274            "PublishStateMachineVersion" => self.publish_state_machine_version(&req),
275            "DeleteStateMachineVersion" => self.delete_state_machine_version(&req),
276            "ListStateMachineVersions" => self.list_state_machine_versions(&req),
277            "CreateStateMachineAlias" => self.create_state_machine_alias(&req),
278            "DeleteStateMachineAlias" => self.delete_state_machine_alias(&req),
279            "DescribeStateMachineAlias" => self.describe_state_machine_alias(&req),
280            "ListStateMachineAliases" => self.list_state_machine_aliases(&req),
281            "UpdateStateMachineAlias" => self.update_state_machine_alias(&req),
282            "DescribeMapRun" => self.describe_map_run(&req),
283            "ListMapRuns" => self.list_map_runs(&req),
284            "UpdateMapRun" => self.update_map_run(&req),
285            "RedriveExecution" => self.redrive_execution(&req),
286            "StartSyncExecution" => self.start_sync_execution(&req).await,
287            "TestState" => self.test_state(&req),
288            "ValidateStateMachineDefinition" => self.validate_state_machine_definition(&req),
289            _ => Err(AwsServiceError::action_not_implemented(
290                "states",
291                &req.action,
292            )),
293        };
294        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
295            self.save_snapshot().await;
296        }
297        result
298    }
299
300    fn supported_actions(&self) -> &[&str] {
301        SUPPORTED
302    }
303}
304
305impl StepFunctionsService {
306    fn list_activities(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
307        let body = req.json_body();
308        let raw_max_results = body["maxResults"].as_i64();
309        if let Some(mr) = raw_max_results {
310            validate_max_results(mr)?;
311        }
312        let next_token = body["nextToken"].as_str();
313        if let Some(t) = next_token {
314            validate_page_token(t)?;
315        }
316        // Smithy default: maxResults=0 means "use the service default"
317        // (100 for Step Functions), which we honour by treating both
318        // `None` and `0` as 100.
319        let max_results = match raw_max_results.unwrap_or(0) {
320            0 => 100,
321            n => n as usize,
322        };
323        let accounts = self.state.read();
324        let empty = crate::state::StepFunctionsState::new(&req.account_id, &req.region);
325        let state = accounts
326            .regional(&req.account_id, &req.region)
327            .unwrap_or(&empty);
328        let mut activities: Vec<&crate::state::Activity> = state.activities.values().collect();
329        activities.sort_by(|a, b| a.name.cmp(&b.name));
330        let items: Vec<Value> = activities
331            .iter()
332            .map(|a| {
333                json!({
334                    "activityArn": a.arn,
335                    "name": a.name,
336                    "creationDate": a.creation_date.timestamp(),
337                })
338            })
339            .collect();
340        let (page, token) =
341            paginate_checked(&items, next_token, max_results).map_err(|_| invalid_token())?;
342        let mut resp = json!({ "activities": page });
343        if let Some(t) = token {
344            resp["nextToken"] = json!(t);
345        }
346        Ok(AwsResponse::ok_json(resp))
347    }
348}
349
350fn state_machine_alias_to_json(alias: &crate::state::StateMachineAlias) -> Value {
351    json!({
352        "stateMachineAliasArn": alias.arn,
353        "name": alias.name,
354        "description": alias.description,
355        "routingConfiguration": alias.routing_configuration.iter().map(|r| json!({
356            "stateMachineVersionArn": r.state_machine_version_arn,
357            "weight": r.weight,
358        })).collect::<Vec<_>>(),
359        "creationDate": alias.creation_date.timestamp(),
360        "updateDate": alias.update_date.timestamp(),
361    })
362}
363
364fn map_run_to_json(mr: &crate::state::MapRun) -> Value {
365    // Iterations still in flight while the map run is RUNNING; zero once it has
366    // reached a terminal status.
367    let accounted = mr.succeeded_count + mr.failed_count;
368    let running = if mr.status == "RUNNING" {
369        (mr.total_count - accounted).max(0)
370    } else {
371        0
372    };
373    // AWS reports parallel `itemCounts` and `executionCounts` maps; with one
374    // child execution per item they carry identical tallies here.
375    let counts = json!({
376        "pending": 0,
377        "running": running,
378        "succeeded": mr.succeeded_count,
379        "failed": mr.failed_count,
380        "timedOut": 0,
381        "aborted": 0,
382        "results": mr.succeeded_count,
383        "total": mr.total_count,
384        "failuresNotRedrivable": 0,
385        "pendingRedrive": 0,
386    });
387    json!({
388        "mapRunArn": mr.map_run_arn,
389        "executionArn": mr.execution_arn,
390        "maxConcurrency": mr.max_concurrency,
391        "toleratedFailurePercentage": mr.tolerated_failure_percentage,
392        "toleratedFailureCount": mr.tolerated_failure_count,
393        "status": mr.status,
394        "startDate": mr.start_date.timestamp(),
395        "stopDate": mr.stop_date.map(|d| d.timestamp()),
396        "itemCounts": counts,
397        "executionCounts": counts,
398    })
399}
400
401// ─── Helpers ────────────────────────────────────────────────────────────
402
403fn state_machine_to_json(sm: &StateMachine) -> Value {
404    let mut resp = json!({
405        "name": sm.name,
406        "stateMachineArn": sm.arn,
407        "definition": sm.definition,
408        "roleArn": sm.role_arn,
409        "type": sm.machine_type.as_str(),
410        "status": sm.status.as_str(),
411        "creationDate": sm.creation_date.timestamp() as f64,
412        "updateDate": sm.update_date.timestamp() as f64,
413        "revisionId": sm.revision_id,
414        "label": sm.name,
415    });
416
417    if !sm.description.is_empty() {
418        resp["description"] = json!(sm.description);
419    }
420
421    if let Some(ref logging) = sm.logging_configuration {
422        resp["loggingConfiguration"] = logging.clone();
423    } else {
424        resp["loggingConfiguration"] = json!({
425            "level": "OFF",
426            "includeExecutionData": false,
427            "destinations": [],
428        });
429    }
430
431    if let Some(ref tracing) = sm.tracing_configuration {
432        resp["tracingConfiguration"] = tracing.clone();
433    } else {
434        resp["tracingConfiguration"] = json!({
435            "enabled": false,
436        });
437    }
438
439    if let Some(ref enc) = sm.encryption_configuration {
440        resp["encryptionConfiguration"] = enc.clone();
441    }
442
443    resp
444}
445
446fn missing(name: &str) -> AwsServiceError {
447    AwsServiceError::aws_error(
448        StatusCode::BAD_REQUEST,
449        "ValidationException",
450        format!("The request must contain the parameter {name}."),
451    )
452}
453
454fn state_machine_not_found(arn: &str) -> AwsServiceError {
455    AwsServiceError::aws_error(
456        StatusCode::BAD_REQUEST,
457        "StateMachineDoesNotExist",
458        format!("State Machine Does Not Exist: '{arn}'"),
459    )
460}
461
462fn activity_not_found(arn: &str) -> AwsServiceError {
463    AwsServiceError::aws_error(
464        StatusCode::BAD_REQUEST,
465        "ActivityDoesNotExist",
466        format!("Activity does not exist: {arn}"),
467    )
468}
469
470fn task_does_not_exist(token: &str) -> AwsServiceError {
471    AwsServiceError::aws_error(
472        StatusCode::BAD_REQUEST,
473        "TaskDoesNotExist",
474        format!("Task does not exist: {token}"),
475    )
476}
477
478fn resource_not_found(arn: &str) -> AwsServiceError {
479    AwsServiceError::aws_error(
480        StatusCode::BAD_REQUEST,
481        "ResourceNotFound",
482        format!("Resource not found: '{arn}'"),
483    )
484}
485
486/// Parse + validate an alias `routingConfiguration` array.
487///
488/// AWS rules: 1 or 2 routes; weights are 0-100 and sum to 100; each
489/// route must include `stateMachineVersionArn`.
490fn parse_routing_configuration(
491    routes: &[serde_json::Value],
492) -> Result<Vec<crate::state::AliasRoute>, AwsServiceError> {
493    if routes.is_empty() || routes.len() > 2 {
494        return Err(AwsServiceError::aws_error(
495            StatusCode::BAD_REQUEST,
496            "ValidationException",
497            "routingConfiguration must contain 1 or 2 routes.",
498        ));
499    }
500    let parsed: Vec<crate::state::AliasRoute> = routes
501        .iter()
502        .map(|r| {
503            let arn = r["stateMachineVersionArn"].as_str().ok_or_else(|| {
504                AwsServiceError::aws_error(
505                    StatusCode::BAD_REQUEST,
506                    "ValidationException",
507                    "routingConfiguration entries must contain stateMachineVersionArn.",
508                )
509            })?;
510            let weight = r["weight"].as_i64().ok_or_else(|| {
511                AwsServiceError::aws_error(
512                    StatusCode::BAD_REQUEST,
513                    "ValidationException",
514                    "routingConfiguration entries must contain a numeric weight.",
515                )
516            })?;
517            if !(0..=100).contains(&weight) {
518                return Err(AwsServiceError::aws_error(
519                    StatusCode::BAD_REQUEST,
520                    "ValidationException",
521                    format!("Invalid routing weight {weight}; must be 0-100."),
522                ));
523            }
524            Ok(crate::state::AliasRoute {
525                state_machine_version_arn: arn.to_string(),
526                weight: weight as i32,
527            })
528        })
529        .collect::<Result<_, _>>()?;
530    let total: i32 = parsed.iter().map(|r| r.weight).sum();
531    if total != 100 {
532        return Err(AwsServiceError::aws_error(
533            StatusCode::BAD_REQUEST,
534            "ValidationException",
535            format!("routingConfiguration weights must sum to 100, got {total}."),
536        ));
537    }
538    Ok(parsed)
539}
540
541fn validate_name(name: &str) -> Result<(), AwsServiceError> {
542    if name.is_empty() || name.len() > 80 {
543        return Err(AwsServiceError::aws_error(
544            StatusCode::BAD_REQUEST,
545            "InvalidName",
546            format!("Invalid Name: '{name}' (length must be between 1 and 80 characters)"),
547        ));
548    }
549    // Only allow alphanumeric, hyphens, and underscores
550    if !name
551        .chars()
552        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
553    {
554        return Err(AwsServiceError::aws_error(
555            StatusCode::BAD_REQUEST,
556            "InvalidName",
557            format!(
558                "Invalid Name: '{name}' (must only contain alphanumeric characters, hyphens, and underscores)"
559            ),
560        ));
561    }
562    Ok(())
563}
564
565fn validate_definition(definition: &str) -> Result<(), AwsServiceError> {
566    let parsed: Value = serde_json::from_str(definition).map_err(|e| {
567        AwsServiceError::aws_error(
568            StatusCode::BAD_REQUEST,
569            "InvalidDefinition",
570            format!("Invalid State Machine Definition: '{e}'"),
571        )
572    })?;
573
574    if parsed.get("StartAt").and_then(|v| v.as_str()).is_none() {
575        return Err(AwsServiceError::aws_error(
576            StatusCode::BAD_REQUEST,
577            "InvalidDefinition",
578            "Invalid State Machine Definition: 'MISSING_START_AT' (StartAt field is required)"
579                .to_string(),
580        ));
581    }
582
583    let states_obj = parsed
584        .get("States")
585        .and_then(|v| v.as_object())
586        .ok_or_else(|| {
587            AwsServiceError::aws_error(
588                StatusCode::BAD_REQUEST,
589                "InvalidDefinition",
590                "Invalid State Machine Definition: 'MISSING_STATES' (States field is required)"
591                    .to_string(),
592            )
593        })?;
594
595    let start_at = parsed["StartAt"].as_str().ok_or_else(|| {
596        AwsServiceError::aws_error(
597            StatusCode::BAD_REQUEST,
598            "InvalidDefinition",
599            "Invalid State Machine Definition: 'MISSING_START_AT' (StartAt field is required)"
600                .to_string(),
601        )
602    })?;
603    if !states_obj.contains_key(start_at) {
604        return Err(AwsServiceError::aws_error(
605            StatusCode::BAD_REQUEST,
606            "InvalidDefinition",
607            format!(
608                "Invalid State Machine Definition: 'MISSING_TRANSITION_TARGET' \
609                 (StartAt '{start_at}' does not reference a valid state)"
610            ),
611        ));
612    }
613
614    validate_timeout_field("<root>", &parsed, "TimeoutSeconds")?;
615
616    // Reject malformed JSONPath reference fields. AWS rejects bad reference
617    // paths at CreateStateMachine; accepting them here lets a panic-inducing
618    // path reach the interpreter at execution time.
619    for (state_name, state_val) in states_obj {
620        validate_state_paths(state_name, state_val)?;
621    }
622
623    Ok(())
624}
625
626/// Validate the JSONPath reference fields of a single state. Recurses into the
627/// `Choices` array of Choice states, the `Branches` of Parallel states, and the
628/// `Iterator`/`ItemProcessor` of Map states. Returns an `InvalidDefinition`
629/// error for any syntactically malformed path or payload-template key.
630fn validate_state_paths(state_name: &str, state: &Value) -> Result<(), AwsServiceError> {
631    validate_timeout_field(state_name, state, "TimeoutSeconds")?;
632    validate_timeout_field(state_name, state, "HeartbeatSeconds")?;
633    for field in ["InputPath", "OutputPath", "ResultPath"] {
634        if let Some(p) = state.get(field).and_then(|v| v.as_str()) {
635            // `InputPath`/`OutputPath` accept the literal "null" to mean "no
636            // value"; `ResultPath` accepts JSON null (handled separately) but
637            // not the string "null". Both accept "$"-rooted reference paths.
638            let valid = if field == "ResultPath" {
639                is_valid_reference_path(p)
640            } else {
641                p == "null" || is_valid_path(p)
642            };
643            if valid {
644                continue;
645            }
646            return Err(invalid_reference_path(state_name, field, p));
647        }
648    }
649
650    // Reference-path fields that select a value from the input (or the context
651    // object). Unlike Input/Output/ResultPath they do not accept the literal
652    // "null", but they may reference the context object via `$$`.
653    for field in [
654        "SecondsPath",
655        "TimestampPath",
656        "ItemsPath",
657        "MaxConcurrencyPath",
658        "ToleratedFailureCountPath",
659        "ToleratedFailurePercentagePath",
660    ] {
661        if let Some(p) = state.get(field).and_then(|v| v.as_str()) {
662            if is_reference_or_context_path(p) {
663                continue;
664            }
665            return Err(invalid_reference_path(state_name, field, p));
666        }
667    }
668
669    // Payload templates: every key ending in `.$` must carry a string value
670    // that is either a JSONPath reference (`$`/`$$`) or an intrinsic call. AWS
671    // rejects non-string and bare-literal `.$` values at CreateStateMachine.
672    for field in ["Parameters", "ResultSelector", "ItemSelector"] {
673        if let Some(template) = state.get(field) {
674            validate_payload_template(state_name, field, template)?;
675        }
676    }
677
678    match state.get("Type").and_then(|v| v.as_str()) {
679        Some("Choice") => {
680            if let Some(choices) = state.get("Choices").and_then(|v| v.as_array()) {
681                for rule in choices {
682                    validate_choice_variables(state_name, rule)?;
683                }
684            }
685        }
686        Some("Parallel") => {
687            if let Some(branches) = state.get("Branches").and_then(|v| v.as_array()) {
688                for branch in branches {
689                    validate_nested_definition(state_name, branch)?;
690                }
691            }
692        }
693        Some("Map") => {
694            // Distributed Map uses `ItemProcessor`; legacy inline Map uses
695            // `Iterator`. Both carry a nested `States` block.
696            for key in ["ItemProcessor", "Iterator"] {
697                if let Some(processor) = state.get(key) {
698                    validate_nested_definition(state_name, processor)?;
699                }
700            }
701        }
702        _ => {}
703    }
704
705    Ok(())
706}
707
708/// Largest `TimeoutSeconds` / `HeartbeatSeconds` AWS accepts in an ASL definition.
709const MAX_TIMEOUT_SECONDS: u64 = 99_999_999;
710
711/// AWS requires `TimeoutSeconds` / `HeartbeatSeconds` to be a positive integer
712/// no larger than 99999999 and rejects anything else at CreateStateMachine /
713/// UpdateStateMachine with `SCHEMA_VALIDATION_FAILED`.
714fn validate_timeout_field(
715    state_name: &str,
716    state: &Value,
717    field: &str,
718) -> Result<(), AwsServiceError> {
719    let Some(v) = state.get(field) else {
720        return Ok(());
721    };
722    match v.as_u64() {
723        Some(n) if (1..=MAX_TIMEOUT_SECONDS).contains(&n) => Ok(()),
724        _ => Err(AwsServiceError::aws_error(
725            StatusCode::BAD_REQUEST,
726            "InvalidDefinition",
727            format!(
728                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED' \
729                 (The value of field '{field}' of state '{state_name}' must be a positive \
730                 integer between 1 and {MAX_TIMEOUT_SECONDS}, got {v})"
731            ),
732        )),
733    }
734}
735
736/// Validate every state inside a nested definition (a Parallel branch or a Map
737/// processor). Errors are attributed to the enclosing state so the message
738/// still points at a real state name.
739fn validate_nested_definition(parent: &str, def: &Value) -> Result<(), AwsServiceError> {
740    if let Some(states) = def.get("States").and_then(|v| v.as_object()) {
741        for (name, state) in states {
742            validate_state_paths(&format!("{parent}/{name}"), state)?;
743        }
744    }
745    Ok(())
746}
747
748/// Recursively validate a payload template (Parameters / ResultSelector /
749/// ItemSelector). Any object key ending in `.$` must map to a string that is a
750/// JSONPath reference or an intrinsic-function invocation.
751fn validate_payload_template(
752    state_name: &str,
753    field: &str,
754    template: &Value,
755) -> Result<(), AwsServiceError> {
756    match template {
757        Value::Object(map) => {
758            for (key, value) in map {
759                if let Some(stripped) = key.strip_suffix(".$") {
760                    let expr = value.as_str().ok_or_else(|| {
761                        invalid_payload_template(
762                            state_name,
763                            field,
764                            &format!(
765                                "the '{key}' field must be a JSONPath or intrinsic string, \
766                                 not {value}"
767                            ),
768                        )
769                    })?;
770                    let is_ref = expr.starts_with('$');
771                    if !is_ref && !crate::intrinsics::is_intrinsic_call(expr) {
772                        return Err(invalid_payload_template(
773                            state_name,
774                            field,
775                            &format!(
776                                "the '{stripped}.$' field value '{expr}' is neither a JSONPath \
777                                 reference nor an intrinsic function"
778                            ),
779                        ));
780                    }
781                } else {
782                    validate_payload_template(state_name, field, value)?;
783                }
784            }
785        }
786        Value::Array(arr) => {
787            for v in arr {
788                validate_payload_template(state_name, field, v)?;
789            }
790        }
791        _ => {}
792    }
793    Ok(())
794}
795
796/// A `*Path` field value: either a `$`-rooted reference path the interpreter can
797/// parse, or a `$$`-rooted context-object reference.
798fn is_reference_or_context_path(p: &str) -> bool {
799    if let Some(rest) = p.strip_prefix("$$") {
800        return is_valid_path(&format!("${rest}"));
801    }
802    is_valid_path(p)
803}
804
805fn invalid_payload_template(state_name: &str, field: &str, detail: &str) -> AwsServiceError {
806    AwsServiceError::aws_error(
807        StatusCode::BAD_REQUEST,
808        "InvalidDefinition",
809        format!(
810            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED' \
811             (The {field} of state '{state_name}' is invalid: {detail})"
812        ),
813    )
814}
815
816/// Validate `Variable` reference paths inside a Choice rule, recursing through
817/// nested `And` / `Or` / `Not` boolean combinators.
818fn validate_choice_variables(state_name: &str, rule: &Value) -> Result<(), AwsServiceError> {
819    if let Some(v) = rule.get("Variable").and_then(|v| v.as_str()) {
820        // A Choice Variable may read the context object (`$$.Execution.Name`).
821        if !is_reference_or_context_path(v) {
822            return Err(invalid_reference_path(state_name, "Variable", v));
823        }
824    }
825    for combinator in ["And", "Or"] {
826        if let Some(nested) = rule.get(combinator).and_then(|v| v.as_array()) {
827            for n in nested {
828                validate_choice_variables(state_name, n)?;
829            }
830        }
831    }
832    if let Some(n) = rule.get("Not") {
833        validate_choice_variables(state_name, n)?;
834    }
835    Ok(())
836}
837
838/// A reference path must start with `$` and every `[...]` index segment must be
839/// a balanced, non-empty, decimal-integer index. This mirrors the subset of
840/// JSONPath the interpreter understands; anything it cannot parse is rejected.
841/// A Reference Path (ResultPath): a JSONPath that identifies a single node,
842/// i.e. only property names and single array indexes.
843fn is_valid_reference_path(path: &str) -> bool {
844    crate::jsonpath::JsonPath::parse(path).is_ok_and(|p| p.is_definite())
845}
846
847/// A Path (InputPath, OutputPath, ItemsPath, Choice `Variable`, ...): any
848/// JSONPath the interpreter can evaluate, including wildcards, slices and
849/// filters.
850fn is_valid_path(path: &str) -> bool {
851    crate::jsonpath::JsonPath::parse(path).is_ok()
852}
853
854fn invalid_reference_path(state_name: &str, field: &str, path: &str) -> AwsServiceError {
855    AwsServiceError::aws_error(
856        StatusCode::BAD_REQUEST,
857        "InvalidDefinition",
858        format!(
859            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED' \
860             (The {field} field of state '{state_name}' is not a valid reference path: '{path}')"
861        ),
862    )
863}
864
865fn execution_not_found(arn: &str) -> AwsServiceError {
866    AwsServiceError::aws_error(
867        StatusCode::BAD_REQUEST,
868        "ExecutionDoesNotExist",
869        format!("Execution Does Not Exist: '{arn}'"),
870    )
871}
872
873fn execution_to_json(exec: &Execution) -> Value {
874    let mut resp = json!({
875        "executionArn": exec.execution_arn,
876        "stateMachineArn": exec.state_machine_arn,
877        "name": exec.name,
878        "status": exec.status.as_str(),
879        "startDate": exec.start_date.timestamp() as f64,
880        "redriveCount": exec.redrive_count,
881    });
882
883    if let Some(ref input) = exec.input {
884        resp["input"] = json!(input);
885    }
886    if let Some(ref output) = exec.output {
887        resp["output"] = json!(output);
888    }
889    if let Some(stop) = exec.stop_date {
890        resp["stopDate"] = json!(stop.timestamp() as f64);
891    }
892    if let Some(ref error) = exec.error {
893        resp["error"] = json!(error);
894    }
895    if let Some(ref cause) = exec.cause {
896        resp["cause"] = json!(cause);
897    }
898
899    resp
900}
901
902/// Convert an event type to the JSON key prefix used in the `HistoryEvent`
903/// shape (the full key is `<prefix>EventDetails`).
904///
905/// AWS collapses every `*StateEntered` event type (Pass/Task/Choice/Wait/Map/
906/// Parallel/Succeed/...) into a single `stateEnteredEventDetails` member, and
907/// every `*StateExited` into `stateExitedEventDetails`. All other event types
908/// map by lowercasing the first character (`TaskScheduled` ->
909/// `taskScheduledEventDetails`). Without the collapse, SDK deserializers see an
910/// unknown key like `passStateEnteredEventDetails` and return null name/input.
911fn camel_to_details_key(event_type: &str) -> String {
912    if event_type.ends_with("StateEntered") {
913        return "stateEntered".to_string();
914    }
915    if event_type.ends_with("StateExited") {
916        return "stateExited".to_string();
917    }
918    let mut chars = event_type.chars();
919    match chars.next() {
920        None => String::new(),
921        Some(c) => c.to_lowercase().to_string() + chars.as_str(),
922    }
923}
924
925/// Compare two StartExecution inputs for STANDARD idempotency. A missing input
926/// defaults to `{}` (AWS's default execution input). Comparison is structural
927/// (parsed JSON), so insignificant whitespace differences still dedup; inputs
928/// that fail to parse fall back to an exact string match.
929fn execution_input_matches(stored: Option<&str>, incoming: Option<&str>) -> bool {
930    let stored = stored.unwrap_or("{}");
931    let incoming = incoming.unwrap_or("{}");
932    match (
933        serde_json::from_str::<Value>(stored),
934        serde_json::from_str::<Value>(incoming),
935    ) {
936        (Ok(a), Ok(b)) => a == b,
937        _ => stored == incoming,
938    }
939}
940
941fn validate_arn(arn: &str) -> Result<(), AwsServiceError> {
942    if !arn.starts_with("arn:") {
943        return Err(AwsServiceError::aws_error(
944            StatusCode::BAD_REQUEST,
945            "InvalidArn",
946            format!("Invalid Arn: '{arn}'"),
947        ));
948    }
949    Ok(())
950}
951
952/// Enforce the Smithy @length on an ARN-typed field (`Arn` = 1..=256,
953/// `LongArn` = 1..=2000). Empty / oversize ARNs map to `InvalidArn`, which
954/// every ARN-bearing Step Functions operation declares.
955fn validate_arn_length(field: &str, value: &str, max: usize) -> Result<(), AwsServiceError> {
956    if value.is_empty() || value.len() > max {
957        return Err(AwsServiceError::aws_error(
958            StatusCode::BAD_REQUEST,
959            "InvalidArn",
960            format!("Invalid Arn at '{field}': must be 1..={max} characters"),
961        ));
962    }
963    Ok(())
964}
965
966/// Shared error for a malformed (unparseable) `nextToken` reaching the
967/// pagination helper — `InvalidToken` is the only pagination error code
968/// declared on every list op.
969pub(super) fn invalid_token() -> AwsServiceError {
970    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidToken", "Invalid nextToken")
971}
972
973/// `nextToken` is declared as `PageToken` (length 1..=1024). The only
974/// error code declared on every paginated list op is `InvalidToken`, so
975/// route both empty and oversize tokens through it.
976fn validate_page_token(value: &str) -> Result<(), AwsServiceError> {
977    if value.is_empty() || value.len() > 1024 {
978        return Err(AwsServiceError::aws_error(
979            StatusCode::BAD_REQUEST,
980            "InvalidToken",
981            "nextToken must be 1..=1024 characters",
982        ));
983    }
984    Ok(())
985}
986
987/// `maxResults` is typed as `PageSize` (range 0..=1000). The negative
988/// probes flip below-min / above-max; since the only error declared on
989/// `ListActivities` is `InvalidToken`, we map page-size violations to
990/// the same code (matches how AWS surfaces malformed pagination input
991/// for ops that don't model a separate `ValidationException`).
992fn validate_max_results(value: i64) -> Result<(), AwsServiceError> {
993    if !(0..=1000).contains(&value) {
994        return Err(AwsServiceError::aws_error(
995            StatusCode::BAD_REQUEST,
996            "InvalidToken",
997            format!("maxResults '{value}' is outside 0..=1000"),
998        ));
999    }
1000    Ok(())
1001}
1002
1003/// Start a Step Functions execution from a cross-service delivery (e.g. EventBridge).
1004///
1005/// This is the public entry point used by `StepFunctionsDeliveryImpl` in the server crate.
1006/// It mirrors the logic from `StartExecution` but without the AWS request/response wrapper.
1007/// Start a Step Functions execution from a cross-service delivery (e.g. EventBridge).
1008///
1009/// This is the public entry point used by `StepFunctionsDeliveryImpl` in the server crate.
1010/// It mirrors the logic from `StartExecution` but without the AWS request/response wrapper.
1011pub fn start_execution_from_delivery(
1012    state: &SharedStepFunctionsState,
1013    delivery: &Option<Arc<DeliveryBus>>,
1014    dynamodb_state: &Option<SharedDynamoDbState>,
1015    registry: &Option<SharedServiceRegistry>,
1016    state_machine_arn: &str,
1017    input: &str,
1018) {
1019    // Validate input is valid JSON
1020    if serde_json::from_str::<serde_json::Value>(input).is_err() {
1021        tracing::warn!(
1022            state_machine_arn,
1023            "Step Functions delivery: invalid JSON input, skipping execution"
1024        );
1025        return;
1026    }
1027
1028    let execution_name = uuid::Uuid::new_v4().to_string();
1029
1030    let mut accounts = state.write();
1031    // The state machine lives in the account and region its ARN names.
1032    let Some(st) = accounts.by_arn_mut(state_machine_arn) else {
1033        tracing::warn!(
1034            state_machine_arn,
1035            "Step Functions delivery: state machine not found"
1036        );
1037        return;
1038    };
1039    let sm = match st.state_machines.get(state_machine_arn) {
1040        Some(sm) => sm,
1041        None => {
1042            tracing::warn!(
1043                state_machine_arn,
1044                "Step Functions delivery: state machine not found"
1045            );
1046            return;
1047        }
1048    };
1049
1050    let sm_name = sm.name.clone();
1051    let sm_role_arn = sm.role_arn.clone();
1052    let definition = sm.definition.clone();
1053    // No request in this delivery path; the execution inherits the region its
1054    // state machine ARN was minted in (the request region at CreateStateMachine).
1055    let region = state_machine_arn
1056        .split(':')
1057        .nth(3)
1058        .unwrap_or("us-east-1")
1059        .to_string();
1060    let exec_arn = st.execution_arn(&region, &sm_name, &execution_name);
1061
1062    let now = Utc::now();
1063    let execution = Execution {
1064        execution_arn: exec_arn.clone(),
1065        state_machine_arn: state_machine_arn.to_string(),
1066        state_machine_name: sm_name,
1067        name: execution_name,
1068        status: ExecutionStatus::Running,
1069        input: Some(input.to_string()),
1070        output: None,
1071        start_date: now,
1072        stop_date: None,
1073        error: None,
1074        cause: None,
1075        history_events: vec![],
1076        parent_execution_arn: None,
1077        is_sync: false,
1078        billed_duration_ms: None,
1079        billed_memory_mb: None,
1080        role_arn: sm_role_arn,
1081        redrive_count: 0,
1082    };
1083
1084    st.executions.insert(exec_arn.clone(), execution);
1085    let logging_config = sm.logging_configuration.clone();
1086    drop(accounts);
1087
1088    let shared_state = state.clone();
1089    let delivery = delivery.clone();
1090    let dynamodb_state = dynamodb_state.clone();
1091    let registry = registry.clone();
1092    let input = Some(input.to_string());
1093    tokio::spawn(async move {
1094        interpreter::execute_state_machine(
1095            shared_state,
1096            exec_arn,
1097            definition,
1098            input,
1099            delivery,
1100            dynamodb_state,
1101            registry,
1102            logging_config,
1103        )
1104        .await;
1105    });
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use http::{HeaderMap, Method};
1112    use parking_lot::RwLock;
1113    use serde_json::Value;
1114    use std::collections::HashMap;
1115    use std::sync::Arc;
1116
1117    fn make_state() -> SharedStepFunctionsState {
1118        Arc::new(RwLock::new(
1119            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
1120        ))
1121    }
1122
1123    fn make_request(action: &str, body: &str) -> AwsRequest {
1124        AwsRequest {
1125            service: "states".to_string(),
1126            action: action.to_string(),
1127            region: "us-east-1".to_string(),
1128            account_id: "123456789012".to_string(),
1129            request_id: "test-id".to_string(),
1130            headers: HeaderMap::new(),
1131            query_params: HashMap::new(),
1132            body: body.as_bytes().to_vec().into(),
1133            body_stream: parking_lot::Mutex::new(None),
1134            path_segments: vec![],
1135            raw_path: "/".to_string(),
1136            raw_query: String::new(),
1137            method: Method::POST,
1138            is_query_protocol: false,
1139            access_key_id: None,
1140            principal: None,
1141        }
1142    }
1143
1144    fn body_json(resp: &AwsResponse) -> Value {
1145        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1146    }
1147
1148    fn expect_err(result: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
1149        match result {
1150            Err(e) => e,
1151            Ok(_) => panic!("expected error, got Ok"),
1152        }
1153    }
1154
1155    const VALID_DEF: &str = r#"{"StartAt":"Pass","States":{"Pass":{"Type":"Pass","End":true}}}"#;
1156
1157    fn create_sm(svc: &StepFunctionsService, name: &str) -> String {
1158        let body = json!({
1159            "name": name,
1160            "definition": VALID_DEF,
1161            "roleArn": "arn:aws:iam::123456789012:role/test",
1162        });
1163        let req = make_request("CreateStateMachine", &body.to_string());
1164        let resp = svc.create_state_machine(&req).unwrap();
1165        let b = body_json(&resp);
1166        b["stateMachineArn"].as_str().unwrap().to_string()
1167    }
1168
1169    #[test]
1170    fn same_state_machine_name_coexists_in_two_regions() {
1171        let svc = StepFunctionsService::new(make_state());
1172        let in_region = |action: &str, body: Value, region: &str| {
1173            let mut req = make_request(action, &body.to_string());
1174            req.region = region.to_string();
1175            req
1176        };
1177        let create = |region: &str| {
1178            let body = json!({
1179                "name": "orders",
1180                "definition": VALID_DEF,
1181                "roleArn": "arn:aws:iam::123456789012:role/test",
1182            });
1183            let resp = svc
1184                .create_state_machine(&in_region("CreateStateMachine", body, region))
1185                .unwrap();
1186            body_json(&resp)["stateMachineArn"]
1187                .as_str()
1188                .unwrap()
1189                .to_string()
1190        };
1191        let east = create("us-east-1");
1192        let west = create("eu-west-1");
1193        assert_eq!(
1194            east,
1195            "arn:aws:states:us-east-1:123456789012:stateMachine:orders"
1196        );
1197        assert_eq!(
1198            west,
1199            "arn:aws:states:eu-west-1:123456789012:stateMachine:orders"
1200        );
1201
1202        // Each region lists only its own state machine.
1203        for (region, arn) in [("us-east-1", &east), ("eu-west-1", &west)] {
1204            let resp = svc
1205                .list_state_machines(&in_region("ListStateMachines", json!({}), region))
1206                .unwrap();
1207            let listed: Vec<String> = body_json(&resp)["stateMachines"]
1208                .as_array()
1209                .unwrap()
1210                .iter()
1211                .map(|m| m["stateMachineArn"].as_str().unwrap().to_string())
1212                .collect();
1213            assert_eq!(listed, vec![arn.clone()]);
1214        }
1215
1216        // A state machine ARN of another region is not found here.
1217        let err = svc
1218            .describe_state_machine(&in_region(
1219                "DescribeStateMachine",
1220                json!({ "stateMachineArn": west }),
1221                "us-east-1",
1222            ))
1223            .err()
1224            .unwrap();
1225        assert_eq!(err.code(), "StateMachineDoesNotExist");
1226
1227        // Deleting one region's state machine leaves the other's.
1228        svc.delete_state_machine(&in_region(
1229            "DeleteStateMachine",
1230            json!({ "stateMachineArn": west }),
1231            "eu-west-1",
1232        ))
1233        .unwrap();
1234        assert!(svc
1235            .describe_state_machine(&in_region(
1236                "DescribeStateMachine",
1237                json!({ "stateMachineArn": east }),
1238                "us-east-1",
1239            ))
1240            .is_ok());
1241    }
1242
1243    #[test]
1244    fn v2_snapshot_migrates_resources_into_their_arn_region() {
1245        let mut legacy: fakecloud_core::multi_account::MultiAccountState<StepFunctionsState> =
1246            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "");
1247        {
1248            let s = legacy.default_mut();
1249            s.activities.insert(
1250                "arn:aws:states:eu-west-1:123456789012:activity:work".into(),
1251                crate::state::Activity {
1252                    name: "work".into(),
1253                    arn: "arn:aws:states:eu-west-1:123456789012:activity:work".into(),
1254                    creation_date: Utc::now(),
1255                    tags: Default::default(),
1256                },
1257            );
1258        }
1259        let bytes = serde_json::to_vec(&json!({"schema_version": 2, "accounts": legacy})).unwrap();
1260        let snap = crate::state::parse_stepfunctions_snapshot(&bytes).unwrap();
1261        assert_eq!(snap.schema_version, STEPFUNCTIONS_SNAPSHOT_SCHEMA_VERSION);
1262        let accounts = snap.accounts.unwrap();
1263        let west = accounts.regional("123456789012", "eu-west-1").unwrap();
1264        assert_eq!(west.region, "eu-west-1");
1265        assert_eq!(west.activities.len(), 1);
1266        assert!(accounts.regional("123456789012", "us-east-1").is_none());
1267    }
1268
1269    #[test]
1270    fn china_region_arns_use_the_aws_cn_partition() {
1271        let svc = StepFunctionsService::new(make_state());
1272        let in_china = |action: &str, body: Value| {
1273            let mut req = make_request(action, &body.to_string());
1274            req.region = "cn-north-1".to_string();
1275            req
1276        };
1277        let resp = svc
1278            .create_state_machine(&in_china(
1279                "CreateStateMachine",
1280                json!({
1281                    "name": "cn-sm",
1282                    "definition": VALID_DEF,
1283                    "roleArn": "arn:aws-cn:iam::123456789012:role/test",
1284                }),
1285            ))
1286            .unwrap();
1287        let sm_arn = body_json(&resp)["stateMachineArn"]
1288            .as_str()
1289            .unwrap()
1290            .to_string();
1291        assert_eq!(
1292            sm_arn,
1293            "arn:aws-cn:states:cn-north-1:123456789012:stateMachine:cn-sm"
1294        );
1295        let resp = svc
1296            .describe_state_machine(&in_china(
1297                "DescribeStateMachine",
1298                json!({"stateMachineArn": sm_arn}),
1299            ))
1300            .unwrap();
1301        assert_eq!(body_json(&resp)["name"], "cn-sm");
1302
1303        let resp = svc
1304            .create_activity(&in_china("CreateActivity", json!({"name": "cn-act"})))
1305            .unwrap();
1306        let activity_arn = body_json(&resp)["activityArn"]
1307            .as_str()
1308            .unwrap()
1309            .to_string();
1310        assert_eq!(
1311            activity_arn,
1312            "arn:aws-cn:states:cn-north-1:123456789012:activity:cn-act"
1313        );
1314        let resp = svc
1315            .describe_activity(&in_china(
1316                "DescribeActivity",
1317                json!({"activityArn": activity_arn}),
1318            ))
1319            .unwrap();
1320        assert_eq!(body_json(&resp)["name"], "cn-act");
1321    }
1322
1323    // ── CreateStateMachine ──
1324
1325    #[test]
1326    fn create_state_machine_basic() {
1327        let svc = StepFunctionsService::new(make_state());
1328        let arn = create_sm(&svc, "test-sm");
1329        assert!(arn.contains("test-sm"));
1330    }
1331
1332    // bug-hunt 2026-07-19: CreateStateMachine with publish=true creates a real
1333    // version that ListStateMachineVersions returns; without publish, no version
1334    // ARN is returned and no version exists.
1335    #[test]
1336    fn create_state_machine_publish_creates_listable_version() {
1337        let svc = StepFunctionsService::new(make_state());
1338        let body = json!({
1339            "name": "pub-sm",
1340            "definition": VALID_DEF,
1341            "roleArn": "arn:aws:iam::123456789012:role/r",
1342            "publish": true,
1343            "versionDescription": "first",
1344        });
1345        let req = make_request("CreateStateMachine", &body.to_string());
1346        let resp = svc.create_state_machine(&req).unwrap();
1347        let b = body_json(&resp);
1348        let arn = b["stateMachineArn"].as_str().unwrap().to_string();
1349        let version_arn = b["stateMachineVersionArn"].as_str().unwrap().to_string();
1350        assert_eq!(version_arn, format!("{arn}:1"));
1351
1352        let list_req = make_request(
1353            "ListStateMachineVersions",
1354            &json!({ "stateMachineArn": arn }).to_string(),
1355        );
1356        let list = body_json(&svc.list_state_machine_versions(&list_req).unwrap());
1357        let versions = list["stateMachineVersions"].as_array().unwrap();
1358        assert_eq!(versions.len(), 1);
1359        assert_eq!(versions[0]["stateMachineVersionArn"], version_arn);
1360    }
1361
1362    #[test]
1363    fn create_state_machine_no_publish_has_no_version() {
1364        let svc = StepFunctionsService::new(make_state());
1365        let arn = create_sm(&svc, "nopub-sm");
1366        // No version ARN is returned when publish is absent.
1367        let body = json!({
1368            "name": "nopub-sm2",
1369            "definition": VALID_DEF,
1370            "roleArn": "arn:aws:iam::123456789012:role/r",
1371        });
1372        let req = make_request("CreateStateMachine", &body.to_string());
1373        let b = body_json(&svc.create_state_machine(&req).unwrap());
1374        assert!(b.get("stateMachineVersionArn").is_none());
1375
1376        let list_req = make_request(
1377            "ListStateMachineVersions",
1378            &json!({ "stateMachineArn": arn }).to_string(),
1379        );
1380        let list = body_json(&svc.list_state_machine_versions(&list_req).unwrap());
1381        assert!(list["stateMachineVersions"].as_array().unwrap().is_empty());
1382    }
1383
1384    // bug-hunt 2026-07-19: UpdateStateMachine with publish=true publishes the
1385    // next version, and encryptionConfiguration persists + round-trips on
1386    // Describe.
1387    #[test]
1388    fn update_state_machine_publish_and_encryption_round_trip() {
1389        let svc = StepFunctionsService::new(make_state());
1390        let create = json!({
1391            "name": "enc-sm",
1392            "definition": VALID_DEF,
1393            "roleArn": "arn:aws:iam::123456789012:role/r",
1394            "encryptionConfiguration": { "type": "CUSTOMER_MANAGED_KMS_KEY", "kmsKeyId": "key-123" },
1395        });
1396        let req = make_request("CreateStateMachine", &create.to_string());
1397        let arn = body_json(&svc.create_state_machine(&req).unwrap())["stateMachineArn"]
1398            .as_str()
1399            .unwrap()
1400            .to_string();
1401
1402        // encryptionConfiguration round-trips on Describe.
1403        let desc_req = make_request(
1404            "DescribeStateMachine",
1405            &json!({ "stateMachineArn": arn }).to_string(),
1406        );
1407        let desc = body_json(&svc.describe_state_machine(&desc_req).unwrap());
1408        assert_eq!(
1409            desc["encryptionConfiguration"]["type"],
1410            "CUSTOMER_MANAGED_KMS_KEY"
1411        );
1412        assert_eq!(desc["encryptionConfiguration"]["kmsKeyId"], "key-123");
1413
1414        // Update with publish creates version 1.
1415        let upd = json!({
1416            "stateMachineArn": arn,
1417            "definition": VALID_DEF,
1418            "publish": true,
1419            "versionDescription": "v via update",
1420        });
1421        let upd_req = make_request("UpdateStateMachine", &upd.to_string());
1422        let upd_body = body_json(&svc.update_state_machine(&upd_req).unwrap());
1423        assert_eq!(
1424            upd_body["stateMachineVersionArn"].as_str().unwrap(),
1425            format!("{arn}:1")
1426        );
1427
1428        let list_req = make_request(
1429            "ListStateMachineVersions",
1430            &json!({ "stateMachineArn": arn }).to_string(),
1431        );
1432        let list = body_json(&svc.list_state_machine_versions(&list_req).unwrap());
1433        assert_eq!(list["stateMachineVersions"].as_array().unwrap().len(), 1);
1434    }
1435
1436    #[test]
1437    fn create_state_machine_with_express_type() {
1438        let svc = StepFunctionsService::new(make_state());
1439        let body = json!({
1440            "name": "express-sm",
1441            "definition": VALID_DEF,
1442            "roleArn": "arn:aws:iam::123456789012:role/r",
1443            "type": "EXPRESS",
1444        });
1445        let req = make_request("CreateStateMachine", &body.to_string());
1446        let resp = svc.create_state_machine(&req).unwrap();
1447        let b = body_json(&resp);
1448        assert!(b["stateMachineArn"].as_str().is_some());
1449    }
1450
1451    #[test]
1452    fn create_state_machine_duplicate_fails() {
1453        let svc = StepFunctionsService::new(make_state());
1454        create_sm(&svc, "dup-sm");
1455        let body = json!({
1456            "name": "dup-sm",
1457            "definition": VALID_DEF,
1458            "roleArn": "arn:aws:iam::123456789012:role/r",
1459        });
1460        let req = make_request("CreateStateMachine", &body.to_string());
1461        let err = expect_err(svc.create_state_machine(&req));
1462        assert!(err.to_string().contains("StateMachineAlreadyExists"));
1463    }
1464
1465    #[test]
1466    fn create_state_machine_missing_name() {
1467        let svc = StepFunctionsService::new(make_state());
1468        let body = json!({
1469            "definition": VALID_DEF,
1470            "roleArn": "arn:aws:iam::123456789012:role/r",
1471        });
1472        let req = make_request("CreateStateMachine", &body.to_string());
1473        assert!(svc.create_state_machine(&req).is_err());
1474    }
1475
1476    #[test]
1477    fn create_state_machine_invalid_definition() {
1478        let svc = StepFunctionsService::new(make_state());
1479        let body = json!({
1480            "name": "bad-def",
1481            "definition": "not json",
1482            "roleArn": "arn:aws:iam::123456789012:role/r",
1483        });
1484        let req = make_request("CreateStateMachine", &body.to_string());
1485        let err = expect_err(svc.create_state_machine(&req));
1486        assert!(err.to_string().contains("InvalidDefinition"));
1487    }
1488
1489    #[test]
1490    fn create_state_machine_definition_missing_start_at() {
1491        let svc = StepFunctionsService::new(make_state());
1492        let body = json!({
1493            "name": "no-start",
1494            "definition": r#"{"States":{"S":{"Type":"Pass","End":true}}}"#,
1495            "roleArn": "arn:aws:iam::123456789012:role/r",
1496        });
1497        let req = make_request("CreateStateMachine", &body.to_string());
1498        let err = expect_err(svc.create_state_machine(&req));
1499        assert!(err.to_string().contains("InvalidDefinition"));
1500    }
1501
1502    #[test]
1503    fn create_state_machine_definition_missing_states() {
1504        let svc = StepFunctionsService::new(make_state());
1505        let body = json!({
1506            "name": "no-states",
1507            "definition": r#"{"StartAt":"S"}"#,
1508            "roleArn": "arn:aws:iam::123456789012:role/r",
1509        });
1510        let req = make_request("CreateStateMachine", &body.to_string());
1511        let err = expect_err(svc.create_state_machine(&req));
1512        assert!(err.to_string().contains("InvalidDefinition"));
1513    }
1514
1515    #[test]
1516    fn create_state_machine_definition_start_at_not_in_states() {
1517        let svc = StepFunctionsService::new(make_state());
1518        let body = json!({
1519            "name": "bad-start",
1520            "definition": r#"{"StartAt":"Missing","States":{"S":{"Type":"Pass","End":true}}}"#,
1521            "roleArn": "arn:aws:iam::123456789012:role/r",
1522        });
1523        let req = make_request("CreateStateMachine", &body.to_string());
1524        let err = expect_err(svc.create_state_machine(&req));
1525        assert!(err.to_string().contains("MISSING_TRANSITION_TARGET"));
1526    }
1527
1528    #[test]
1529    fn create_state_machine_invalid_type() {
1530        let svc = StepFunctionsService::new(make_state());
1531        let body = json!({
1532            "name": "bad-type",
1533            "definition": VALID_DEF,
1534            "roleArn": "arn:aws:iam::123456789012:role/r",
1535            "type": "INVALID",
1536        });
1537        let req = make_request("CreateStateMachine", &body.to_string());
1538        assert!(svc.create_state_machine(&req).is_err());
1539    }
1540
1541    #[test]
1542    fn create_state_machine_invalid_arn() {
1543        let svc = StepFunctionsService::new(make_state());
1544        let body = json!({
1545            "name": "bad-arn",
1546            "definition": VALID_DEF,
1547            "roleArn": "not-an-arn",
1548        });
1549        let req = make_request("CreateStateMachine", &body.to_string());
1550        let err = expect_err(svc.create_state_machine(&req));
1551        assert!(err.to_string().contains("InvalidArn"));
1552    }
1553
1554    #[test]
1555    fn create_state_machine_invalid_name() {
1556        let svc = StepFunctionsService::new(make_state());
1557        let body = json!({
1558            "name": "has spaces!",
1559            "definition": VALID_DEF,
1560            "roleArn": "arn:aws:iam::123456789012:role/r",
1561        });
1562        let req = make_request("CreateStateMachine", &body.to_string());
1563        let err = expect_err(svc.create_state_machine(&req));
1564        assert!(err.to_string().contains("InvalidName"));
1565    }
1566
1567    #[test]
1568    fn create_state_machine_name_too_long() {
1569        let svc = StepFunctionsService::new(make_state());
1570        let long_name = "a".repeat(81);
1571        let body = json!({
1572            "name": long_name,
1573            "definition": VALID_DEF,
1574            "roleArn": "arn:aws:iam::123456789012:role/r",
1575        });
1576        let req = make_request("CreateStateMachine", &body.to_string());
1577        let err = expect_err(svc.create_state_machine(&req));
1578        assert!(err.to_string().contains("InvalidName"));
1579    }
1580
1581    // ── DescribeStateMachine ──
1582
1583    #[test]
1584    fn describe_state_machine_found() {
1585        let svc = StepFunctionsService::new(make_state());
1586        let arn = create_sm(&svc, "desc-sm");
1587
1588        let req = make_request(
1589            "DescribeStateMachine",
1590            &json!({"stateMachineArn": arn}).to_string(),
1591        );
1592        let resp = svc.describe_state_machine(&req).unwrap();
1593        let b = body_json(&resp);
1594        assert_eq!(b["name"], "desc-sm");
1595        assert_eq!(b["status"], "ACTIVE");
1596        assert!(b["definition"].as_str().is_some());
1597    }
1598
1599    #[test]
1600    fn describe_state_machine_not_found() {
1601        let svc = StepFunctionsService::new(make_state());
1602        let req = make_request(
1603            "DescribeStateMachine",
1604            &json!({"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope"})
1605                .to_string(),
1606        );
1607        let err = expect_err(svc.describe_state_machine(&req));
1608        assert!(err.to_string().contains("StateMachineDoesNotExist"));
1609    }
1610
1611    // ── ListStateMachines ──
1612
1613    #[test]
1614    fn list_state_machines_empty() {
1615        let svc = StepFunctionsService::new(make_state());
1616        let req = make_request("ListStateMachines", "{}");
1617        let resp = svc.list_state_machines(&req).unwrap();
1618        let b = body_json(&resp);
1619        assert!(b["stateMachines"].as_array().unwrap().is_empty());
1620    }
1621
1622    #[test]
1623    fn list_state_machines_returns_created() {
1624        let svc = StepFunctionsService::new(make_state());
1625        create_sm(&svc, "sm-1");
1626        create_sm(&svc, "sm-2");
1627
1628        let req = make_request("ListStateMachines", "{}");
1629        let resp = svc.list_state_machines(&req).unwrap();
1630        let b = body_json(&resp);
1631        assert_eq!(b["stateMachines"].as_array().unwrap().len(), 2);
1632    }
1633
1634    // ── DeleteStateMachine ──
1635
1636    #[test]
1637    fn delete_state_machine() {
1638        let svc = StepFunctionsService::new(make_state());
1639        let arn = create_sm(&svc, "del-sm");
1640
1641        let req = make_request(
1642            "DeleteStateMachine",
1643            &json!({"stateMachineArn": arn}).to_string(),
1644        );
1645        svc.delete_state_machine(&req).unwrap();
1646
1647        // Describe should fail
1648        let req = make_request(
1649            "DescribeStateMachine",
1650            &json!({"stateMachineArn": arn}).to_string(),
1651        );
1652        assert!(svc.describe_state_machine(&req).is_err());
1653    }
1654
1655    #[test]
1656    fn delete_state_machine_nonexistent_succeeds() {
1657        let svc = StepFunctionsService::new(make_state());
1658        let req = make_request(
1659            "DeleteStateMachine",
1660            &json!({"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope"})
1661                .to_string(),
1662        );
1663        // AWS returns success even for nonexistent
1664        svc.delete_state_machine(&req).unwrap();
1665    }
1666
1667    // ── UpdateStateMachine ──
1668
1669    #[test]
1670    fn update_state_machine() {
1671        let svc = StepFunctionsService::new(make_state());
1672        let arn = create_sm(&svc, "upd-sm");
1673
1674        let new_def = r#"{"StartAt":"NewPass","States":{"NewPass":{"Type":"Pass","End":true}}}"#;
1675        let body = json!({
1676            "stateMachineArn": arn,
1677            "definition": new_def,
1678            "description": "updated",
1679        });
1680        let req = make_request("UpdateStateMachine", &body.to_string());
1681        let resp = svc.update_state_machine(&req).unwrap();
1682        let b = body_json(&resp);
1683        assert!(b["updateDate"].as_f64().is_some());
1684
1685        // Verify
1686        let req = make_request(
1687            "DescribeStateMachine",
1688            &json!({"stateMachineArn": arn}).to_string(),
1689        );
1690        let resp = svc.describe_state_machine(&req).unwrap();
1691        let b = body_json(&resp);
1692        assert!(b["definition"].as_str().unwrap().contains("NewPass"));
1693        assert_eq!(b["description"], "updated");
1694    }
1695
1696    #[test]
1697    fn update_state_machine_not_found() {
1698        let svc = StepFunctionsService::new(make_state());
1699        let body = json!({
1700            "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
1701            "definition": VALID_DEF,
1702        });
1703        let req = make_request("UpdateStateMachine", &body.to_string());
1704        let err = expect_err(svc.update_state_machine(&req));
1705        assert!(err.to_string().contains("StateMachineDoesNotExist"));
1706    }
1707
1708    // ── StartExecution ──
1709
1710    #[tokio::test]
1711    async fn start_execution_basic() {
1712        let svc = StepFunctionsService::new(make_state());
1713        let arn = create_sm(&svc, "exec-sm");
1714
1715        let body = json!({
1716            "stateMachineArn": arn,
1717            "input": r#"{"key":"value"}"#,
1718        });
1719        let req = make_request("StartExecution", &body.to_string());
1720        let resp = svc.start_execution(&req).unwrap();
1721        let b = body_json(&resp);
1722        assert!(b["executionArn"].as_str().is_some());
1723        assert!(b["startDate"].as_f64().is_some());
1724    }
1725
1726    #[tokio::test]
1727    async fn start_execution_with_name() {
1728        let svc = StepFunctionsService::new(make_state());
1729        let arn = create_sm(&svc, "named-exec");
1730
1731        let body = json!({
1732            "stateMachineArn": arn,
1733            "name": "my-execution",
1734        });
1735        let req = make_request("StartExecution", &body.to_string());
1736        let resp = svc.start_execution(&req).unwrap();
1737        let b = body_json(&resp);
1738        assert!(b["executionArn"].as_str().unwrap().contains("my-execution"));
1739    }
1740
1741    #[tokio::test]
1742    async fn start_execution_sm_not_found() {
1743        let svc = StepFunctionsService::new(make_state());
1744        let body = json!({
1745            "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
1746        });
1747        let req = make_request("StartExecution", &body.to_string());
1748        let err = expect_err(svc.start_execution(&req));
1749        assert!(err.to_string().contains("StateMachineDoesNotExist"));
1750    }
1751
1752    #[tokio::test]
1753    async fn start_execution_invalid_input() {
1754        let svc = StepFunctionsService::new(make_state());
1755        let arn = create_sm(&svc, "bad-input");
1756
1757        let body = json!({
1758            "stateMachineArn": arn,
1759            "input": "not json",
1760        });
1761        let req = make_request("StartExecution", &body.to_string());
1762        let err = expect_err(svc.start_execution(&req));
1763        assert!(err.to_string().contains("InvalidExecutionInput"));
1764    }
1765
1766    #[tokio::test]
1767    async fn start_execution_same_name_same_input_is_idempotent() {
1768        let svc = StepFunctionsService::new(make_state());
1769        let arn = create_sm(&svc, "dup-exec");
1770
1771        let body = json!({
1772            "stateMachineArn": arn,
1773            "name": "same-name",
1774            "input": "{\"a\":1}",
1775        });
1776        let req = make_request("StartExecution", &body.to_string());
1777        let first = body_json(&svc.start_execution(&req).unwrap());
1778
1779        // Same name AND same input -> 200 with the existing executionArn.
1780        let req = make_request("StartExecution", &body.to_string());
1781        let second = body_json(&svc.start_execution(&req).unwrap());
1782        assert_eq!(first["executionArn"], second["executionArn"]);
1783        assert_eq!(first["startDate"], second["startDate"]);
1784    }
1785
1786    #[tokio::test]
1787    async fn start_execution_same_name_different_input_conflicts() {
1788        let svc = StepFunctionsService::new(make_state());
1789        let arn = create_sm(&svc, "dup-exec-diff");
1790
1791        let req = make_request(
1792            "StartExecution",
1793            &json!({
1794                "stateMachineArn": arn,
1795                "name": "same-name",
1796                "input": "{\"a\":1}",
1797            })
1798            .to_string(),
1799        );
1800        svc.start_execution(&req).unwrap();
1801
1802        // Same name, DIFFERENT input -> 400 ExecutionAlreadyExists.
1803        let req = make_request(
1804            "StartExecution",
1805            &json!({
1806                "stateMachineArn": arn,
1807                "name": "same-name",
1808                "input": "{\"a\":2}",
1809            })
1810            .to_string(),
1811        );
1812        let err = expect_err(svc.start_execution(&req));
1813        assert!(err.to_string().contains("ExecutionAlreadyExists"));
1814    }
1815
1816    #[tokio::test]
1817    async fn start_execution_express_name_collision_never_idempotent() {
1818        let svc = StepFunctionsService::new(make_state());
1819        let arn = create_express_sm(&svc, "dup-exec-express");
1820
1821        let body = json!({
1822            "stateMachineArn": arn,
1823            "name": "same-name",
1824            "input": "{\"a\":1}",
1825        });
1826        let req = make_request("StartExecution", &body.to_string());
1827        svc.start_execution(&req).unwrap();
1828
1829        // EXPRESS has no idempotency: even an identical re-issue conflicts.
1830        let req = make_request("StartExecution", &body.to_string());
1831        let err = expect_err(svc.start_execution(&req));
1832        assert!(err.to_string().contains("ExecutionAlreadyExists"));
1833    }
1834
1835    // ── DescribeExecution ──
1836
1837    #[tokio::test]
1838    async fn describe_execution_found() {
1839        let svc = StepFunctionsService::new(make_state());
1840        let sm_arn = create_sm(&svc, "desc-exec");
1841
1842        let body = json!({"stateMachineArn": sm_arn, "name": "e1"});
1843        let req = make_request("StartExecution", &body.to_string());
1844        let resp = svc.start_execution(&req).unwrap();
1845        let exec_arn = body_json(&resp)["executionArn"]
1846            .as_str()
1847            .unwrap()
1848            .to_string();
1849
1850        let req = make_request(
1851            "DescribeExecution",
1852            &json!({"executionArn": exec_arn}).to_string(),
1853        );
1854        let resp = svc.describe_execution(&req).unwrap();
1855        let b = body_json(&resp);
1856        assert_eq!(b["name"], "e1");
1857        assert_eq!(b["status"], "RUNNING");
1858    }
1859
1860    #[tokio::test]
1861    async fn describe_execution_not_found() {
1862        let svc = StepFunctionsService::new(make_state());
1863        let req = make_request(
1864            "DescribeExecution",
1865            &json!({"executionArn": "arn:aws:states:us-east-1:123456789012:execution:sm:nope"})
1866                .to_string(),
1867        );
1868        let err = expect_err(svc.describe_execution(&req));
1869        assert!(err.to_string().contains("ExecutionDoesNotExist"));
1870    }
1871
1872    // ── StopExecution ──
1873
1874    #[tokio::test]
1875    async fn stop_execution() {
1876        let svc = StepFunctionsService::new(make_state());
1877        let sm_arn = create_sm(&svc, "stop-sm");
1878
1879        let body = json!({"stateMachineArn": sm_arn, "name": "stop-e"});
1880        let req = make_request("StartExecution", &body.to_string());
1881        let resp = svc.start_execution(&req).unwrap();
1882        let exec_arn = body_json(&resp)["executionArn"]
1883            .as_str()
1884            .unwrap()
1885            .to_string();
1886
1887        let body = json!({
1888            "executionArn": exec_arn,
1889            "error": "UserAborted",
1890            "cause": "test stop",
1891        });
1892        let req = make_request("StopExecution", &body.to_string());
1893        let resp = svc.stop_execution(&req).unwrap();
1894        let b = body_json(&resp);
1895        assert!(b["stopDate"].as_f64().is_some());
1896
1897        // Verify aborted
1898        let req = make_request(
1899            "DescribeExecution",
1900            &json!({"executionArn": exec_arn}).to_string(),
1901        );
1902        let resp = svc.describe_execution(&req).unwrap();
1903        let b = body_json(&resp);
1904        assert_eq!(b["status"], "ABORTED");
1905        assert_eq!(b["error"], "UserAborted");
1906    }
1907
1908    #[tokio::test]
1909    async fn redrive_execution_reruns_to_terminal() {
1910        let svc = StepFunctionsService::new(make_state());
1911        let sm_arn = create_sm(&svc, "redrive-sm");
1912
1913        let body = json!({"stateMachineArn": sm_arn, "name": "redrive-e"});
1914        let req = make_request("StartExecution", &body.to_string());
1915        let resp = svc.start_execution(&req).unwrap();
1916        let exec_arn = body_json(&resp)["executionArn"]
1917            .as_str()
1918            .unwrap()
1919            .to_string();
1920
1921        // Stop before the spawned interpreter runs -> terminal ABORTED.
1922        let req = make_request(
1923            "StopExecution",
1924            &json!({"executionArn": exec_arn, "error": "UserAborted", "cause": "stop"}).to_string(),
1925        );
1926        svc.stop_execution(&req).unwrap();
1927
1928        // Redrive: must reset to RUNNING and re-spawn the interpreter.
1929        let req = make_request(
1930            "RedriveExecution",
1931            &json!({"executionArn": exec_arn}).to_string(),
1932        );
1933        let resp = svc.redrive_execution(&req).unwrap();
1934        assert!(body_json(&resp)["redriveDate"].as_i64().is_some());
1935
1936        // The redriven Pass-state execution must reach a terminal state
1937        // (SUCCEEDED) instead of hanging RUNNING forever.
1938        let mut status = String::new();
1939        for _ in 0..100 {
1940            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1941            let req = make_request(
1942                "DescribeExecution",
1943                &json!({"executionArn": exec_arn}).to_string(),
1944            );
1945            let b = body_json(&svc.describe_execution(&req).unwrap());
1946            status = b["status"].as_str().unwrap().to_string();
1947            if status != "RUNNING" {
1948                break;
1949            }
1950        }
1951        assert_eq!(
1952            status, "SUCCEEDED",
1953            "redriven execution must run to completion"
1954        );
1955        let req = make_request(
1956            "DescribeExecution",
1957            &json!({"executionArn": exec_arn}).to_string(),
1958        );
1959        let b = body_json(&svc.describe_execution(&req).unwrap());
1960        assert_eq!(b["redriveCount"], 1);
1961    }
1962
1963    #[tokio::test]
1964    async fn redrive_execution_running_rejected() {
1965        let svc = StepFunctionsService::new(make_state());
1966        let sm_arn = create_sm(&svc, "redrive-running-sm");
1967        let body = json!({"stateMachineArn": sm_arn, "name": "still-running"});
1968        let req = make_request("StartExecution", &body.to_string());
1969        let exec_arn = body_json(&svc.start_execution(&req).unwrap())["executionArn"]
1970            .as_str()
1971            .unwrap()
1972            .to_string();
1973
1974        // A still-RUNNING execution is not redrivable (it already has a driver).
1975        let req = make_request(
1976            "RedriveExecution",
1977            &json!({"executionArn": exec_arn}).to_string(),
1978        );
1979        let err = expect_err(svc.redrive_execution(&req));
1980        assert!(err.to_string().contains("ExecutionNotRedrivable"));
1981    }
1982
1983    #[tokio::test]
1984    async fn stop_execution_not_found() {
1985        let svc = StepFunctionsService::new(make_state());
1986        let req = make_request(
1987            "StopExecution",
1988            &json!({"executionArn": "arn:aws:states:us-east-1:123456789012:execution:sm:nope"})
1989                .to_string(),
1990        );
1991        let err = expect_err(svc.stop_execution(&req));
1992        assert!(err.to_string().contains("ExecutionDoesNotExist"));
1993    }
1994
1995    // ── ListExecutions ──
1996
1997    #[tokio::test]
1998    async fn list_executions() {
1999        let svc = StepFunctionsService::new(make_state());
2000        let sm_arn = create_sm(&svc, "list-exec");
2001
2002        for i in 0..3 {
2003            let body = json!({"stateMachineArn": sm_arn, "name": format!("e{i}")});
2004            let req = make_request("StartExecution", &body.to_string());
2005            svc.start_execution(&req).unwrap();
2006        }
2007
2008        let req = make_request(
2009            "ListExecutions",
2010            &json!({"stateMachineArn": sm_arn}).to_string(),
2011        );
2012        let resp = svc.list_executions(&req).unwrap();
2013        let b = body_json(&resp);
2014        assert_eq!(b["executions"].as_array().unwrap().len(), 3);
2015    }
2016
2017    #[tokio::test]
2018    async fn list_executions_sm_not_found() {
2019        let svc = StepFunctionsService::new(make_state());
2020        let req = make_request(
2021            "ListExecutions",
2022            &json!({"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope"})
2023                .to_string(),
2024        );
2025        let err = expect_err(svc.list_executions(&req));
2026        assert!(err.to_string().contains("StateMachineDoesNotExist"));
2027    }
2028
2029    // ── GetExecutionHistory ──
2030
2031    #[tokio::test]
2032    async fn get_execution_history_not_found() {
2033        let svc = StepFunctionsService::new(make_state());
2034        let req = make_request(
2035            "GetExecutionHistory",
2036            &json!({"executionArn": "arn:aws:states:us-east-1:123456789012:execution:sm:nope"})
2037                .to_string(),
2038        );
2039        let err = expect_err(svc.get_execution_history(&req));
2040        assert!(err.to_string().contains("ExecutionDoesNotExist"));
2041    }
2042
2043    // ── DescribeStateMachineForExecution ──
2044
2045    #[tokio::test]
2046    async fn describe_sm_for_execution() {
2047        let svc = StepFunctionsService::new(make_state());
2048        let sm_arn = create_sm(&svc, "sm-for-exec");
2049
2050        let body = json!({"stateMachineArn": sm_arn, "name": "e1"});
2051        let req = make_request("StartExecution", &body.to_string());
2052        let resp = svc.start_execution(&req).unwrap();
2053        let exec_arn = body_json(&resp)["executionArn"]
2054            .as_str()
2055            .unwrap()
2056            .to_string();
2057
2058        let req = make_request(
2059            "DescribeStateMachineForExecution",
2060            &json!({"executionArn": exec_arn}).to_string(),
2061        );
2062        let resp = svc.describe_state_machine_for_execution(&req).unwrap();
2063        let b = body_json(&resp);
2064        assert_eq!(b["name"], "sm-for-exec");
2065    }
2066
2067    // ── Tags ──
2068
2069    #[test]
2070    fn tag_untag_list_tags() {
2071        let svc = StepFunctionsService::new(make_state());
2072        let arn = create_sm(&svc, "tagged-sm");
2073
2074        // Tag
2075        let body = json!({
2076            "resourceArn": arn,
2077            "tags": [{"key": "env", "value": "prod"}],
2078        });
2079        let req = make_request("TagResource", &body.to_string());
2080        svc.tag_resource(&req).unwrap();
2081
2082        // List
2083        let req = make_request(
2084            "ListTagsForResource",
2085            &json!({"resourceArn": arn}).to_string(),
2086        );
2087        let resp = svc.list_tags_for_resource(&req).unwrap();
2088        let b = body_json(&resp);
2089        let tags = b["tags"].as_array().unwrap();
2090        assert_eq!(tags.len(), 1);
2091        assert_eq!(tags[0]["key"], "env");
2092
2093        // Untag
2094        let body = json!({
2095            "resourceArn": arn,
2096            "tagKeys": ["env"],
2097        });
2098        let req = make_request("UntagResource", &body.to_string());
2099        svc.untag_resource(&req).unwrap();
2100
2101        // Verify empty
2102        let req = make_request(
2103            "ListTagsForResource",
2104            &json!({"resourceArn": arn}).to_string(),
2105        );
2106        let resp = svc.list_tags_for_resource(&req).unwrap();
2107        let b = body_json(&resp);
2108        assert!(b["tags"].as_array().unwrap().is_empty());
2109    }
2110
2111    #[test]
2112    fn tag_resource_not_found() {
2113        let svc = StepFunctionsService::new(make_state());
2114        let body = json!({
2115            "resourceArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
2116            "tags": [{"key": "k", "value": "v"}],
2117        });
2118        let req = make_request("TagResource", &body.to_string());
2119        let err = expect_err(svc.tag_resource(&req));
2120        assert!(err.to_string().contains("ResourceNotFound"));
2121    }
2122
2123    // ── Helper function tests ──
2124
2125    #[test]
2126    fn test_validate_name() {
2127        assert!(validate_name("valid-name").is_ok());
2128        assert!(validate_name("under_score").is_ok());
2129        assert!(validate_name("").is_err());
2130        assert!(validate_name("has spaces").is_err());
2131        assert!(validate_name(&"a".repeat(81)).is_err());
2132    }
2133
2134    #[test]
2135    fn test_validate_definition() {
2136        assert!(validate_definition(VALID_DEF).is_ok());
2137        assert!(validate_definition("not json").is_err());
2138        assert!(validate_definition(r#"{"States":{}}"#).is_err()); // missing StartAt
2139        assert!(validate_definition(r#"{"StartAt":"S"}"#).is_err()); // missing States
2140    }
2141
2142    #[test]
2143    fn test_validate_definition_timeout_range() {
2144        let task = |field: &str, v: &str| {
2145            format!(
2146                r#"{{"StartAt":"T","States":{{"T":{{"Type":"Task","Resource":"arn:aws:states:::lambda:invoke","{field}":{v},"End":true}}}}}}"#
2147            )
2148        };
2149        for field in ["TimeoutSeconds", "HeartbeatSeconds"] {
2150            assert!(validate_definition(&task(field, "1")).is_ok());
2151            assert!(validate_definition(&task(field, "99999999")).is_ok());
2152            for bad in [
2153                "0",
2154                "-1",
2155                "100000000",
2156                "18446744073709551615",
2157                "1.5",
2158                "\"10\"",
2159            ] {
2160                let err = validate_definition(&task(field, bad)).unwrap_err();
2161                assert!(
2162                    err.to_string().contains("InvalidDefinition"),
2163                    "{field}={bad}: {err}"
2164                );
2165            }
2166        }
2167        // Top-level TimeoutSeconds.
2168        let def = r#"{"StartAt":"P","TimeoutSeconds":18446744073709551615,"States":{"P":{"Type":"Pass","End":true}}}"#;
2169        assert!(validate_definition(def).is_err());
2170        // Nested inside a Parallel branch.
2171        let def = r#"{"StartAt":"Par","States":{"Par":{"Type":"Parallel","End":true,"Branches":[{"StartAt":"T","States":{"T":{"Type":"Task","Resource":"arn:aws:states:::lambda:invoke","TimeoutSeconds":100000000,"End":true}}}]}}}"#;
2172        assert!(validate_definition(def).is_err());
2173    }
2174
2175    #[test]
2176    fn test_validate_definition_rejects_malformed_paths() {
2177        // Unterminated bracket in InputPath.
2178        let def =
2179            r#"{"StartAt":"P","States":{"P":{"Type":"Pass","InputPath":"$.arr[","End":true}}}"#;
2180        assert!(validate_definition(def).is_err());
2181
2182        // Multibyte char where the close bracket would be, in OutputPath.
2183        let def =
2184            "{\"StartAt\":\"P\",\"States\":{\"P\":{\"Type\":\"Pass\",\"OutputPath\":\"$.x[\u{00e9}\",\"End\":true}}}";
2185        assert!(validate_definition(def).is_err());
2186
2187        // Malformed Choice Variable.
2188        let def = r#"{"StartAt":"C","States":{"C":{"Type":"Choice","Choices":[{"Variable":"$.n[","NumericEquals":1,"Next":"P"}]},"P":{"Type":"Pass","End":true}}}"#;
2189        assert!(validate_definition(def).is_err());
2190
2191        // Path not rooted at $.
2192        let def =
2193            r#"{"StartAt":"P","States":{"P":{"Type":"Pass","InputPath":"foo.bar","End":true}}}"#;
2194        assert!(validate_definition(def).is_err());
2195
2196        // Empty index brackets.
2197        let def =
2198            r#"{"StartAt":"P","States":{"P":{"Type":"Pass","ResultPath":"$.x[]","End":true}}}"#;
2199        assert!(validate_definition(def).is_err());
2200    }
2201
2202    #[test]
2203    fn test_validate_definition_accepts_well_formed_paths() {
2204        // Valid reference paths, the literal "null" for Input/OutputPath, and
2205        // nested Choice combinators must all be accepted.
2206        let def = r#"{"StartAt":"P","States":{
2207            "P":{"Type":"Pass","InputPath":"$.a.b[0].c","OutputPath":"$","ResultPath":"$.out","Next":"C"},
2208            "C":{"Type":"Choice","Choices":[
2209                {"And":[{"Variable":"$.items[2]","NumericEquals":1},{"Variable":"$.flag","BooleanEquals":true}],"Next":"S"}
2210            ],"Default":"S"},
2211            "S":{"Type":"Succeed","InputPath":"null"}
2212        }}"#;
2213        assert!(validate_definition(def).is_ok());
2214    }
2215
2216    // M5: malformed paths nested inside Parallel branches and Map processors
2217    // must be rejected.
2218    #[test]
2219    fn test_validate_definition_recurses_into_parallel_and_map() {
2220        let par = r#"{"StartAt":"Par","States":{"Par":{"Type":"Parallel","End":true,
2221            "Branches":[{"StartAt":"I","States":{"I":{"Type":"Pass","InputPath":"nope","End":true}}}]}}}"#;
2222        assert!(validate_definition(par).is_err());
2223
2224        let map = r#"{"StartAt":"M","States":{"M":{"Type":"Map","End":true,
2225            "ItemProcessor":{"StartAt":"E","States":{"E":{"Type":"Pass","OutputPath":"nope","End":true}}}}}}"#;
2226        assert!(validate_definition(map).is_err());
2227
2228        // Legacy inline Map uses `Iterator`.
2229        let iter = r#"{"StartAt":"M","States":{"M":{"Type":"Map","End":true,
2230            "Iterator":{"StartAt":"E","States":{"E":{"Type":"Pass","ResultPath":"$.x[]","End":true}}}}}}"#;
2231        assert!(validate_definition(iter).is_err());
2232
2233        // A well-formed Parallel/Map still validates.
2234        let ok = r#"{"StartAt":"M","States":{"M":{"Type":"Map","ItemsPath":"$.items","End":true,
2235            "ItemProcessor":{"StartAt":"E","States":{"E":{"Type":"Pass","InputPath":"$.a","End":true}}}}}}"#;
2236        assert!(validate_definition(ok).is_ok());
2237    }
2238
2239    // L7: `.$` payload-template keys must carry a JSONPath/intrinsic string.
2240    #[test]
2241    fn test_validate_definition_payload_template_dollar_keys() {
2242        // Bare literal value.
2243        let lit = r#"{"StartAt":"P","States":{"P":{"Type":"Pass",
2244            "Parameters":{"v.$":"just text"},"End":true}}}"#;
2245        assert!(validate_definition(lit).is_err());
2246
2247        // Non-string value.
2248        let num = r#"{"StartAt":"P","States":{"P":{"Type":"Pass",
2249            "Parameters":{"v.$":42},"End":true}}}"#;
2250        assert!(validate_definition(num).is_err());
2251
2252        // Valid: JSONPath reference, context-object ref, and intrinsic.
2253        let ok = r#"{"StartAt":"P","States":{"P":{"Type":"Pass","Parameters":{
2254            "a.$":"$.input","b.$":"$$.Execution.Id","c.$":"States.Format('{}',$.n)",
2255            "nested":{"d.$":"$.x"},"plain":"literal-ok"},"End":true}}}"#;
2256        assert!(validate_definition(ok).is_ok());
2257
2258        // A bad `.$` nested inside an array under a plain key is also caught.
2259        let arr = r#"{"StartAt":"P","States":{"P":{"Type":"Pass",
2260            "ResultSelector":{"items":[{"bad.$":"literal"}]},"End":true}}}"#;
2261        assert!(validate_definition(arr).is_err());
2262    }
2263
2264    #[test]
2265    fn test_paths_accept_full_jsonpath_but_result_path_must_be_definite() {
2266        let def = |field: &str, path: &str| {
2267            json!({
2268                "StartAt": "P",
2269                "States": {"P": {"Type": "Pass", field: path, "End": true}}
2270            })
2271            .to_string()
2272        };
2273        for p in [
2274            "$.items[*].id",
2275            "$.a[?(@.x == 1)]",
2276            "$['a']",
2277            "$.a[-1]",
2278            "$..id",
2279            "$.a[1:2]",
2280        ] {
2281            assert!(validate_definition(&def("InputPath", p)).is_ok(), "{p}");
2282            assert!(validate_definition(&def("OutputPath", p)).is_ok(), "{p}");
2283        }
2284        assert!(validate_definition(&def("ResultPath", "$['a'][0]")).is_ok());
2285        let choice = |var: &str| {
2286            json!({
2287                "StartAt": "C",
2288                "States": {
2289                    "C": {"Type": "Choice", "Choices": [{"Variable": var, "IsPresent": true, "Next": "D"}], "Default": "D"},
2290                    "D": {"Type": "Succeed"}
2291                }
2292            })
2293            .to_string()
2294        };
2295        assert!(validate_definition(&choice("$$.Execution.Name")).is_ok());
2296        assert!(validate_definition(&choice("$$.x[")).is_err());
2297        for p in ["$.items[*]", "$..a", "$.a[?(@.x)]"] {
2298            assert!(validate_definition(&def("ResultPath", p)).is_err(), "{p}");
2299        }
2300    }
2301
2302    #[test]
2303    fn test_is_valid_reference_path() {
2304        assert!(is_valid_reference_path("$"));
2305        assert!(is_valid_reference_path("$.foo"));
2306        assert!(is_valid_reference_path("$.foo.bar[3].baz"));
2307        assert!(is_valid_reference_path("$[0]"));
2308        assert!(!is_valid_reference_path("$.arr["));
2309        assert!(!is_valid_reference_path("$.x[\u{00e9}"));
2310        assert!(!is_valid_reference_path("$.x[]"));
2311        assert!(!is_valid_reference_path("$.x[abc]"));
2312        assert!(!is_valid_reference_path("foo.bar"));
2313        assert!(!is_valid_reference_path(""));
2314    }
2315
2316    #[test]
2317    fn test_validate_arn() {
2318        assert!(validate_arn("arn:aws:states:us-east-1:123:sm:test").is_ok());
2319        assert!(validate_arn("not-an-arn").is_err());
2320    }
2321
2322    #[test]
2323    fn test_camel_to_details_key() {
2324        // All *StateEntered / *StateExited types collapse to one key each.
2325        assert_eq!(camel_to_details_key("PassStateEntered"), "stateEntered");
2326        assert_eq!(camel_to_details_key("TaskStateEntered"), "stateEntered");
2327        assert_eq!(camel_to_details_key("ChoiceStateExited"), "stateExited");
2328        assert_eq!(camel_to_details_key("MapStateExited"), "stateExited");
2329        // Other event types keep first-char-lowercased prefix.
2330        assert_eq!(camel_to_details_key("TaskScheduled"), "taskScheduled");
2331        assert_eq!(camel_to_details_key("ExecutionStarted"), "executionStarted");
2332        assert_eq!(camel_to_details_key(""), "");
2333    }
2334
2335    #[test]
2336    fn test_is_mutating_action() {
2337        assert!(is_mutating_action("CreateStateMachine"));
2338        assert!(is_mutating_action("StartExecution"));
2339        assert!(!is_mutating_action("DescribeStateMachine"));
2340        assert!(!is_mutating_action("ListStateMachines"));
2341    }
2342
2343    // ── StartSyncExecution ──
2344
2345    fn create_express_sm(svc: &StepFunctionsService, name: &str) -> String {
2346        let body = json!({
2347            "name": name,
2348            "definition": VALID_DEF,
2349            "roleArn": "arn:aws:iam::123456789012:role/test",
2350            "type": "EXPRESS",
2351        });
2352        let req = make_request("CreateStateMachine", &body.to_string());
2353        let resp = svc.create_state_machine(&req).unwrap();
2354        let b = body_json(&resp);
2355        b["stateMachineArn"].as_str().unwrap().to_string()
2356    }
2357
2358    #[tokio::test]
2359    async fn start_sync_execution_basic() {
2360        let svc = StepFunctionsService::new(make_state());
2361        let arn = create_express_sm(&svc, "sync-sm");
2362
2363        let body = json!({
2364            "stateMachineArn": arn,
2365            "input": r#"{"key":"value"}"#,
2366        });
2367        let req = make_request("StartSyncExecution", &body.to_string());
2368        let resp = svc.start_sync_execution(&req).await.unwrap();
2369        let b = body_json(&resp);
2370        assert!(b["executionArn"]
2371            .as_str()
2372            .unwrap()
2373            .contains("express:sync-sm"));
2374        assert_eq!(b["stateMachineArn"], arn);
2375        assert_eq!(b["status"], "SUCCEEDED");
2376        assert!(b["startDate"].as_i64().is_some());
2377        assert!(b["stopDate"].as_i64().is_some());
2378        assert!(b["output"].as_str().is_some());
2379        assert!(b["billingDetails"]["billedDurationInMilliseconds"]
2380            .as_i64()
2381            .is_some());
2382    }
2383
2384    #[tokio::test]
2385    async fn start_sync_execution_not_express() {
2386        let svc = StepFunctionsService::new(make_state());
2387        let arn = create_sm(&svc, "std-sm");
2388
2389        let body = json!({"stateMachineArn": arn});
2390        let req = make_request("StartSyncExecution", &body.to_string());
2391        let err = expect_err(svc.start_sync_execution(&req).await);
2392        assert!(err.to_string().contains("StateMachineTypeNotSupported"));
2393    }
2394
2395    #[tokio::test]
2396    async fn start_sync_execution_sm_not_found() {
2397        let svc = StepFunctionsService::new(make_state());
2398        let body = json!({
2399            "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
2400        });
2401        let req = make_request("StartSyncExecution", &body.to_string());
2402        let err = expect_err(svc.start_sync_execution(&req).await);
2403        assert!(err.to_string().contains("StateMachineDoesNotExist"));
2404    }
2405
2406    #[tokio::test]
2407    async fn start_sync_execution_records_introspection_fields() {
2408        let svc = StepFunctionsService::new(make_state());
2409        let arn = create_express_sm(&svc, "sync-introspect");
2410
2411        let body = json!({"stateMachineArn": arn, "input": "{}"});
2412        let req = make_request("StartSyncExecution", &body.to_string());
2413        let resp = svc.start_sync_execution(&req).await.unwrap();
2414        let b = body_json(&resp);
2415        let exec_arn = b["executionArn"].as_str().unwrap().to_string();
2416
2417        let accounts = svc.state.read();
2418        let state = accounts.regional("123456789012", "us-east-1").unwrap();
2419        let stored = state
2420            .executions
2421            .get(&exec_arn)
2422            .expect("sync execution should be persisted for introspection");
2423        assert!(stored.is_sync, "sync executions must be marked is_sync");
2424        assert_eq!(stored.billed_memory_mb, Some(64));
2425        assert!(
2426            stored.billed_duration_ms.is_some(),
2427            "billed_duration_ms must be populated after sync run"
2428        );
2429        assert!(
2430            stored.parent_execution_arn.is_none(),
2431            "top-level sync execution has no parent"
2432        );
2433    }
2434
2435    #[tokio::test]
2436    async fn start_sync_execution_invalid_input() {
2437        let svc = StepFunctionsService::new(make_state());
2438        let arn = create_express_sm(&svc, "bad-input-sync");
2439
2440        let body = json!({
2441            "stateMachineArn": arn,
2442            "input": "not json",
2443        });
2444        let req = make_request("StartSyncExecution", &body.to_string());
2445        let err = expect_err(svc.start_sync_execution(&req).await);
2446        assert!(err.to_string().contains("InvalidExecutionInput"));
2447    }
2448
2449    /// No snapshot store (memory mode) -> no persist hook for the CFN provisioner.
2450    #[test]
2451    fn snapshot_hook_is_none_without_store() {
2452        let svc = StepFunctionsService::new(make_state());
2453        assert!(svc.snapshot_hook().is_none());
2454    }
2455
2456    /// With a store, the hook is present and invoking it runs the whole-state
2457    /// persist path the CloudFormation provisioner uses after mutating Step
2458    /// Functions state directly.
2459    #[tokio::test]
2460    async fn snapshot_hook_fires_with_store() {
2461        let store: Arc<dyn fakecloud_persistence::SnapshotStore> =
2462            Arc::new(fakecloud_persistence::MemorySnapshotStore::new());
2463        let svc = StepFunctionsService::new(make_state()).with_snapshot_store(store);
2464        let hook = svc
2465            .snapshot_hook()
2466            .expect("hook present when a store is set");
2467        hook().await;
2468    }
2469
2470    fn make_execution(arn: &str, status: ExecutionStatus) -> Execution {
2471        Execution {
2472            execution_arn: arn.to_string(),
2473            state_machine_arn: "arn:aws:states:us-east-1:123456789012:stateMachine:sm".to_string(),
2474            state_machine_name: "sm".to_string(),
2475            name: arn.to_string(),
2476            status,
2477            input: None,
2478            output: None,
2479            start_date: Utc::now(),
2480            stop_date: None,
2481            error: None,
2482            cause: None,
2483            history_events: vec![],
2484            parent_execution_arn: None,
2485            is_sync: false,
2486            billed_duration_ms: None,
2487            billed_memory_mb: None,
2488            role_arn: String::new(),
2489            redrive_count: 0,
2490        }
2491    }
2492
2493    #[test]
2494    fn reconcile_aborts_running_executions_on_restart() {
2495        // After a restart a RUNNING execution has no interpreter driving it, so
2496        // it must be aborted rather than left RUNNING forever (0.A2). A
2497        // completed execution is untouched.
2498        let state = make_state();
2499        {
2500            let mut accounts = state.write();
2501            let s = accounts.regional_mut("123456789012", "us-east-1");
2502            s.executions.insert(
2503                "running".into(),
2504                make_execution("running", ExecutionStatus::Running),
2505            );
2506            s.executions.insert(
2507                "done".into(),
2508                make_execution("done", ExecutionStatus::Succeeded),
2509            );
2510        }
2511
2512        let n = reconcile_interrupted_executions(&state);
2513        assert_eq!(n, 1, "only the RUNNING execution is reconciled");
2514
2515        let accounts = state.read();
2516        let s = accounts.regional("123456789012", "us-east-1").unwrap();
2517        let running = &s.executions["running"];
2518        assert_eq!(running.status, ExecutionStatus::Aborted);
2519        assert!(running.stop_date.is_some());
2520        assert_eq!(running.error.as_deref(), Some("Fakecloud.Restart"));
2521        assert_eq!(s.executions["done"].status, ExecutionStatus::Succeeded);
2522    }
2523
2524    // ── Distributed Map -> MapRun population (bug-hunt read-shape) ──
2525
2526    #[tokio::test]
2527    async fn distributed_map_populates_map_run() {
2528        let state = make_state();
2529        let svc = StepFunctionsService::new(state.clone());
2530        let execution_arn =
2531            "arn:aws:states:us-east-1:123456789012:execution:dist-sm:exec-1".to_string();
2532
2533        let def = json!({
2534            "StartAt": "M",
2535            "States": {
2536                "M": {
2537                    "Type": "Map",
2538                    "ItemsPath": "$.items",
2539                    "ItemProcessor": {
2540                        "ProcessorConfig": { "Mode": "DISTRIBUTED", "ExecutionType": "STANDARD" },
2541                        "StartAt": "Item",
2542                        "States": { "Item": { "Type": "Pass", "End": true } }
2543                    },
2544                    "End": true
2545                }
2546            }
2547        });
2548
2549        interpreter::execute_state_machine(
2550            state.clone(),
2551            execution_arn.clone(),
2552            def.to_string(),
2553            Some(r#"{"items":[1,2,3]}"#.to_string()),
2554            None,
2555            None,
2556            None,
2557            None,
2558        )
2559        .await;
2560
2561        // ListMapRuns(executionArn) surfaces the newly created MapRun.
2562        let req = make_request(
2563            "ListMapRuns",
2564            &json!({ "executionArn": execution_arn }).to_string(),
2565        );
2566        let listed = body_json(&svc.list_map_runs(&req).unwrap());
2567        let runs = listed["mapRuns"].as_array().unwrap();
2568        assert_eq!(
2569            runs.len(),
2570            1,
2571            "distributed map must create exactly one MapRun"
2572        );
2573        let map_run_arn = runs[0]["mapRunArn"].as_str().unwrap().to_string();
2574        assert!(map_run_arn.contains(":mapRun:"));
2575        assert_eq!(runs[0]["executionArn"], execution_arn);
2576
2577        // DescribeMapRun(mapRunArn) returns the record with real counts.
2578        let req = make_request(
2579            "DescribeMapRun",
2580            &json!({ "mapRunArn": map_run_arn }).to_string(),
2581        );
2582        let mr = body_json(&svc.describe_map_run(&req).unwrap());
2583        assert_eq!(mr["status"], "SUCCEEDED");
2584        assert_eq!(mr["itemCounts"]["total"], 3);
2585        assert_eq!(mr["itemCounts"]["succeeded"], 3);
2586        assert_eq!(mr["itemCounts"]["failed"], 0);
2587        assert_eq!(mr["executionCounts"]["succeeded"], 3);
2588    }
2589
2590    #[tokio::test]
2591    async fn inline_map_does_not_create_map_run() {
2592        let state = make_state();
2593        let svc = StepFunctionsService::new(state.clone());
2594        let execution_arn =
2595            "arn:aws:states:us-east-1:123456789012:execution:inline-sm:exec-1".to_string();
2596
2597        // No ProcessorConfig.Mode => inline Map: AWS creates no MapRun.
2598        let def = json!({
2599            "StartAt": "M",
2600            "States": {
2601                "M": {
2602                    "Type": "Map",
2603                    "ItemsPath": "$.items",
2604                    "ItemProcessor": {
2605                        "StartAt": "Item",
2606                        "States": { "Item": { "Type": "Pass", "End": true } }
2607                    },
2608                    "End": true
2609                }
2610            }
2611        });
2612
2613        interpreter::execute_state_machine(
2614            state.clone(),
2615            execution_arn.clone(),
2616            def.to_string(),
2617            Some(r#"{"items":[1,2]}"#.to_string()),
2618            None,
2619            None,
2620            None,
2621            None,
2622        )
2623        .await;
2624
2625        let req = make_request(
2626            "ListMapRuns",
2627            &json!({ "executionArn": execution_arn }).to_string(),
2628        );
2629        let listed = body_json(&svc.list_map_runs(&req).unwrap());
2630        assert!(listed["mapRuns"].as_array().unwrap().is_empty());
2631    }
2632}
2633
2634#[cfg(test)]
2635mod pagination_reject_test {
2636    #[test]
2637    fn paginate_checked_rejects_invalid_token() {
2638        use fakecloud_core::pagination::paginate_checked;
2639        let items: Vec<i32> = (0..5).collect();
2640        assert!(paginate_checked(&items, Some("bad"), 3).is_err());
2641        assert!(paginate_checked(&items, Some("2"), 3).is_ok());
2642    }
2643}