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