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