Skip to main content

fakecloud_core/
delivery.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4/// Cross-service message delivery.
5///
6/// Services use this to deliver messages to other services without
7/// direct dependencies between service crates. The server wires up
8/// the delivery functions at startup.
9pub struct DeliveryBus {
10    /// Deliver a message to an SQS queue by ARN.
11    sqs_sender: Option<Arc<dyn SqsDelivery>>,
12    /// Publish a message to an SNS topic by ARN.
13    sns_sender: Option<Arc<dyn SnsDelivery>>,
14    /// Put an event onto an EventBridge bus.
15    eventbridge_sender: Option<Arc<dyn EventBridgeDelivery>>,
16    /// Invoke a Lambda function by ARN.
17    lambda_invoker: Option<Arc<dyn LambdaDelivery>>,
18    /// Put records to a Kinesis Data Stream by ARN.
19    kinesis_sender: Option<Arc<dyn KinesisDelivery>>,
20    /// Start Step Functions executions.
21    stepfunctions_starter: Option<Arc<dyn StepFunctionsDelivery>>,
22    /// Start SageMaker pipeline executions (Scheduler sagemaker target).
23    sagemaker_pipeline_starter: Option<Arc<dyn SageMakerPipelineDelivery>>,
24    /// Write objects to S3 buckets.
25    s3_writer: Option<Arc<dyn S3Delivery>>,
26    /// Put records into Firehose delivery streams.
27    firehose_sender: Option<Arc<dyn FirehoseDelivery>>,
28    /// Send a high-level SES SendEmail call (cross-service universal target).
29    ses_dispatcher: Option<Arc<dyn SesSendEmailDispatcher>>,
30    /// Run an ECS task on a cluster (cross-service universal target).
31    ecs_task_runner: Option<Arc<dyn EcsTaskRunner>>,
32    /// Register/deregister ELBv2 targets from ECS runtime.
33    elbv2_target_registration: Option<Arc<dyn Elbv2TargetRegistration>>,
34    ec2_network_lookup: Option<Arc<dyn Ec2NetworkLookup>>,
35    /// Publish CloudWatch metric data points (CloudWatch Logs metric
36    /// filters extract these on PutLogEvents).
37    cloudwatch_metrics: Option<Arc<dyn CloudwatchDelivery>>,
38    /// Put log events into CloudWatch Logs log groups. Used by Step
39    /// Functions Express execution logging and ECS awslogs driver.
40    cloudwatch_logs: Option<Arc<dyn CloudwatchLogsDelivery>>,
41    /// Verify a Cognito-issued JWT against the user pool that issued it.
42    /// Used by API Gateway v1's `COGNITO_USER_POOLS` authorizer to
43    /// validate signature/expiry/audience and extract claims.
44    cognito_jwt_verifier: Option<Arc<dyn CognitoJwtVerifier>>,
45    /// Encrypt/decrypt via KMS for cross-service SSE (S3 SSE-KMS, SES
46    /// S3Action KmsKeyArn, etc.).
47    kms_hook: Option<Arc<dyn KmsHook>>,
48}
49
50/// Message attribute for SQS delivery from SNS.
51#[derive(Debug, Clone)]
52pub struct SqsMessageAttribute {
53    pub data_type: String,
54    pub string_value: Option<String>,
55    pub binary_value: Option<String>,
56}
57
58/// Error returned by fallible SQS delivery. Used by Scheduler's DLQ
59/// routing, which must distinguish "target queue missing" from
60/// "delivered successfully" to decide whether to send to the
61/// `DeadLetterConfig.Arn`.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum SqsDeliveryError {
64    /// The target queue ARN did not resolve to any existing queue.
65    QueueNotFound(String),
66    /// The ARN could not be parsed into a valid SQS queue identifier.
67    InvalidArn(String),
68    /// The message violated a constraint required by the target queue
69    /// (e.g. FIFO send missing MessageDeduplicationId without
70    /// content-based dedup enabled). Surfaces as a non-retriable
71    /// failure so the upstream service can route to its configured DLQ.
72    InvalidParameter(String),
73    /// The target refused the delivery for lack of authorization (e.g. a
74    /// Scheduler target bus in another account whose resource policy does
75    /// not allow the schedule's role).
76    AccessDenied(String),
77    /// The target could not take the delivery right now (e.g. a service's
78    /// delivery not wired yet at startup).
79    TargetUnavailable(String),
80    /// The queue is encrypted and KMS refused to encrypt the message for the
81    /// sender (e.g. an AWS service principal delivering into a queue under
82    /// the AWS-managed `aws/sqs` key, whose policy admits only callers of
83    /// the account).
84    Kms(KmsError),
85}
86
87impl std::fmt::Display for SqsDeliveryError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::QueueNotFound(arn) => write!(f, "queue not found: {arn}"),
91            Self::InvalidArn(arn) => write!(f, "invalid queue ARN: {arn}"),
92            Self::InvalidParameter(msg) => write!(f, "invalid parameter: {msg}"),
93            Self::AccessDenied(msg) => write!(f, "access denied: {msg}"),
94            Self::TargetUnavailable(msg) => write!(f, "target unavailable: {msg}"),
95            Self::Kms(err) => write!(f, "KMS refused the delivery: {err}"),
96        }
97    }
98}
99
100impl std::error::Error for SqsDeliveryError {}
101
102impl SqsDeliveryError {
103    /// The message a service that relays SQS's failure reports (an
104    /// EventBridge dead-letter message's `ERROR_MESSAGE`), recorded in the
105    /// `aws-managed-keys` fixtures (`eventbridge-sqs-target-failures.json`):
106    /// for a KMS refusal, KMS's relayed message wrapped in SQS's own AWS SDK
107    /// service suffix; for a missing queue, SQS's `NonExistentQueue`. The
108    /// other variants are not recorded and report their description.
109    pub fn relayed_message(&self) -> String {
110        match self {
111            Self::QueueNotFound(_) => format!(
112                "The specified queue does not exist or you do not have access to it. (Service: AmazonSQS; Status Code: 400; Error Code: AWS.SimpleQueueService.NonExistentQueue; Request ID: {}; Proxy: null)",
113                uuid::Uuid::new_v4()
114            ),
115            Self::Kms(err) => {
116                let (status, code) = err.kind.sqs_code();
117                format!(
118                    "{} (Service: AmazonSQS; Status Code: {status}; Error Code: {code}; Request ID: {}; Proxy: null)",
119                    err.relayed_message(),
120                    uuid::Uuid::new_v4()
121                )
122            }
123            other => other.to_string(),
124        }
125    }
126}
127
128/// Who sends a message into an SQS queue through the delivery bus, which
129/// decides how SQS calls KMS for an encrypted queue.
130///
131/// AWS services deliver in one of two ways. SNS (topic subscriptions), S3
132/// (event notifications) and EventBridge (rule targets) send as their own
133/// service principal, so the queue's key policy must name that principal
134/// for SQS to encrypt the message; the AWS-managed `aws/sqs` key's policy
135/// admits only callers of the account, so these deliveries fail (recorded).
136/// Scheduler, Pipes, Step Functions, API Gateway and Lambda destinations
137/// send with an IAM role of the account (or the caller's identity), which
138/// `aws/sqs` admits.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum DeliverySender<'a> {
141    /// An AWS service principal (`sns.amazonaws.com`) acting for its
142    /// resource `source_arn` owned by `source_account`.
143    Service {
144        principal: &'a str,
145        source_arn: &'a str,
146        source_account: &'a str,
147    },
148    /// An IAM identity of the queue's account: the role a service acts as,
149    /// or the caller, named by ARN when known.
150    Identity { arn: Option<&'a str> },
151}
152
153/// Trait for delivering messages to SQS queues.
154pub trait SqsDelivery: Send + Sync {
155    fn deliver_to_queue(
156        &self,
157        sender: DeliverySender<'_>,
158        queue_arn: &str,
159        message_body: &str,
160        attributes: &HashMap<String, String>,
161    );
162
163    /// Deliver with message attributes and FIFO fields
164    fn deliver_to_queue_with_attrs(
165        &self,
166        sender: DeliverySender<'_>,
167        queue_arn: &str,
168        message_body: &str,
169        message_attributes: &HashMap<String, SqsMessageAttribute>,
170        message_group_id: Option<&str>,
171        message_dedup_id: Option<&str>,
172    ) {
173        // Default implementation: fall back to simple delivery
174        let _ = (message_attributes, message_group_id, message_dedup_id);
175        self.deliver_to_queue(sender, queue_arn, message_body, &HashMap::new());
176    }
177
178    /// Whether the queue would take a message from `sender` as far as its
179    /// encryption goes, without delivering one: the check a producer makes
180    /// when it validates a destination (S3 sends a test event when a bucket
181    /// notification configuration names the queue). A queue that does not
182    /// exist (or is not encrypted) passes.
183    fn check_sender(
184        &self,
185        sender: DeliverySender<'_>,
186        queue_arn: &str,
187    ) -> Result<(), SqsDeliveryError> {
188        let _ = (sender, queue_arn);
189        Ok(())
190    }
191
192    /// The stored ARN of the queue a QueueUrl (`<endpoint>/<account>/<name>`)
193    /// names, looked up by account and queue name. A fakecloud QueueUrl
194    /// carries no region, so the caller passes the region it is acting in:
195    /// that region's queue wins, else the queue of that account and name in
196    /// the one other region that has it. `None` when there is no such queue
197    /// (or it is ambiguous).
198    fn queue_arn_for_url(&self, region: &str, queue_url: &str) -> Option<String> {
199        let _ = (region, queue_url);
200        None
201    }
202
203    /// Fallible variant used by Scheduler's DLQ routing. Default
204    /// implementation assumes the queue exists (preserving the
205    /// fire-and-forget semantics of `deliver_to_queue`); the real SQS
206    /// impl overrides this to actually look up the queue and report
207    /// `QueueNotFound` so the caller can route to a DLQ.
208    #[allow(clippy::too_many_arguments)]
209    fn try_deliver_to_queue_with_attrs(
210        &self,
211        sender: DeliverySender<'_>,
212        queue_arn: &str,
213        message_body: &str,
214        message_attributes: &HashMap<String, SqsMessageAttribute>,
215        message_group_id: Option<&str>,
216        message_dedup_id: Option<&str>,
217    ) -> Result<(), SqsDeliveryError> {
218        self.deliver_to_queue_with_attrs(
219            sender,
220            queue_arn,
221            message_body,
222            message_attributes,
223            message_group_id,
224            message_dedup_id,
225        );
226        Ok(())
227    }
228}
229
230/// Trait for publishing messages to SNS topics.
231pub trait SnsDelivery: Send + Sync {
232    fn publish_to_topic(&self, topic_arn: &str, message: &str, subject: Option<&str>);
233
234    /// Publish to a FIFO SNS topic carrying the message group/dedup IDs
235    /// that downstream SQS subscribers need for ordering. Default impl
236    /// drops the IDs so non-FIFO callers don't have to override.
237    fn publish_to_topic_fifo(
238        &self,
239        topic_arn: &str,
240        message: &str,
241        subject: Option<&str>,
242        _message_group_id: Option<&str>,
243        _message_dedup_id: Option<&str>,
244    ) {
245        self.publish_to_topic(topic_arn, message, subject);
246    }
247
248    /// Publish carrying typed message attributes (e.g. Lambda's dead-letter
249    /// `RequestID` / `ErrorCode` / `ErrorMessage`), delivered to subscribers
250    /// like `Publish` `MessageAttributes`. Implementations that cannot carry
251    /// attributes must override this; the default refuses to drop them
252    /// silently and publishes nothing.
253    fn publish_to_topic_with_attributes(
254        &self,
255        topic_arn: &str,
256        message: &str,
257        subject: Option<&str>,
258        attributes: &HashMap<String, SqsMessageAttribute>,
259    ) {
260        let _ = (message, subject, attributes);
261        tracing::warn!(
262            topic_arn,
263            "SNS delivery does not support message attributes; message not published"
264        );
265    }
266}
267
268/// An event another service puts onto an EventBridge bus (S3 bucket
269/// notifications, ECS task state changes, Scheduler/Pipes/Step Functions
270/// targets, ...).
271///
272/// `account_id` and `region` name where the event *originates*: the owner
273/// account and region of the emitting resource. They are stamped into the
274/// event's `account` and `region` fields and are what rule patterns match on,
275/// exactly as AWS does for service events. There is deliberately no
276/// "default account" form: every caller knows the resource the event is
277/// about, and an event that silently lands on another account's bus is
278/// invisible to the rules that should have fired.
279#[derive(Debug, Clone, Copy)]
280pub struct CrossServiceEvent<'a> {
281    pub source: &'a str,
282    pub detail_type: &'a str,
283    /// JSON detail document.
284    pub detail: &'a str,
285    /// Bus name (a bus owned by `account_id`) or a full event-bus ARN. An ARN
286    /// routes the event to the bus owner's account (a cross-account target),
287    /// while the event keeps its originating `account` / `region`.
288    pub event_bus: &'a str,
289    /// Account the event originates in.
290    pub account_id: &'a str,
291    /// Region the event originates in.
292    pub region: &'a str,
293    /// ARNs of the resources the event is about (`resources` field).
294    pub resources: &'a [String],
295    /// The IAM role the source acts as when it puts the event (a schedule's
296    /// or pipe's role, a state machine's execution role). A bus in another
297    /// account authorizes the put against this principal through its
298    /// resource policy; `None` means the originating account's root.
299    pub principal_arn: Option<&'a str>,
300}
301
302/// Why EventBridge refused a cross-service event.
303#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
304pub enum EventBridgeDeliveryError {
305    /// The target bus belongs to another account and its resource policy
306    /// does not allow the source's principal to put events on it. The event
307    /// is not stored, archived or delivered.
308    #[error("AccessDeniedException: {0}")]
309    AccessDenied(String),
310    /// EventBridge could not take the event (e.g. delivery not wired yet at
311    /// startup). The event is dropped; the source records a failed delivery.
312    #[error("InternalException: {0}")]
313    Unavailable(String),
314}
315
316/// Trait for putting events onto an EventBridge bus from cross-service integrations.
317pub trait EventBridgeDelivery: Send + Sync {
318    /// Put `event` onto the bus it names, scoped to the account that owns that
319    /// bus. The implementation handles rule matching and target delivery.
320    /// Returns the new event's ID, or why the put was refused.
321    fn put_event(&self, event: &CrossServiceEvent<'_>) -> Result<String, EventBridgeDeliveryError>;
322}
323
324/// The `X-Amz-Function-Error` value Lambda reports for a function error:
325/// the handler threw, the runtime crashed or exited, init failed, or the
326/// invocation timed out. (`Handled` is only used by the retired Node.js
327/// 0.10 callback-error convention.)
328pub const FUNCTION_ERROR_UNHANDLED: &str = "Unhandled";
329
330/// What a synchronous Lambda invocation produced: the response payload and,
331/// when the function failed, the `X-Amz-Function-Error` value. This is the
332/// signal real Lambda gives (`Invoke` still returns 200); integrations must
333/// branch on `function_error`, never on the payload's shape, because a
334/// function may legitimately return an object carrying `errorType` /
335/// `errorMessage`, and some failures (a timeout reported without an
336/// `errorType` by older runtimes) carry only one of them.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct LambdaInvocationOutput {
339    /// The response payload. For a function error this is the error object
340    /// (`{"errorType", "errorMessage", ...}`) the runtime reported.
341    pub payload: Vec<u8>,
342    /// `Some("Unhandled")` when the invocation produced a function error.
343    pub function_error: Option<String>,
344}
345
346impl LambdaInvocationOutput {
347    /// A successful invocation that returned `payload`.
348    pub fn success(payload: impl Into<Vec<u8>>) -> Self {
349        Self {
350            payload: payload.into(),
351            function_error: None,
352        }
353    }
354
355    /// An invocation that failed with a function error reported as `payload`.
356    pub fn unhandled(payload: impl Into<Vec<u8>>) -> Self {
357        Self {
358            payload: payload.into(),
359            function_error: Some(FUNCTION_ERROR_UNHANDLED.to_string()),
360        }
361    }
362
363    /// True when the function failed (`X-Amz-Function-Error` was set).
364    pub fn is_function_error(&self) -> bool {
365        self.function_error.is_some()
366    }
367
368    /// The error object's `errorType`, for a function error whose payload
369    /// names one. `None` for a success or an error payload without a type.
370    pub fn error_type(&self) -> Option<String> {
371        self.error_field("errorType")
372    }
373
374    /// The error object's `errorMessage`, for a function error whose payload
375    /// carries one.
376    pub fn error_message(&self) -> Option<String> {
377        self.error_field("errorMessage")
378    }
379
380    fn error_field(&self, key: &str) -> Option<String> {
381        if !self.is_function_error() {
382            return None;
383        }
384        serde_json::from_slice::<serde_json::Value>(&self.payload)
385            .ok()?
386            .get(key)?
387            .as_str()
388            .map(str::to_string)
389    }
390}
391
392/// Trait for invoking Lambda functions from cross-service integrations.
393/// Told why Lambda discarded a queued `Event` invocation without running the
394/// function.
395pub type AsyncUnrunnableHook = Box<dyn FnOnce(String) + Send>;
396
397pub trait LambdaDelivery: Send + Sync {
398    /// Invoke a Lambda function with the given payload, synchronously
399    /// (`RequestResponse`). The function is identified by ARN. `Ok` means
400    /// Lambda ran the invocation; a function error is reported in the
401    /// output's `function_error`, not as `Err`. `Err` is a failure to invoke
402    /// at all (function not found, no deployment package, runtime
403    /// unavailable).
404    fn invoke_lambda(
405        &self,
406        function_arn: &str,
407        payload: &str,
408    ) -> std::pin::Pin<
409        Box<dyn std::future::Future<Output = Result<LambdaInvocationOutput, String>> + Send>,
410    >;
411
412    /// Queue an asynchronous (`Event`) invocation, as S3, SNS, EventBridge
413    /// and Scheduler invoke functions. `Ok` once Lambda accepted the event;
414    /// a function error is then handled by Lambda itself (retries,
415    /// `MaximumEventAge`, `OnSuccess`/`OnFailure` destinations, dead-letter
416    /// queue) and never reaches the caller. `Err` when the event could not
417    /// be queued (function not found, no deployment package), which the
418    /// caller treats as a failed delivery.
419    fn invoke_lambda_async(&self, function_arn: &str, payload: &str) -> Result<(), String> {
420        let _ = (function_arn, payload);
421        Err("this Lambda invoker does not support asynchronous invocation".to_string())
422    }
423
424    /// [`Self::invoke_lambda_async`], also telling `on_unrunnable` why if
425    /// Lambda discards the queued event without having run the function (it
426    /// can never run as configured, or every attempt failed to start it
427    /// until the event aged out), so a caller waiting on the event's effects
428    /// learns it will never come. A single failed attempt that a retry may
429    /// still fix is never reported. Invokers that cannot report it never call
430    /// it.
431    fn invoke_lambda_async_observed(
432        &self,
433        function_arn: &str,
434        payload: &str,
435        on_unrunnable: AsyncUnrunnableHook,
436    ) -> Result<(), String> {
437        drop(on_unrunnable);
438        self.invoke_lambda_async(function_arn, payload)
439    }
440
441    /// When `function_arn` names a durable function, start a durable
442    /// execution for an asynchronous delivery of `payload` without waiting
443    /// for it (what [`Self::invoke_lambda_async`] does for such a function),
444    /// for integrations that queue other functions themselves. `None` for a
445    /// function that is not durable.
446    fn start_durable_async(&self, function_arn: &str, payload: &str) -> Option<Result<(), String>> {
447        let _ = (function_arn, payload);
448        None
449    }
450}
451
452/// Trait for putting records to Kinesis Data Streams.
453pub trait KinesisDelivery: Send + Sync {
454    /// Put a record to a Kinesis stream identified by ARN.
455    /// The data should be base64-encoded. partition_key is used for shard distribution.
456    fn put_record(&self, stream_arn: &str, data: &str, partition_key: &str);
457}
458
459/// Trait for starting Step Functions executions from cross-service integrations.
460pub trait StepFunctionsDelivery: Send + Sync {
461    /// Start a state machine execution with the given input.
462    /// The state machine is identified by ARN.
463    fn start_execution(&self, state_machine_arn: &str, input: &str);
464}
465
466/// Cross-service SageMaker pipeline dispatch used by EventBridge Scheduler's
467/// `sagemaker:pipeline` target. The pipeline is identified by ARN
468/// (`arn:aws:sagemaker:<region>:<account>:pipeline/<name>`); `parameters` is the
469/// target's `PipelineParameterList`.
470pub trait SageMakerPipelineDelivery: Send + Sync {
471    fn start_pipeline_execution(&self, pipeline_arn: &str, parameters: &serde_json::Value);
472}
473
474/// Cross-service Kinesis Data Firehose dispatch used by services
475/// (CloudWatch Logs subscription filters, EventBridge targets) that
476/// route records into a delivery stream without depending on the
477/// firehose crate directly. ARN form is
478/// `arn:aws:firehose:<region>:<account>:deliverystream/<name>`.
479pub trait FirehoseDelivery: Send + Sync {
480    fn put_record(&self, delivery_stream_arn: &str, data: &[u8]);
481}
482
483/// Cross-service S3 writer used by services that need to deliver
484/// content to S3 buckets without taking a direct dep on the S3 crate
485/// (CloudWatch Logs export tasks, Kinesis Firehose, ELB access logs).
486pub trait S3Delivery: Send + Sync {
487    /// Put an object to a bucket. Returns the bucket name on success
488    /// or an error string the caller can surface in tests / logs.
489    fn put_object(
490        &self,
491        account_id: &str,
492        bucket: &str,
493        key: &str,
494        body: Vec<u8>,
495        content_type: Option<&str>,
496    ) -> Result<(), String>;
497
498    /// Read an object's body. Returns Err when the bucket or key does
499    /// not exist or when the body cannot be read. Used by RDS
500    /// `RestoreDBInstanceFromS3` to ingest a backup blob without taking
501    /// a direct dep on the S3 crate.
502    fn get_object(&self, account_id: &str, bucket: &str, key: &str) -> Result<Vec<u8>, String>;
503}
504
505/// SES SendEmail dispatch for callers that already speak the AWS SES
506/// SendEmail / SendEmailV2 shape (multiple to/cc/bcc, optional subject,
507/// text/html bodies). Distinct from `EmailDispatcher`, which is the
508/// single-recipient cross-service primitive used by Cognito.
509pub trait SesSendEmailDispatcher: Send + Sync {
510    #[allow(clippy::too_many_arguments)]
511    fn send_email(
512        &self,
513        account_id: &str,
514        from: &str,
515        to: Vec<String>,
516        cc: Vec<String>,
517        bcc: Vec<String>,
518        subject: Option<&str>,
519        text_body: Option<&str>,
520        html_body: Option<&str>,
521    ) -> Result<(), String>;
522}
523
524/// Synthesize an ECS RunTask call from outside the ECS crate. Used by
525/// EventBridge Scheduler and EventBridge Rules to start tasks without
526/// depending directly on `fakecloud_ecs`.
527pub trait EcsTaskRunner: Send + Sync {
528    fn run_task(
529        &self,
530        account_id: &str,
531        cluster: &str,
532        task_definition: &str,
533        launch_type: Option<&str>,
534        count: usize,
535    ) -> Result<(), String>;
536}
537
538/// Register/deregister targets with ELBv2 target groups from outside
539/// the elbv2 crate. Used by ECS runtime when tasks with load balancers
540/// reach RUNNING or STOPPED.
541pub trait Elbv2TargetRegistration: Send + Sync {
542    fn register_targets(
543        &self,
544        account_id: &str,
545        target_group_arn: &str,
546        targets: Vec<(String, Option<i64>)>,
547    );
548    fn deregister_targets(
549        &self,
550        account_id: &str,
551        target_group_arn: &str,
552        targets: Vec<(String, Option<i64>)>,
553    );
554}
555
556/// A requester-managed network interface EC2 created for a task.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct TaskEni {
559    pub eni_id: String,
560    pub private_ip: String,
561    pub mac_address: String,
562}
563
564/// EC2 VPC networking from outside the ec2 crate. Used by the ECS runtime to
565/// give an `awsvpc` task a real ENI in its subnet (visible to
566/// DescribeNetworkInterfaces, its private IP allocated by EC2 like any other
567/// interface's) and to delete it when the task stops.
568pub trait Ec2NetworkLookup: Send + Sync {
569    /// Create a requester-managed ENI in `subnet_id`. `Err` when the subnet
570    /// doesn't exist or has no free address.
571    fn create_task_eni(
572        &self,
573        account_id: &str,
574        region: &str,
575        subnet_id: &str,
576        group_ids: Vec<String>,
577        description: String,
578    ) -> Result<TaskEni, String>;
579    /// Delete an ENI [`Self::create_task_eni`] created (no-op otherwise).
580    fn delete_task_eni(&self, account_id: &str, region: &str, eni_id: &str);
581}
582
583/// Publish CloudWatch metric data points from outside the cloudwatch
584/// crate. Used by CloudWatch Logs metric filters when an incoming log
585/// event matches their pattern.
586pub trait CloudwatchDelivery: Send + Sync {
587    #[allow(clippy::too_many_arguments)]
588    fn put_metric(
589        &self,
590        account_id: &str,
591        region: &str,
592        namespace: &str,
593        metric_name: &str,
594        value: f64,
595        unit: Option<&str>,
596        dimensions: std::collections::BTreeMap<String, String>,
597        timestamp_ms: i64,
598    );
599}
600
601/// Put log events into CloudWatch Logs log groups from outside the
602/// logs crate. Used by Step Functions Express execution logging and
603/// ECS awslogs driver so they can deliver without depending directly
604/// on fakecloud_logs.
605pub trait CloudwatchLogsDelivery: Send + Sync {
606    fn put_log_events(
607        &self,
608        account_id: &str,
609        region: &str,
610        log_group_name: &str,
611        log_stream_name: &str,
612        events: &[(i64, String)],
613    );
614}
615
616/// Outbound email dispatch used by services that emulate AWS flows that
617/// route through SES (Cognito verification, etc.) without taking a direct
618/// dependency on the SES crate.
619pub trait EmailDispatcher: Send + Sync {
620    fn send_email(
621        &self,
622        account_id: &str,
623        from: &str,
624        to: &str,
625        subject: &str,
626        body_text: &str,
627        body_html: Option<&str>,
628    );
629}
630
631/// Outbound SMS dispatch used by services that emulate AWS flows that route
632/// through SNS phone-number publish (Cognito SMS MFA, etc.).
633pub trait SmsDispatcher: Send + Sync {
634    /// `region` is the region the SMS is sent from (the SNS account settings
635    /// and recorded messages are per region).
636    fn send_sms(&self, account_id: &str, region: &str, phone_number: &str, message: &str);
637}
638
639/// Why an AWS service principal cannot use a KMS key
640/// ([`KmsHook::check_service_key_use`]).
641#[derive(Debug, Clone, PartialEq, Eq)]
642pub enum KmsKeyUnusable {
643    /// No key with that id, ARN or alias exists.
644    NotFound,
645    /// The key is disabled.
646    Disabled,
647    /// The key is in another non-usable state (`PendingDeletion`, ...).
648    InvalidState(String),
649    /// The key policy of the (resolved) key, whose ARN this carries, does
650    /// not allow the service principal.
651    AccessDenied(String),
652}
653
654/// Who a cross-service KMS call is made as, which decides what the key
655/// policy sees.
656///
657/// AWS services use KMS in one of two ways, and key policies are written for
658/// each: most (S3 SSE-KMS, SQS, SNS, DynamoDB, Secrets Manager, SSM) call KMS
659/// *on behalf of* the caller of their own API, with the caller's identity,
660/// and KMS adds `kms:ViaService = <service>.<region>.amazonaws.com` (the
661/// AWS-managed `aws/<service>` keys only allow that); a few (SES writing
662/// received mail to S3, Lambda for durable executions) call KMS *as their
663/// service principal*, which a key policy must name in a `"Service"`
664/// principal.
665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666pub enum KmsCaller<'a> {
667    /// The service identified by `service_principal` (`s3.amazonaws.com`)
668    /// acting for a caller of its API in the request's account.
669    OnBehalfOf {
670        service_principal: &'a str,
671        /// The ARN of the caller the service acts for (an IAM user or
672        /// assumed role of the account), whom KMS names in a refusal.
673        /// `None` when the service knows no identity (the account itself).
674        caller_arn: Option<&'a str>,
675    },
676    /// The service principal itself (`ses.us-east-1.amazonaws.com`) acting
677    /// for its resource `source_arn` owned by `source_account` (the
678    /// `aws:SourceArn` / `aws:SourceAccount` of the request).
679    Service {
680        service_principal: &'a str,
681        source_arn: &'a str,
682        source_account: &'a str,
683        /// The service that makes the KMS call on the principal's behalf,
684        /// when it is not the principal's own (SQS encrypting a message SNS
685        /// delivers: `sqs.amazonaws.com`); it sets `kms:ViaService`.
686        via_service: Option<&'a str>,
687    },
688}
689
690impl<'a> KmsCaller<'a> {
691    /// A service calling KMS on behalf of the account, with no known caller
692    /// identity (see [`KmsCaller::OnBehalfOf`]).
693    pub fn via(service_principal: &'a str) -> Self {
694        KmsCaller::OnBehalfOf {
695            service_principal,
696            caller_arn: None,
697        }
698    }
699
700    /// A service calling KMS on behalf of the caller `caller_arn` of its API.
701    pub fn via_for(service_principal: &'a str, caller_arn: Option<&'a str>) -> Self {
702        KmsCaller::OnBehalfOf {
703            service_principal,
704            caller_arn,
705        }
706    }
707
708    /// The service principal the call is recorded under.
709    pub fn service_principal(&self) -> &'a str {
710        match self {
711            KmsCaller::OnBehalfOf {
712                service_principal, ..
713            }
714            | KmsCaller::Service {
715                service_principal, ..
716            } => service_principal,
717        }
718    }
719}
720
721/// Why a cross-service KMS call failed, as KMS reports it: the KMS error
722/// code ([`KmsErrorKind::code`]) and KMS's message. Each consuming service
723/// maps it onto the error its own API declares (SQS `KMS.AccessDeniedException`,
724/// SNS `KMSAccessDenied`, S3 `AccessDenied`, ...).
725#[derive(Debug, Clone, PartialEq, Eq)]
726pub struct KmsError {
727    pub kind: KmsErrorKind,
728    pub message: String,
729}
730
731/// The KMS failure classes a consuming service distinguishes.
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733pub enum KmsErrorKind {
734    /// No key with that id, ARN or alias (`NotFoundException`).
735    NotFound,
736    /// The key is disabled (`DisabledException`).
737    Disabled,
738    /// The key is in a state that cannot be used, such as `PendingDeletion`
739    /// (`KMSInvalidStateException`).
740    InvalidState,
741    /// The key policy does not allow the call (`AccessDeniedException`).
742    AccessDenied,
743    /// The key's usage or spec does not allow the operation
744    /// (`InvalidKeyUsageException`).
745    InvalidKeyUsage,
746    /// The ciphertext is malformed, was sealed under another key or another
747    /// encryption context (`InvalidCiphertextException`).
748    InvalidCiphertext,
749    /// No KMS is wired, or KMS failed internally (`KMSInternalException`).
750    Internal,
751}
752
753impl KmsErrorKind {
754    /// The KMS error code (`__type`) of this failure.
755    pub fn code(self) -> &'static str {
756        match self {
757            KmsErrorKind::NotFound => "NotFoundException",
758            KmsErrorKind::Disabled => "DisabledException",
759            KmsErrorKind::InvalidState => "KMSInvalidStateException",
760            KmsErrorKind::AccessDenied => "AccessDeniedException",
761            KmsErrorKind::InvalidKeyUsage => "InvalidKeyUsageException",
762            KmsErrorKind::InvalidCiphertext => "InvalidCiphertextException",
763            KmsErrorKind::Internal => "KMSInternalException",
764        }
765    }
766
767    /// The HTTP status KMS answers this failure with.
768    pub fn status(self) -> u16 {
769        match self {
770            KmsErrorKind::Internal => 500,
771            _ => 400,
772        }
773    }
774
775    /// The awsQuery error code and HTTP status SQS answers a send or receive
776    /// with when KMS fails this way for an encrypted queue (`KmsAccessDenied`
777    /// etc., declared by SendMessage / SendMessageBatch / ReceiveMessage). A
778    /// ciphertext KMS cannot open is the key's invalid state.
779    pub fn sqs_code(self) -> (u16, &'static str) {
780        match self {
781            KmsErrorKind::AccessDenied => (400, "KMS.AccessDeniedException"),
782            KmsErrorKind::Disabled => (400, "KMS.DisabledException"),
783            KmsErrorKind::InvalidKeyUsage => (400, "KMS.InvalidKeyUsageException"),
784            KmsErrorKind::InvalidState | KmsErrorKind::InvalidCiphertext => {
785                (400, "KMS.InvalidStateException")
786            }
787            KmsErrorKind::NotFound => (400, "KMS.NotFoundException"),
788            KmsErrorKind::Internal => (500, "KMS.InternalFailureException"),
789        }
790    }
791}
792
793impl KmsError {
794    pub fn new(kind: KmsErrorKind, message: impl Into<String>) -> Self {
795        Self {
796            kind,
797            message: message.into(),
798        }
799    }
800
801    /// KMS's `NotFoundException` for a key reference (key id, key ARN, alias
802    /// name or alias ARN) that resolves to nothing; see
803    /// [`kms_not_found_message`].
804    pub fn not_found(account_id: &str, region: &str, key_ref: &str) -> Self {
805        Self::new(
806            KmsErrorKind::NotFound,
807            kms_not_found_message(account_id, region, key_ref),
808        )
809    }
810
811    /// No KMS hook is wired.
812    pub fn not_configured() -> Self {
813        Self::new(KmsErrorKind::Internal, "KMS hook not configured")
814    }
815
816    /// KMS's message with the suffix the AWS service SDKs append when a
817    /// service relays a KMS failure in its own error (recorded from SQS
818    /// `SendMessage`, SNS `Publish`, SSM `PutParameter`):
819    /// `<message> (Service: AWSKMS; Status Code: 400; Error Code:
820    /// AccessDeniedException; Request ID: <id>; Proxy: null)`.
821    pub fn relayed_message(&self) -> String {
822        format!(
823            "{} (Service: AWSKMS; Status Code: {}; Error Code: {}; Request ID: {}; Proxy: null)",
824            self.message,
825            self.kind.status(),
826            self.kind.code(),
827            uuid::Uuid::new_v4()
828        )
829    }
830}
831
832/// KMS's `NotFoundException` message for a key reference that resolves to
833/// nothing (recorded through SQS, SNS, S3, SSM, Secrets Manager and the KMS
834/// API): an alias is named by its ARN ("Alias arn:...:alias/x is not
835/// found."), a key by its key ARN ("Key 'arn:...:key/<id>' does not
836/// exist"). A reference with no region or account takes `region` and
837/// `account_id`.
838pub fn kms_not_found_message(account_id: &str, region: &str, key_ref: &str) -> String {
839    let parsed = fakecloud_aws::arn::arn_resource(key_ref, "kms").and_then(|rest| {
840        let (r, after) = rest.split_once(':')?;
841        let (a, res) = after.split_once(':')?;
842        Some((r, a, res))
843    });
844    let (ref_region, ref_account, resource) = parsed.unwrap_or((region, account_id, key_ref));
845    let ref_region = if ref_region.is_empty() {
846        region
847    } else {
848        ref_region
849    };
850    let account = if ref_account.is_empty() {
851        account_id
852    } else {
853        ref_account
854    };
855    if resource.starts_with("alias/") {
856        format!(
857            "Alias {} is not found.",
858            fakecloud_aws::arn::Arn::regional("kms", ref_region, account, resource)
859        )
860    } else {
861        let id = resource.strip_prefix("key/").unwrap_or(resource);
862        format!(
863            "Key '{}' does not exist",
864            fakecloud_aws::arn::Arn::regional("kms", ref_region, account, &format!("key/{id}"))
865        )
866    }
867}
868
869impl std::fmt::Display for KmsError {
870    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
871        write!(f, "{}: {}", self.kind.code(), self.message)
872    }
873}
874
875impl std::error::Error for KmsError {}
876
877/// Cross-service KMS hook: services that accept a `KmsKeyId` (Secrets
878/// Manager, SSM `SecureString`, S3 SSE-KMS, SQS / SNS / DynamoDB
879/// encrypted resources, SES receipt rules) call this so that real KMS
880/// calls happen, the invocation is recorded for introspection, and the
881/// returned blob is decryptable by the public KMS API.
882///
883/// Encryption context is the AWS-defined per-service map (e.g.
884/// `{SecretARN: <arn>, SecretVersionId: <id>}` for Secrets Manager). It is
885/// bound into the ciphertext as KMS binds it, so decrypting needs the same
886/// context, and it is recorded with the call so test code can assert it.
887pub trait KmsHook: Send + Sync {
888    /// Encrypt `plaintext` under `key_id` (the service's data-key
889    /// derivation, authorized as `kms:GenerateDataKey`).
890    fn encrypt(
891        &self,
892        account_id: &str,
893        region: &str,
894        key_id: &str,
895        plaintext: &[u8],
896        caller: KmsCaller<'_>,
897        encryption_context: std::collections::HashMap<String, String>,
898    ) -> Result<String, KmsError>;
899
900    /// [`KmsHook::encrypt`] authorized and recorded as the KMS `operation`
901    /// the service really calls (`GenerateDataKey`, or `Encrypt` for SSM
902    /// parameter values). Hooks that do not distinguish operations encrypt.
903    #[allow(clippy::too_many_arguments)]
904    fn encrypt_with_operation(
905        &self,
906        _operation: &str,
907        account_id: &str,
908        region: &str,
909        key_id: &str,
910        plaintext: &[u8],
911        caller: KmsCaller<'_>,
912        encryption_context: std::collections::HashMap<String, String>,
913    ) -> Result<String, KmsError> {
914        self.encrypt(
915            account_id,
916            region,
917            key_id,
918            plaintext,
919            caller,
920            encryption_context,
921        )
922    }
923
924    /// Decrypt a ciphertext a service sealed with [`KmsHook::encrypt`]
925    /// (authorized as `kms:Decrypt`).
926    fn decrypt(
927        &self,
928        account_id: &str,
929        ciphertext_b64: &str,
930        caller: KmsCaller<'_>,
931        encryption_context: std::collections::HashMap<String, String>,
932    ) -> Result<Vec<u8>, KmsError>;
933
934    /// [`KmsHook::decrypt`] for a call the service makes in `region` (the
935    /// region of the resource whose data it decrypts): a ciphertext of a
936    /// multi-Region key is opened by the related key of that region, under
937    /// that key's policy and grants. The default ignores the region.
938    fn decrypt_in_region(
939        &self,
940        account_id: &str,
941        _region: &str,
942        ciphertext_b64: &str,
943        caller: KmsCaller<'_>,
944        encryption_context: std::collections::HashMap<String, String>,
945    ) -> Result<Vec<u8>, KmsError> {
946        self.decrypt(account_id, ciphertext_b64, caller, encryption_context)
947    }
948
949    /// Whether `caller` may use `key_id` for every one of `actions`
950    /// (`kms:CreateGrant`, ...) with `encryption_context`, as a service
951    /// checks a key when a resource is configured with it (DynamoDB
952    /// `CreateTable` creates a grant). Resolves `key_id` and returns the
953    /// key ARN. Hooks that cannot evaluate keys accept it.
954    #[allow(clippy::too_many_arguments)]
955    fn authorize_key_use(
956        &self,
957        _account_id: &str,
958        _region: &str,
959        key_id: &str,
960        _caller: KmsCaller<'_>,
961        _actions: &[&str],
962        _encryption_context: &std::collections::HashMap<String, String>,
963    ) -> Result<String, KmsError> {
964        Ok(key_id.to_string())
965    }
966
967    /// Resolve `key_id` (key id, key ARN, alias name or alias ARN) to the
968    /// key's ARN, provisioning the AWS-managed `aws/<service>` key on first
969    /// use. Services report the resolved ARN on their resource descriptions.
970    fn resolve_key_arn(
971        &self,
972        _account_id: &str,
973        _region: &str,
974        _key_id: &str,
975        _service_principal: &str,
976    ) -> Result<String, String> {
977        Err("key resolution is not supported by this hook".to_string())
978    }
979
980    /// Whether the AWS service `service_principal`, acting for its resource
981    /// `source_arn` (owned by `account_id`), may use `key_id` for every one
982    /// of `actions` (e.g. `kms:GenerateDataKey`, `kms:Decrypt`) with
983    /// `encryption_context`: the key must exist, be `Enabled`, and its key
984    /// policy must allow the service principal. Returns the key ARN.
985    ///
986    /// Hooks that cannot evaluate key state or policies accept the key.
987    #[allow(clippy::too_many_arguments)]
988    fn check_service_key_use(
989        &self,
990        _account_id: &str,
991        _region: &str,
992        key_id: &str,
993        _service_principal: &str,
994        _source_arn: &str,
995        _actions: &[&str],
996        _encryption_context: &std::collections::HashMap<String, String>,
997    ) -> Result<String, KmsKeyUnusable> {
998        Ok(key_id.to_string())
999    }
1000
1001    /// Whether `stored` is a ciphertext KMS sealed for `account_id` (a
1002    /// well-formed envelope), as opposed to a value a service stored in
1003    /// plaintext before it encrypted at rest. A consumer that fails to
1004    /// decrypt a ciphertext reports the KMS failure; only a value that is
1005    /// not one reads as plaintext. Every hook must answer for its own
1006    /// envelope format: a hook that said no to its own ciphertexts would
1007    /// hand them out as plaintext.
1008    fn is_ciphertext(&self, account_id: &str, stored: &str) -> bool;
1009
1010    /// The ARN of the AWS-managed key for `service` (the key behind
1011    /// `alias/aws/<service>`) in `account_id` and `region`, minted on first
1012    /// use. AWS-managed keys exist once per account AND region, so each
1013    /// region gets its own key in its own partition.
1014    fn aws_managed_key_arn(
1015        &self,
1016        _account_id: &str,
1017        _region: &str,
1018        _service: &str,
1019        _service_principal: &str,
1020    ) -> Result<String, String> {
1021        Err("AWS-managed key resolution is not supported by this hook".to_string())
1022    }
1023}
1024
1025/// The ARN of the AWS-managed KMS key for `service` (the key behind
1026/// `alias/aws/<service>`) in `account_id` and `region`, which a service reports
1027/// for a resource encrypted at rest when the caller named no key. The key is
1028/// resolved through the KMS hook, which mints it on first use in that account
1029/// and region (and the region's partition), as real KMS does, so the ARN is a
1030/// real key `DescribeKey` / `Decrypt` accept. `None` when no hook is wired (a
1031/// service running outside the server) or resolution fails; a failure is
1032/// logged, never swallowed.
1033pub fn aws_managed_kms_key_arn(
1034    hook: Option<&dyn KmsHook>,
1035    account_id: &str,
1036    region: &str,
1037    service: &str,
1038) -> Option<String> {
1039    let hook = hook?;
1040    let alias = format!("alias/aws/{service}");
1041    let principal = format!("{service}.amazonaws.com");
1042    match hook.aws_managed_key_arn(account_id, region, service, &principal) {
1043        Ok(arn) => Some(arn),
1044        Err(err) => {
1045            tracing::warn!(
1046                %err,
1047                account_id,
1048                region,
1049                alias = %alias,
1050                "could not resolve the AWS-managed KMS key; reporting no key"
1051            );
1052            None
1053        }
1054    }
1055}
1056
1057/// The KMS key a resource is encrypted with: the caller-named key (`named`,
1058/// ignored when empty) or, when none is named, the AWS-managed key for
1059/// `service` in `account_id` and `region` (see [`aws_managed_kms_key_arn`]).
1060pub fn kms_key_or_aws_managed(
1061    hook: Option<&dyn KmsHook>,
1062    named: Option<&str>,
1063    account_id: &str,
1064    region: &str,
1065    service: &str,
1066) -> Option<String> {
1067    match named.filter(|k| !k.is_empty()) {
1068        Some(key) => Some(key.to_string()),
1069        None => aws_managed_kms_key_arn(hook, account_id, region, service),
1070    }
1071}
1072
1073/// [`kms_key_or_aws_managed`] for services that report the key as a key ARN
1074/// whatever form the caller named it in (RDS, DocumentDB, Neptune, EBS): a
1075/// named key is resolved with [`resolve_named_kms_key_arn`].
1076pub fn kms_key_arn_or_aws_managed(
1077    hook: Option<&dyn KmsHook>,
1078    named: Option<&str>,
1079    account_id: &str,
1080    region: &str,
1081    service: &str,
1082) -> Option<String> {
1083    match named.filter(|k| !k.is_empty()) {
1084        Some(key) => Some(resolve_named_kms_key_arn(
1085            hook, key, account_id, region, service,
1086        )),
1087        None => aws_managed_kms_key_arn(hook, account_id, region, service),
1088    }
1089}
1090
1091/// The ARN of the key a caller named (key id, key ARN, alias name or alias
1092/// ARN) as KMS resolves it: an alias reports its target key's ARN, and an
1093/// AWS-managed alias (`alias/aws/<svc>`) that region's AWS-managed key, minted
1094/// on first use. A key KMS cannot resolve (one never created in fakecloud, one
1095/// in another region than `region`, or no hook wired) is reported as given.
1096pub fn resolve_named_kms_key_arn(
1097    hook: Option<&dyn KmsHook>,
1098    key: &str,
1099    account_id: &str,
1100    region: &str,
1101    service: &str,
1102) -> String {
1103    let Some(h) = hook else {
1104        return key.to_string();
1105    };
1106    // A KMS ARN naming another region is a key the resource's region cannot
1107    // use: report it as given rather than resolving (or minting) anything in
1108    // that other region.
1109    if let Some(rest) = key.strip_prefix("arn:") {
1110        if rest.split(':').nth(2).is_some_and(|r| r != region) {
1111            return key.to_string();
1112        }
1113    }
1114    // KMS resolves an alias name in `region` and an alias ARN in the region
1115    // it names; `alias/aws/<svc>` resolves to (minting on first use) that
1116    // region's AWS-managed key for `<svc>`, provisioned for that service.
1117    let resource = key.rsplit(':').next().unwrap_or(key);
1118    let owner = resource
1119        .strip_prefix("alias/aws/")
1120        .filter(|s| !s.is_empty())
1121        .unwrap_or(service);
1122    let principal = format!("{owner}.amazonaws.com");
1123    match h.resolve_key_arn(account_id, region, key, &principal) {
1124        Ok(arn) => arn,
1125        Err(err) => {
1126            tracing::debug!(%err, key, "KMS key not resolvable; reporting it as given");
1127            key.to_string()
1128        }
1129    }
1130}
1131
1132/// Cognito-issued JWT verification hook. Implementations are wired by
1133/// fakecloud-server and back the `COGNITO_USER_POOLS` authorizer in
1134/// API Gateway v1. The verifier validates RS256 signature, exp/nbf,
1135/// `iss`, and `aud`/`client_id` against the user pool referenced by
1136/// the authorizer's `providerArns`. On success returns the decoded
1137/// claims as a JSON object; on failure returns an error string the
1138/// caller surfaces as `401 Unauthorized`.
1139pub trait CognitoJwtVerifier: Send + Sync {
1140    fn verify_token(
1141        &self,
1142        account_id: &str,
1143        user_pool_arn: &str,
1144        token: &str,
1145    ) -> Result<serde_json::Value, String>;
1146}
1147
1148impl DeliveryBus {
1149    pub fn new() -> Self {
1150        Self {
1151            sqs_sender: None,
1152            sns_sender: None,
1153            eventbridge_sender: None,
1154            lambda_invoker: None,
1155            kinesis_sender: None,
1156            stepfunctions_starter: None,
1157            sagemaker_pipeline_starter: None,
1158            s3_writer: None,
1159            firehose_sender: None,
1160            ses_dispatcher: None,
1161            ecs_task_runner: None,
1162            elbv2_target_registration: None,
1163            ec2_network_lookup: None,
1164            cloudwatch_metrics: None,
1165            cloudwatch_logs: None,
1166            cognito_jwt_verifier: None,
1167            kms_hook: None,
1168        }
1169    }
1170
1171    pub fn with_cognito_jwt_verifier(mut self, verifier: Arc<dyn CognitoJwtVerifier>) -> Self {
1172        self.cognito_jwt_verifier = Some(verifier);
1173        self
1174    }
1175
1176    pub fn with_kms_hook(mut self, hook: Arc<dyn KmsHook>) -> Self {
1177        self.kms_hook = Some(hook);
1178        self
1179    }
1180
1181    /// Encrypt plaintext with the supplied KMS key. Returns Err when no
1182    /// KMS hook is wired or the encryption fails.
1183    pub fn kms_encrypt(
1184        &self,
1185        account_id: &str,
1186        region: &str,
1187        key_id: &str,
1188        plaintext: &[u8],
1189        caller: KmsCaller<'_>,
1190        encryption_context: std::collections::HashMap<String, String>,
1191    ) -> Result<String, KmsError> {
1192        match self.kms_hook {
1193            Some(ref h) => h.encrypt(
1194                account_id,
1195                region,
1196                key_id,
1197                plaintext,
1198                caller,
1199                encryption_context,
1200            ),
1201            None => Err(KmsError::not_configured()),
1202        }
1203    }
1204
1205    /// Decrypt a KMS ciphertext blob. Returns Err when no KMS hook is
1206    /// wired or the decryption fails.
1207    pub fn kms_decrypt(
1208        &self,
1209        account_id: &str,
1210        ciphertext_b64: &str,
1211        caller: KmsCaller<'_>,
1212        encryption_context: std::collections::HashMap<String, String>,
1213    ) -> Result<Vec<u8>, KmsError> {
1214        match self.kms_hook {
1215            Some(ref h) => h.decrypt(account_id, ciphertext_b64, caller, encryption_context),
1216            None => Err(KmsError::not_configured()),
1217        }
1218    }
1219
1220    /// Verify a Cognito JWT against a user pool. Returns `Err` when no
1221    /// verifier is wired or when the token fails validation.
1222    pub fn verify_cognito_jwt(
1223        &self,
1224        account_id: &str,
1225        user_pool_arn: &str,
1226        token: &str,
1227    ) -> Result<serde_json::Value, String> {
1228        match self.cognito_jwt_verifier {
1229            Some(ref v) => v.verify_token(account_id, user_pool_arn, token),
1230            None => Err("Cognito JWT verifier not configured".to_string()),
1231        }
1232    }
1233
1234    pub fn with_cloudwatch_metrics(mut self, sender: Arc<dyn CloudwatchDelivery>) -> Self {
1235        self.cloudwatch_metrics = Some(sender);
1236        self
1237    }
1238
1239    /// Publish a CloudWatch metric data point. Silently no-ops when no
1240    /// CloudWatch sender is wired (in-process tests not exercising the
1241    /// metrics path).
1242    #[allow(clippy::too_many_arguments)]
1243    pub fn put_cloudwatch_metric(
1244        &self,
1245        account_id: &str,
1246        region: &str,
1247        namespace: &str,
1248        metric_name: &str,
1249        value: f64,
1250        unit: Option<&str>,
1251        dimensions: std::collections::BTreeMap<String, String>,
1252        timestamp_ms: i64,
1253    ) {
1254        if let Some(ref sender) = self.cloudwatch_metrics {
1255            sender.put_metric(
1256                account_id,
1257                region,
1258                namespace,
1259                metric_name,
1260                value,
1261                unit,
1262                dimensions,
1263                timestamp_ms,
1264            );
1265        }
1266    }
1267
1268    pub fn with_cloudwatch_logs(mut self, sender: Arc<dyn CloudwatchLogsDelivery>) -> Self {
1269        self.cloudwatch_logs = Some(sender);
1270        self
1271    }
1272
1273    /// Put log events to a CloudWatch Logs log group / stream.
1274    /// Silently no-ops when no CloudWatch Logs sender is wired.
1275    pub fn put_log_events(
1276        &self,
1277        account_id: &str,
1278        region: &str,
1279        log_group_name: &str,
1280        log_stream_name: &str,
1281        events: &[(i64, String)],
1282    ) {
1283        if let Some(ref sender) = self.cloudwatch_logs {
1284            sender.put_log_events(account_id, region, log_group_name, log_stream_name, events);
1285        }
1286    }
1287
1288    pub fn with_ses_dispatcher(mut self, dispatcher: Arc<dyn SesSendEmailDispatcher>) -> Self {
1289        self.ses_dispatcher = Some(dispatcher);
1290        self
1291    }
1292
1293    pub fn with_ecs_task_runner(mut self, runner: Arc<dyn EcsTaskRunner>) -> Self {
1294        self.ecs_task_runner = Some(runner);
1295        self
1296    }
1297
1298    pub fn with_elbv2_target_registration(mut self, reg: Arc<dyn Elbv2TargetRegistration>) -> Self {
1299        self.elbv2_target_registration = Some(reg);
1300        self
1301    }
1302
1303    pub fn with_ec2_network_lookup(mut self, lookup: Arc<dyn Ec2NetworkLookup>) -> Self {
1304        self.ec2_network_lookup = Some(lookup);
1305        self
1306    }
1307
1308    /// Create a task ENI in an EC2 subnet. `None` when no EC2 lookup is
1309    /// wired; `Some(Err)` when EC2 refused (unknown subnet, subnet full).
1310    pub fn create_task_eni(
1311        &self,
1312        account_id: &str,
1313        region: &str,
1314        subnet_id: &str,
1315        group_ids: Vec<String>,
1316        description: String,
1317    ) -> Option<Result<TaskEni, String>> {
1318        self.ec2_network_lookup
1319            .as_ref()
1320            .map(|l| l.create_task_eni(account_id, region, subnet_id, group_ids, description))
1321    }
1322
1323    /// Delete a task ENI created with [`Self::create_task_eni`].
1324    pub fn delete_task_eni(&self, account_id: &str, region: &str, eni_id: &str) {
1325        if let Some(l) = &self.ec2_network_lookup {
1326            l.delete_task_eni(account_id, region, eni_id);
1327        }
1328    }
1329
1330    /// Register targets with an ELBv2 target group. Silently no-ops when
1331    /// no ELBv2 target registration hook is wired.
1332    pub fn register_elbv2_targets(
1333        &self,
1334        account_id: &str,
1335        target_group_arn: &str,
1336        targets: Vec<(String, Option<i64>)>,
1337    ) {
1338        if let Some(ref reg) = self.elbv2_target_registration {
1339            reg.register_targets(account_id, target_group_arn, targets);
1340        }
1341    }
1342
1343    /// Deregister targets from an ELBv2 target group. Silently no-ops
1344    /// when no ELBv2 target registration hook is wired.
1345    pub fn deregister_elbv2_targets(
1346        &self,
1347        account_id: &str,
1348        target_group_arn: &str,
1349        targets: Vec<(String, Option<i64>)>,
1350    ) {
1351        if let Some(ref reg) = self.elbv2_target_registration {
1352            reg.deregister_targets(account_id, target_group_arn, targets);
1353        }
1354    }
1355
1356    /// Send an email via SES. Returns `Err` when no SES dispatcher is
1357    /// wired or the underlying impl rejects (bad source/dest).
1358    #[allow(clippy::too_many_arguments)]
1359    pub fn send_ses_email(
1360        &self,
1361        account_id: &str,
1362        from: &str,
1363        to: Vec<String>,
1364        cc: Vec<String>,
1365        bcc: Vec<String>,
1366        subject: Option<&str>,
1367        text_body: Option<&str>,
1368        html_body: Option<&str>,
1369    ) -> Result<(), String> {
1370        match self.ses_dispatcher {
1371            Some(ref d) => {
1372                d.send_email(account_id, from, to, cc, bcc, subject, text_body, html_body)
1373            }
1374            None => Err("SES dispatcher not configured".to_string()),
1375        }
1376    }
1377
1378    /// Run an ECS task. Returns `Err` when no ECS runner is wired or
1379    /// the impl rejects (unknown cluster / task definition).
1380    pub fn run_ecs_task(
1381        &self,
1382        account_id: &str,
1383        cluster: &str,
1384        task_definition: &str,
1385        launch_type: Option<&str>,
1386        count: usize,
1387    ) -> Result<(), String> {
1388        match self.ecs_task_runner {
1389            Some(ref r) => r.run_task(account_id, cluster, task_definition, launch_type, count),
1390            None => Err("ECS task runner not configured".to_string()),
1391        }
1392    }
1393
1394    pub fn with_s3(mut self, sender: Arc<dyn S3Delivery>) -> Self {
1395        self.s3_writer = Some(sender);
1396        self
1397    }
1398
1399    pub fn with_firehose(mut self, sender: Arc<dyn FirehoseDelivery>) -> Self {
1400        self.firehose_sender = Some(sender);
1401        self
1402    }
1403
1404    /// Send a single record to a Firehose delivery stream by ARN.
1405    /// Silently no-ops when no Firehose sender is wired (in-process
1406    /// tests). Production wiring goes through fakecloud_firehose.
1407    pub fn put_record_to_firehose(&self, delivery_stream_arn: &str, data: &[u8]) {
1408        if let Some(ref sender) = self.firehose_sender {
1409            sender.put_record(delivery_stream_arn, data);
1410        }
1411    }
1412
1413    /// Write content to S3. Returns Err when no S3 writer is wired or
1414    /// when the underlying impl rejects the bucket / payload.
1415    pub fn put_object_to_s3(
1416        &self,
1417        account_id: &str,
1418        bucket: &str,
1419        key: &str,
1420        body: Vec<u8>,
1421        content_type: Option<&str>,
1422    ) -> Result<(), String> {
1423        match self.s3_writer {
1424            Some(ref sender) => sender.put_object(account_id, bucket, key, body, content_type),
1425            None => Err("S3 writer not configured".to_string()),
1426        }
1427    }
1428
1429    /// Read content from S3. Returns Err when no S3 client is wired or
1430    /// when the underlying impl cannot resolve the object.
1431    pub fn get_object_from_s3(
1432        &self,
1433        account_id: &str,
1434        bucket: &str,
1435        key: &str,
1436    ) -> Result<Vec<u8>, String> {
1437        match self.s3_writer {
1438            Some(ref sender) => sender.get_object(account_id, bucket, key),
1439            None => Err("S3 client not configured".to_string()),
1440        }
1441    }
1442
1443    pub fn with_sqs(mut self, sender: Arc<dyn SqsDelivery>) -> Self {
1444        self.sqs_sender = Some(sender);
1445        self
1446    }
1447
1448    pub fn with_sns(mut self, sender: Arc<dyn SnsDelivery>) -> Self {
1449        self.sns_sender = Some(sender);
1450        self
1451    }
1452
1453    pub fn with_eventbridge(mut self, sender: Arc<dyn EventBridgeDelivery>) -> Self {
1454        self.eventbridge_sender = Some(sender);
1455        self
1456    }
1457
1458    pub fn with_lambda(mut self, invoker: Arc<dyn LambdaDelivery>) -> Self {
1459        self.lambda_invoker = Some(invoker);
1460        self
1461    }
1462
1463    pub fn with_kinesis(mut self, sender: Arc<dyn KinesisDelivery>) -> Self {
1464        self.kinesis_sender = Some(sender);
1465        self
1466    }
1467
1468    /// Put a record to a Kinesis Data Stream identified by ARN.
1469    /// Silently no-ops when no Kinesis sender is wired.
1470    pub fn put_record_to_kinesis(&self, stream_arn: &str, data: &str, partition_key: &str) {
1471        if let Some(ref sender) = self.kinesis_sender {
1472            sender.put_record(stream_arn, data, partition_key);
1473        }
1474    }
1475
1476    pub fn with_sagemaker_pipeline(mut self, starter: Arc<dyn SageMakerPipelineDelivery>) -> Self {
1477        self.sagemaker_pipeline_starter = Some(starter);
1478        self
1479    }
1480
1481    /// Start a SageMaker pipeline execution if a starter is wired.
1482    pub fn start_sagemaker_pipeline(&self, pipeline_arn: &str, parameters: &serde_json::Value) {
1483        if let Some(ref starter) = self.sagemaker_pipeline_starter {
1484            starter.start_pipeline_execution(pipeline_arn, parameters);
1485        }
1486    }
1487
1488    pub fn with_stepfunctions(mut self, starter: Arc<dyn StepFunctionsDelivery>) -> Self {
1489        self.stepfunctions_starter = Some(starter);
1490        self
1491    }
1492
1493    /// The stored ARN of the SQS queue a QueueUrl names in `region`, if it
1494    /// exists.
1495    pub fn sqs_queue_arn_for_url(&self, region: &str, queue_url: &str) -> Option<String> {
1496        self.sqs_sender
1497            .as_ref()?
1498            .queue_arn_for_url(region, queue_url)
1499    }
1500
1501    /// Whether the queue `queue_arn` would take a message from `sender` (see
1502    /// [`SqsDelivery::check_sender`]). Passes when no SQS is wired.
1503    pub fn check_sqs_sender(
1504        &self,
1505        sender: DeliverySender<'_>,
1506        queue_arn: &str,
1507    ) -> Result<(), SqsDeliveryError> {
1508        match self.sqs_sender {
1509            Some(ref s) => s.check_sender(sender, queue_arn),
1510            None => Ok(()),
1511        }
1512    }
1513
1514    /// Send a message to an SQS queue identified by ARN.
1515    pub fn send_to_sqs(
1516        &self,
1517        from: DeliverySender<'_>,
1518        queue_arn: &str,
1519        message_body: &str,
1520        attributes: &HashMap<String, String>,
1521    ) {
1522        if let Some(ref sender) = self.sqs_sender {
1523            sender.deliver_to_queue(from, queue_arn, message_body, attributes);
1524        }
1525    }
1526
1527    /// Send a message to an SQS queue with message attributes and FIFO fields.
1528    pub fn send_to_sqs_with_attrs(
1529        &self,
1530        from: DeliverySender<'_>,
1531        queue_arn: &str,
1532        message_body: &str,
1533        message_attributes: &HashMap<String, SqsMessageAttribute>,
1534        message_group_id: Option<&str>,
1535        message_dedup_id: Option<&str>,
1536    ) {
1537        if let Some(ref sender) = self.sqs_sender {
1538            sender.deliver_to_queue_with_attrs(
1539                from,
1540                queue_arn,
1541                message_body,
1542                message_attributes,
1543                message_group_id,
1544                message_dedup_id,
1545            );
1546        }
1547    }
1548
1549    /// Fallible SQS send — returns `Err` when the target queue does not
1550    /// exist, so callers (Scheduler) can route to a DLQ. Returns
1551    /// `Err(QueueNotFound)` when no SQS sender is wired up at all,
1552    /// matching the "target unreachable" semantics Scheduler relies on.
1553    pub fn try_send_to_sqs_with_attrs(
1554        &self,
1555        from: DeliverySender<'_>,
1556        queue_arn: &str,
1557        message_body: &str,
1558        message_attributes: &HashMap<String, SqsMessageAttribute>,
1559        message_group_id: Option<&str>,
1560        message_dedup_id: Option<&str>,
1561    ) -> Result<(), SqsDeliveryError> {
1562        match self.sqs_sender {
1563            Some(ref sender) => sender.try_deliver_to_queue_with_attrs(
1564                from,
1565                queue_arn,
1566                message_body,
1567                message_attributes,
1568                message_group_id,
1569                message_dedup_id,
1570            ),
1571            None => Err(SqsDeliveryError::QueueNotFound(queue_arn.to_string())),
1572        }
1573    }
1574
1575    /// Publish a message to an SNS topic identified by ARN.
1576    pub fn publish_to_sns(&self, topic_arn: &str, message: &str, subject: Option<&str>) {
1577        if let Some(ref sender) = self.sns_sender {
1578            sender.publish_to_topic(topic_arn, message, subject);
1579        }
1580    }
1581
1582    /// Publish to an SNS topic with typed message attributes.
1583    pub fn publish_to_sns_with_attributes(
1584        &self,
1585        topic_arn: &str,
1586        message: &str,
1587        subject: Option<&str>,
1588        attributes: &HashMap<String, SqsMessageAttribute>,
1589    ) {
1590        if let Some(ref sender) = self.sns_sender {
1591            sender.publish_to_topic_with_attributes(topic_arn, message, subject, attributes);
1592        }
1593    }
1594
1595    /// Put a cross-service event onto an EventBridge bus (see
1596    /// [`CrossServiceEvent`] for how the bus and account are resolved).
1597    /// `Ok(None)` when no EventBridge sender is wired.
1598    pub fn put_event_to_eventbridge(
1599        &self,
1600        event: &CrossServiceEvent<'_>,
1601    ) -> Result<Option<String>, EventBridgeDeliveryError> {
1602        match self.eventbridge_sender {
1603            Some(ref sender) => sender.put_event(event).map(Some),
1604            None => Ok(None),
1605        }
1606    }
1607
1608    /// Invoke a Lambda function identified by ARN.
1609    pub async fn invoke_lambda(
1610        &self,
1611        function_arn: &str,
1612        payload: &str,
1613    ) -> Option<Result<LambdaInvocationOutput, String>> {
1614        if let Some(ref invoker) = self.lambda_invoker {
1615            Some(invoker.invoke_lambda(function_arn, payload).await)
1616        } else {
1617            None
1618        }
1619    }
1620
1621    /// Queue an asynchronous (`Event`) invocation of the function
1622    /// `function_arn` names (see [`LambdaDelivery::invoke_lambda_async`]).
1623    /// `None` when no Lambda invoker is wired.
1624    pub fn invoke_lambda_async(
1625        &self,
1626        function_arn: &str,
1627        payload: &str,
1628    ) -> Option<Result<(), String>> {
1629        self.lambda_invoker
1630            .as_ref()
1631            .map(|invoker| invoker.invoke_lambda_async(function_arn, payload))
1632    }
1633
1634    /// [`Self::invoke_lambda_async`] with an
1635    /// [`LambdaDelivery::invoke_lambda_async_observed`] hook.
1636    pub fn invoke_lambda_async_observed(
1637        &self,
1638        function_arn: &str,
1639        payload: &str,
1640        on_unrunnable: AsyncUnrunnableHook,
1641    ) -> Option<Result<(), String>> {
1642        self.lambda_invoker.as_ref().map(|invoker| {
1643            invoker.invoke_lambda_async_observed(function_arn, payload, on_unrunnable)
1644        })
1645    }
1646
1647    /// [`LambdaDelivery::start_durable_async`]. `None` when no Lambda
1648    /// invoker is wired or the function is not durable.
1649    pub fn start_durable_lambda_async(
1650        &self,
1651        function_arn: &str,
1652        payload: &str,
1653    ) -> Option<Result<(), String>> {
1654        self.lambda_invoker
1655            .as_ref()?
1656            .start_durable_async(function_arn, payload)
1657    }
1658
1659    /// Put a record to a Kinesis stream identified by ARN.
1660    pub fn send_to_kinesis(&self, stream_arn: &str, data: &str, partition_key: &str) {
1661        if let Some(ref sender) = self.kinesis_sender {
1662            sender.put_record(stream_arn, data, partition_key);
1663        }
1664    }
1665
1666    /// Start a Step Functions execution identified by state machine ARN.
1667    pub fn start_stepfunctions_execution(&self, state_machine_arn: &str, input: &str) {
1668        if let Some(ref starter) = self.stepfunctions_starter {
1669            starter.start_execution(state_machine_arn, input);
1670        }
1671    }
1672}
1673
1674impl Default for DeliveryBus {
1675    fn default() -> Self {
1676        Self::new()
1677    }
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682    use super::*;
1683    use std::sync::atomic::{AtomicUsize, Ordering};
1684    use std::sync::Arc;
1685
1686    // Mock implementations
1687    struct MockSqs {
1688        call_count: AtomicUsize,
1689    }
1690    impl SqsDelivery for MockSqs {
1691        fn deliver_to_queue(
1692            &self,
1693            _sender: DeliverySender<'_>,
1694            _queue_arn: &str,
1695            _message_body: &str,
1696            _attributes: &HashMap<String, String>,
1697        ) {
1698            self.call_count.fetch_add(1, Ordering::SeqCst);
1699        }
1700    }
1701
1702    struct MockSns {
1703        call_count: AtomicUsize,
1704    }
1705    impl SnsDelivery for MockSns {
1706        fn publish_to_topic(&self, _topic_arn: &str, _message: &str, _subject: Option<&str>) {
1707            self.call_count.fetch_add(1, Ordering::SeqCst);
1708        }
1709    }
1710
1711    struct MockEventBridge {
1712        call_count: AtomicUsize,
1713    }
1714    impl EventBridgeDelivery for MockEventBridge {
1715        fn put_event(
1716            &self,
1717            _event: &CrossServiceEvent<'_>,
1718        ) -> Result<String, EventBridgeDeliveryError> {
1719            self.call_count.fetch_add(1, Ordering::SeqCst);
1720            Ok("id".to_string())
1721        }
1722    }
1723
1724    fn test_event<'a>(source: &'a str, bus: &'a str) -> CrossServiceEvent<'a> {
1725        CrossServiceEvent {
1726            source,
1727            detail_type: "type",
1728            detail: "{}",
1729            event_bus: bus,
1730            account_id: "111111111111",
1731            region: "eu-west-1",
1732            resources: &[],
1733            principal_arn: None,
1734        }
1735    }
1736
1737    struct MockKinesis {
1738        call_count: AtomicUsize,
1739    }
1740    impl KinesisDelivery for MockKinesis {
1741        fn put_record(&self, _stream_arn: &str, _data: &str, _partition_key: &str) {
1742            self.call_count.fetch_add(1, Ordering::SeqCst);
1743        }
1744    }
1745
1746    struct MockStepFunctions {
1747        call_count: AtomicUsize,
1748    }
1749    impl StepFunctionsDelivery for MockStepFunctions {
1750        fn start_execution(&self, _state_machine_arn: &str, _input: &str) {
1751            self.call_count.fetch_add(1, Ordering::SeqCst);
1752        }
1753    }
1754
1755    #[test]
1756    fn delivery_bus_new_has_no_senders() {
1757        let bus = DeliveryBus::new();
1758        // Calling methods without senders should be no-ops
1759        bus.send_to_sqs(
1760            DeliverySender::Identity { arn: None },
1761            "arn:queue",
1762            "body",
1763            &HashMap::new(),
1764        );
1765        bus.publish_to_sns("arn:topic", "msg", None);
1766        let _ = bus.put_event_to_eventbridge(&test_event("src", "default"));
1767        bus.send_to_kinesis("arn:stream", "data", "pk");
1768        bus.start_stepfunctions_execution("arn:sfn", "{}");
1769        // No panics = success
1770    }
1771
1772    #[test]
1773    fn delivery_bus_default_is_same_as_new() {
1774        let bus = DeliveryBus::default();
1775        bus.send_to_sqs(
1776            DeliverySender::Identity { arn: None },
1777            "arn:q",
1778            "b",
1779            &HashMap::new(),
1780        );
1781    }
1782
1783    #[test]
1784    fn send_to_sqs_calls_sender() {
1785        let mock = Arc::new(MockSqs {
1786            call_count: AtomicUsize::new(0),
1787        });
1788        let bus = DeliveryBus::new().with_sqs(mock.clone());
1789
1790        bus.send_to_sqs(
1791            DeliverySender::Identity { arn: None },
1792            "arn:queue",
1793            "msg",
1794            &HashMap::new(),
1795        );
1796        assert_eq!(mock.call_count.load(Ordering::SeqCst), 1);
1797
1798        bus.send_to_sqs(
1799            DeliverySender::Identity { arn: None },
1800            "arn:queue2",
1801            "msg2",
1802            &HashMap::new(),
1803        );
1804        assert_eq!(mock.call_count.load(Ordering::SeqCst), 2);
1805    }
1806
1807    #[test]
1808    fn send_to_sqs_with_attrs_calls_sender() {
1809        let mock = Arc::new(MockSqs {
1810            call_count: AtomicUsize::new(0),
1811        });
1812        let bus = DeliveryBus::new().with_sqs(mock.clone());
1813
1814        let mut attrs = HashMap::new();
1815        attrs.insert(
1816            "key".to_string(),
1817            SqsMessageAttribute {
1818                data_type: "String".to_string(),
1819                string_value: Some("val".to_string()),
1820                binary_value: None,
1821            },
1822        );
1823        bus.send_to_sqs_with_attrs(
1824            DeliverySender::Identity { arn: None },
1825            "arn:q",
1826            "body",
1827            &attrs,
1828            Some("group"),
1829            Some("dedup"),
1830        );
1831        assert_eq!(mock.call_count.load(Ordering::SeqCst), 1);
1832    }
1833
1834    #[test]
1835    fn publish_to_sns_calls_sender() {
1836        let mock = Arc::new(MockSns {
1837            call_count: AtomicUsize::new(0),
1838        });
1839        let bus = DeliveryBus::new().with_sns(mock.clone());
1840
1841        bus.publish_to_sns("arn:topic", "message", Some("subject"));
1842        assert_eq!(mock.call_count.load(Ordering::SeqCst), 1);
1843    }
1844
1845    #[test]
1846    fn put_event_to_eventbridge_calls_sender() {
1847        let mock = Arc::new(MockEventBridge {
1848            call_count: AtomicUsize::new(0),
1849        });
1850        let bus = DeliveryBus::new().with_eventbridge(mock.clone());
1851
1852        let _ = bus.put_event_to_eventbridge(&test_event("aws.s3", "default"));
1853        assert_eq!(mock.call_count.load(Ordering::SeqCst), 1);
1854    }
1855
1856    #[test]
1857    fn send_to_kinesis_calls_sender() {
1858        let mock = Arc::new(MockKinesis {
1859            call_count: AtomicUsize::new(0),
1860        });
1861        let bus = DeliveryBus::new().with_kinesis(mock.clone());
1862
1863        bus.send_to_kinesis("arn:stream", "data", "partition-key");
1864        assert_eq!(mock.call_count.load(Ordering::SeqCst), 1);
1865    }
1866
1867    #[test]
1868    fn start_stepfunctions_calls_sender() {
1869        let mock = Arc::new(MockStepFunctions {
1870            call_count: AtomicUsize::new(0),
1871        });
1872        let bus = DeliveryBus::new().with_stepfunctions(mock.clone());
1873
1874        bus.start_stepfunctions_execution("arn:sfn:machine", r#"{"key":"val"}"#);
1875        assert_eq!(mock.call_count.load(Ordering::SeqCst), 1);
1876    }
1877
1878    #[test]
1879    fn builder_chaining_works() {
1880        let sqs = Arc::new(MockSqs {
1881            call_count: AtomicUsize::new(0),
1882        });
1883        let sns = Arc::new(MockSns {
1884            call_count: AtomicUsize::new(0),
1885        });
1886        let eb = Arc::new(MockEventBridge {
1887            call_count: AtomicUsize::new(0),
1888        });
1889        let kin = Arc::new(MockKinesis {
1890            call_count: AtomicUsize::new(0),
1891        });
1892        let sfn = Arc::new(MockStepFunctions {
1893            call_count: AtomicUsize::new(0),
1894        });
1895
1896        let bus = DeliveryBus::new()
1897            .with_sqs(sqs.clone())
1898            .with_sns(sns.clone())
1899            .with_eventbridge(eb.clone())
1900            .with_kinesis(kin.clone())
1901            .with_stepfunctions(sfn.clone());
1902
1903        bus.send_to_sqs(
1904            DeliverySender::Identity { arn: None },
1905            "q",
1906            "m",
1907            &HashMap::new(),
1908        );
1909        bus.publish_to_sns("t", "m", None);
1910        let _ = bus.put_event_to_eventbridge(&test_event("s", "b"));
1911        bus.send_to_kinesis("s", "d", "k");
1912        bus.start_stepfunctions_execution("sm", "{}");
1913
1914        assert_eq!(sqs.call_count.load(Ordering::SeqCst), 1);
1915        assert_eq!(sns.call_count.load(Ordering::SeqCst), 1);
1916        assert_eq!(eb.call_count.load(Ordering::SeqCst), 1);
1917        assert_eq!(kin.call_count.load(Ordering::SeqCst), 1);
1918        assert_eq!(sfn.call_count.load(Ordering::SeqCst), 1);
1919    }
1920
1921    #[test]
1922    fn invoke_lambda_async_returns_none_without_invoker() {
1923        let bus = DeliveryBus::new();
1924        assert!(bus.invoke_lambda_async("arn:lambda", "{}").is_none());
1925    }
1926
1927    #[test]
1928    fn invocation_output_reads_error_fields_only_for_function_errors() {
1929        let shaped = br#"{"errorType":"X","errorMessage":"m"}"#;
1930        let ok = LambdaInvocationOutput::success(shaped.to_vec());
1931        assert!(!ok.is_function_error());
1932        assert_eq!(ok.error_type(), None);
1933        assert_eq!(ok.error_message(), None);
1934
1935        let failed = LambdaInvocationOutput::unhandled(shaped.to_vec());
1936        assert_eq!(failed.function_error.as_deref(), Some("Unhandled"));
1937        assert_eq!(failed.error_type().as_deref(), Some("X"));
1938        assert_eq!(failed.error_message().as_deref(), Some("m"));
1939
1940        let untyped =
1941            LambdaInvocationOutput::unhandled(br#"{"errorMessage":"timed out"}"#.to_vec());
1942        assert_eq!(untyped.error_type(), None);
1943        assert_eq!(untyped.error_message().as_deref(), Some("timed out"));
1944    }
1945
1946    #[tokio::test]
1947    async fn invoke_lambda_returns_none_without_invoker() {
1948        let bus = DeliveryBus::new();
1949        let result = bus.invoke_lambda("arn:lambda", "{}").await;
1950        assert!(result.is_none());
1951    }
1952}