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