Skip to main content

alien_commands/
dispatchers.rs

1use std::any::Any;
2use std::fmt::Debug;
3
4use alien_error::{Context, ContextError, IntoAlienError, IntoAlienErrorDirect};
5use async_trait::async_trait;
6
7use crate::{error::Result, types::Envelope};
8
9/// Trait for dispatching command envelopes to agents via platform-specific transport
10#[async_trait]
11pub trait CommandDispatcher: Send + Sync + Debug {
12    /// Dispatch an envelope to the target agent
13    async fn dispatch(&self, envelope: &Envelope) -> Result<()>;
14
15    /// Helper method for downcasting to concrete types in tests
16    fn as_any(&self) -> &dyn Any;
17}
18
19/// No-op command dispatcher that succeeds without doing anything
20#[derive(Debug)]
21pub struct NullCommandDispatcher;
22
23#[async_trait]
24impl CommandDispatcher for NullCommandDispatcher {
25    async fn dispatch(&self, envelope: &Envelope) -> Result<()> {
26        tracing::debug!(
27            command_id = %envelope.command_id,
28            command = %envelope.command,
29            "NullCommandDispatcher: no-op dispatch"
30        );
31        Ok(())
32    }
33
34    fn as_any(&self) -> &dyn Any {
35        self
36    }
37}
38
39#[cfg(any(feature = "server", feature = "dispatchers"))]
40mod platform_dispatchers {
41    use super::*;
42    use alien_aws_clients::aws::{
43        lambda::{InvocationType, InvokeRequest, LambdaApi, LambdaClient},
44        AwsClientConfig,
45    };
46    use alien_aws_clients::AwsCredentialProvider;
47    use alien_azure_clients::azure::{
48        service_bus::{
49            AzureServiceBusDataPlaneClient, SendMessageParameters, ServiceBusDataPlaneApi,
50        },
51        token_cache::AzureTokenCache,
52        AzureClientConfig,
53    };
54    use alien_client_core::{
55        redact_request_body, Error as CloudClientError, ErrorData as CloudClientErrorData,
56    };
57    use alien_gcp_clients::gcp::{
58        pubsub::{PubSubApi, PubSubClient, PublishRequest, PubsubMessage},
59        GcpClientConfig,
60    };
61    use base64::prelude::*;
62    use reqwest::Client;
63    use std::collections::HashMap;
64    use std::time::Duration;
65
66    const HTTP_COMMAND_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
67
68    /// Provider responses that unambiguously reject the current request.
69    ///
70    /// Timeouts and server errors are deliberately excluded: the provider may
71    /// have accepted the command before the acknowledgement was lost. Keep the
72    /// allowlist narrow instead of treating every 4xx as proof of non-delivery.
73    fn is_definite_cloud_rejection_status(status: u16) -> bool {
74        matches!(status, 400 | 401 | 403 | 404 | 409 | 413 | 415 | 422 | 429)
75    }
76
77    /// Service Bus obtains credentials and builds the request before sending
78    /// it. Those local failures, plus an explicit allowlisted HTTP response,
79    /// prove that the queue did not accept this command.
80    fn is_definite_service_bus_rejection(error: &CloudClientError) -> bool {
81        match error.error.as_ref() {
82            Some(
83                CloudClientErrorData::AuthenticationError { .. }
84                | CloudClientErrorData::InvalidClientConfig { .. }
85                | CloudClientErrorData::SerializationError { .. },
86            ) => true,
87            Some(CloudClientErrorData::HttpResponseError { http_status, .. }) => {
88                is_definite_cloud_rejection_status(*http_status)
89            }
90            _ => false,
91        }
92    }
93
94    fn http_status_from_context(context: Option<&serde_json::Value>) -> Option<u16> {
95        context
96            .and_then(serde_json::Value::as_object)
97            .and_then(|fields| {
98                fields
99                    .get("http_status")
100                    .or_else(|| fields.get("httpStatus"))
101            })
102            .and_then(serde_json::Value::as_u64)
103            .and_then(|status| u16::try_from(status).ok())
104    }
105
106    /// Find the provider HTTP status before erasing response-derived context.
107    fn command_provider_http_status(error: &CloudClientError) -> Option<u16> {
108        if let Some(CloudClientErrorData::HttpResponseError { http_status, .. }) =
109            error.error.as_ref()
110        {
111            return Some(*http_status);
112        }
113        if let Some(status) = http_status_from_context(error.context.as_ref()) {
114            return Some(status);
115        }
116
117        let mut layer = error.source.as_deref();
118        while let Some(source) = layer {
119            if let Some(status) = http_status_from_context(source.context.as_ref()) {
120                return Some(status);
121            }
122            layer = source.source.as_deref();
123        }
124        None
125    }
126
127    /// Remove all provider request/response-derived details from a command
128    /// dispatch error while retaining machine-readable error codes and the
129    /// provider HTTP status. Provider and proxy response bodies can reflect the
130    /// submitted envelope, including inline params and signed URLs, so keeping
131    /// only the category and numeric status is the safe command boundary.
132    fn scrub_command_provider_error(mut error: CloudClientError) -> CloudClientError {
133        let provider_status = command_provider_http_status(&error);
134        error.message = provider_status.map_or_else(
135            || format!("Cloud provider request failed ({})", error.code),
136            |status| format!("Cloud provider request returned HTTP {status}"),
137        );
138        error.context = provider_status.map(|status| {
139            serde_json::json!({
140                "http_status": status,
141            })
142        });
143        error.hint = None;
144        error.error = None;
145
146        let mut layer = error.source.as_deref_mut();
147        while let Some(source) = layer {
148            source.message = format!("Cloud provider error ({})", source.code);
149            source.context = None;
150            source.hint = None;
151            source.error = None;
152            layer = source.source.as_deref_mut();
153        }
154
155        error
156    }
157
158    /// HTTP command dispatcher used by Local and Kubernetes Workers.
159    ///
160    /// The target is the runtime-owned command endpoint, not an application
161    /// route. The deployment token authenticates the operator/manager relay to
162    /// the Worker runtime.
163    pub struct HttpCommandDispatcher {
164        client: Client,
165        target_url: String,
166        token: String,
167        request_timeout: Duration,
168    }
169
170    impl Debug for HttpCommandDispatcher {
171        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172            f.debug_struct("HttpCommandDispatcher")
173                .field("target_url", &self.target_url)
174                .field("token", &"[REDACTED]")
175                .finish()
176        }
177    }
178
179    impl HttpCommandDispatcher {
180        pub fn new(client: Client, target_url: String, token: String) -> Self {
181            Self {
182                client,
183                target_url,
184                token,
185                request_timeout: HTTP_COMMAND_REQUEST_TIMEOUT,
186            }
187        }
188
189        #[cfg(test)]
190        pub(crate) fn with_request_timeout(mut self, request_timeout: Duration) -> Self {
191            self.request_timeout = request_timeout;
192            self
193        }
194    }
195
196    #[async_trait]
197    impl CommandDispatcher for HttpCommandDispatcher {
198        async fn dispatch(&self, envelope: &Envelope) -> Result<()> {
199            let response = match self
200                .client
201                .post(&self.target_url)
202                .bearer_auth(&self.token)
203                .json(envelope)
204                .timeout(self.request_timeout)
205                .send()
206                .await
207            {
208                Ok(response) => response,
209                Err(error) => {
210                    // Builder/connect failures happen before the runtime can
211                    // accept the envelope. Timeouts and other request errors
212                    // are ambiguous: the runtime may have returned 202 after
213                    // the acknowledgement path was lost.
214                    let definite_non_delivery = error.is_builder() || error.is_connect();
215                    let error = error.without_url();
216                    let context = if definite_non_delivery {
217                        crate::ErrorData::TransportDispatchRejected {
218                            message: "Worker runtime was unreachable before command delivery"
219                                .to_string(),
220                            transport_type: Some("http".to_string()),
221                            target: Some(envelope.command_id.clone()),
222                        }
223                    } else {
224                        crate::ErrorData::TransportDispatchFailed {
225                            message: "Worker runtime acknowledgement was not received".to_string(),
226                            transport_type: Some("http".to_string()),
227                            target: Some(envelope.command_id.clone()),
228                        }
229                    };
230                    return Err(error.into_alien_error().context(context));
231                }
232            };
233
234            // The runtime contract returns 202 only after validation,
235            // duplicate suppression, and tracked-task acceptance. A legacy
236            // application route returning another 2xx is not delivery.
237            if response.status() != reqwest::StatusCode::ACCEPTED {
238                return Err(alien_error::AlienError::new(
239                    crate::ErrorData::TransportDispatchRejected {
240                        message: format!(
241                            "Worker runtime rejected command push with HTTP {}",
242                            response.status()
243                        ),
244                        transport_type: Some("http".to_string()),
245                        target: Some(envelope.command_id.clone()),
246                    },
247                ));
248            }
249
250            tracing::debug!(
251                command_id = %envelope.command_id,
252                command = %envelope.command,
253                target_url = %self.target_url,
254                "Successfully pushed command envelope to Worker runtime"
255            );
256
257            Ok(())
258        }
259
260        fn as_any(&self) -> &dyn Any {
261            self
262        }
263    }
264
265    /// AWS Lambda command dispatcher using InvokeFunction API
266    #[derive(Debug)]
267    pub struct LambdaCommandDispatcher {
268        lambda_client: LambdaClient,
269        function_name: String,
270    }
271
272    impl LambdaCommandDispatcher {
273        pub async fn new(
274            client: Client,
275            config: AwsClientConfig,
276            function_name: String,
277        ) -> Result<Self> {
278            let credentials = AwsCredentialProvider::from_config(config)
279                .await
280                .into_alien_error()
281                .context(crate::ErrorData::TransportDispatchFailed {
282                    message: "Failed to create AWS credential provider".to_string(),
283                    transport_type: Some("lambda".to_string()),
284                    target: None,
285                })?;
286            Ok(Self {
287                lambda_client: LambdaClient::new(client, credentials),
288                function_name,
289            })
290        }
291    }
292
293    #[async_trait]
294    impl CommandDispatcher for LambdaCommandDispatcher {
295        async fn dispatch(&self, envelope: &Envelope) -> Result<()> {
296            // Serialize the command envelope as JSON payload
297            let payload = serde_json::to_vec(envelope).into_alien_error().context(
298                crate::ErrorData::TransportDispatchRejected {
299                    message: "Failed to serialize command envelope before Lambda dispatch"
300                        .to_string(),
301                    transport_type: Some("lambda".to_string()),
302                    target: Some(envelope.command_id.clone()),
303                },
304            )?;
305
306            let function_name = self.function_name.clone();
307
308            // Use async invocation to send the envelope to the Lambda function
309            // The Lambda function should have alien-worker-runtime configured to handle command envelopes
310            let invoke_request = InvokeRequest::builder()
311                .function_name(function_name.clone())
312                .invocation_type(InvocationType::Event) // Async invocation
313                .payload(payload)
314                .build();
315
316            let invoke_response = self.lambda_client.invoke(invoke_request).await.context(
317                crate::ErrorData::TransportDispatchFailed {
318                    message: format!("Failed to invoke Lambda function {}", function_name),
319                    transport_type: Some("lambda".to_string()),
320                    target: Some(envelope.command_id.clone()),
321                },
322            )?;
323
324            // AWS Lambda's Event invocation contract acknowledges queueing
325            // with exactly 202. The client intentionally exposes other HTTP
326            // statuses in InvokeResponse, so classify them here without
327            // changing the general-purpose Lambda client API.
328            if invoke_response.status_code != reqwest::StatusCode::ACCEPTED.as_u16() {
329                let context = if is_definite_cloud_rejection_status(invoke_response.status_code) {
330                    crate::ErrorData::TransportDispatchRejected {
331                        message: format!(
332                            "Lambda rejected asynchronous invocation with HTTP {}",
333                            invoke_response.status_code
334                        ),
335                        transport_type: Some("lambda".to_string()),
336                        target: Some(envelope.command_id.clone()),
337                    }
338                } else {
339                    crate::ErrorData::TransportDispatchFailed {
340                        message: format!(
341                            "Lambda asynchronous invocation acknowledgement was HTTP {} instead of 202",
342                            invoke_response.status_code
343                        ),
344                        transport_type: Some("lambda".to_string()),
345                        target: Some(envelope.command_id.clone()),
346                    }
347                };
348                return Err(alien_error::AlienError::new(context));
349            }
350
351            tracing::debug!(
352                command_id = %envelope.command_id,
353                command = %envelope.command,
354                function_name = %function_name,
355                "Successfully dispatched command envelope to Lambda function"
356            );
357
358            Ok(())
359        }
360
361        fn as_any(&self) -> &dyn Any {
362            self
363        }
364    }
365
366    /// GCP Pub/Sub command dispatcher
367    #[derive(Debug)]
368    pub struct PubSubCommandDispatcher {
369        pubsub_client: PubSubClient,
370        #[allow(dead_code)]
371        project_id: String,
372        topic_id: String,
373    }
374
375    impl PubSubCommandDispatcher {
376        pub fn new(client: Client, config: GcpClientConfig, topic_id: String) -> Self {
377            let project_id = config.project_id.clone();
378            Self {
379                pubsub_client: PubSubClient::new(client, config),
380                project_id,
381                topic_id,
382            }
383        }
384    }
385
386    #[async_trait]
387    impl CommandDispatcher for PubSubCommandDispatcher {
388        async fn dispatch(&self, envelope: &Envelope) -> Result<()> {
389            // Serialize the command envelope as JSON
390            let envelope_json = serde_json::to_string(envelope).into_alien_error().context(
391                crate::ErrorData::TransportDispatchRejected {
392                    message: "Failed to serialize command envelope before Pub/Sub dispatch"
393                        .to_string(),
394                    transport_type: Some("pubsub".to_string()),
395                    target: Some(envelope.command_id.clone()),
396                },
397            )?;
398
399            // Base64 encode the JSON payload as required by Pub/Sub
400            let data = BASE64_STANDARD.encode(envelope_json.as_bytes());
401
402            let topic_id = self.topic_id.clone();
403
404            // Create the Pub/Sub message with command envelope metadata
405            let mut attributes = HashMap::new();
406            attributes.insert("cmd-protocol".to_string(), envelope.protocol.clone());
407            attributes.insert("cmd-command-id".to_string(), envelope.command_id.clone());
408            attributes.insert("cmd-command".to_string(), envelope.command.clone());
409
410            let message = PubsubMessage::builder()
411                .data(data)
412                .attributes(attributes)
413                .build();
414
415            let publish_request = PublishRequest::builder().messages(vec![message]).build();
416
417            // Pub/Sub's generic client retries internally. A final 4xx does
418            // not prove an earlier timed-out attempt was not accepted, so all
419            // provider-call errors remain ambiguous. The canonical scrub must
420            // run before adding a non-internal command error layer because the
421            // captured request contains the base64 command envelope.
422            let publish_result = redact_request_body(
423                self.pubsub_client
424                    .publish(topic_id.clone(), publish_request)
425                    .await,
426            );
427            if let Err(error) = publish_result {
428                return Err(scrub_command_provider_error(error).context(
429                    crate::ErrorData::TransportDispatchFailed {
430                        message: format!("Failed to publish to Pub/Sub topic {}", topic_id),
431                        transport_type: Some("pubsub".to_string()),
432                        target: Some(envelope.command_id.clone()),
433                    },
434                ));
435            }
436
437            tracing::debug!(
438                command_id = %envelope.command_id,
439                command = %envelope.command,
440                topic_id = %topic_id,
441                "Successfully dispatched command envelope to Pub/Sub topic"
442            );
443
444            Ok(())
445        }
446
447        fn as_any(&self) -> &dyn Any {
448            self
449        }
450    }
451
452    /// Azure Service Bus command dispatcher
453    #[derive(Debug)]
454    pub struct ServiceBusCommandDispatcher {
455        servicebus_client: AzureServiceBusDataPlaneClient,
456        namespace_name: String,
457        queue_name: String,
458    }
459
460    impl ServiceBusCommandDispatcher {
461        pub fn new(
462            client: Client,
463            config: AzureClientConfig,
464            namespace_name: String,
465            queue_name: String,
466        ) -> Self {
467            Self {
468                servicebus_client: AzureServiceBusDataPlaneClient::new(
469                    client,
470                    AzureTokenCache::new(config),
471                ),
472                namespace_name,
473                queue_name,
474            }
475        }
476    }
477
478    #[async_trait]
479    impl CommandDispatcher for ServiceBusCommandDispatcher {
480        async fn dispatch(&self, envelope: &Envelope) -> Result<()> {
481            // Serialize the command envelope as JSON
482            let envelope_json = serde_json::to_string(envelope).into_alien_error().context(
483                crate::ErrorData::TransportDispatchRejected {
484                    message: "Failed to serialize command envelope before Service Bus dispatch"
485                        .to_string(),
486                    transport_type: Some("servicebus".to_string()),
487                    target: Some(envelope.command_id.clone()),
488                },
489            )?;
490
491            let namespace_name = self.namespace_name.clone();
492            let queue_name = self.queue_name.clone();
493
494            // Create custom properties for command metadata
495            let mut custom_properties = HashMap::new();
496            custom_properties.insert("cmd-protocol".to_string(), envelope.protocol.clone());
497            custom_properties.insert("cmd-command-id".to_string(), envelope.command_id.clone());
498            custom_properties.insert("cmd-command".to_string(), envelope.command.clone());
499
500            let message = SendMessageParameters {
501                body: envelope_json,
502                broker_properties: None,
503                custom_properties,
504            };
505
506            // Service Bus does not retry SendMessage internally, so an
507            // allowlisted 4xx is a reliable rejection. Network failures,
508            // request timeouts, and 5xx responses are ambiguous. Scrub the
509            // command body before wrapping either category.
510            let send_result = redact_request_body(
511                self.servicebus_client
512                    .send_message(namespace_name.clone(), queue_name.clone(), message)
513                    .await,
514            );
515            if let Err(error) = send_result {
516                let context = if is_definite_service_bus_rejection(&error) {
517                    crate::ErrorData::TransportDispatchRejected {
518                        message: format!(
519                            "Service Bus rejected message for queue {}/{}",
520                            namespace_name, queue_name
521                        ),
522                        transport_type: Some("servicebus".to_string()),
523                        target: Some(envelope.command_id.clone()),
524                    }
525                } else {
526                    crate::ErrorData::TransportDispatchFailed {
527                        message: format!(
528                            "Service Bus acknowledgement failed for queue {}/{}",
529                            namespace_name, queue_name
530                        ),
531                        transport_type: Some("servicebus".to_string()),
532                        target: Some(envelope.command_id.clone()),
533                    }
534                };
535                return Err(scrub_command_provider_error(error).context(context));
536            }
537
538            tracing::debug!(
539                command_id = %envelope.command_id,
540                command = %envelope.command,
541                namespace = %namespace_name,
542                queue = %queue_name,
543                "Successfully dispatched command envelope to Service Bus queue"
544            );
545
546            Ok(())
547        }
548
549        fn as_any(&self) -> &dyn Any {
550            self
551        }
552    }
553}
554
555#[cfg(any(feature = "server", feature = "dispatchers"))]
556pub use platform_dispatchers::*;
557
558#[cfg(all(test, feature = "test-utils"))]
559mod tests {
560    use std::collections::HashMap;
561    use std::time::Duration;
562
563    use alien_core::{
564        AwsClientConfig, AwsCredentials, AwsServiceOverrides, AzureClientConfig, AzureCredentials,
565        AzureServiceOverrides, BodySpec, GcpClientConfig, GcpCredentials, GcpServiceOverrides,
566    };
567    use axum::{
568        extract::Json,
569        http::{header::AUTHORIZATION, HeaderMap, StatusCode},
570        routing::post,
571        Router,
572    };
573    use base64::prelude::*;
574
575    use super::{
576        CommandDispatcher, HttpCommandDispatcher, LambdaCommandDispatcher, PubSubCommandDispatcher,
577        ServiceBusCommandDispatcher,
578    };
579
580    const COMMAND_BODY_SENTINEL: &str = "COMMAND_ERROR_BODY_MUST_BE_REDACTED";
581    const SIGNED_URL_SENTINEL: &str =
582        "https://storage.example.test/result?signature=MUST_NOT_ESCAPE";
583
584    async fn spawn_static_response(
585        status: StatusCode,
586        body: &'static str,
587    ) -> (String, tokio::task::JoinHandle<()>) {
588        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
589        let address = listener.local_addr().unwrap();
590        let server = tokio::spawn(async move {
591            axum::serve(
592                listener,
593                Router::new().fallback(move || async move { (status, body) }),
594            )
595            .await
596            .unwrap();
597        });
598        (format!("http://{address}"), server)
599    }
600
601    fn sensitive_envelope(command_id: &str) -> crate::Envelope {
602        let mut envelope = crate::test_utils::test_envelope(
603            command_id,
604            COMMAND_BODY_SENTINEL,
605            BodySpec::inline(COMMAND_BODY_SENTINEL.as_bytes()),
606        );
607        envelope.response_handling.submit_response_url = SIGNED_URL_SENTINEL.to_string();
608        envelope
609    }
610
611    fn aws_config(lambda_endpoint: String) -> AwsClientConfig {
612        AwsClientConfig {
613            account_id: "123456789012".to_string(),
614            region: "us-east-1".to_string(),
615            credentials: AwsCredentials::AccessKeys {
616                access_key_id: "test-access-key".to_string(),
617                secret_access_key: "test-secret-key".to_string(),
618                session_token: None,
619            },
620            service_overrides: Some(AwsServiceOverrides {
621                endpoints: HashMap::from([("lambda".to_string(), lambda_endpoint)]),
622            }),
623        }
624    }
625
626    fn azure_config(service_bus_endpoint: String) -> AzureClientConfig {
627        AzureClientConfig {
628            subscription_id: "test-subscription".to_string(),
629            tenant_id: "test-tenant".to_string(),
630            region: Some("eastus".to_string()),
631            credentials: AzureCredentials::AccessToken {
632                token: "test-token".to_string(),
633            },
634            service_overrides: Some(AzureServiceOverrides {
635                endpoints: HashMap::from([("servicebus".to_string(), service_bus_endpoint)]),
636            }),
637        }
638    }
639
640    fn gcp_config(pubsub_endpoint: String) -> GcpClientConfig {
641        GcpClientConfig {
642            project_id: "test-project".to_string(),
643            region: "us-central1".to_string(),
644            credentials: GcpCredentials::AccessToken {
645                token: "test-token".to_string(),
646            },
647            service_overrides: Some(GcpServiceOverrides {
648                endpoints: HashMap::from([("pubsub".to_string(), pubsub_endpoint)]),
649            }),
650            project_number: Some("123456789012".to_string()),
651        }
652    }
653
654    async fn accept_command(
655        headers: HeaderMap,
656        Json(envelope): Json<crate::Envelope>,
657    ) -> StatusCode {
658        if headers
659            .get(AUTHORIZATION)
660            .and_then(|value| value.to_str().ok())
661            == Some("Bearer secret")
662            && envelope.command_id == "cmd-http"
663        {
664            StatusCode::ACCEPTED
665        } else {
666            StatusCode::UNAUTHORIZED
667        }
668    }
669
670    async fn legacy_success_route() -> StatusCode {
671        StatusCode::OK
672    }
673
674    #[tokio::test]
675    async fn http_dispatcher_posts_authenticated_envelope_and_checks_status() {
676        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
677        let address = listener.local_addr().unwrap();
678        let server = tokio::spawn(async move {
679            axum::serve(
680                listener,
681                Router::new().route(crate::WORKER_COMMAND_PUSH_PATH, post(accept_command)),
682            )
683            .await
684            .unwrap();
685        });
686        let target_url = format!("http://{address}{}", crate::WORKER_COMMAND_PUSH_PATH);
687        let envelope = crate::test_utils::test_simple_envelope("cmd-http", "sync");
688
689        HttpCommandDispatcher::new(
690            reqwest::Client::new(),
691            target_url.clone(),
692            "secret".to_string(),
693        )
694        .dispatch(&envelope)
695        .await
696        .unwrap();
697
698        let error =
699            HttpCommandDispatcher::new(reqwest::Client::new(), target_url, "wrong".to_string())
700                .dispatch(&envelope)
701                .await
702                .unwrap_err();
703        assert_eq!(error.code, "TRANSPORT_DISPATCH_REJECTED");
704
705        server.abort();
706    }
707
708    #[tokio::test]
709    async fn http_dispatcher_rejects_legacy_200_route() {
710        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
711        let address = listener.local_addr().unwrap();
712        let server = tokio::spawn(async move {
713            axum::serve(
714                listener,
715                Router::new().route(crate::WORKER_COMMAND_PUSH_PATH, post(legacy_success_route)),
716            )
717            .await
718            .unwrap();
719        });
720        let envelope = crate::test_utils::test_simple_envelope("cmd-http", "sync");
721        let error = HttpCommandDispatcher::new(
722            reqwest::Client::new(),
723            format!("http://{address}{}", crate::WORKER_COMMAND_PUSH_PATH),
724            "secret".to_string(),
725        )
726        .dispatch(&envelope)
727        .await
728        .expect_err("only the runtime's 202 acceptance is delivery");
729
730        assert_eq!(error.code, "TRANSPORT_DISPATCH_REJECTED");
731        server.abort();
732    }
733
734    #[tokio::test]
735    async fn http_dispatcher_classifies_connection_refusal_as_definite_rejection() {
736        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
737        let address = listener.local_addr().unwrap();
738        drop(listener);
739        let envelope = crate::test_utils::test_simple_envelope("cmd-http", "sync");
740
741        let error = HttpCommandDispatcher::new(
742            reqwest::Client::new(),
743            format!("http://{address}{}", crate::WORKER_COMMAND_PUSH_PATH),
744            "secret".to_string(),
745        )
746        .dispatch(&envelope)
747        .await
748        .expect_err("connection refusal happens before delivery");
749
750        assert_eq!(error.code, "TRANSPORT_DISPATCH_REJECTED");
751    }
752
753    #[tokio::test]
754    async fn http_dispatcher_classifies_request_builder_failure_as_definite_rejection() {
755        let envelope = crate::test_utils::test_simple_envelope("cmd-http", "sync");
756
757        let error = HttpCommandDispatcher::new(
758            reqwest::Client::new(),
759            "http://[invalid-address".to_string(),
760            "secret".to_string(),
761        )
762        .dispatch(&envelope)
763        .await
764        .expect_err("invalid URL fails before delivery");
765
766        assert_eq!(error.code, "TRANSPORT_DISPATCH_REJECTED");
767    }
768
769    #[tokio::test]
770    async fn http_dispatcher_bounds_ambiguous_acknowledgement_wait() {
771        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
772        let address = listener.local_addr().unwrap();
773        let server = tokio::spawn(async move {
774            let (_socket, _) = listener.accept().await.unwrap();
775            std::future::pending::<()>().await;
776        });
777        let envelope = crate::test_utils::test_simple_envelope("cmd-http", "sync");
778
779        let error = HttpCommandDispatcher::new(
780            reqwest::Client::new(),
781            format!("http://{address}{}", crate::WORKER_COMMAND_PUSH_PATH),
782            "secret".to_string(),
783        )
784        .with_request_timeout(Duration::from_millis(50))
785        .dispatch(&envelope)
786        .await
787        .expect_err("blackholed acknowledgement must time out");
788
789        assert_eq!(error.code, "TRANSPORT_DISPATCH_FAILED");
790        server.abort();
791    }
792
793    #[test]
794    fn http_dispatcher_debug_redacts_token() {
795        let debug = format!(
796            "{:?}",
797            HttpCommandDispatcher::new(
798                reqwest::Client::new(),
799                "http://worker/_alien/commands".to_string(),
800                "super-secret".to_string(),
801            )
802        );
803        assert!(debug.contains("[REDACTED]"));
804        assert!(!debug.contains("super-secret"));
805    }
806
807    #[tokio::test]
808    async fn lambda_event_dispatch_accepts_exact_202() {
809        let (endpoint, server) = spawn_static_response(StatusCode::ACCEPTED, "").await;
810        let dispatcher = LambdaCommandDispatcher::new(
811            reqwest::Client::new(),
812            aws_config(endpoint),
813            "test-function".to_string(),
814        )
815        .await
816        .unwrap();
817
818        dispatcher
819            .dispatch(&sensitive_envelope("cmd-lambda-202"))
820            .await
821            .expect("Lambda Event invocation must accept exact HTTP 202");
822
823        server.abort();
824    }
825
826    #[tokio::test]
827    async fn lambda_event_dispatch_classifies_404_as_definite_rejection() {
828        let (endpoint, server) = spawn_static_response(
829            StatusCode::NOT_FOUND,
830            r#"{"__type":"ResourceNotFoundException","message":"missing"}"#,
831        )
832        .await;
833        let dispatcher = LambdaCommandDispatcher::new(
834            reqwest::Client::new(),
835            aws_config(endpoint),
836            "missing-function".to_string(),
837        )
838        .await
839        .unwrap();
840
841        let error = dispatcher
842            .dispatch(&sensitive_envelope("cmd-lambda-404"))
843            .await
844            .expect_err("an explicit Lambda 404 cannot have accepted the invocation");
845
846        assert_eq!(error.code, "TRANSPORT_DISPATCH_REJECTED");
847        server.abort();
848    }
849
850    #[tokio::test]
851    async fn lambda_event_dispatch_keeps_500_ambiguous() {
852        let (endpoint, server) = spawn_static_response(
853            StatusCode::INTERNAL_SERVER_ERROR,
854            r#"{"__type":"ServiceException","message":"unknown outcome"}"#,
855        )
856        .await;
857        let dispatcher = LambdaCommandDispatcher::new(
858            reqwest::Client::new(),
859            aws_config(endpoint),
860            "test-function".to_string(),
861        )
862        .await
863        .unwrap();
864
865        let error = dispatcher
866            .dispatch(&sensitive_envelope("cmd-lambda-500"))
867            .await
868            .expect_err("Lambda 5xx does not prove non-delivery");
869
870        assert_eq!(error.code, "TRANSPORT_DISPATCH_FAILED");
871        server.abort();
872    }
873
874    #[tokio::test]
875    async fn service_bus_dispatch_accepts_success_status() {
876        let (endpoint, server) = spawn_static_response(StatusCode::CREATED, "").await;
877        let dispatcher = ServiceBusCommandDispatcher::new(
878            reqwest::Client::new(),
879            azure_config(endpoint),
880            "test-namespace".to_string(),
881            "test-queue".to_string(),
882        );
883
884        dispatcher
885            .dispatch(&sensitive_envelope("cmd-servicebus-201"))
886            .await
887            .expect("Service Bus success status must acknowledge message acceptance");
888
889        server.abort();
890    }
891
892    #[tokio::test]
893    async fn service_bus_dispatch_rejects_404_and_redacts_command_body() {
894        let (endpoint, server) = spawn_static_response(
895            StatusCode::NOT_FOUND,
896            "COMMAND_ERROR_BODY_MUST_BE_REDACTED https://storage.example.test/result?signature=MUST_NOT_ESCAPE",
897        )
898        .await;
899        let dispatcher = ServiceBusCommandDispatcher::new(
900            reqwest::Client::new(),
901            azure_config(endpoint),
902            "test-namespace".to_string(),
903            "missing-queue".to_string(),
904        );
905
906        let error = dispatcher
907            .dispatch(&sensitive_envelope("cmd-servicebus-404"))
908            .await
909            .expect_err("an explicit Service Bus 404 cannot have accepted the message");
910        let serialized = serde_json::to_string(&error).unwrap();
911
912        assert_eq!(error.code, "TRANSPORT_DISPATCH_REJECTED");
913        assert!(serialized.contains(r#""http_status":404"#));
914        assert!(!serialized.contains(COMMAND_BODY_SENTINEL));
915        assert!(!serialized.contains(SIGNED_URL_SENTINEL));
916        assert!(!serialized.contains("http_request_text"));
917        server.abort();
918    }
919
920    #[tokio::test]
921    async fn service_bus_dispatch_keeps_500_ambiguous_and_redacts_command_body() {
922        let (endpoint, server) = spawn_static_response(
923            StatusCode::INTERNAL_SERVER_ERROR,
924            "COMMAND_ERROR_BODY_MUST_BE_REDACTED https://storage.example.test/result?signature=MUST_NOT_ESCAPE",
925        )
926        .await;
927        let dispatcher = ServiceBusCommandDispatcher::new(
928            reqwest::Client::new(),
929            azure_config(endpoint),
930            "test-namespace".to_string(),
931            "test-queue".to_string(),
932        );
933
934        let error = dispatcher
935            .dispatch(&sensitive_envelope("cmd-servicebus-500"))
936            .await
937            .expect_err("Service Bus 5xx does not prove non-delivery");
938        let serialized = serde_json::to_string(&error).unwrap();
939
940        assert_eq!(error.code, "TRANSPORT_DISPATCH_FAILED");
941        assert!(serialized.contains(r#""http_status":500"#));
942        assert!(!serialized.contains(COMMAND_BODY_SENTINEL));
943        assert!(!serialized.contains(SIGNED_URL_SENTINEL));
944        assert!(!serialized.contains("http_request_text"));
945        server.abort();
946    }
947
948    #[tokio::test]
949    async fn service_bus_dispatch_keeps_request_timeout_ambiguous() {
950        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
951        let address = listener.local_addr().unwrap();
952        let server = tokio::spawn(async move {
953            let (_socket, _) = listener.accept().await.unwrap();
954            std::future::pending::<()>().await;
955        });
956        let client = reqwest::Client::builder()
957            .timeout(Duration::from_millis(50))
958            .build()
959            .unwrap();
960        let dispatcher = ServiceBusCommandDispatcher::new(
961            client,
962            azure_config(format!("http://{address}")),
963            "test-namespace".to_string(),
964            "test-queue".to_string(),
965        );
966
967        let error = dispatcher
968            .dispatch(&sensitive_envelope("cmd-servicebus-timeout"))
969            .await
970            .expect_err("lost Service Bus acknowledgement is ambiguous");
971
972        assert_eq!(error.code, "TRANSPORT_DISPATCH_FAILED");
973        server.abort();
974    }
975
976    #[tokio::test]
977    async fn pubsub_403_remains_ambiguous_and_redacts_command_body() {
978        let (endpoint, server) = spawn_static_response(
979            StatusCode::FORBIDDEN,
980            r#"{"error":{"code":403,"message":"COMMAND_ERROR_BODY_MUST_BE_REDACTED https://storage.example.test/result?signature=MUST_NOT_ESCAPE","status":"PERMISSION_DENIED"}}"#,
981        )
982        .await;
983        let dispatcher = PubSubCommandDispatcher::new(
984            reqwest::Client::new(),
985            gcp_config(format!("{endpoint}/v1")),
986            "test-topic".to_string(),
987        );
988        let envelope = sensitive_envelope("cmd-pubsub-403");
989        let encoded_envelope = BASE64_STANDARD.encode(serde_json::to_vec(&envelope).unwrap());
990
991        let error = dispatcher
992            .dispatch(&envelope)
993            .await
994            .expect_err("Pub/Sub attempt history is insufficient for a definite rejection");
995        let serialized = serde_json::to_string(&error).unwrap();
996
997        assert_eq!(error.code, "TRANSPORT_DISPATCH_FAILED");
998        assert!(serialized.contains(r#""http_status":403"#));
999        assert!(!serialized.contains(COMMAND_BODY_SENTINEL));
1000        assert!(!serialized.contains(SIGNED_URL_SENTINEL));
1001        assert!(!serialized.contains(&encoded_envelope));
1002        assert!(!serialized.contains("http_request_text"));
1003        server.abort();
1004    }
1005}