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#[async_trait]
11pub trait CommandDispatcher: Send + Sync + Debug {
12 async fn dispatch(&self, envelope: &Envelope) -> Result<()>;
14
15 fn as_any(&self) -> &dyn Any;
17}
18
19#[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 fn is_definite_cloud_rejection_status(status: u16) -> bool {
74 matches!(status, 400 | 401 | 403 | 404 | 409 | 413 | 415 | 422 | 429)
75 }
76
77 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 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 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 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 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 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 #[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 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 let invoke_request = InvokeRequest::builder()
311 .function_name(function_name.clone())
312 .invocation_type(InvocationType::Event) .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 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 #[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 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 let data = BASE64_STANDARD.encode(envelope_json.as_bytes());
401
402 let topic_id = self.topic_id.clone();
403
404 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 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 #[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 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 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 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}