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 mut 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    // What follows depends on each state's effective query language.
575    crate::jsonata::propagate_query_language(&mut parsed, None);
576
577    if parsed.get("StartAt").and_then(|v| v.as_str()).is_none() {
578        return Err(AwsServiceError::aws_error(
579            StatusCode::BAD_REQUEST,
580            "InvalidDefinition",
581            "Invalid State Machine Definition: 'MISSING_START_AT' (StartAt field is required)"
582                .to_string(),
583        ));
584    }
585
586    let states_obj = parsed
587        .get("States")
588        .and_then(|v| v.as_object())
589        .ok_or_else(|| {
590            AwsServiceError::aws_error(
591                StatusCode::BAD_REQUEST,
592                "InvalidDefinition",
593                "Invalid State Machine Definition: 'MISSING_STATES' (States field is required)"
594                    .to_string(),
595            )
596        })?;
597
598    let start_at = parsed["StartAt"].as_str().ok_or_else(|| {
599        AwsServiceError::aws_error(
600            StatusCode::BAD_REQUEST,
601            "InvalidDefinition",
602            "Invalid State Machine Definition: 'MISSING_START_AT' (StartAt field is required)"
603                .to_string(),
604        )
605    })?;
606    if !states_obj.contains_key(start_at) {
607        return Err(AwsServiceError::aws_error(
608            StatusCode::BAD_REQUEST,
609            "InvalidDefinition",
610            format!(
611                "Invalid State Machine Definition: 'MISSING_TRANSITION_TARGET' \
612                 (StartAt '{start_at}' does not reference a valid state)"
613            ),
614        ));
615    }
616
617    validate_states_are_objects("", &parsed)?;
618    validate_state_schemas(&parsed)?;
619    validate_timeout_field("<root>", &parsed, "TimeoutSeconds")?;
620
621    // Reject malformed JSONPath reference fields. AWS rejects bad reference
622    // paths at CreateStateMachine; accepting them here lets a panic-inducing
623    // path reach the interpreter at execution time.
624    for (state_name, state_val) in states_obj {
625        validate_state_paths(state_name, state_val)?;
626    }
627    validate_integration_schemas("", states_obj)?;
628
629    Ok(())
630}
631
632/// Variables one state assigns, through its own, its Choice rules' and its
633/// catchers' `Assign`. A JSONPath key's `.$` isn't part of the name; a JSONata one's
634/// would be.
635fn assigned_variables(state: &Value) -> Vec<String> {
636    let mut holders = vec![state];
637    for list in ["Choices", "Catch"] {
638        holders.extend(state[list].as_array().into_iter().flatten());
639    }
640    holders
641        .into_iter()
642        .filter_map(|holder| holder["Assign"].as_object())
643        .flat_map(|assign| assign.keys())
644        .map(|key| {
645            let name = if crate::jsonata::is_jsonata(state) {
646                key.as_str()
647            } else {
648                key.strip_suffix(".$").unwrap_or(key)
649            };
650            name.to_string()
651        })
652        .collect()
653}
654
655/// What is wrong with a variable name: an identifier of at most 80 characters.
656/// Parts of a variable (`x.y`, `x[2]`) cannot be assigned.
657fn variable_name_problem(name: &str) -> Option<&'static str> {
658    if name.chars().count() > 80 {
659        return Some("is too long");
660    }
661    let mut chars = name.chars();
662    let valid = chars.next().is_some_and(|c| c.is_alphabetic() || c == '_')
663        && chars.all(|c| c.is_alphanumeric() || c == '_');
664    (!valid).then_some("contains invalid characters")
665}
666
667/// The fields of a state only one query language has.
668const JSONPATH_ONLY_FIELDS: &[&str] = &[
669    "InputPath",
670    "Parameters",
671    "ResultSelector",
672    "ResultPath",
673    "OutputPath",
674    "ItemsPath",
675    "MaxConcurrencyPath",
676    "ToleratedFailureCountPath",
677    "ToleratedFailurePercentagePath",
678    "SecondsPath",
679    "TimestampPath",
680    "ErrorPath",
681    "CausePath",
682    "TimeoutSecondsPath",
683    "HeartbeatSecondsPath",
684];
685const JSONATA_ONLY_FIELDS: &[&str] = &["Arguments", "Output", "Items"];
686
687/// The fields of a JSONata state, or of a catcher or Choice rule in one, that can
688/// hold `{% %}` strings.
689const JSONATA_TEMPLATE_FIELDS: &[&str] = &[
690    "Arguments",
691    "Output",
692    "Assign",
693    "Condition",
694    "Items",
695    "ItemSelector",
696    "ItemBatcher",
697    "ItemReader",
698    "ResultWriter",
699    "Seconds",
700    "Timestamp",
701    "Error",
702    "Cause",
703    "TimeoutSeconds",
704    "HeartbeatSeconds",
705    "MaxConcurrency",
706    "ToleratedFailureCount",
707    "ToleratedFailurePercentage",
708];
709
710const WAIT_DURATION_FIELDS: &[&str] = &["SecondsPath", "Seconds", "TimestampPath", "Timestamp"];
711
712fn schema_detail(detail: String) -> String {
713    format!("SCHEMA_VALIDATION_FAILED: {detail}")
714}
715
716/// Exactly one of `fields`, worded as recorded for none and for several. A field
717/// counts when it is present, even as null.
718fn check_exactly_one(state: &Value, fields: &[&str], path: &str, errors: &mut Vec<String>) {
719    let list = fields.join(", ");
720    match fields.iter().filter(|f| state.get(**f).is_some()).count() {
721        1 => {}
722        0 => errors.push(schema_detail(format!(
723            "This object should have only one of the following fields: [{list}] at {path}"
724        ))),
725        _ => errors.push(schema_detail(format!(
726            "There should only be one of the following fields: [{list}] at {path}"
727        ))),
728    }
729}
730
731/// Every string in a JSONata template that opens `{%` without closing it. Text
732/// merely containing delimiters is a literal.
733fn check_jsonata_delimiters(value: &Value, path: &str, errors: &mut Vec<String>) {
734    match value {
735        Value::String(text) if crate::jsonata::is_unclosed_expression(text) => {
736            errors.push(schema_detail(format!(
737                "JSONata string must start with {{% and end with %}} at {path}"
738            )));
739        }
740        Value::Object(members) => {
741            for (key, member) in members {
742                check_jsonata_delimiters(member, &format!("{path}/{key}"), errors);
743            }
744        }
745        Value::Array(items) => {
746            for (index, item) in items.iter().enumerate() {
747                check_jsonata_delimiters(item, &format!("{path}/{index}"), errors);
748            }
749        }
750        _ => {}
751    }
752}
753
754fn check_jsonata_holder(holder: &Value, path: &str, errors: &mut Vec<String>) {
755    for field in JSONATA_TEMPLATE_FIELDS {
756        if let Some(value) = holder.get(*field) {
757            check_jsonata_delimiters(value, &format!("{path}/{field}"), errors);
758        }
759    }
760}
761
762/// Everything a state's shape can get wrong independently of the rest, collected
763/// so a definition reports them together, as AWS does. `machine_jsonata` is whether
764/// the machine sets JSONata as a whole; every state then is JSONata. Under a
765/// JSONPath machine a state's own language holds, which by now (the definition's
766/// languages propagated) is what it declares or inherits from its enclosing
767/// state, and a JSONPath state inside a JSONata Parallel or Map is allowed, as
768/// recorded.
769fn collect_state_schema_errors(
770    at: &str,
771    states: &serde_json::Map<String, Value>,
772    machine_jsonata: bool,
773    errors: &mut Vec<String>,
774) {
775    for (name, state) in states {
776        let path = format!("{at}/States/{name}");
777        let declared = state["QueryLanguage"].as_str();
778        if machine_jsonata && declared == Some("JSONPath") {
779            errors.push(schema_detail(format!(
780                "'QueryLanguage' can not be 'JSONPath' if set to 'JSONata' for whole state \
781                 machine at {path}"
782            )));
783        }
784        let jsonata = machine_jsonata || declared == Some("JSONata");
785        let (language, other, wrong_fields) = if jsonata {
786            ("JSONata", "JSONPath", JSONPATH_ONLY_FIELDS)
787        } else {
788            ("JSONPath", "JSONata", JSONATA_ONLY_FIELDS)
789        };
790        for field in wrong_fields {
791            if state.get(*field).is_some() {
792                errors.push(schema_detail(format!(
793                    "The QueryLanguage is set to '{language}', but field '{field}' is only \
794                     supported for the '{other}' QueryLanguage at {path}"
795                )));
796            }
797        }
798
799        let kind = state["Type"].as_str().unwrap_or_default();
800        if !jsonata && kind == "Task" && state.get("Parameters").is_none() {
801            if let Some(resource) = state["Resource"]
802                .as_str()
803                .filter(|resource| resource.contains(":::aws-sdk:"))
804            {
805                errors.push(schema_detail(format!(
806                    "Parameters field is required for resource ARN: {resource} at \
807                     {path}/Resource"
808                )));
809            }
810        }
811        if !matches!(kind, "Task" | "Parallel" | "Map") {
812            for field in ["Retry", "Catch"] {
813                if state.get(field).is_some() {
814                    errors.push(schema_detail(format!(
815                        "Field '{field}' is not supported at {path}"
816                    )));
817                }
818            }
819        }
820        if matches!(kind, "Task" | "Pass" | "Wait" | "Parallel" | "Map") {
821            check_exactly_one(state, &["Next", "End"], &path, errors);
822        }
823        if kind == "Wait" {
824            check_exactly_one(state, WAIT_DURATION_FIELDS, &path, errors);
825        }
826        // A literal count is an integer in range (`2.0` is not one, as recorded);
827        // a JSONata expression's value is checked when it runs.
828        let integer_fields: &[(&str, Option<i64>)] = match kind {
829            "Wait" => &[("Seconds", Some(MAX_TIMEOUT_SECONDS as i64))],
830            "Map" => &[("ToleratedFailureCount", None)],
831            _ => &[],
832        };
833        for (field, max) in integer_fields {
834            let Some(value) = state.get(*field) else {
835                continue;
836            };
837            if jsonata && value.is_string() {
838                continue;
839            }
840            let problem = match value.as_i64() {
841                None if value.is_u64() => max.map(|max| format!("Maximum value is {max}")),
842                None => Some("Expected value of type Integer".to_string()),
843                Some(n) if n < 0 => Some("Minimum value is 0".to_string()),
844                Some(n) => max
845                    .filter(|max| n > *max)
846                    .map(|max| format!("Maximum value is {max}")),
847            };
848            if let Some(problem) = problem {
849                errors.push(schema_detail(format!("{problem} at {path}/{field}")));
850            }
851        }
852        if kind == "Fail" && !jsonata {
853            for field in ["ErrorPath", "CausePath"] {
854                let Some(value) = state.get(field) else {
855                    continue;
856                };
857                let valid = value.as_str().is_some_and(|text| {
858                    (text.starts_with('$') && is_reference_or_context_path(text))
859                        || crate::intrinsics::is_intrinsic_call(text)
860                });
861                if !valid {
862                    errors.push(schema_detail(format!(
863                        "The value must be a valid Reference Path or a valid intrinsic function \
864                         call at {path}/{field}"
865                    )));
866                }
867            }
868        }
869
870        if jsonata {
871            check_jsonata_holder(state, &path, errors);
872            for list in ["Catch", "Choices"] {
873                for (index, holder) in state[list].as_array().into_iter().flatten().enumerate() {
874                    check_jsonata_holder(holder, &format!("{path}/{list}/{index}"), errors);
875                }
876            }
877        }
878
879        for key in ["ItemProcessor", "Iterator"] {
880            if let Some(nested) = state[key]["States"].as_object() {
881                collect_state_schema_errors(
882                    &format!("{path}/{key}"),
883                    nested,
884                    machine_jsonata,
885                    errors,
886                );
887            }
888        }
889        for (index, branch) in state["Branches"]
890            .as_array()
891            .into_iter()
892            .flatten()
893            .enumerate()
894        {
895            if let Some(nested) = branch["States"].as_object() {
896                collect_state_schema_errors(
897                    &format!("{path}/Branches[{index}]"),
898                    nested,
899                    machine_jsonata,
900                    errors,
901                );
902            }
903        }
904    }
905}
906
907/// Variables are valid names, and a scope can't assign one that an outer scope
908/// assigns in another state: a Parallel or Map state's own `Assign` runs after its
909/// nested scopes are gone, so they can reuse its names, as recorded.
910fn collect_variable_errors(
911    at: &str,
912    states: &serde_json::Map<String, Value>,
913    outer: &[String],
914    errors: &mut Vec<String>,
915) {
916    let assigned_by: Vec<(&String, Vec<String>)> = states
917        .iter()
918        .map(|(name, state)| (name, assigned_variables(state)))
919        .collect();
920    for (name, state) in states {
921        let state_path = format!("{at}/States/{name}");
922        let mut holders = vec![(state_path.clone(), state)];
923        for list in ["Choices", "Catch"] {
924            for (index, holder) in state[list].as_array().into_iter().flatten().enumerate() {
925                holders.push((format!("{state_path}/{list}/{index}"), holder));
926            }
927        }
928        for (path, holder) in holders {
929            if holder
930                .get("Assign")
931                .is_some_and(|assign| !assign.is_object())
932            {
933                errors.push(schema_detail(format!(
934                    "Expected value of type [OBJECT] at {path}/Assign"
935                )));
936            }
937        }
938        for variable in assigned_variables(state) {
939            if variable == "states" {
940                errors.push(format!(
941                    "RESERVED_VARIABLE_NAME: Cannot assign to reserved variable '$states'. at \
942                     {state_path}"
943                ));
944            } else if let Some(problem) = variable_name_problem(&variable) {
945                errors.push(format!(
946                    "INVALID_VARIABLE_NAME: Invalid variable name '{variable}': the variable \
947                     name {problem}. at {state_path}/Assign/{variable}"
948                ));
949            }
950            if outer.contains(&variable) {
951                errors.push(schema_detail(format!(
952                    "The variable '{variable}' is already assigned in an outer scope at \
953                     {state_path}/Assign"
954                )));
955            }
956        }
957
958        let visible: Vec<String> = outer
959            .iter()
960            .cloned()
961            .chain(
962                assigned_by
963                    .iter()
964                    .filter(|(other, _)| *other != name)
965                    .flat_map(|(_, variables)| variables.iter().cloned()),
966            )
967            .collect();
968        for key in ["ItemProcessor", "Iterator"] {
969            if let Some(nested) = state[key]["States"].as_object() {
970                collect_variable_errors(&format!("{state_path}/{key}"), nested, &visible, errors);
971            }
972        }
973        for (index, branch) in state["Branches"]
974            .as_array()
975            .into_iter()
976            .flatten()
977            .enumerate()
978        {
979            if let Some(nested) = branch["States"].as_object() {
980                collect_variable_errors(
981                    &format!("{state_path}/Branches[{index}]"),
982                    nested,
983                    &visible,
984                    errors,
985                );
986            }
987        }
988    }
989}
990
991/// The recorded `SCHEMA_VALIDATION_FAILED`, `RESERVED_VARIABLE_NAME` and
992/// `INVALID_VARIABLE_NAME` problems of a definition, comma-joined.
993fn validate_state_schemas(def: &Value) -> Result<(), AwsServiceError> {
994    let Some(states) = def["States"].as_object() else {
995        return Ok(());
996    };
997    let mut errors = Vec::new();
998    collect_state_schema_errors("", states, def["QueryLanguage"] == "JSONata", &mut errors);
999    collect_variable_errors("", states, &[], &mut errors);
1000    if errors.is_empty() {
1001        return Ok(());
1002    }
1003    Err(AwsServiceError::aws_error(
1004        StatusCode::BAD_REQUEST,
1005        "InvalidDefinition",
1006        format!("Invalid State Machine Definition: '{}'", errors.join(", ")),
1007    ))
1008}
1009
1010/// The fields a `ReaderConfig` may hold.
1011const READER_CONFIG_FIELDS: &[&str] = &[
1012    "InputType",
1013    "Transformation",
1014    "ManifestType",
1015    "CSVDelimiter",
1016    "CSVHeaderLocation",
1017    "CSVHeaders",
1018    "ItemsPointer",
1019    "MaxItems",
1020    "MaxItemsPath",
1021];
1022
1023/// Every state is a JSON object, nested ones too, worded as recorded. A
1024/// definition reaching this far has a valid `StartAt`.
1025fn validate_states_are_objects(at: &str, def: &Value) -> Result<(), AwsServiceError> {
1026    let Some(states) = def["States"].as_object() else {
1027        return Ok(());
1028    };
1029    for (name, state) in states {
1030        let path = format!("{at}/States/{name}");
1031        if !state.is_object() {
1032            let mut detail = format!("Expected value of type [OBJECT] at {path}");
1033            let targeted = def["StartAt"] == name.as_str()
1034                || states.values().any(|other| {
1035                    other["Next"] == name.as_str()
1036                        || other["Default"] == name.as_str()
1037                        || ["Choices", "Catch"].iter().any(|list| {
1038                            other[*list].as_array().is_some_and(|items| {
1039                                items.iter().any(|i| i["Next"] == name.as_str())
1040                            })
1041                        })
1042                });
1043            if !targeted {
1044                detail.push_str(&format!(
1045                    ", MISSING_TRANSITION_TARGET: State \"{name}\" is not reachable. at {path}"
1046                ));
1047            }
1048            return Err(schema_error(detail));
1049        }
1050        for key in ["ItemProcessor", "Iterator"] {
1051            validate_states_are_objects(&format!("{path}/{key}"), &state[key])?;
1052        }
1053        for (index, branch) in state["Branches"]
1054            .as_array()
1055            .into_iter()
1056            .flatten()
1057            .enumerate()
1058        {
1059            validate_states_are_objects(&format!("{path}/Branches[{index}]"), branch)?;
1060        }
1061    }
1062    Ok(())
1063}
1064
1065fn schema_error(detail: String) -> AwsServiceError {
1066    AwsServiceError::aws_error(
1067        StatusCode::BAD_REQUEST,
1068        "InvalidDefinition",
1069        format!("Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: {detail}'"),
1070    )
1071}
1072
1073/// The integration tail of a `Resource`: `s3:getObject` for
1074/// `arn:aws:states:::s3:getObject`.
1075fn integration_of(resource: &str) -> Option<&str> {
1076    fakecloud_aws::arn::arn_resource(resource, "states").and_then(|r| r.strip_prefix("::"))
1077}
1078
1079/// The required fields of optimized integrations, which AWS checks when the
1080/// definition is created rather than when the Task runs. `at` is the JSON
1081/// pointer of the enclosing `States`' owner.
1082fn validate_integration_schemas(
1083    at: &str,
1084    states: &serde_json::Map<String, Value>,
1085) -> Result<(), AwsServiceError> {
1086    for (name, state) in states {
1087        let path = format!("{at}/States/{name}");
1088        match state["Type"].as_str() {
1089            Some("Task") => {
1090                let is_nested_start = state["Resource"]
1091                    .as_str()
1092                    .and_then(integration_of)
1093                    .is_some_and(|tail| tail.starts_with("states:startExecution"));
1094                let field = if crate::jsonata::is_jsonata(state) {
1095                    "Arguments"
1096                } else {
1097                    "Parameters"
1098                };
1099                // A whole-value expression can only be checked when it runs.
1100                let is_expression = state[field].is_string();
1101                let has_arn = ["StateMachineArn", "StateMachineArn.$"]
1102                    .iter()
1103                    .any(|key| state[field].get(*key).is_some());
1104                if is_nested_start && !is_expression && !has_arn {
1105                    return Err(schema_error(format!(
1106                        "The field 'StateMachineArn' is required but was missing at \
1107                         {path}/{field}"
1108                    )));
1109                }
1110            }
1111            Some("Map") => {
1112                let reader = &state["ItemReader"];
1113                if reader["Resource"].as_str().and_then(integration_of) == Some("s3:getObject")
1114                    && reader["ReaderConfig"].as_object().is_none()
1115                {
1116                    return Err(schema_error(format!(
1117                        "These fields are required: [ReaderConfig] at {path}/ItemReader"
1118                    )));
1119                }
1120                if let Some(config) = reader["ReaderConfig"].as_object() {
1121                    let at = format!("{path}/ItemReader/ReaderConfig");
1122                    // As recorded: `InputType: MANIFEST` already names an inventory.
1123                    if config.contains_key("ManifestType")
1124                        && config.get("InputType") == Some(&Value::from("MANIFEST"))
1125                    {
1126                        return Err(schema_error(format!(
1127                            "Field 'ManifestType' is not supported at {at}"
1128                        )));
1129                    }
1130                    if let Some(unknown) = config
1131                        .keys()
1132                        .find(|field| !READER_CONFIG_FIELDS.contains(&field.as_str()))
1133                    {
1134                        return Err(schema_error(format!(
1135                            "Field '{unknown}' is not supported at {at}"
1136                        )));
1137                    }
1138                }
1139                for key in ["ItemProcessor", "Iterator"] {
1140                    if let Some(nested) = state[key]["States"].as_object() {
1141                        validate_integration_schemas(&format!("{path}/{key}"), nested)?;
1142                    }
1143                }
1144            }
1145            Some("Parallel") => {
1146                for (index, branch) in state["Branches"]
1147                    .as_array()
1148                    .into_iter()
1149                    .flatten()
1150                    .enumerate()
1151                {
1152                    if let Some(nested) = branch["States"].as_object() {
1153                        validate_integration_schemas(&format!("{path}/Branches[{index}]"), nested)?;
1154                    }
1155                }
1156            }
1157            _ => {}
1158        }
1159    }
1160    Ok(())
1161}
1162
1163/// Validate the JSONPath reference fields of a single state. Recurses into the
1164/// `Choices` array of Choice states, the `Branches` of Parallel states, and the
1165/// `Iterator`/`ItemProcessor` of Map states. Returns an `InvalidDefinition`
1166/// error for any syntactically malformed path or payload-template key.
1167fn validate_state_paths(state_name: &str, state: &Value) -> Result<(), AwsServiceError> {
1168    validate_timeout_field(state_name, state, "TimeoutSeconds")?;
1169    validate_timeout_field(state_name, state, "HeartbeatSeconds")?;
1170    for field in ["InputPath", "OutputPath", "ResultPath"] {
1171        if let Some(p) = state.get(field).and_then(|v| v.as_str()) {
1172            // `InputPath`/`OutputPath` accept the literal "null" to mean "no
1173            // value"; `ResultPath` accepts JSON null (handled separately) but
1174            // not the string "null". Both accept "$"-rooted reference paths.
1175            let valid = if field == "ResultPath" {
1176                is_valid_reference_path(p)
1177            } else {
1178                p == "null" || is_valid_path(p)
1179            };
1180            if valid {
1181                continue;
1182            }
1183            return Err(invalid_reference_path(state_name, field, p));
1184        }
1185    }
1186
1187    // Reference-path fields that select a value from the input (or the context
1188    // object). Unlike Input/Output/ResultPath they do not accept the literal
1189    // "null", but they may reference the context object via `$$`.
1190    for field in [
1191        "SecondsPath",
1192        "TimestampPath",
1193        "ItemsPath",
1194        "MaxConcurrencyPath",
1195        "ToleratedFailureCountPath",
1196        "ToleratedFailurePercentagePath",
1197    ] {
1198        if let Some(p) = state.get(field).and_then(|v| v.as_str()) {
1199            if is_reference_or_context_path(p) {
1200                continue;
1201            }
1202            return Err(invalid_reference_path(state_name, field, p));
1203        }
1204    }
1205
1206    // Payload templates: every key ending in `.$` must carry a string value
1207    // that is either a JSONPath reference (`$`/`$$`) or an intrinsic call. AWS
1208    // rejects non-string and bare-literal `.$` values at CreateStateMachine.
1209    // In a JSONata state `.$` means nothing: such a key is a literal.
1210    if !crate::jsonata::is_jsonata(state) {
1211        for field in ["Parameters", "ResultSelector", "ItemSelector", "Assign"] {
1212            if let Some(template) = state.get(field) {
1213                validate_payload_template(state_name, field, template)?;
1214            }
1215        }
1216    }
1217    match state.get("Type").and_then(|v| v.as_str()) {
1218        Some("Choice") => {
1219            if let Some(choices) = state.get("Choices").and_then(|v| v.as_array()) {
1220                for rule in choices {
1221                    validate_choice_variables(state_name, rule)?;
1222                }
1223            }
1224        }
1225        Some("Parallel") => {
1226            if let Some(branches) = state.get("Branches").and_then(|v| v.as_array()) {
1227                for branch in branches {
1228                    validate_nested_definition(state_name, branch)?;
1229                }
1230            }
1231        }
1232        Some("Map") => {
1233            // Distributed Map uses `ItemProcessor`; legacy inline Map uses
1234            // `Iterator`. Both carry a nested `States` block.
1235            for key in ["ItemProcessor", "Iterator"] {
1236                if let Some(processor) = state.get(key) {
1237                    validate_nested_definition(state_name, processor)?;
1238                }
1239            }
1240        }
1241        _ => {}
1242    }
1243
1244    Ok(())
1245}
1246
1247/// Largest `TimeoutSeconds` / `HeartbeatSeconds` AWS accepts in an ASL definition.
1248const MAX_TIMEOUT_SECONDS: u64 = 99_999_999;
1249
1250/// AWS requires `TimeoutSeconds` / `HeartbeatSeconds` to be a positive integer
1251/// no larger than 99999999 and rejects anything else at CreateStateMachine /
1252/// UpdateStateMachine with `SCHEMA_VALIDATION_FAILED`.
1253fn validate_timeout_field(
1254    state_name: &str,
1255    state: &Value,
1256    field: &str,
1257) -> Result<(), AwsServiceError> {
1258    let Some(v) = state.get(field) else {
1259        return Ok(());
1260    };
1261    // In a JSONata state the value may be an expression, checked when it runs.
1262    if crate::jsonata::is_jsonata(state) && v.as_str().is_some_and(|text| text.starts_with("{%")) {
1263        return Ok(());
1264    }
1265    match v.as_u64() {
1266        Some(n) if (1..=MAX_TIMEOUT_SECONDS).contains(&n) => Ok(()),
1267        _ => Err(AwsServiceError::aws_error(
1268            StatusCode::BAD_REQUEST,
1269            "InvalidDefinition",
1270            format!(
1271                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED' \
1272                 (The value of field '{field}' of state '{state_name}' must be a positive \
1273                 integer between 1 and {MAX_TIMEOUT_SECONDS}, got {v})"
1274            ),
1275        )),
1276    }
1277}
1278
1279/// Validate every state inside a nested definition (a Parallel branch or a Map
1280/// processor). Errors are attributed to the enclosing state so the message
1281/// still points at a real state name.
1282fn validate_nested_definition(parent: &str, def: &Value) -> Result<(), AwsServiceError> {
1283    if let Some(states) = def.get("States").and_then(|v| v.as_object()) {
1284        for (name, state) in states {
1285            validate_state_paths(&format!("{parent}/{name}"), state)?;
1286        }
1287    }
1288    Ok(())
1289}
1290
1291/// Recursively validate a payload template (Parameters / ResultSelector /
1292/// ItemSelector). Any object key ending in `.$` must map to a string that is a
1293/// JSONPath reference or an intrinsic-function invocation.
1294fn validate_payload_template(
1295    state_name: &str,
1296    field: &str,
1297    template: &Value,
1298) -> Result<(), AwsServiceError> {
1299    match template {
1300        Value::Object(map) => {
1301            for (key, value) in map {
1302                if let Some(stripped) = key.strip_suffix(".$") {
1303                    let expr = value.as_str().ok_or_else(|| {
1304                        invalid_payload_template(
1305                            state_name,
1306                            field,
1307                            &format!(
1308                                "the '{key}' field must be a JSONPath or intrinsic string, \
1309                                 not {value}"
1310                            ),
1311                        )
1312                    })?;
1313                    let is_ref = expr.starts_with('$');
1314                    if !is_ref && !crate::intrinsics::is_intrinsic_call(expr) {
1315                        return Err(invalid_payload_template(
1316                            state_name,
1317                            field,
1318                            &format!(
1319                                "the '{stripped}.$' field value '{expr}' is neither a JSONPath \
1320                                 reference nor an intrinsic function"
1321                            ),
1322                        ));
1323                    }
1324                } else {
1325                    validate_payload_template(state_name, field, value)?;
1326                }
1327            }
1328        }
1329        Value::Array(arr) => {
1330            for v in arr {
1331                validate_payload_template(state_name, field, v)?;
1332            }
1333        }
1334        _ => {}
1335    }
1336    Ok(())
1337}
1338
1339/// A `*Path` field value: either a `$`-rooted reference path the interpreter can
1340/// parse, or a `$$`-rooted context-object reference.
1341fn is_reference_or_context_path(p: &str) -> bool {
1342    if let Some(rest) = p.strip_prefix("$$") {
1343        return is_valid_path(&format!("${rest}"));
1344    }
1345    is_valid_path(p)
1346}
1347
1348fn invalid_payload_template(state_name: &str, field: &str, detail: &str) -> AwsServiceError {
1349    AwsServiceError::aws_error(
1350        StatusCode::BAD_REQUEST,
1351        "InvalidDefinition",
1352        format!(
1353            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED' \
1354             (The {field} of state '{state_name}' is invalid: {detail})"
1355        ),
1356    )
1357}
1358
1359/// Validate `Variable` reference paths inside a Choice rule, recursing through
1360/// nested `And` / `Or` / `Not` boolean combinators.
1361fn validate_choice_variables(state_name: &str, rule: &Value) -> Result<(), AwsServiceError> {
1362    if let Some(v) = rule.get("Variable").and_then(|v| v.as_str()) {
1363        // A Choice Variable may read the context object (`$$.Execution.Name`).
1364        if !is_reference_or_context_path(v) {
1365            return Err(invalid_reference_path(state_name, "Variable", v));
1366        }
1367    }
1368    for combinator in ["And", "Or"] {
1369        if let Some(nested) = rule.get(combinator).and_then(|v| v.as_array()) {
1370            for n in nested {
1371                validate_choice_variables(state_name, n)?;
1372            }
1373        }
1374    }
1375    if let Some(n) = rule.get("Not") {
1376        validate_choice_variables(state_name, n)?;
1377    }
1378    Ok(())
1379}
1380
1381/// A reference path must start with `$` and every `[...]` index segment must be
1382/// a balanced, non-empty, decimal-integer index. This mirrors the subset of
1383/// JSONPath the interpreter understands; anything it cannot parse is rejected.
1384/// A Reference Path (ResultPath): a JSONPath that identifies a single node,
1385/// i.e. only property names and single array indexes.
1386fn is_valid_reference_path(path: &str) -> bool {
1387    crate::jsonpath::JsonPath::parse(path).is_ok_and(|p| p.is_definite())
1388}
1389
1390/// A Path (InputPath, OutputPath, ItemsPath, Choice `Variable`, ...): any
1391/// JSONPath the interpreter can evaluate, including wildcards, slices and
1392/// filters.
1393fn is_valid_path(path: &str) -> bool {
1394    // A variable reference is a path within that variable.
1395    let within = crate::variables::split_reference(path).map(|(_, within)| within);
1396    crate::jsonpath::JsonPath::parse(within.as_deref().unwrap_or(path)).is_ok()
1397}
1398
1399fn invalid_reference_path(state_name: &str, field: &str, path: &str) -> AwsServiceError {
1400    AwsServiceError::aws_error(
1401        StatusCode::BAD_REQUEST,
1402        "InvalidDefinition",
1403        format!(
1404            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED' \
1405             (The {field} field of state '{state_name}' is not a valid reference path: '{path}')"
1406        ),
1407    )
1408}
1409
1410fn execution_not_found(arn: &str) -> AwsServiceError {
1411    AwsServiceError::aws_error(
1412        StatusCode::BAD_REQUEST,
1413        "ExecutionDoesNotExist",
1414        format!("Execution Does Not Exist: '{arn}'"),
1415    )
1416}
1417
1418fn execution_to_json(exec: &Execution) -> Value {
1419    let mut resp = json!({
1420        "executionArn": exec.execution_arn,
1421        "stateMachineArn": exec.state_machine_arn,
1422        "name": exec.name,
1423        "status": exec.status.as_str(),
1424        "startDate": exec.start_date.timestamp_millis() as f64 / 1000.0,
1425        "redriveCount": exec.redrive_count,
1426    });
1427
1428    if let Some(ref input) = exec.input {
1429        resp["input"] = json!(input);
1430    }
1431    if let Some(ref output) = exec.output {
1432        resp["output"] = json!(output);
1433    }
1434    if let Some(stop) = exec.stop_date {
1435        resp["stopDate"] = json!(stop.timestamp_millis() as f64 / 1000.0);
1436    }
1437    if let Some(ref error) = exec.error {
1438        resp["error"] = json!(error);
1439    }
1440    if let Some(ref cause) = exec.cause {
1441        resp["cause"] = json!(cause);
1442    }
1443
1444    resp
1445}
1446
1447/// Convert an event type to the JSON key prefix used in the `HistoryEvent`
1448/// shape (the full key is `<prefix>EventDetails`).
1449///
1450/// AWS collapses every `*StateEntered` event type (Pass/Task/Choice/Wait/Map/
1451/// Parallel/Succeed/...) into a single `stateEnteredEventDetails` member, and
1452/// every `*StateExited` into `stateExitedEventDetails`. All other event types
1453/// map by lowercasing the first character (`TaskScheduled` ->
1454/// `taskScheduledEventDetails`). Without the collapse, SDK deserializers see an
1455/// unknown key like `passStateEnteredEventDetails` and return null name/input.
1456fn camel_to_details_key(event_type: &str) -> String {
1457    if event_type.ends_with("StateEntered") {
1458        return "stateEntered".to_string();
1459    }
1460    if event_type.ends_with("StateExited") {
1461        return "stateExited".to_string();
1462    }
1463    let mut chars = event_type.chars();
1464    match chars.next() {
1465        None => String::new(),
1466        Some(c) => c.to_lowercase().to_string() + chars.as_str(),
1467    }
1468}
1469
1470/// Compare two StartExecution inputs for STANDARD idempotency. A missing input
1471/// defaults to `{}` (AWS's default execution input). Comparison is structural
1472/// (parsed JSON), so insignificant whitespace differences still dedup; inputs
1473/// that fail to parse fall back to an exact string match.
1474fn execution_input_matches(stored: Option<&str>, incoming: Option<&str>) -> bool {
1475    let stored = stored.unwrap_or("{}");
1476    let incoming = incoming.unwrap_or("{}");
1477    match (
1478        serde_json::from_str::<Value>(stored),
1479        serde_json::from_str::<Value>(incoming),
1480    ) {
1481        (Ok(a), Ok(b)) => a == b,
1482        _ => stored == incoming,
1483    }
1484}
1485
1486fn validate_arn(arn: &str) -> Result<(), AwsServiceError> {
1487    if !arn.starts_with("arn:") {
1488        return Err(AwsServiceError::aws_error(
1489            StatusCode::BAD_REQUEST,
1490            "InvalidArn",
1491            format!("Invalid Arn: '{arn}'"),
1492        ));
1493    }
1494    Ok(())
1495}
1496
1497/// Enforce the Smithy @length on an ARN-typed field (`Arn` = 1..=256,
1498/// `LongArn` = 1..=2000). Empty / oversize ARNs map to `InvalidArn`, which
1499/// every ARN-bearing Step Functions operation declares.
1500fn validate_arn_length(field: &str, value: &str, max: usize) -> Result<(), AwsServiceError> {
1501    if value.is_empty() || value.len() > max {
1502        return Err(AwsServiceError::aws_error(
1503            StatusCode::BAD_REQUEST,
1504            "InvalidArn",
1505            format!("Invalid Arn at '{field}': must be 1..={max} characters"),
1506        ));
1507    }
1508    Ok(())
1509}
1510
1511/// Shared error for a malformed (unparseable) `nextToken` reaching the
1512/// pagination helper — `InvalidToken` is the only pagination error code
1513/// declared on every list op.
1514pub(super) fn invalid_token() -> AwsServiceError {
1515    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidToken", "Invalid nextToken")
1516}
1517
1518/// `nextToken` is declared as `PageToken` (length 1..=1024). The only
1519/// error code declared on every paginated list op is `InvalidToken`, so
1520/// route both empty and oversize tokens through it.
1521fn validate_page_token(value: &str) -> Result<(), AwsServiceError> {
1522    if value.is_empty() || value.len() > 1024 {
1523        return Err(AwsServiceError::aws_error(
1524            StatusCode::BAD_REQUEST,
1525            "InvalidToken",
1526            "nextToken must be 1..=1024 characters",
1527        ));
1528    }
1529    Ok(())
1530}
1531
1532/// `maxResults` is typed as `PageSize` (range 0..=1000). The negative
1533/// probes flip below-min / above-max; since the only error declared on
1534/// `ListActivities` is `InvalidToken`, we map page-size violations to
1535/// the same code (matches how AWS surfaces malformed pagination input
1536/// for ops that don't model a separate `ValidationException`).
1537fn validate_max_results(value: i64) -> Result<(), AwsServiceError> {
1538    if !(0..=1000).contains(&value) {
1539        return Err(AwsServiceError::aws_error(
1540            StatusCode::BAD_REQUEST,
1541            "InvalidToken",
1542            format!("maxResults '{value}' is outside 0..=1000"),
1543        ));
1544    }
1545    Ok(())
1546}
1547
1548/// Start a Step Functions execution from a cross-service delivery (e.g. EventBridge).
1549///
1550/// This is the public entry point used by `StepFunctionsDeliveryImpl` in the server crate.
1551/// It mirrors the logic from `StartExecution` but without the AWS request/response wrapper.
1552/// Start a Step Functions execution from a cross-service delivery (e.g. EventBridge).
1553///
1554/// This is the public entry point used by `StepFunctionsDeliveryImpl` in the server crate.
1555/// It mirrors the logic from `StartExecution` but without the AWS request/response wrapper.
1556pub fn start_execution_from_delivery(
1557    state: &SharedStepFunctionsState,
1558    delivery: &Option<Arc<DeliveryBus>>,
1559    dynamodb_state: &Option<SharedDynamoDbState>,
1560    registry: &Option<SharedServiceRegistry>,
1561    state_machine_arn: &str,
1562    input: &str,
1563) {
1564    // Validate input is valid JSON
1565    if serde_json::from_str::<serde_json::Value>(input).is_err() {
1566        tracing::warn!(
1567            state_machine_arn,
1568            "Step Functions delivery: invalid JSON input, skipping execution"
1569        );
1570        return;
1571    }
1572
1573    let execution_name = uuid::Uuid::new_v4().to_string();
1574
1575    let mut accounts = state.write();
1576    // The state machine lives in the account and region its ARN names.
1577    let Some(st) = accounts.by_arn_mut(state_machine_arn) else {
1578        tracing::warn!(
1579            state_machine_arn,
1580            "Step Functions delivery: state machine not found"
1581        );
1582        return;
1583    };
1584    let sm = match st.state_machines.get(state_machine_arn) {
1585        Some(sm) => sm,
1586        None => {
1587            tracing::warn!(
1588                state_machine_arn,
1589                "Step Functions delivery: state machine not found"
1590            );
1591            return;
1592        }
1593    };
1594
1595    let sm_name = sm.name.clone();
1596    let sm_role_arn = sm.role_arn.clone();
1597    let sm_type = sm.machine_type;
1598    let definition = sm.definition.clone();
1599    // No request in this delivery path; the execution inherits the region its
1600    // state machine ARN was minted in (the request region at CreateStateMachine).
1601    let region = state_machine_arn
1602        .split(':')
1603        .nth(3)
1604        .unwrap_or("us-east-1")
1605        .to_string();
1606    let exec_arn = st.new_execution_arn(&region, sm_type, &sm_name, &execution_name);
1607
1608    let now = Utc::now();
1609    let execution = Execution {
1610        execution_arn: exec_arn.clone(),
1611        state_machine_arn: state_machine_arn.to_string(),
1612        state_machine_name: sm_name,
1613        name: execution_name,
1614        status: ExecutionStatus::Running,
1615        input: Some(input.to_string()),
1616        output: None,
1617        start_date: now,
1618        stop_date: None,
1619        error: None,
1620        cause: None,
1621        history_events: vec![],
1622        parent_execution_arn: None,
1623        is_sync: false,
1624        billed_duration_ms: None,
1625        billed_memory_mb: None,
1626        role_arn: sm_role_arn,
1627        redrive_count: 0,
1628    };
1629
1630    st.executions.insert(exec_arn.clone(), execution);
1631    let logging_config = sm.logging_configuration.clone();
1632    drop(accounts);
1633
1634    let shared_state = state.clone();
1635    let delivery = delivery.clone();
1636    let dynamodb_state = dynamodb_state.clone();
1637    let registry = registry.clone();
1638    let input = Some(input.to_string());
1639    tokio::spawn(async move {
1640        interpreter::execute_state_machine(
1641            shared_state,
1642            exec_arn,
1643            definition,
1644            input,
1645            delivery,
1646            dynamodb_state,
1647            registry,
1648            logging_config,
1649        )
1650        .await;
1651    });
1652}
1653
1654#[cfg(test)]
1655mod integration_schema_tests {
1656    use super::*;
1657
1658    fn message(definition: serde_json::Value) -> Option<String> {
1659        validate_definition(&definition.to_string())
1660            .err()
1661            .map(|e| e.message())
1662    }
1663
1664    #[test]
1665    fn nested_start_requires_the_definition_casing() {
1666        assert_eq!(
1667            message(serde_json::json!({"StartAt":"T","States":{"T":{
1668                "Type":"Task",
1669                "Resource":"arn:aws:states:::states:startExecution.sync:2",
1670                "Parameters":{"stateMachineArn":"arn"},
1671                "End":true
1672            }}})),
1673            Some(
1674                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The field \
1675                 'StateMachineArn' is required but was missing at /States/T/Parameters'"
1676                    .to_string()
1677            )
1678        );
1679        for key in ["StateMachineArn", "StateMachineArn.$"] {
1680            let definition = serde_json::json!({"StartAt":"T","States":{"T":{
1681                "Type":"Task",
1682                "Resource":"arn:aws:states:::states:startExecution",
1683                "Parameters":{key:"$.arn"},
1684                "End":true
1685            }}});
1686            assert_eq!(message(definition), None);
1687        }
1688    }
1689
1690    #[test]
1691    fn get_object_item_reader_requires_reader_config() {
1692        let map = |reader: serde_json::Value| {
1693            serde_json::json!({"StartAt":"M","States":{"M":{
1694                "Type":"Map",
1695                "ItemProcessor":{"ProcessorConfig":{"Mode":"DISTRIBUTED"},"StartAt":"P",
1696                    "States":{"P":{"Type":"Pass","End":true}}},
1697                "ItemReader":reader,
1698                "End":true
1699            }}})
1700        };
1701        assert_eq!(
1702            message(map(serde_json::json!({
1703                "Resource":"arn:aws:states:::s3:getObject",
1704                "Parameters":{"Bucket":"b","Key":"k"}
1705            }))),
1706            Some(
1707                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: These fields are \
1708                 required: [ReaderConfig] at /States/M/ItemReader'"
1709                    .to_string()
1710            )
1711        );
1712        assert_eq!(
1713            message(map(serde_json::json!({
1714                "Resource":"arn:aws:states:::s3:listObjectsV2",
1715                "Parameters":{"Bucket":"b"}
1716            }))),
1717            None
1718        );
1719    }
1720
1721    #[test]
1722    fn reader_config_fields_are_checked() {
1723        let map = |config: serde_json::Value| {
1724            serde_json::json!({"StartAt":"M","States":{"M":{
1725                "Type":"Map",
1726                "ItemProcessor":{"ProcessorConfig":{"Mode":"DISTRIBUTED"},"StartAt":"P",
1727                    "States":{"P":{"Type":"Pass","End":true}}},
1728                "ItemReader":{
1729                    "Resource":"arn:aws:states:::s3:getObject",
1730                    "ReaderConfig":config,
1731                    "Parameters":{"Bucket":"b","Key":"k"}
1732                },
1733                "End":true
1734            }}})
1735        };
1736        assert_eq!(
1737            message(map(serde_json::json!({"InputType":"MANIFEST","ManifestType":"S3_INVENTORY"}))),
1738            Some(
1739                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Field 'ManifestType' \
1740                 is not supported at /States/M/ItemReader/ReaderConfig'"
1741                    .to_string()
1742            )
1743        );
1744        assert!(
1745            message(map(serde_json::json!({"InputType":"JSON","Bogus":1})))
1746                .unwrap()
1747                .contains("Field 'Bogus' is not supported")
1748        );
1749        assert_eq!(
1750            message(map(
1751                serde_json::json!({"InputType":"CSV","CSVHeaderLocation":"FIRST_ROW","MaxItems":1})
1752            )),
1753            None
1754        );
1755    }
1756
1757    #[test]
1758    fn variable_names_and_scopes_are_checked() {
1759        let with_assign = |assign: serde_json::Value| serde_json::json!({"StartAt":"P","States":{"P":{"Type":"Pass","Assign":assign,"End":true}}});
1760        for bad in ["x.y", "x[2]", "1x"] {
1761            let error = message(with_assign(serde_json::json!({ bad: 1 }))).unwrap();
1762            assert!(
1763                error.contains(&format!(
1764                    "INVALID_VARIABLE_NAME: Invalid variable name '{bad}': the variable name \
1765                     contains invalid characters. at /States/P/Assign/{bad}"
1766                )),
1767                "{error}"
1768            );
1769        }
1770        assert_eq!(
1771            message(with_assign(serde_json::json!({"states": 1}))),
1772            Some(
1773                "Invalid State Machine Definition: 'RESERVED_VARIABLE_NAME: Cannot assign to \
1774                 reserved variable '$states'. at /States/P'"
1775                    .to_string()
1776            )
1777        );
1778        assert_eq!(
1779            message(with_assign(serde_json::json!({"ok_1": 1, "p.$": "$.a"}))),
1780            None
1781        );
1782
1783        // A state's own `Assign` runs after its nested scopes are gone, as recorded.
1784        let own = serde_json::json!({"StartAt":"M","States":{"M":{
1785            "Type":"Map",
1786            "Assign":{"x":1},
1787            "ItemProcessor":{"StartAt":"P","States":{"P":{"Type":"Pass","Assign":{"x":2},"End":true}}},
1788            "End":true
1789        }}});
1790        assert_eq!(message(own), None);
1791        let sibling = serde_json::json!({"StartAt":"A","States":{
1792            "A":{"Type":"Pass","Assign":{"x":1},"Next":"M"},
1793            "M":{
1794                "Type":"Map",
1795                "ItemProcessor":{"StartAt":"P","States":{"P":{"Type":"Pass","Assign":{"x":2},"End":true}}},
1796                "End":true
1797            }
1798        }});
1799        assert!(message(sibling).unwrap().contains("outer scope"));
1800    }
1801
1802    #[test]
1803    fn several_problems_are_reported_together_and_nested_ones_found() {
1804        let def = serde_json::json!({"StartAt":"P","States":{"P":{
1805            "Type":"Parallel",
1806            "Branches":[{"StartAt":"W","States":{"W":{"Type":"Wait","Next":"X","End":true}}}],
1807            "End":true
1808        }}});
1809        let error = message(def).unwrap();
1810        assert!(
1811            error.contains(
1812                "There should only be one of the following fields: [Next, End] at \
1813                 /States/P/Branches[0]/States/W, SCHEMA_VALIDATION_FAILED: This object should have \
1814                 only one of the following fields: [SecondsPath, Seconds, TimestampPath, \
1815                 Timestamp] at /States/P/Branches[0]/States/W"
1816            ),
1817            "{error}"
1818        );
1819    }
1820
1821    #[test]
1822    fn jsonata_catchers_and_nested_templates_check_their_delimiters() {
1823        let def = serde_json::json!({"QueryLanguage":"JSONata","StartAt":"T","States":{
1824            "T":{"Type":"Task","Resource":"arn:aws:states:::aws-sdk:sfn:listStateMachines",
1825                 "Arguments":{"a":["{% 1"]},
1826                 "Catch":[{"ErrorEquals":["States.ALL"],"Output":"{% 2","Next":"E"}],
1827                 "End":true},
1828            "E":{"Type":"Succeed"}
1829        }});
1830        let error = message(def).unwrap();
1831        assert!(error.contains("at /States/T/Arguments/a/0"), "{error}");
1832        assert!(error.contains("at /States/T/Catch/0/Output"), "{error}");
1833    }
1834
1835    #[test]
1836    fn variable_references_are_valid_paths() {
1837        assert!(is_valid_path("$order.items[0]"));
1838        assert!(is_valid_path("$x"));
1839        assert!(!is_valid_path("$x["));
1840    }
1841
1842    #[test]
1843    fn a_non_object_state_is_refused_as_recorded() {
1844        let unreachable = serde_json::json!({"StartAt": "A", "States": {
1845            "A": {"Type": "Pass", "End": true},
1846            "B": 1
1847        }});
1848        assert_eq!(
1849            message(unreachable),
1850            Some(
1851                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Expected value of \
1852                 type [OBJECT] at /States/B, MISSING_TRANSITION_TARGET: State \"B\" is not \
1853                 reachable. at /States/B'"
1854                    .to_string()
1855            )
1856        );
1857        let start = serde_json::json!({"StartAt": "S", "States": {"S": 1}});
1858        assert_eq!(
1859            message(start),
1860            Some(
1861                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Expected value of \
1862                 type [OBJECT] at /States/S'"
1863                    .to_string()
1864            )
1865        );
1866        let nested = serde_json::json!({"StartAt": "M", "States": {"M": {
1867            "Type": "Map",
1868            "ItemProcessor": {"StartAt": "S", "States": {"S": "nope"}},
1869            "End": true
1870        }}});
1871        assert!(message(nested)
1872            .unwrap()
1873            .contains("at /States/M/ItemProcessor/States/S"));
1874    }
1875
1876    #[test]
1877    fn nested_start_without_a_template_is_refused() {
1878        let definition = |language: &str, field: Option<serde_json::Value>| {
1879            let mut state = serde_json::json!({
1880                "Type": "Task",
1881                "Resource": "arn:aws:states:::states:startExecution",
1882                "End": true
1883            });
1884            if let Some(value) = field {
1885                state["Arguments"] = value;
1886            }
1887            serde_json::json!({"QueryLanguage": language, "StartAt": "T", "States": {"T": state}})
1888        };
1889        assert!(message(definition("JSONPath", None))
1890            .unwrap()
1891            .contains("/States/T/Parameters"));
1892        assert!(message(definition("JSONata", None))
1893            .unwrap()
1894            .contains("/States/T/Arguments"));
1895        assert_eq!(
1896            message(definition(
1897                "JSONata",
1898                Some(serde_json::json!("{% $args %}"))
1899            )),
1900            None
1901        );
1902    }
1903
1904    #[test]
1905    fn timeout_expressions_need_a_jsonata_state() {
1906        let definition = |language: &str| {
1907            serde_json::json!({"QueryLanguage": language, "StartAt": "T", "States": {"T": {
1908                "Type": "Task",
1909                "Resource": "arn:aws:states:::sqs:sendMessage",
1910                "TimeoutSeconds": "{% 5 %}",
1911                "End": true
1912            }}})
1913        };
1914        assert_eq!(message(definition("JSONata")), None);
1915        assert!(message(definition("JSONPath")).is_some());
1916    }
1917
1918    #[test]
1919    fn assign_and_reader_config_must_be_objects() {
1920        let pass = serde_json::json!({"StartAt": "P", "States": {"P": {
1921            "Type": "Pass", "Assign": 5, "End": true
1922        }}});
1923        assert!(message(pass)
1924            .unwrap()
1925            .contains("Expected value of type [OBJECT] at /States/P/Assign"));
1926        let rule = serde_json::json!({"StartAt": "C", "States": {
1927            "C": {"Type": "Choice", "Choices": [
1928                {"Variable": "$.a", "IsPresent": true, "Assign": [], "Next": "E"}
1929            ]},
1930            "E": {"Type": "Succeed"}
1931        }});
1932        assert!(message(rule)
1933            .unwrap()
1934            .contains("/States/C/Choices/0/Assign"));
1935        let map = serde_json::json!({"StartAt": "M", "States": {"M": {
1936            "Type": "Map",
1937            "ItemProcessor": {"ProcessorConfig": {"Mode": "DISTRIBUTED"}, "StartAt": "P",
1938                "States": {"P": {"Type": "Pass", "End": true}}},
1939            "ItemReader": {
1940                "Resource": "arn:aws:states:::s3:getObject",
1941                "ReaderConfig": "JSON",
1942                "Parameters": {"Bucket": "b", "Key": "k"}
1943            },
1944            "End": true
1945        }}});
1946        assert!(message(map).unwrap().contains("[ReaderConfig]"));
1947    }
1948
1949    #[test]
1950    fn fail_state_paths_must_be_references_or_intrinsics() {
1951        let fail = |path: serde_json::Value| serde_json::json!({"StartAt": "F", "States": {"F": {"Type": "Fail", "ErrorPath": path}}});
1952        assert_eq!(message(fail(serde_json::json!("$.error"))), None);
1953        assert_eq!(
1954            message(fail(serde_json::json!("States.Format('{}', $.a)"))),
1955            None
1956        );
1957        // Recorded.
1958        assert_eq!(
1959            message(fail(serde_json::json!("notapath"))).unwrap(),
1960            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The value must be a \
1961             valid Reference Path or a valid intrinsic function call at /States/F/ErrorPath'"
1962        );
1963        assert!(message(fail(serde_json::json!(5))).is_some());
1964        let cause = serde_json::json!({"StartAt": "F", "States": {"F": {"Type": "Fail", "Error": "e", "CausePath": "notapath"}}});
1965        assert!(message(cause)
1966            .unwrap()
1967            .contains("intrinsic function call at /States/F/CausePath"));
1968    }
1969
1970    /// Recorded: a literal count must be an integer, `2.0` included.
1971    #[test]
1972    fn literal_counts_must_be_integers() {
1973        let map = |count: serde_json::Value| {
1974            serde_json::json!({"StartAt":"M","States":{"M":{
1975                "Type":"Map",
1976                "ToleratedFailureCount": count,
1977                "ItemProcessor":{"StartAt":"P","States":{"P":{"Type":"Pass","End":true}}},
1978                "End":true
1979            }}})
1980        };
1981        for bad in [serde_json::json!(2.0), serde_json::json!(2.5)] {
1982            assert_eq!(
1983                message(map(bad)).unwrap(),
1984                "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Expected value of \
1985                 type Integer at /States/M/ToleratedFailureCount'"
1986            );
1987        }
1988        assert_eq!(message(map(serde_json::json!(2))), None);
1989        assert!(message(map(serde_json::json!(-1)))
1990            .unwrap()
1991            .contains("Minimum value is 0 at /States/M/ToleratedFailureCount"));
1992        let literal = |seconds: i64| serde_json::json!({"StartAt":"W","States":{"W":{"Type":"Wait","Seconds":seconds,"End":true}}});
1993        assert_eq!(message(literal(99_999_999)), None);
1994        assert_eq!(
1995            message(literal(100_000_000)).unwrap(),
1996            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Maximum value is \
1997             99999999 at /States/W/Seconds'"
1998        );
1999        assert!(message(literal(-1))
2000            .unwrap()
2001            .contains("Minimum value is 0 at /States/W/Seconds"));
2002        let wait = serde_json::json!({"QueryLanguage":"JSONata","StartAt":"W","States":{
2003            "W":{"Type":"Wait","Seconds":1.5,"End":true}
2004        }});
2005        assert!(message(wait)
2006            .unwrap()
2007            .contains("Expected value of type Integer at /States/W/Seconds"));
2008        let expression = serde_json::json!({"QueryLanguage":"JSONata","StartAt":"W","States":{
2009            "W":{"Type":"Wait","Seconds":"{% 1 %}","End":true}
2010        }});
2011        assert_eq!(message(expression), None);
2012    }
2013
2014    /// Recorded: only a machine-wide JSONata refuses a JSONPath state; under a
2015    /// JSONata Parallel or Map in a JSONPath machine one is allowed.
2016    #[test]
2017    fn jsonpath_states_are_refused_only_under_a_jsonata_machine() {
2018        let parallel = |machine: Option<&str>, state: Option<&str>| {
2019            let mut def = serde_json::json!({"StartAt":"A","States":{"A":{
2020                "Type":"Parallel",
2021                "Branches":[{"StartAt":"X","States":{"X":{"Type":"Pass","QueryLanguage":"JSONPath","End":true}}}],
2022                "End":true
2023            }}});
2024            if let Some(language) = machine {
2025                def["QueryLanguage"] = serde_json::json!(language);
2026            }
2027            if let Some(language) = state {
2028                def["States"]["A"]["QueryLanguage"] = serde_json::json!(language);
2029            }
2030            def
2031        };
2032        assert_eq!(message(parallel(None, Some("JSONata"))), None);
2033        assert_eq!(
2034            message(parallel(Some("JSONata"), None)).unwrap(),
2035            "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: 'QueryLanguage' can \
2036             not be 'JSONPath' if set to 'JSONata' for whole state machine at \
2037             /States/A/Branches[0]/States/X'"
2038        );
2039        let map = serde_json::json!({"StartAt":"A","States":{"A":{
2040            "Type":"Map",
2041            "QueryLanguage":"JSONata",
2042            "Items":"{% [1] %}",
2043            "ItemProcessor":{"StartAt":"X","States":{"X":{"Type":"Pass","QueryLanguage":"JSONPath","End":true}}},
2044            "End":true
2045        }}});
2046        assert_eq!(message(map), None);
2047    }
2048
2049    #[test]
2050    fn nested_states_report_their_full_path() {
2051        let definition = serde_json::json!({"StartAt":"M","States":{"M":{
2052            "Type":"Map",
2053            "ItemProcessor":{"StartAt":"T","States":{"T":{
2054                "Type":"Task",
2055                "Resource":"arn:aws:states:::states:startExecution",
2056                "Parameters":{},
2057                "End":true
2058            }}},
2059            "End":true
2060        }}});
2061        assert!(message(definition)
2062            .unwrap()
2063            .contains("/States/M/ItemProcessor/States/T/Parameters"));
2064    }
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069    use super::*;
2070    use http::{HeaderMap, Method};
2071    use parking_lot::RwLock;
2072    use serde_json::Value;
2073    use std::collections::HashMap;
2074    use std::sync::Arc;
2075
2076    fn make_state() -> SharedStepFunctionsState {
2077        Arc::new(RwLock::new(
2078            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
2079        ))
2080    }
2081
2082    fn make_request(action: &str, body: &str) -> AwsRequest {
2083        AwsRequest {
2084            service: "states".to_string(),
2085            action: action.to_string(),
2086            region: "us-east-1".to_string(),
2087            account_id: "123456789012".to_string(),
2088            request_id: "test-id".to_string(),
2089            headers: HeaderMap::new(),
2090            query_params: HashMap::new(),
2091            body: body.as_bytes().to_vec().into(),
2092            body_stream: parking_lot::Mutex::new(None),
2093            path_segments: vec![],
2094            raw_path: "/".to_string(),
2095            raw_query: String::new(),
2096            method: Method::POST,
2097            is_query_protocol: false,
2098            access_key_id: None,
2099            principal: None,
2100        }
2101    }
2102
2103    fn body_json(resp: &AwsResponse) -> Value {
2104        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
2105    }
2106
2107    fn expect_err(result: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
2108        match result {
2109            Err(e) => e,
2110            Ok(_) => panic!("expected error, got Ok"),
2111        }
2112    }
2113
2114    const VALID_DEF: &str = r#"{"StartAt":"Pass","States":{"Pass":{"Type":"Pass","End":true}}}"#;
2115
2116    fn create_sm(svc: &StepFunctionsService, name: &str) -> String {
2117        let body = json!({
2118            "name": name,
2119            "definition": VALID_DEF,
2120            "roleArn": "arn:aws:iam::123456789012:role/test",
2121        });
2122        let req = make_request("CreateStateMachine", &body.to_string());
2123        let resp = svc.create_state_machine(&req).unwrap();
2124        let b = body_json(&resp);
2125        b["stateMachineArn"].as_str().unwrap().to_string()
2126    }
2127
2128    #[test]
2129    fn same_state_machine_name_coexists_in_two_regions() {
2130        let svc = StepFunctionsService::new(make_state());
2131        let in_region = |action: &str, body: Value, region: &str| {
2132            let mut req = make_request(action, &body.to_string());
2133            req.region = region.to_string();
2134            req
2135        };
2136        let create = |region: &str| {
2137            let body = json!({
2138                "name": "orders",
2139                "definition": VALID_DEF,
2140                "roleArn": "arn:aws:iam::123456789012:role/test",
2141            });
2142            let resp = svc
2143                .create_state_machine(&in_region("CreateStateMachine", body, region))
2144                .unwrap();
2145            body_json(&resp)["stateMachineArn"]
2146                .as_str()
2147                .unwrap()
2148                .to_string()
2149        };
2150        let east = create("us-east-1");
2151        let west = create("eu-west-1");
2152        assert_eq!(
2153            east,
2154            "arn:aws:states:us-east-1:123456789012:stateMachine:orders"
2155        );
2156        assert_eq!(
2157            west,
2158            "arn:aws:states:eu-west-1:123456789012:stateMachine:orders"
2159        );
2160
2161        // Each region lists only its own state machine.
2162        for (region, arn) in [("us-east-1", &east), ("eu-west-1", &west)] {
2163            let resp = svc
2164                .list_state_machines(&in_region("ListStateMachines", json!({}), region))
2165                .unwrap();
2166            let listed: Vec<String> = body_json(&resp)["stateMachines"]
2167                .as_array()
2168                .unwrap()
2169                .iter()
2170                .map(|m| m["stateMachineArn"].as_str().unwrap().to_string())
2171                .collect();
2172            assert_eq!(listed, vec![arn.clone()]);
2173        }
2174
2175        // A state machine ARN of another region is not found here.
2176        let err = svc
2177            .describe_state_machine(&in_region(
2178                "DescribeStateMachine",
2179                json!({ "stateMachineArn": west }),
2180                "us-east-1",
2181            ))
2182            .err()
2183            .unwrap();
2184        assert_eq!(err.code(), "StateMachineDoesNotExist");
2185
2186        // Deleting one region's state machine leaves the other's.
2187        svc.delete_state_machine(&in_region(
2188            "DeleteStateMachine",
2189            json!({ "stateMachineArn": west }),
2190            "eu-west-1",
2191        ))
2192        .unwrap();
2193        assert!(svc
2194            .describe_state_machine(&in_region(
2195                "DescribeStateMachine",
2196                json!({ "stateMachineArn": east }),
2197                "us-east-1",
2198            ))
2199            .is_ok());
2200    }
2201
2202    #[test]
2203    fn v2_snapshot_migrates_resources_into_their_arn_region() {
2204        let mut legacy: fakecloud_core::multi_account::MultiAccountState<StepFunctionsState> =
2205            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "");
2206        {
2207            let s = legacy.default_mut();
2208            s.activities.insert(
2209                "arn:aws:states:eu-west-1:123456789012:activity:work".into(),
2210                crate::state::Activity {
2211                    name: "work".into(),
2212                    arn: "arn:aws:states:eu-west-1:123456789012:activity:work".into(),
2213                    creation_date: Utc::now(),
2214                    tags: Default::default(),
2215                },
2216            );
2217        }
2218        let bytes = serde_json::to_vec(&json!({"schema_version": 2, "accounts": legacy})).unwrap();
2219        let snap = crate::state::parse_stepfunctions_snapshot(&bytes).unwrap();
2220        assert_eq!(snap.schema_version, STEPFUNCTIONS_SNAPSHOT_SCHEMA_VERSION);
2221        let accounts = snap.accounts.unwrap();
2222        let west = accounts.regional("123456789012", "eu-west-1").unwrap();
2223        assert_eq!(west.region, "eu-west-1");
2224        assert_eq!(west.activities.len(), 1);
2225        assert!(accounts.regional("123456789012", "us-east-1").is_none());
2226    }
2227
2228    #[test]
2229    fn china_region_arns_use_the_aws_cn_partition() {
2230        let svc = StepFunctionsService::new(make_state());
2231        let in_china = |action: &str, body: Value| {
2232            let mut req = make_request(action, &body.to_string());
2233            req.region = "cn-north-1".to_string();
2234            req
2235        };
2236        let resp = svc
2237            .create_state_machine(&in_china(
2238                "CreateStateMachine",
2239                json!({
2240                    "name": "cn-sm",
2241                    "definition": VALID_DEF,
2242                    "roleArn": "arn:aws-cn:iam::123456789012:role/test",
2243                }),
2244            ))
2245            .unwrap();
2246        let sm_arn = body_json(&resp)["stateMachineArn"]
2247            .as_str()
2248            .unwrap()
2249            .to_string();
2250        assert_eq!(
2251            sm_arn,
2252            "arn:aws-cn:states:cn-north-1:123456789012:stateMachine:cn-sm"
2253        );
2254        let resp = svc
2255            .describe_state_machine(&in_china(
2256                "DescribeStateMachine",
2257                json!({"stateMachineArn": sm_arn}),
2258            ))
2259            .unwrap();
2260        assert_eq!(body_json(&resp)["name"], "cn-sm");
2261
2262        let resp = svc
2263            .create_activity(&in_china("CreateActivity", json!({"name": "cn-act"})))
2264            .unwrap();
2265        let activity_arn = body_json(&resp)["activityArn"]
2266            .as_str()
2267            .unwrap()
2268            .to_string();
2269        assert_eq!(
2270            activity_arn,
2271            "arn:aws-cn:states:cn-north-1:123456789012:activity:cn-act"
2272        );
2273        let resp = svc
2274            .describe_activity(&in_china(
2275                "DescribeActivity",
2276                json!({"activityArn": activity_arn}),
2277            ))
2278            .unwrap();
2279        assert_eq!(body_json(&resp)["name"], "cn-act");
2280    }
2281
2282    // ── CreateStateMachine ──
2283
2284    #[test]
2285    fn create_state_machine_basic() {
2286        let svc = StepFunctionsService::new(make_state());
2287        let arn = create_sm(&svc, "test-sm");
2288        assert!(arn.contains("test-sm"));
2289    }
2290
2291    // bug-hunt 2026-07-19: CreateStateMachine with publish=true creates a real
2292    // version that ListStateMachineVersions returns; without publish, no version
2293    // ARN is returned and no version exists.
2294    #[test]
2295    fn create_state_machine_publish_creates_listable_version() {
2296        let svc = StepFunctionsService::new(make_state());
2297        let body = json!({
2298            "name": "pub-sm",
2299            "definition": VALID_DEF,
2300            "roleArn": "arn:aws:iam::123456789012:role/r",
2301            "publish": true,
2302            "versionDescription": "first",
2303        });
2304        let req = make_request("CreateStateMachine", &body.to_string());
2305        let resp = svc.create_state_machine(&req).unwrap();
2306        let b = body_json(&resp);
2307        let arn = b["stateMachineArn"].as_str().unwrap().to_string();
2308        let version_arn = b["stateMachineVersionArn"].as_str().unwrap().to_string();
2309        assert_eq!(version_arn, format!("{arn}:1"));
2310
2311        let list_req = make_request(
2312            "ListStateMachineVersions",
2313            &json!({ "stateMachineArn": arn }).to_string(),
2314        );
2315        let list = body_json(&svc.list_state_machine_versions(&list_req).unwrap());
2316        let versions = list["stateMachineVersions"].as_array().unwrap();
2317        assert_eq!(versions.len(), 1);
2318        assert_eq!(versions[0]["stateMachineVersionArn"], version_arn);
2319    }
2320
2321    #[test]
2322    fn create_state_machine_no_publish_has_no_version() {
2323        let svc = StepFunctionsService::new(make_state());
2324        let arn = create_sm(&svc, "nopub-sm");
2325        // No version ARN is returned when publish is absent.
2326        let body = json!({
2327            "name": "nopub-sm2",
2328            "definition": VALID_DEF,
2329            "roleArn": "arn:aws:iam::123456789012:role/r",
2330        });
2331        let req = make_request("CreateStateMachine", &body.to_string());
2332        let b = body_json(&svc.create_state_machine(&req).unwrap());
2333        assert!(b.get("stateMachineVersionArn").is_none());
2334
2335        let list_req = make_request(
2336            "ListStateMachineVersions",
2337            &json!({ "stateMachineArn": arn }).to_string(),
2338        );
2339        let list = body_json(&svc.list_state_machine_versions(&list_req).unwrap());
2340        assert!(list["stateMachineVersions"].as_array().unwrap().is_empty());
2341    }
2342
2343    // bug-hunt 2026-07-19: UpdateStateMachine with publish=true publishes the
2344    // next version, and encryptionConfiguration persists + round-trips on
2345    // Describe.
2346    #[test]
2347    fn update_state_machine_publish_and_encryption_round_trip() {
2348        let svc = StepFunctionsService::new(make_state());
2349        let create = json!({
2350            "name": "enc-sm",
2351            "definition": VALID_DEF,
2352            "roleArn": "arn:aws:iam::123456789012:role/r",
2353            "encryptionConfiguration": { "type": "CUSTOMER_MANAGED_KMS_KEY", "kmsKeyId": "key-123" },
2354        });
2355        let req = make_request("CreateStateMachine", &create.to_string());
2356        let arn = body_json(&svc.create_state_machine(&req).unwrap())["stateMachineArn"]
2357            .as_str()
2358            .unwrap()
2359            .to_string();
2360
2361        // encryptionConfiguration round-trips on Describe.
2362        let desc_req = make_request(
2363            "DescribeStateMachine",
2364            &json!({ "stateMachineArn": arn }).to_string(),
2365        );
2366        let desc = body_json(&svc.describe_state_machine(&desc_req).unwrap());
2367        assert_eq!(
2368            desc["encryptionConfiguration"]["type"],
2369            "CUSTOMER_MANAGED_KMS_KEY"
2370        );
2371        assert_eq!(desc["encryptionConfiguration"]["kmsKeyId"], "key-123");
2372
2373        // Update with publish creates version 1.
2374        let upd = json!({
2375            "stateMachineArn": arn,
2376            "definition": VALID_DEF,
2377            "publish": true,
2378            "versionDescription": "v via update",
2379        });
2380        let upd_req = make_request("UpdateStateMachine", &upd.to_string());
2381        let upd_body = body_json(&svc.update_state_machine(&upd_req).unwrap());
2382        assert_eq!(
2383            upd_body["stateMachineVersionArn"].as_str().unwrap(),
2384            format!("{arn}:1")
2385        );
2386
2387        let list_req = make_request(
2388            "ListStateMachineVersions",
2389            &json!({ "stateMachineArn": arn }).to_string(),
2390        );
2391        let list = body_json(&svc.list_state_machine_versions(&list_req).unwrap());
2392        assert_eq!(list["stateMachineVersions"].as_array().unwrap().len(), 1);
2393    }
2394
2395    #[test]
2396    fn create_state_machine_with_express_type() {
2397        let svc = StepFunctionsService::new(make_state());
2398        let body = json!({
2399            "name": "express-sm",
2400            "definition": VALID_DEF,
2401            "roleArn": "arn:aws:iam::123456789012:role/r",
2402            "type": "EXPRESS",
2403        });
2404        let req = make_request("CreateStateMachine", &body.to_string());
2405        let resp = svc.create_state_machine(&req).unwrap();
2406        let b = body_json(&resp);
2407        assert!(b["stateMachineArn"].as_str().is_some());
2408    }
2409
2410    #[test]
2411    fn create_state_machine_duplicate_fails() {
2412        let svc = StepFunctionsService::new(make_state());
2413        create_sm(&svc, "dup-sm");
2414        let body = json!({
2415            "name": "dup-sm",
2416            "definition": VALID_DEF,
2417            "roleArn": "arn:aws:iam::123456789012:role/r",
2418        });
2419        let req = make_request("CreateStateMachine", &body.to_string());
2420        let err = expect_err(svc.create_state_machine(&req));
2421        assert!(err.to_string().contains("StateMachineAlreadyExists"));
2422    }
2423
2424    #[test]
2425    fn create_state_machine_missing_name() {
2426        let svc = StepFunctionsService::new(make_state());
2427        let body = json!({
2428            "definition": VALID_DEF,
2429            "roleArn": "arn:aws:iam::123456789012:role/r",
2430        });
2431        let req = make_request("CreateStateMachine", &body.to_string());
2432        assert!(svc.create_state_machine(&req).is_err());
2433    }
2434
2435    #[test]
2436    fn create_state_machine_invalid_definition() {
2437        let svc = StepFunctionsService::new(make_state());
2438        let body = json!({
2439            "name": "bad-def",
2440            "definition": "not json",
2441            "roleArn": "arn:aws:iam::123456789012:role/r",
2442        });
2443        let req = make_request("CreateStateMachine", &body.to_string());
2444        let err = expect_err(svc.create_state_machine(&req));
2445        assert!(err.to_string().contains("InvalidDefinition"));
2446    }
2447
2448    #[test]
2449    fn create_state_machine_definition_missing_start_at() {
2450        let svc = StepFunctionsService::new(make_state());
2451        let body = json!({
2452            "name": "no-start",
2453            "definition": r#"{"States":{"S":{"Type":"Pass","End":true}}}"#,
2454            "roleArn": "arn:aws:iam::123456789012:role/r",
2455        });
2456        let req = make_request("CreateStateMachine", &body.to_string());
2457        let err = expect_err(svc.create_state_machine(&req));
2458        assert!(err.to_string().contains("InvalidDefinition"));
2459    }
2460
2461    #[test]
2462    fn create_state_machine_definition_missing_states() {
2463        let svc = StepFunctionsService::new(make_state());
2464        let body = json!({
2465            "name": "no-states",
2466            "definition": r#"{"StartAt":"S"}"#,
2467            "roleArn": "arn:aws:iam::123456789012:role/r",
2468        });
2469        let req = make_request("CreateStateMachine", &body.to_string());
2470        let err = expect_err(svc.create_state_machine(&req));
2471        assert!(err.to_string().contains("InvalidDefinition"));
2472    }
2473
2474    #[test]
2475    fn create_state_machine_definition_start_at_not_in_states() {
2476        let svc = StepFunctionsService::new(make_state());
2477        let body = json!({
2478            "name": "bad-start",
2479            "definition": r#"{"StartAt":"Missing","States":{"S":{"Type":"Pass","End":true}}}"#,
2480            "roleArn": "arn:aws:iam::123456789012:role/r",
2481        });
2482        let req = make_request("CreateStateMachine", &body.to_string());
2483        let err = expect_err(svc.create_state_machine(&req));
2484        assert!(err.to_string().contains("MISSING_TRANSITION_TARGET"));
2485    }
2486
2487    #[test]
2488    fn create_state_machine_invalid_type() {
2489        let svc = StepFunctionsService::new(make_state());
2490        let body = json!({
2491            "name": "bad-type",
2492            "definition": VALID_DEF,
2493            "roleArn": "arn:aws:iam::123456789012:role/r",
2494            "type": "INVALID",
2495        });
2496        let req = make_request("CreateStateMachine", &body.to_string());
2497        assert!(svc.create_state_machine(&req).is_err());
2498    }
2499
2500    #[test]
2501    fn create_state_machine_invalid_arn() {
2502        let svc = StepFunctionsService::new(make_state());
2503        let body = json!({
2504            "name": "bad-arn",
2505            "definition": VALID_DEF,
2506            "roleArn": "not-an-arn",
2507        });
2508        let req = make_request("CreateStateMachine", &body.to_string());
2509        let err = expect_err(svc.create_state_machine(&req));
2510        assert!(err.to_string().contains("InvalidArn"));
2511    }
2512
2513    #[test]
2514    fn create_state_machine_invalid_name() {
2515        let svc = StepFunctionsService::new(make_state());
2516        let body = json!({
2517            "name": "has spaces!",
2518            "definition": VALID_DEF,
2519            "roleArn": "arn:aws:iam::123456789012:role/r",
2520        });
2521        let req = make_request("CreateStateMachine", &body.to_string());
2522        let err = expect_err(svc.create_state_machine(&req));
2523        assert!(err.to_string().contains("InvalidName"));
2524    }
2525
2526    #[test]
2527    fn create_state_machine_name_too_long() {
2528        let svc = StepFunctionsService::new(make_state());
2529        let long_name = "a".repeat(81);
2530        let body = json!({
2531            "name": long_name,
2532            "definition": VALID_DEF,
2533            "roleArn": "arn:aws:iam::123456789012:role/r",
2534        });
2535        let req = make_request("CreateStateMachine", &body.to_string());
2536        let err = expect_err(svc.create_state_machine(&req));
2537        assert!(err.to_string().contains("InvalidName"));
2538    }
2539
2540    // ── DescribeStateMachine ──
2541
2542    #[test]
2543    fn describe_state_machine_found() {
2544        let svc = StepFunctionsService::new(make_state());
2545        let arn = create_sm(&svc, "desc-sm");
2546
2547        let req = make_request(
2548            "DescribeStateMachine",
2549            &json!({"stateMachineArn": arn}).to_string(),
2550        );
2551        let resp = svc.describe_state_machine(&req).unwrap();
2552        let b = body_json(&resp);
2553        assert_eq!(b["name"], "desc-sm");
2554        assert_eq!(b["status"], "ACTIVE");
2555        assert!(b["definition"].as_str().is_some());
2556    }
2557
2558    #[test]
2559    fn describe_state_machine_not_found() {
2560        let svc = StepFunctionsService::new(make_state());
2561        let req = make_request(
2562            "DescribeStateMachine",
2563            &json!({"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope"})
2564                .to_string(),
2565        );
2566        let err = expect_err(svc.describe_state_machine(&req));
2567        assert!(err.to_string().contains("StateMachineDoesNotExist"));
2568    }
2569
2570    // ── ListStateMachines ──
2571
2572    #[test]
2573    fn list_state_machines_empty() {
2574        let svc = StepFunctionsService::new(make_state());
2575        let req = make_request("ListStateMachines", "{}");
2576        let resp = svc.list_state_machines(&req).unwrap();
2577        let b = body_json(&resp);
2578        assert!(b["stateMachines"].as_array().unwrap().is_empty());
2579    }
2580
2581    #[test]
2582    fn list_state_machines_returns_created() {
2583        let svc = StepFunctionsService::new(make_state());
2584        create_sm(&svc, "sm-1");
2585        create_sm(&svc, "sm-2");
2586
2587        let req = make_request("ListStateMachines", "{}");
2588        let resp = svc.list_state_machines(&req).unwrap();
2589        let b = body_json(&resp);
2590        assert_eq!(b["stateMachines"].as_array().unwrap().len(), 2);
2591    }
2592
2593    // ── DeleteStateMachine ──
2594
2595    #[test]
2596    fn delete_state_machine() {
2597        let svc = StepFunctionsService::new(make_state());
2598        let arn = create_sm(&svc, "del-sm");
2599
2600        let req = make_request(
2601            "DeleteStateMachine",
2602            &json!({"stateMachineArn": arn}).to_string(),
2603        );
2604        svc.delete_state_machine(&req).unwrap();
2605
2606        // Describe should fail
2607        let req = make_request(
2608            "DescribeStateMachine",
2609            &json!({"stateMachineArn": arn}).to_string(),
2610        );
2611        assert!(svc.describe_state_machine(&req).is_err());
2612    }
2613
2614    #[test]
2615    fn delete_state_machine_nonexistent_succeeds() {
2616        let svc = StepFunctionsService::new(make_state());
2617        let req = make_request(
2618            "DeleteStateMachine",
2619            &json!({"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope"})
2620                .to_string(),
2621        );
2622        // AWS returns success even for nonexistent
2623        svc.delete_state_machine(&req).unwrap();
2624    }
2625
2626    // ── UpdateStateMachine ──
2627
2628    #[test]
2629    fn update_state_machine() {
2630        let svc = StepFunctionsService::new(make_state());
2631        let arn = create_sm(&svc, "upd-sm");
2632
2633        let new_def = r#"{"StartAt":"NewPass","States":{"NewPass":{"Type":"Pass","End":true}}}"#;
2634        let body = json!({
2635            "stateMachineArn": arn,
2636            "definition": new_def,
2637            "description": "updated",
2638        });
2639        let req = make_request("UpdateStateMachine", &body.to_string());
2640        let resp = svc.update_state_machine(&req).unwrap();
2641        let b = body_json(&resp);
2642        assert!(b["updateDate"].as_f64().is_some());
2643
2644        // Verify
2645        let req = make_request(
2646            "DescribeStateMachine",
2647            &json!({"stateMachineArn": arn}).to_string(),
2648        );
2649        let resp = svc.describe_state_machine(&req).unwrap();
2650        let b = body_json(&resp);
2651        assert!(b["definition"].as_str().unwrap().contains("NewPass"));
2652        assert_eq!(b["description"], "updated");
2653    }
2654
2655    #[test]
2656    fn update_state_machine_not_found() {
2657        let svc = StepFunctionsService::new(make_state());
2658        let body = json!({
2659            "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
2660            "definition": VALID_DEF,
2661        });
2662        let req = make_request("UpdateStateMachine", &body.to_string());
2663        let err = expect_err(svc.update_state_machine(&req));
2664        assert!(err.to_string().contains("StateMachineDoesNotExist"));
2665    }
2666
2667    // ── StartExecution ──
2668
2669    #[tokio::test]
2670    async fn start_execution_basic() {
2671        let svc = StepFunctionsService::new(make_state());
2672        let arn = create_sm(&svc, "exec-sm");
2673
2674        let body = json!({
2675            "stateMachineArn": arn,
2676            "input": r#"{"key":"value"}"#,
2677        });
2678        let req = make_request("StartExecution", &body.to_string());
2679        let resp = svc.start_execution(&req).unwrap();
2680        let b = body_json(&resp);
2681        assert!(b["executionArn"].as_str().is_some());
2682        assert!(b["startDate"].as_f64().is_some());
2683    }
2684
2685    #[tokio::test]
2686    async fn start_execution_with_name() {
2687        let svc = StepFunctionsService::new(make_state());
2688        let arn = create_sm(&svc, "named-exec");
2689
2690        let body = json!({
2691            "stateMachineArn": arn,
2692            "name": "my-execution",
2693        });
2694        let req = make_request("StartExecution", &body.to_string());
2695        let resp = svc.start_execution(&req).unwrap();
2696        let b = body_json(&resp);
2697        assert!(b["executionArn"].as_str().unwrap().contains("my-execution"));
2698    }
2699
2700    #[tokio::test]
2701    async fn start_execution_sm_not_found() {
2702        let svc = StepFunctionsService::new(make_state());
2703        let body = json!({
2704            "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
2705        });
2706        let req = make_request("StartExecution", &body.to_string());
2707        let err = expect_err(svc.start_execution(&req));
2708        assert!(err.to_string().contains("StateMachineDoesNotExist"));
2709    }
2710
2711    #[tokio::test]
2712    async fn start_execution_invalid_input() {
2713        let svc = StepFunctionsService::new(make_state());
2714        let arn = create_sm(&svc, "bad-input");
2715
2716        let body = json!({
2717            "stateMachineArn": arn,
2718            "input": "not json",
2719        });
2720        let req = make_request("StartExecution", &body.to_string());
2721        let err = expect_err(svc.start_execution(&req));
2722        assert!(err.to_string().contains("InvalidExecutionInput"));
2723    }
2724
2725    #[tokio::test]
2726    async fn start_execution_same_name_same_input_is_idempotent() {
2727        let svc = StepFunctionsService::new(make_state());
2728        let arn = create_sm(&svc, "dup-exec");
2729
2730        let body = json!({
2731            "stateMachineArn": arn,
2732            "name": "same-name",
2733            "input": "{\"a\":1}",
2734        });
2735        let req = make_request("StartExecution", &body.to_string());
2736        let first = body_json(&svc.start_execution(&req).unwrap());
2737
2738        // Same name AND same input -> 200 with the existing executionArn.
2739        let req = make_request("StartExecution", &body.to_string());
2740        let second = body_json(&svc.start_execution(&req).unwrap());
2741        assert_eq!(first["executionArn"], second["executionArn"]);
2742        assert_eq!(first["startDate"], second["startDate"]);
2743    }
2744
2745    #[tokio::test]
2746    async fn start_execution_same_name_same_input_conflicts_once_closed() {
2747        let svc = StepFunctionsService::new(make_state());
2748        let arn = create_sm(&svc, "dup-exec-closed");
2749        let body = json!({
2750            "stateMachineArn": arn,
2751            "name": "same-name",
2752            "input": "{\"a\":1}",
2753        });
2754        let req = make_request("StartExecution", &body.to_string());
2755        let first = body_json(&svc.start_execution(&req).unwrap());
2756        let exec_arn = first["executionArn"].as_str().unwrap().to_string();
2757        svc.state
2758            .write()
2759            .by_arn_mut(&exec_arn)
2760            .and_then(|s| s.executions.get_mut(&exec_arn))
2761            .unwrap()
2762            .status = ExecutionStatus::Succeeded;
2763
2764        // AWS: idempotent only while RUNNING; a closed execution conflicts.
2765        let req = make_request("StartExecution", &body.to_string());
2766        let err = expect_err(svc.start_execution(&req));
2767        assert!(err.to_string().contains("ExecutionAlreadyExists"));
2768        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
2769    }
2770
2771    fn parent_of(svc: &StepFunctionsService, exec_arn: &str) -> Option<String> {
2772        svc.state
2773            .read()
2774            .by_arn(exec_arn)
2775            .and_then(|s| s.executions.get(exec_arn))
2776            .and_then(|e| e.parent_execution_arn.clone())
2777    }
2778
2779    const PARENT_EXEC: &str = "arn:aws:states:us-east-1:123456789012:execution:parent:p";
2780
2781    #[tokio::test]
2782    async fn start_execution_records_the_execution_starting_it() {
2783        let svc = StepFunctionsService::new(make_state());
2784        let arn = create_sm(&svc, "child-sm");
2785        let req = make_request(
2786            "StartExecution",
2787            &json!({"stateMachineArn": arn}).to_string(),
2788        );
2789
2790        let started = crate::state::STARTED_BY_EXECUTION
2791            .scope(PARENT_EXEC.to_string(), async { svc.start_execution(&req) })
2792            .await;
2793        let child = body_json(&started.unwrap())["executionArn"]
2794            .as_str()
2795            .unwrap()
2796            .to_string();
2797        assert_eq!(parent_of(&svc, &child).as_deref(), Some(PARENT_EXEC));
2798
2799        // Started directly, an execution has no parent.
2800        let direct = body_json(&svc.start_execution(&req).unwrap())["executionArn"]
2801            .as_str()
2802            .unwrap()
2803            .to_string();
2804        assert_eq!(parent_of(&svc, &direct), None);
2805    }
2806
2807    #[tokio::test]
2808    async fn idempotent_start_execution_never_reparents_the_running_execution() {
2809        let svc = StepFunctionsService::new(make_state());
2810        let arn = create_sm(&svc, "idem-child-sm");
2811        let req = make_request(
2812            "StartExecution",
2813            &json!({"stateMachineArn": arn, "name": "fixed", "input": "{}"}).to_string(),
2814        );
2815        let first = body_json(&svc.start_execution(&req).unwrap())["executionArn"]
2816            .as_str()
2817            .unwrap()
2818            .to_string();
2819
2820        // A Task re-issuing the call gets the running execution back, but did
2821        // not start it.
2822        let again = crate::state::STARTED_BY_EXECUTION
2823            .scope(PARENT_EXEC.to_string(), async { svc.start_execution(&req) })
2824            .await;
2825        assert_eq!(body_json(&again.unwrap())["executionArn"], first.as_str());
2826        assert_eq!(parent_of(&svc, &first), None);
2827    }
2828
2829    #[tokio::test]
2830    async fn start_execution_same_name_different_input_conflicts() {
2831        let svc = StepFunctionsService::new(make_state());
2832        let arn = create_sm(&svc, "dup-exec-diff");
2833
2834        let req = make_request(
2835            "StartExecution",
2836            &json!({
2837                "stateMachineArn": arn,
2838                "name": "same-name",
2839                "input": "{\"a\":1}",
2840            })
2841            .to_string(),
2842        );
2843        svc.start_execution(&req).unwrap();
2844
2845        // Same name, DIFFERENT input -> 400 ExecutionAlreadyExists.
2846        let req = make_request(
2847            "StartExecution",
2848            &json!({
2849                "stateMachineArn": arn,
2850                "name": "same-name",
2851                "input": "{\"a\":2}",
2852            })
2853            .to_string(),
2854        );
2855        let err = expect_err(svc.start_execution(&req));
2856        assert!(err.to_string().contains("ExecutionAlreadyExists"));
2857    }
2858
2859    #[tokio::test]
2860    async fn start_execution_express_reuses_names_as_new_executions() {
2861        let svc = StepFunctionsService::new(make_state());
2862        let arn = create_express_sm(&svc, "dup-exec-express");
2863
2864        let body = json!({
2865            "stateMachineArn": arn,
2866            "name": "same-name",
2867            "input": "{\"a\":1}",
2868        });
2869        // AWS: EXPRESS names can be reused immediately and StartExecution is
2870        // never idempotent, so an identical re-issue starts a second execution.
2871        let req = make_request("StartExecution", &body.to_string());
2872        let first = body_json(&svc.start_execution(&req).unwrap());
2873        let req = make_request("StartExecution", &body.to_string());
2874        let second = body_json(&svc.start_execution(&req).unwrap());
2875
2876        let (a, b) = (
2877            first["executionArn"].as_str().unwrap(),
2878            second["executionArn"].as_str().unwrap(),
2879        );
2880        assert_ne!(a, b);
2881        for exec_arn in [a, b] {
2882            assert!(
2883                exec_arn.starts_with(
2884                    "arn:aws:states:us-east-1:123456789012:express:dup-exec-express:same-name:"
2885                ),
2886                "{exec_arn}"
2887            );
2888        }
2889        let accounts = svc.state.read();
2890        let state = accounts.by_arn(a).unwrap();
2891        assert_eq!(state.executions[a].name, "same-name");
2892        assert_eq!(state.executions[b].name, "same-name");
2893    }
2894
2895    #[tokio::test]
2896    async fn delivered_express_executions_get_express_arns() {
2897        let svc = StepFunctionsService::new(make_state());
2898        let arn = create_express_sm(&svc, "delivered-express");
2899        start_execution_from_delivery(&svc.state, &None, &None, &None, &arn, "{}");
2900        start_execution_from_delivery(&svc.state, &None, &None, &None, &arn, "{}");
2901
2902        let accounts = svc.state.read();
2903        let state = accounts.by_arn(&arn).unwrap();
2904        let arns: Vec<_> = state
2905            .executions
2906            .keys()
2907            .filter(|k| k.contains("delivered-express"))
2908            .collect();
2909        assert_eq!(arns.len(), 2);
2910        for exec_arn in arns {
2911            assert!(
2912                exec_arn.starts_with(
2913                    "arn:aws:states:us-east-1:123456789012:express:delivered-express:"
2914                ),
2915                "{exec_arn}"
2916            );
2917        }
2918    }
2919
2920    #[tokio::test]
2921    async fn start_sync_execution_rejects_a_malformed_arn() {
2922        let svc = StepFunctionsService::new(make_state());
2923        let req = make_request(
2924            "StartSyncExecution",
2925            &json!({"stateMachineArn": "foo"}).to_string(),
2926        );
2927        let err = expect_err(svc.start_sync_execution(&req).await);
2928        assert!(err.to_string().contains("InvalidArn"), "{err}");
2929    }
2930
2931    #[tokio::test]
2932    async fn start_sync_execution_uses_the_given_name() {
2933        let svc = StepFunctionsService::new(make_state());
2934        let arn = create_express_sm(&svc, "sync-named");
2935        let body = json!({"stateMachineArn": arn, "name": "my-run"});
2936
2937        let req = make_request("StartSyncExecution", &body.to_string());
2938        let first = body_json(&svc.start_sync_execution(&req).await.unwrap());
2939        let req = make_request("StartSyncExecution", &body.to_string());
2940        let second = body_json(&svc.start_sync_execution(&req).await.unwrap());
2941
2942        assert_eq!(first["name"], "my-run");
2943        assert_eq!(second["name"], "my-run");
2944        assert_ne!(first["executionArn"], second["executionArn"]);
2945        assert!(first["executionArn"]
2946            .as_str()
2947            .unwrap()
2948            .contains(":express:sync-named:my-run:"));
2949
2950        let bad = json!({"stateMachineArn": arn, "name": "has space"});
2951        let req = make_request("StartSyncExecution", &bad.to_string());
2952        assert!(svc.start_sync_execution(&req).await.is_err());
2953    }
2954
2955    // ── DescribeExecution ──
2956
2957    #[tokio::test]
2958    async fn describe_execution_found() {
2959        let svc = StepFunctionsService::new(make_state());
2960        let sm_arn = create_sm(&svc, "desc-exec");
2961
2962        let body = json!({"stateMachineArn": sm_arn, "name": "e1"});
2963        let req = make_request("StartExecution", &body.to_string());
2964        let resp = svc.start_execution(&req).unwrap();
2965        let exec_arn = body_json(&resp)["executionArn"]
2966            .as_str()
2967            .unwrap()
2968            .to_string();
2969
2970        let req = make_request(
2971            "DescribeExecution",
2972            &json!({"executionArn": exec_arn}).to_string(),
2973        );
2974        let resp = svc.describe_execution(&req).unwrap();
2975        let b = body_json(&resp);
2976        assert_eq!(b["name"], "e1");
2977        assert_eq!(b["status"], "RUNNING");
2978    }
2979
2980    #[tokio::test]
2981    async fn describe_execution_not_found() {
2982        let svc = StepFunctionsService::new(make_state());
2983        let req = make_request(
2984            "DescribeExecution",
2985            &json!({"executionArn": "arn:aws:states:us-east-1:123456789012:execution:sm:nope"})
2986                .to_string(),
2987        );
2988        let err = expect_err(svc.describe_execution(&req));
2989        assert!(err.to_string().contains("ExecutionDoesNotExist"));
2990    }
2991
2992    // ── StopExecution ──
2993
2994    #[tokio::test]
2995    async fn stop_execution() {
2996        let svc = StepFunctionsService::new(make_state());
2997        let sm_arn = create_sm(&svc, "stop-sm");
2998
2999        let body = json!({"stateMachineArn": sm_arn, "name": "stop-e"});
3000        let req = make_request("StartExecution", &body.to_string());
3001        let resp = svc.start_execution(&req).unwrap();
3002        let exec_arn = body_json(&resp)["executionArn"]
3003            .as_str()
3004            .unwrap()
3005            .to_string();
3006
3007        let body = json!({
3008            "executionArn": exec_arn,
3009            "error": "UserAborted",
3010            "cause": "test stop",
3011        });
3012        let req = make_request("StopExecution", &body.to_string());
3013        let resp = svc.stop_execution(&req).unwrap();
3014        let b = body_json(&resp);
3015        assert!(b["stopDate"].as_f64().is_some());
3016
3017        // Verify aborted
3018        let req = make_request(
3019            "DescribeExecution",
3020            &json!({"executionArn": exec_arn}).to_string(),
3021        );
3022        let resp = svc.describe_execution(&req).unwrap();
3023        let b = body_json(&resp);
3024        assert_eq!(b["status"], "ABORTED");
3025        assert_eq!(b["error"], "UserAborted");
3026    }
3027
3028    #[tokio::test]
3029    async fn redrive_execution_reruns_to_terminal() {
3030        let svc = StepFunctionsService::new(make_state());
3031        let sm_arn = create_sm(&svc, "redrive-sm");
3032
3033        let body = json!({"stateMachineArn": sm_arn, "name": "redrive-e"});
3034        let req = make_request("StartExecution", &body.to_string());
3035        let resp = svc.start_execution(&req).unwrap();
3036        let exec_arn = body_json(&resp)["executionArn"]
3037            .as_str()
3038            .unwrap()
3039            .to_string();
3040
3041        // Stop before the spawned interpreter runs -> terminal ABORTED.
3042        let req = make_request(
3043            "StopExecution",
3044            &json!({"executionArn": exec_arn, "error": "UserAborted", "cause": "stop"}).to_string(),
3045        );
3046        svc.stop_execution(&req).unwrap();
3047
3048        // Redrive: must reset to RUNNING and re-spawn the interpreter.
3049        let req = make_request(
3050            "RedriveExecution",
3051            &json!({"executionArn": exec_arn}).to_string(),
3052        );
3053        let resp = svc.redrive_execution(&req).unwrap();
3054        assert!(body_json(&resp)["redriveDate"].as_i64().is_some());
3055
3056        // The redriven Pass-state execution must reach a terminal state
3057        // (SUCCEEDED) instead of hanging RUNNING forever.
3058        let mut status = String::new();
3059        for _ in 0..100 {
3060            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3061            let req = make_request(
3062                "DescribeExecution",
3063                &json!({"executionArn": exec_arn}).to_string(),
3064            );
3065            let b = body_json(&svc.describe_execution(&req).unwrap());
3066            status = b["status"].as_str().unwrap().to_string();
3067            if status != "RUNNING" {
3068                break;
3069            }
3070        }
3071        assert_eq!(
3072            status, "SUCCEEDED",
3073            "redriven execution must run to completion"
3074        );
3075        let req = make_request(
3076            "DescribeExecution",
3077            &json!({"executionArn": exec_arn}).to_string(),
3078        );
3079        let b = body_json(&svc.describe_execution(&req).unwrap());
3080        assert_eq!(b["redriveCount"], 1);
3081    }
3082
3083    #[tokio::test]
3084    async fn redrive_execution_running_rejected() {
3085        let svc = StepFunctionsService::new(make_state());
3086        let sm_arn = create_sm(&svc, "redrive-running-sm");
3087        let body = json!({"stateMachineArn": sm_arn, "name": "still-running"});
3088        let req = make_request("StartExecution", &body.to_string());
3089        let exec_arn = body_json(&svc.start_execution(&req).unwrap())["executionArn"]
3090            .as_str()
3091            .unwrap()
3092            .to_string();
3093
3094        // A still-RUNNING execution is not redrivable (it already has a driver).
3095        let req = make_request(
3096            "RedriveExecution",
3097            &json!({"executionArn": exec_arn}).to_string(),
3098        );
3099        let err = expect_err(svc.redrive_execution(&req));
3100        assert!(err.to_string().contains("ExecutionNotRedrivable"));
3101    }
3102
3103    #[tokio::test]
3104    async fn stop_execution_not_found() {
3105        let svc = StepFunctionsService::new(make_state());
3106        let req = make_request(
3107            "StopExecution",
3108            &json!({"executionArn": "arn:aws:states:us-east-1:123456789012:execution:sm:nope"})
3109                .to_string(),
3110        );
3111        let err = expect_err(svc.stop_execution(&req));
3112        assert!(err.to_string().contains("ExecutionDoesNotExist"));
3113    }
3114
3115    // ── ListExecutions ──
3116
3117    #[tokio::test]
3118    async fn list_executions() {
3119        let svc = StepFunctionsService::new(make_state());
3120        let sm_arn = create_sm(&svc, "list-exec");
3121
3122        for i in 0..3 {
3123            let body = json!({"stateMachineArn": sm_arn, "name": format!("e{i}")});
3124            let req = make_request("StartExecution", &body.to_string());
3125            svc.start_execution(&req).unwrap();
3126        }
3127
3128        let req = make_request(
3129            "ListExecutions",
3130            &json!({"stateMachineArn": sm_arn}).to_string(),
3131        );
3132        let resp = svc.list_executions(&req).unwrap();
3133        let b = body_json(&resp);
3134        assert_eq!(b["executions"].as_array().unwrap().len(), 3);
3135    }
3136
3137    #[tokio::test]
3138    async fn list_executions_sm_not_found() {
3139        let svc = StepFunctionsService::new(make_state());
3140        let req = make_request(
3141            "ListExecutions",
3142            &json!({"stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope"})
3143                .to_string(),
3144        );
3145        let err = expect_err(svc.list_executions(&req));
3146        assert!(err.to_string().contains("StateMachineDoesNotExist"));
3147    }
3148
3149    // ── GetExecutionHistory ──
3150
3151    #[tokio::test]
3152    async fn get_execution_history_not_found() {
3153        let svc = StepFunctionsService::new(make_state());
3154        let req = make_request(
3155            "GetExecutionHistory",
3156            &json!({"executionArn": "arn:aws:states:us-east-1:123456789012:execution:sm:nope"})
3157                .to_string(),
3158        );
3159        let err = expect_err(svc.get_execution_history(&req));
3160        assert!(err.to_string().contains("ExecutionDoesNotExist"));
3161    }
3162
3163    // ── DescribeStateMachineForExecution ──
3164
3165    #[tokio::test]
3166    async fn describe_sm_for_execution() {
3167        let svc = StepFunctionsService::new(make_state());
3168        let sm_arn = create_sm(&svc, "sm-for-exec");
3169
3170        let body = json!({"stateMachineArn": sm_arn, "name": "e1"});
3171        let req = make_request("StartExecution", &body.to_string());
3172        let resp = svc.start_execution(&req).unwrap();
3173        let exec_arn = body_json(&resp)["executionArn"]
3174            .as_str()
3175            .unwrap()
3176            .to_string();
3177
3178        let req = make_request(
3179            "DescribeStateMachineForExecution",
3180            &json!({"executionArn": exec_arn}).to_string(),
3181        );
3182        let resp = svc.describe_state_machine_for_execution(&req).unwrap();
3183        let b = body_json(&resp);
3184        assert_eq!(b["name"], "sm-for-exec");
3185    }
3186
3187    // ── Tags ──
3188
3189    #[test]
3190    fn tag_untag_list_tags() {
3191        let svc = StepFunctionsService::new(make_state());
3192        let arn = create_sm(&svc, "tagged-sm");
3193
3194        // Tag
3195        let body = json!({
3196            "resourceArn": arn,
3197            "tags": [{"key": "env", "value": "prod"}],
3198        });
3199        let req = make_request("TagResource", &body.to_string());
3200        svc.tag_resource(&req).unwrap();
3201
3202        // List
3203        let req = make_request(
3204            "ListTagsForResource",
3205            &json!({"resourceArn": arn}).to_string(),
3206        );
3207        let resp = svc.list_tags_for_resource(&req).unwrap();
3208        let b = body_json(&resp);
3209        let tags = b["tags"].as_array().unwrap();
3210        assert_eq!(tags.len(), 1);
3211        assert_eq!(tags[0]["key"], "env");
3212
3213        // Untag
3214        let body = json!({
3215            "resourceArn": arn,
3216            "tagKeys": ["env"],
3217        });
3218        let req = make_request("UntagResource", &body.to_string());
3219        svc.untag_resource(&req).unwrap();
3220
3221        // Verify empty
3222        let req = make_request(
3223            "ListTagsForResource",
3224            &json!({"resourceArn": arn}).to_string(),
3225        );
3226        let resp = svc.list_tags_for_resource(&req).unwrap();
3227        let b = body_json(&resp);
3228        assert!(b["tags"].as_array().unwrap().is_empty());
3229    }
3230
3231    #[test]
3232    fn tag_resource_not_found() {
3233        let svc = StepFunctionsService::new(make_state());
3234        let body = json!({
3235            "resourceArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
3236            "tags": [{"key": "k", "value": "v"}],
3237        });
3238        let req = make_request("TagResource", &body.to_string());
3239        let err = expect_err(svc.tag_resource(&req));
3240        assert!(err.to_string().contains("ResourceNotFound"));
3241    }
3242
3243    // ── Helper function tests ──
3244
3245    #[test]
3246    fn test_validate_name() {
3247        assert!(validate_name("valid-name").is_ok());
3248        assert!(validate_name("under_score").is_ok());
3249        assert!(validate_name("").is_err());
3250        assert!(validate_name("has spaces").is_err());
3251        assert!(validate_name(&"a".repeat(81)).is_err());
3252    }
3253
3254    #[test]
3255    fn test_validate_definition() {
3256        assert!(validate_definition(VALID_DEF).is_ok());
3257        assert!(validate_definition("not json").is_err());
3258        assert!(validate_definition(r#"{"States":{}}"#).is_err()); // missing StartAt
3259        assert!(validate_definition(r#"{"StartAt":"S"}"#).is_err()); // missing States
3260    }
3261
3262    #[test]
3263    fn test_validate_definition_timeout_range() {
3264        let task = |field: &str, v: &str| {
3265            format!(
3266                r#"{{"StartAt":"T","States":{{"T":{{"Type":"Task","Resource":"arn:aws:states:::lambda:invoke","{field}":{v},"End":true}}}}}}"#
3267            )
3268        };
3269        for field in ["TimeoutSeconds", "HeartbeatSeconds"] {
3270            assert!(validate_definition(&task(field, "1")).is_ok());
3271            assert!(validate_definition(&task(field, "99999999")).is_ok());
3272            for bad in [
3273                "0",
3274                "-1",
3275                "100000000",
3276                "18446744073709551615",
3277                "1.5",
3278                "\"10\"",
3279            ] {
3280                let err = validate_definition(&task(field, bad)).unwrap_err();
3281                assert!(
3282                    err.to_string().contains("InvalidDefinition"),
3283                    "{field}={bad}: {err}"
3284                );
3285            }
3286        }
3287        // Top-level TimeoutSeconds.
3288        let def = r#"{"StartAt":"P","TimeoutSeconds":18446744073709551615,"States":{"P":{"Type":"Pass","End":true}}}"#;
3289        assert!(validate_definition(def).is_err());
3290        // Nested inside a Parallel branch.
3291        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}}}]}}}"#;
3292        assert!(validate_definition(def).is_err());
3293    }
3294
3295    #[test]
3296    fn test_validate_definition_rejects_malformed_paths() {
3297        // Unterminated bracket in InputPath.
3298        let def =
3299            r#"{"StartAt":"P","States":{"P":{"Type":"Pass","InputPath":"$.arr[","End":true}}}"#;
3300        assert!(validate_definition(def).is_err());
3301
3302        // Multibyte char where the close bracket would be, in OutputPath.
3303        let def =
3304            "{\"StartAt\":\"P\",\"States\":{\"P\":{\"Type\":\"Pass\",\"OutputPath\":\"$.x[\u{00e9}\",\"End\":true}}}";
3305        assert!(validate_definition(def).is_err());
3306
3307        // Malformed Choice Variable.
3308        let def = r#"{"StartAt":"C","States":{"C":{"Type":"Choice","Choices":[{"Variable":"$.n[","NumericEquals":1,"Next":"P"}]},"P":{"Type":"Pass","End":true}}}"#;
3309        assert!(validate_definition(def).is_err());
3310
3311        // Path not rooted at $.
3312        let def =
3313            r#"{"StartAt":"P","States":{"P":{"Type":"Pass","InputPath":"foo.bar","End":true}}}"#;
3314        assert!(validate_definition(def).is_err());
3315
3316        // Empty index brackets.
3317        let def =
3318            r#"{"StartAt":"P","States":{"P":{"Type":"Pass","ResultPath":"$.x[]","End":true}}}"#;
3319        assert!(validate_definition(def).is_err());
3320    }
3321
3322    #[test]
3323    fn test_validate_definition_accepts_well_formed_paths() {
3324        // Valid reference paths, the literal "null" for Input/OutputPath, and
3325        // nested Choice combinators must all be accepted.
3326        let def = r#"{"StartAt":"P","States":{
3327            "P":{"Type":"Pass","InputPath":"$.a.b[0].c","OutputPath":"$","ResultPath":"$.out","Next":"C"},
3328            "C":{"Type":"Choice","Choices":[
3329                {"And":[{"Variable":"$.items[2]","NumericEquals":1},{"Variable":"$.flag","BooleanEquals":true}],"Next":"S"}
3330            ],"Default":"S"},
3331            "S":{"Type":"Succeed","InputPath":"null"}
3332        }}"#;
3333        assert!(validate_definition(def).is_ok());
3334    }
3335
3336    // M5: malformed paths nested inside Parallel branches and Map processors
3337    // must be rejected.
3338    #[test]
3339    fn test_validate_definition_recurses_into_parallel_and_map() {
3340        let par = r#"{"StartAt":"Par","States":{"Par":{"Type":"Parallel","End":true,
3341            "Branches":[{"StartAt":"I","States":{"I":{"Type":"Pass","InputPath":"nope","End":true}}}]}}}"#;
3342        assert!(validate_definition(par).is_err());
3343
3344        let map = r#"{"StartAt":"M","States":{"M":{"Type":"Map","End":true,
3345            "ItemProcessor":{"StartAt":"E","States":{"E":{"Type":"Pass","OutputPath":"nope","End":true}}}}}}"#;
3346        assert!(validate_definition(map).is_err());
3347
3348        // Legacy inline Map uses `Iterator`.
3349        let iter = r#"{"StartAt":"M","States":{"M":{"Type":"Map","End":true,
3350            "Iterator":{"StartAt":"E","States":{"E":{"Type":"Pass","ResultPath":"$.x[]","End":true}}}}}}"#;
3351        assert!(validate_definition(iter).is_err());
3352
3353        // A well-formed Parallel/Map still validates.
3354        let ok = r#"{"StartAt":"M","States":{"M":{"Type":"Map","ItemsPath":"$.items","End":true,
3355            "ItemProcessor":{"StartAt":"E","States":{"E":{"Type":"Pass","InputPath":"$.a","End":true}}}}}}"#;
3356        assert!(validate_definition(ok).is_ok());
3357    }
3358
3359    // L7: `.$` payload-template keys must carry a JSONPath/intrinsic string.
3360    #[test]
3361    fn test_validate_definition_payload_template_dollar_keys() {
3362        // Bare literal value.
3363        let lit = r#"{"StartAt":"P","States":{"P":{"Type":"Pass",
3364            "Parameters":{"v.$":"just text"},"End":true}}}"#;
3365        assert!(validate_definition(lit).is_err());
3366
3367        // Non-string value.
3368        let num = r#"{"StartAt":"P","States":{"P":{"Type":"Pass",
3369            "Parameters":{"v.$":42},"End":true}}}"#;
3370        assert!(validate_definition(num).is_err());
3371
3372        // Valid: JSONPath reference, context-object ref, and intrinsic.
3373        let ok = r#"{"StartAt":"P","States":{"P":{"Type":"Pass","Parameters":{
3374            "a.$":"$.input","b.$":"$$.Execution.Id","c.$":"States.Format('{}',$.n)",
3375            "nested":{"d.$":"$.x"},"plain":"literal-ok"},"End":true}}}"#;
3376        assert!(validate_definition(ok).is_ok());
3377
3378        // A bad `.$` nested inside an array under a plain key is also caught.
3379        let arr = r#"{"StartAt":"P","States":{"P":{"Type":"Pass",
3380            "ResultSelector":{"items":[{"bad.$":"literal"}]},"End":true}}}"#;
3381        assert!(validate_definition(arr).is_err());
3382    }
3383
3384    #[test]
3385    fn test_paths_accept_full_jsonpath_but_result_path_must_be_definite() {
3386        let def = |field: &str, path: &str| {
3387            json!({
3388                "StartAt": "P",
3389                "States": {"P": {"Type": "Pass", field: path, "End": true}}
3390            })
3391            .to_string()
3392        };
3393        for p in [
3394            "$.items[*].id",
3395            "$.a[?(@.x == 1)]",
3396            "$['a']",
3397            "$.a[-1]",
3398            "$..id",
3399            "$.a[1:2]",
3400        ] {
3401            assert!(validate_definition(&def("InputPath", p)).is_ok(), "{p}");
3402            assert!(validate_definition(&def("OutputPath", p)).is_ok(), "{p}");
3403        }
3404        assert!(validate_definition(&def("ResultPath", "$['a'][0]")).is_ok());
3405        let choice = |var: &str| {
3406            json!({
3407                "StartAt": "C",
3408                "States": {
3409                    "C": {"Type": "Choice", "Choices": [{"Variable": var, "IsPresent": true, "Next": "D"}], "Default": "D"},
3410                    "D": {"Type": "Succeed"}
3411                }
3412            })
3413            .to_string()
3414        };
3415        assert!(validate_definition(&choice("$$.Execution.Name")).is_ok());
3416        assert!(validate_definition(&choice("$$.x[")).is_err());
3417        for p in ["$.items[*]", "$..a", "$.a[?(@.x)]"] {
3418            assert!(validate_definition(&def("ResultPath", p)).is_err(), "{p}");
3419        }
3420    }
3421
3422    #[test]
3423    fn test_is_valid_reference_path() {
3424        assert!(is_valid_reference_path("$"));
3425        assert!(is_valid_reference_path("$.foo"));
3426        assert!(is_valid_reference_path("$.foo.bar[3].baz"));
3427        assert!(is_valid_reference_path("$[0]"));
3428        assert!(!is_valid_reference_path("$.arr["));
3429        assert!(!is_valid_reference_path("$.x[\u{00e9}"));
3430        assert!(!is_valid_reference_path("$.x[]"));
3431        assert!(!is_valid_reference_path("$.x[abc]"));
3432        assert!(!is_valid_reference_path("foo.bar"));
3433        assert!(!is_valid_reference_path(""));
3434    }
3435
3436    #[test]
3437    fn test_validate_arn() {
3438        assert!(validate_arn("arn:aws:states:us-east-1:123:sm:test").is_ok());
3439        assert!(validate_arn("not-an-arn").is_err());
3440    }
3441
3442    #[test]
3443    fn test_camel_to_details_key() {
3444        // All *StateEntered / *StateExited types collapse to one key each.
3445        assert_eq!(camel_to_details_key("PassStateEntered"), "stateEntered");
3446        assert_eq!(camel_to_details_key("TaskStateEntered"), "stateEntered");
3447        assert_eq!(camel_to_details_key("ChoiceStateExited"), "stateExited");
3448        assert_eq!(camel_to_details_key("MapStateExited"), "stateExited");
3449        // Other event types keep first-char-lowercased prefix.
3450        assert_eq!(camel_to_details_key("TaskScheduled"), "taskScheduled");
3451        assert_eq!(camel_to_details_key("ExecutionStarted"), "executionStarted");
3452        assert_eq!(camel_to_details_key(""), "");
3453    }
3454
3455    #[test]
3456    fn test_is_mutating_action() {
3457        assert!(is_mutating_action("CreateStateMachine"));
3458        assert!(is_mutating_action("StartExecution"));
3459        assert!(!is_mutating_action("DescribeStateMachine"));
3460        assert!(!is_mutating_action("ListStateMachines"));
3461    }
3462
3463    // ── StartSyncExecution ──
3464
3465    fn create_express_sm(svc: &StepFunctionsService, name: &str) -> String {
3466        let body = json!({
3467            "name": name,
3468            "definition": VALID_DEF,
3469            "roleArn": "arn:aws:iam::123456789012:role/test",
3470            "type": "EXPRESS",
3471        });
3472        let req = make_request("CreateStateMachine", &body.to_string());
3473        let resp = svc.create_state_machine(&req).unwrap();
3474        let b = body_json(&resp);
3475        b["stateMachineArn"].as_str().unwrap().to_string()
3476    }
3477
3478    #[tokio::test]
3479    async fn start_sync_execution_basic() {
3480        let svc = StepFunctionsService::new(make_state());
3481        let arn = create_express_sm(&svc, "sync-sm");
3482
3483        let body = json!({
3484            "stateMachineArn": arn,
3485            "input": r#"{"key":"value"}"#,
3486        });
3487        let req = make_request("StartSyncExecution", &body.to_string());
3488        let resp = svc.start_sync_execution(&req).await.unwrap();
3489        let b = body_json(&resp);
3490        assert!(b["executionArn"]
3491            .as_str()
3492            .unwrap()
3493            .contains("express:sync-sm"));
3494        assert_eq!(b["stateMachineArn"], arn);
3495        assert_eq!(b["status"], "SUCCEEDED");
3496        assert!(b["startDate"].as_f64().is_some());
3497        assert!(b["stopDate"].as_f64().is_some());
3498        assert!(b["output"].as_str().is_some());
3499        assert!(b["billingDetails"]["billedDurationInMilliseconds"]
3500            .as_i64()
3501            .is_some());
3502    }
3503
3504    #[tokio::test]
3505    async fn start_sync_execution_not_express() {
3506        let svc = StepFunctionsService::new(make_state());
3507        let arn = create_sm(&svc, "std-sm");
3508
3509        let body = json!({"stateMachineArn": arn});
3510        let req = make_request("StartSyncExecution", &body.to_string());
3511        let err = expect_err(svc.start_sync_execution(&req).await);
3512        assert!(err.to_string().contains("StateMachineTypeNotSupported"));
3513    }
3514
3515    #[tokio::test]
3516    async fn start_sync_execution_sm_not_found() {
3517        let svc = StepFunctionsService::new(make_state());
3518        let body = json!({
3519            "stateMachineArn": "arn:aws:states:us-east-1:123456789012:stateMachine:nope",
3520        });
3521        let req = make_request("StartSyncExecution", &body.to_string());
3522        let err = expect_err(svc.start_sync_execution(&req).await);
3523        assert!(err.to_string().contains("StateMachineDoesNotExist"));
3524    }
3525
3526    #[tokio::test]
3527    async fn start_sync_execution_records_introspection_fields() {
3528        let svc = StepFunctionsService::new(make_state());
3529        let arn = create_express_sm(&svc, "sync-introspect");
3530
3531        let body = json!({"stateMachineArn": arn, "input": "{}"});
3532        let req = make_request("StartSyncExecution", &body.to_string());
3533        let resp = svc.start_sync_execution(&req).await.unwrap();
3534        let b = body_json(&resp);
3535        let exec_arn = b["executionArn"].as_str().unwrap().to_string();
3536
3537        let accounts = svc.state.read();
3538        let state = accounts.regional("123456789012", "us-east-1").unwrap();
3539        let stored = state
3540            .executions
3541            .get(&exec_arn)
3542            .expect("sync execution should be persisted for introspection");
3543        assert!(stored.is_sync, "sync executions must be marked is_sync");
3544        assert_eq!(stored.billed_memory_mb, Some(64));
3545        assert!(
3546            stored.billed_duration_ms.is_some(),
3547            "billed_duration_ms must be populated after sync run"
3548        );
3549        assert!(
3550            stored.parent_execution_arn.is_none(),
3551            "top-level sync execution has no parent"
3552        );
3553    }
3554
3555    #[tokio::test]
3556    async fn start_sync_execution_invalid_input() {
3557        let svc = StepFunctionsService::new(make_state());
3558        let arn = create_express_sm(&svc, "bad-input-sync");
3559
3560        let body = json!({
3561            "stateMachineArn": arn,
3562            "input": "not json",
3563        });
3564        let req = make_request("StartSyncExecution", &body.to_string());
3565        let err = expect_err(svc.start_sync_execution(&req).await);
3566        assert!(err.to_string().contains("InvalidExecutionInput"));
3567    }
3568
3569    /// No snapshot store (memory mode) -> no persist hook for the CFN provisioner.
3570    #[test]
3571    fn snapshot_hook_is_none_without_store() {
3572        let svc = StepFunctionsService::new(make_state());
3573        assert!(svc.snapshot_hook().is_none());
3574    }
3575
3576    /// With a store, the hook is present and invoking it runs the whole-state
3577    /// persist path the CloudFormation provisioner uses after mutating Step
3578    /// Functions state directly.
3579    #[tokio::test]
3580    async fn snapshot_hook_fires_with_store() {
3581        let store: Arc<dyn fakecloud_persistence::SnapshotStore> =
3582            Arc::new(fakecloud_persistence::MemorySnapshotStore::new());
3583        let svc = StepFunctionsService::new(make_state()).with_snapshot_store(store);
3584        let hook = svc
3585            .snapshot_hook()
3586            .expect("hook present when a store is set");
3587        hook().await;
3588    }
3589
3590    fn make_execution(arn: &str, status: ExecutionStatus) -> Execution {
3591        Execution {
3592            execution_arn: arn.to_string(),
3593            state_machine_arn: "arn:aws:states:us-east-1:123456789012:stateMachine:sm".to_string(),
3594            state_machine_name: "sm".to_string(),
3595            name: arn.to_string(),
3596            status,
3597            input: None,
3598            output: None,
3599            start_date: Utc::now(),
3600            stop_date: None,
3601            error: None,
3602            cause: None,
3603            history_events: vec![],
3604            parent_execution_arn: None,
3605            is_sync: false,
3606            billed_duration_ms: None,
3607            billed_memory_mb: None,
3608            role_arn: String::new(),
3609            redrive_count: 0,
3610        }
3611    }
3612
3613    #[test]
3614    fn reconcile_aborts_running_executions_on_restart() {
3615        // After a restart a RUNNING execution has no interpreter driving it, so
3616        // it must be aborted rather than left RUNNING forever (0.A2). A
3617        // completed execution is untouched.
3618        let state = make_state();
3619        {
3620            let mut accounts = state.write();
3621            let s = accounts.regional_mut("123456789012", "us-east-1");
3622            s.executions.insert(
3623                "running".into(),
3624                make_execution("running", ExecutionStatus::Running),
3625            );
3626            s.executions.insert(
3627                "done".into(),
3628                make_execution("done", ExecutionStatus::Succeeded),
3629            );
3630        }
3631
3632        let n = reconcile_interrupted_executions(&state);
3633        assert_eq!(n, 1, "only the RUNNING execution is reconciled");
3634
3635        let accounts = state.read();
3636        let s = accounts.regional("123456789012", "us-east-1").unwrap();
3637        let running = &s.executions["running"];
3638        assert_eq!(running.status, ExecutionStatus::Aborted);
3639        assert!(running.stop_date.is_some());
3640        assert_eq!(running.error.as_deref(), Some("Fakecloud.Restart"));
3641        assert_eq!(s.executions["done"].status, ExecutionStatus::Succeeded);
3642    }
3643
3644    // ── Distributed Map -> MapRun population (bug-hunt read-shape) ──
3645
3646    #[tokio::test]
3647    async fn distributed_map_populates_map_run() {
3648        let state = make_state();
3649        let svc = StepFunctionsService::new(state.clone());
3650        let execution_arn =
3651            "arn:aws:states:us-east-1:123456789012:execution:dist-sm:exec-1".to_string();
3652
3653        let def = json!({
3654            "StartAt": "M",
3655            "States": {
3656                "M": {
3657                    "Type": "Map",
3658                    "ItemsPath": "$.items",
3659                    "ItemProcessor": {
3660                        "ProcessorConfig": { "Mode": "DISTRIBUTED", "ExecutionType": "STANDARD" },
3661                        "StartAt": "Item",
3662                        "States": { "Item": { "Type": "Pass", "End": true } }
3663                    },
3664                    "End": true
3665                }
3666            }
3667        });
3668
3669        interpreter::execute_state_machine(
3670            state.clone(),
3671            execution_arn.clone(),
3672            def.to_string(),
3673            Some(r#"{"items":[1,2,3]}"#.to_string()),
3674            None,
3675            None,
3676            None,
3677            None,
3678        )
3679        .await;
3680
3681        // ListMapRuns(executionArn) surfaces the newly created MapRun.
3682        let req = make_request(
3683            "ListMapRuns",
3684            &json!({ "executionArn": execution_arn }).to_string(),
3685        );
3686        let listed = body_json(&svc.list_map_runs(&req).unwrap());
3687        let runs = listed["mapRuns"].as_array().unwrap();
3688        assert_eq!(
3689            runs.len(),
3690            1,
3691            "distributed map must create exactly one MapRun"
3692        );
3693        let map_run_arn = runs[0]["mapRunArn"].as_str().unwrap().to_string();
3694        assert!(map_run_arn.contains(":mapRun:"));
3695        assert_eq!(runs[0]["executionArn"], execution_arn);
3696
3697        // DescribeMapRun(mapRunArn) returns the record with real counts.
3698        let req = make_request(
3699            "DescribeMapRun",
3700            &json!({ "mapRunArn": map_run_arn }).to_string(),
3701        );
3702        let mr = body_json(&svc.describe_map_run(&req).unwrap());
3703        assert_eq!(mr["status"], "SUCCEEDED");
3704        assert_eq!(mr["itemCounts"]["total"], 3);
3705        assert_eq!(mr["itemCounts"]["succeeded"], 3);
3706        assert_eq!(mr["itemCounts"]["failed"], 0);
3707        assert_eq!(mr["executionCounts"]["succeeded"], 3);
3708    }
3709
3710    #[tokio::test]
3711    async fn inline_map_does_not_create_map_run() {
3712        let state = make_state();
3713        let svc = StepFunctionsService::new(state.clone());
3714        let execution_arn =
3715            "arn:aws:states:us-east-1:123456789012:execution:inline-sm:exec-1".to_string();
3716
3717        // No ProcessorConfig.Mode => inline Map: AWS creates no MapRun.
3718        let def = json!({
3719            "StartAt": "M",
3720            "States": {
3721                "M": {
3722                    "Type": "Map",
3723                    "ItemsPath": "$.items",
3724                    "ItemProcessor": {
3725                        "StartAt": "Item",
3726                        "States": { "Item": { "Type": "Pass", "End": true } }
3727                    },
3728                    "End": true
3729                }
3730            }
3731        });
3732
3733        interpreter::execute_state_machine(
3734            state.clone(),
3735            execution_arn.clone(),
3736            def.to_string(),
3737            Some(r#"{"items":[1,2]}"#.to_string()),
3738            None,
3739            None,
3740            None,
3741            None,
3742        )
3743        .await;
3744
3745        let req = make_request(
3746            "ListMapRuns",
3747            &json!({ "executionArn": execution_arn }).to_string(),
3748        );
3749        let listed = body_json(&svc.list_map_runs(&req).unwrap());
3750        assert!(listed["mapRuns"].as_array().unwrap().is_empty());
3751    }
3752}
3753
3754#[cfg(test)]
3755mod pagination_reject_test {
3756    #[test]
3757    fn paginate_checked_rejects_invalid_token() {
3758        use fakecloud_core::pagination::paginate_checked;
3759        let items: Vec<i32> = (0..5).collect();
3760        assert!(paginate_checked(&items, Some("bad"), 3).is_err());
3761        assert!(paginate_checked(&items, Some("2"), 3).is_ok());
3762    }
3763}