1use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46use std::task::{Context, Poll};
47
48#[cfg(test)]
49use async_trait::async_trait;
50#[cfg(test)]
51use tokio::sync::Mutex;
52use tower::Service;
53use tracing::debug;
54
55use camel_component_api::{
56 Body, BoxProcessor, CamelError, Exchange, RouteAction, RuntimeCommand, RuntimeHandle,
57 RuntimeQuery, RuntimeQueryResult, parse_uri,
58};
59use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
60#[cfg(test)]
61use camel_component_api::{RouteStatus, RuntimeCommandBus, RuntimeCommandResult, RuntimeQueryBus};
62
63pub struct ControlBusComponent;
69
70impl ControlBusComponent {
71 pub fn new() -> Self {
73 Self
74 }
75}
76
77impl Default for ControlBusComponent {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl Component for ControlBusComponent {
84 fn scheme(&self) -> &str {
85 "controlbus"
86 }
87
88 fn create_endpoint(
89 &self,
90 uri: &str,
91 _ctx: &dyn camel_component_api::ComponentContext,
92 ) -> Result<Box<dyn Endpoint>, CamelError> {
93 let parts = parse_uri(uri)?;
94
95 if parts.scheme != "controlbus" {
96 return Err(CamelError::InvalidUri(format!(
97 "expected scheme 'controlbus', got '{}'",
98 parts.scheme
99 )));
100 }
101
102 let command = parts.path.clone();
104
105 if command != "route" {
107 return Err(CamelError::EndpointCreationFailed(format!(
108 "controlbus: unknown command '{}', only 'route' is supported",
109 command
110 )));
111 }
112
113 let route_id = parts.params.get("routeId").cloned();
115
116 let action = if let Some(action_str) = parts.params.get("action") {
118 Some(parse_action(action_str)?)
119 } else {
120 None
121 };
122
123 if command == "route" && action.is_none() {
125 return Err(CamelError::EndpointCreationFailed(
126 "controlbus: 'action' parameter is required for route command".to_string(),
127 ));
128 }
129
130 let authorized_routes: Option<Vec<String>> = parts
135 .params
136 .get("authorizedRoutes")
137 .map(|s| s.split(',').map(|r| r.trim().to_string()).collect());
138
139 Ok(Box::new(ControlBusEndpoint {
140 uri: uri.to_string(),
141 route_id,
142 action,
143 authorized_routes,
144 }))
145 }
146}
147
148fn parse_action(s: &str) -> Result<RouteAction, CamelError> {
150 match s.to_lowercase().as_str() {
151 "start" => Ok(RouteAction::Start),
152 "stop" => Ok(RouteAction::Stop),
153 "suspend" => Ok(RouteAction::Suspend),
154 "resume" => Ok(RouteAction::Resume),
155 "restart" => Ok(RouteAction::Restart),
156 "status" => Ok(RouteAction::Status),
157 _ => Err(CamelError::EndpointCreationFailed(format!(
158 "controlbus: unknown action '{}'",
159 s
160 ))),
161 }
162}
163
164struct ControlBusEndpoint {
170 uri: String,
171 route_id: Option<String>,
172 action: Option<RouteAction>,
173 authorized_routes: Option<Vec<String>>,
175}
176
177impl Endpoint for ControlBusEndpoint {
178 fn uri(&self) -> &str {
179 &self.uri
180 }
181
182 fn create_consumer(
183 &self,
184 _rt: Arc<dyn RuntimeObservability>,
185 ) -> Result<Box<dyn Consumer>, CamelError> {
186 Err(CamelError::EndpointCreationFailed(
187 "controlbus endpoint does not support consumers".to_string(),
188 ))
189 }
190
191 fn create_producer(
192 &self,
193 _rt: Arc<dyn RuntimeObservability>,
194 ctx: &ProducerContext,
195 ) -> Result<BoxProcessor, CamelError> {
196 let action = self.action.clone().ok_or_else(|| {
197 CamelError::EndpointCreationFailed(
198 "controlbus: action is required to create producer".to_string(),
199 )
200 })?;
201 let runtime = ctx.runtime().cloned().ok_or_else(|| {
202 CamelError::EndpointCreationFailed(
203 "controlbus: runtime handle is required in ProducerContext".to_string(),
204 )
205 })?;
206
207 let calling_route_id = ctx.route_id().map(|s| s.to_string());
209
210 Ok(BoxProcessor::new(ControlBusProducer {
211 route_id: self.route_id.clone(),
212 action,
213 runtime,
214 authorized_routes: self.authorized_routes.clone(),
215 calling_route_id,
216 }))
217 }
218}
219
220#[derive(Clone)]
226struct ControlBusProducer {
227 route_id: Option<String>,
229 action: RouteAction,
231 runtime: Arc<dyn RuntimeHandle>,
233 authorized_routes: Option<Vec<String>>,
235 calling_route_id: Option<String>,
237}
238
239impl Service<Exchange> for ControlBusProducer {
240 type Response = Exchange;
241 type Error = CamelError;
242 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
243
244 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
245 Poll::Ready(Ok(()))
246 }
247
248 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
249 let route_id = match self.route_id.clone() {
253 Some(id) => id,
254 None => {
255 return Box::pin(async move {
256 Err(CamelError::Unauthorized(
257 "controlbus: routeId must be declared in URI (header override denied)"
258 .into(),
259 ))
260 });
261 }
262 };
263
264 if let Err(e) = Self::authorize(
266 &route_id,
267 &self.authorized_routes,
268 self.calling_route_id.as_deref(),
269 ) {
270 return Box::pin(async move { Err(e) });
271 }
272
273 let action = self.action.clone();
274 let runtime = self.runtime.clone();
275 let command_scope = format!("controlbus:{route_id}:{}", exchange.correlation_id());
276
277 Box::pin(async move {
278 debug!(
279 route_id = %route_id,
280 action = ?action,
281 "ControlBus executing action"
282 );
283
284 match execute_runtime_action(runtime.as_ref(), &route_id, &action, &command_scope)
285 .await?
286 {
287 Some(status) => {
288 exchange.input.body = Body::Text(status);
289 Ok(exchange)
290 }
291 None => {
292 exchange.input.body = Body::Empty;
293 Ok(exchange)
294 }
295 }
296 })
297 }
298}
299
300impl ControlBusProducer {
301 fn authorize(
306 route_id: &str,
307 authorized_routes: &Option<Vec<String>>,
308 calling_route_id: Option<&str>,
309 ) -> Result<(), CamelError> {
310 match authorized_routes {
311 Some(list) if list.iter().any(|r| r == route_id) => {}
312 Some(_) => {
313 return Err(CamelError::Unauthorized(format!(
314 "controlbus: route '{route_id}' not in authorizedRoutes"
315 )));
316 }
317 None => {
318 return Err(CamelError::Unauthorized(
319 "controlbus: authorizedRoutes not configured — fail-closed".into(),
320 ));
321 }
322 }
323
324 if let Some(caller) = calling_route_id
325 && caller == route_id
326 {
327 return Err(CamelError::Unauthorized(
328 "controlbus: cannot control own route (self-restart denied)".into(),
329 ));
330 }
331
332 Ok(())
333 }
334}
335
336async fn execute_runtime_action(
337 runtime: &dyn RuntimeHandle,
338 route_id: &str,
339 action: &RouteAction,
340 command_scope: &str,
341) -> Result<Option<String>, CamelError> {
342 match action {
343 RouteAction::Start => {
344 runtime
345 .execute(RuntimeCommand::StartRoute {
346 route_id: route_id.to_string(),
347 command_id: command_id(command_scope, "start"),
348 causation_id: None,
349 })
350 .await?;
351 Ok(None)
352 }
353 RouteAction::Stop => {
354 runtime
355 .execute(RuntimeCommand::StopRoute {
356 route_id: route_id.to_string(),
357 command_id: command_id(command_scope, "stop"),
358 causation_id: None,
359 })
360 .await?;
361 Ok(None)
362 }
363 RouteAction::Suspend => {
364 runtime
365 .execute(RuntimeCommand::SuspendRoute {
366 route_id: route_id.to_string(),
367 command_id: command_id(command_scope, "suspend"),
368 causation_id: None,
369 })
370 .await?;
371 Ok(None)
372 }
373 RouteAction::Resume => {
374 runtime
375 .execute(RuntimeCommand::ResumeRoute {
376 route_id: route_id.to_string(),
377 command_id: command_id(command_scope, "resume"),
378 causation_id: None,
379 })
380 .await?;
381 Ok(None)
382 }
383 RouteAction::Restart => {
384 runtime
385 .execute(RuntimeCommand::ReloadRoute {
386 route_id: route_id.to_string(),
387 command_id: command_id(command_scope, "restart"),
388 causation_id: None,
389 })
390 .await?;
391 Ok(None)
392 }
393 RouteAction::Status => match runtime
394 .ask(RuntimeQuery::GetRouteStatus {
395 route_id: route_id.to_string(),
396 })
397 .await?
398 {
399 RuntimeQueryResult::RouteStatus { status, .. } => Ok(Some(status)),
400 _ => Err(CamelError::ProcessorError(
401 "controlbus: runtime returned unexpected response for route status".to_string(),
402 )),
403 },
404 }
405}
406
407fn command_id(route_id: &str, operation: &str) -> String {
408 format!("controlbus:{route_id}:{operation}")
409}
410
411#[cfg(test)]
416mod tests {
417 use camel_component_api::test_support::PanicRuntimeObservability;
418 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
419 std::sync::Arc::new(PanicRuntimeObservability)
420 }
421 use super::*;
422 use camel_component_api::Message;
423 use camel_component_api::NoOpComponentContext;
424 use tower::ServiceExt;
425
426 struct MockRuntime {
427 statuses: std::collections::HashMap<String, String>,
428 commands: Arc<Mutex<Vec<String>>>,
429 }
430
431 impl MockRuntime {
432 fn new() -> Self {
433 Self {
434 statuses: std::collections::HashMap::new(),
435 commands: Arc::new(Mutex::new(Vec::new())),
436 }
437 }
438
439 fn with_status(mut self, route_id: &str, status: &str) -> Self {
440 self.statuses
441 .insert(route_id.to_string(), status.to_string());
442 self
443 }
444
445 fn commands(&self) -> Arc<Mutex<Vec<String>>> {
446 Arc::clone(&self.commands)
447 }
448 }
449
450 #[async_trait]
451 impl RuntimeCommandBus for MockRuntime {
452 async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
453 let marker = match cmd {
454 RuntimeCommand::RegisterRoute { .. } => "register".to_string(),
455 RuntimeCommand::StartRoute { route_id, .. } => {
456 format!("start:{route_id}")
457 }
458 RuntimeCommand::StopRoute { route_id, .. } => {
459 format!("stop:{route_id}")
460 }
461 RuntimeCommand::SuspendRoute { route_id, .. } => {
462 format!("suspend:{route_id}")
463 }
464 RuntimeCommand::ResumeRoute { route_id, .. } => {
465 format!("resume:{route_id}")
466 }
467 RuntimeCommand::ReloadRoute { route_id, .. } => {
468 format!("reload:{route_id}")
469 }
470 RuntimeCommand::FailRoute { route_id, .. } => format!("fail:{route_id}"),
471 RuntimeCommand::RemoveRoute { route_id, .. } => {
472 format!("remove:{route_id}")
473 }
474 RuntimeCommand::ReloadTlsCerts { .. } => "reload_tls_certs".to_string(),
475 };
476 self.commands.lock().await.push(marker);
477 Ok(RuntimeCommandResult::Accepted)
478 }
479 }
480
481 #[async_trait]
482 impl RuntimeQueryBus for MockRuntime {
483 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
484 match query {
485 RuntimeQuery::GetRouteStatus { route_id } => {
486 let status = self.statuses.get(&route_id).ok_or_else(|| {
487 CamelError::ProcessorError(format!(
488 "runtime: route '{}' not found",
489 route_id
490 ))
491 })?;
492 Ok(RuntimeQueryResult::RouteStatus {
493 route_id,
494 status: status.clone(),
495 })
496 }
497 _ => Err(CamelError::ProcessorError(
498 "runtime: unsupported query in test".to_string(),
499 )),
500 }
501 }
502 }
503
504 fn test_producer_ctx() -> ProducerContext {
505 ProducerContext::new().with_runtime(Arc::new(MockRuntime::new()))
506 }
507
508 fn test_producer_ctx_with_route(id: &str, status: RouteStatus) -> ProducerContext {
509 let runtime_status = runtime_status_for(&status);
510 ProducerContext::new().with_runtime(Arc::new(
511 MockRuntime::new().with_status(id, &runtime_status),
512 ))
513 }
514
515 fn test_producer_ctx_with_runtime_status(route_id: &str, status: &str) -> ProducerContext {
516 ProducerContext::new()
517 .with_runtime(Arc::new(MockRuntime::new().with_status(route_id, status)))
518 }
519
520 fn test_producer_ctx_with_empty_runtime() -> ProducerContext {
521 ProducerContext::new().with_runtime(Arc::new(MockRuntime::new()))
522 }
523
524 fn runtime_status_for(status: &RouteStatus) -> String {
525 match status {
526 RouteStatus::Stopped => "Stopped".to_string(),
527 RouteStatus::Starting => "Starting".to_string(),
528 RouteStatus::Started => "Started".to_string(),
529 RouteStatus::Stopping => "Stopping".to_string(),
530 RouteStatus::Suspended => "Suspended".to_string(),
531 RouteStatus::Failed(msg) => format!("Failed: {msg}"),
532 }
533 }
534
535 #[test]
536 fn test_endpoint_requires_action_for_route_command() {
537 let comp = ControlBusComponent::new();
538 let result = comp.create_endpoint("controlbus:route?routeId=foo", &NoOpComponentContext);
539 assert!(result.is_err(), "Should error when action is missing");
540 }
541
542 #[test]
543 fn test_endpoint_rejects_unknown_action() {
544 let comp = ControlBusComponent::new();
545 let result = comp.create_endpoint(
546 "controlbus:route?routeId=foo&action=banana",
547 &NoOpComponentContext,
548 );
549 assert!(result.is_err(), "Should error for unknown action");
550 }
551
552 #[test]
553 fn test_endpoint_parses_valid_uri() {
554 let comp = ControlBusComponent::new();
555 let endpoint = comp
556 .create_endpoint(
557 "controlbus:route?routeId=foo&action=start",
558 &NoOpComponentContext,
559 )
560 .unwrap();
561 assert_eq!(endpoint.uri(), "controlbus:route?routeId=foo&action=start");
562 }
563
564 #[test]
565 fn test_endpoint_returns_no_consumer() {
566 let comp = ControlBusComponent::new();
567 let endpoint = comp
568 .create_endpoint(
569 "controlbus:route?routeId=foo&action=stop",
570 &NoOpComponentContext,
571 )
572 .unwrap();
573 assert!(endpoint.create_consumer(rt()).is_err());
574 }
575
576 #[test]
577 fn test_endpoint_creates_producer() {
578 let ctx = test_producer_ctx();
579 let comp = ControlBusComponent::new();
580 let endpoint = comp
581 .create_endpoint(
582 "controlbus:route?routeId=foo&action=start",
583 &NoOpComponentContext,
584 )
585 .unwrap();
586 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
587 }
588
589 #[test]
590 fn test_component_scheme() {
591 let comp = ControlBusComponent::new();
592 assert_eq!(comp.scheme(), "controlbus");
593 }
594
595 #[tokio::test]
596 async fn test_producer_start_route() {
597 let ctx = test_producer_ctx_with_route("my-route", RouteStatus::Stopped);
598 let comp = ControlBusComponent::new();
599 let endpoint = comp
600 .create_endpoint(
601 "controlbus:route?routeId=my-route&action=start&authorizedRoutes=my-route",
602 &NoOpComponentContext,
603 )
604 .unwrap();
605 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
606
607 let exchange = Exchange::new(Message::default());
608 let result = producer.oneshot(exchange).await.unwrap();
609 assert!(matches!(result.input.body, Body::Empty));
610 }
611
612 #[tokio::test]
613 async fn test_producer_stop_route() {
614 let ctx = test_producer_ctx_with_route("my-route", RouteStatus::Started);
615 let comp = ControlBusComponent::new();
616 let endpoint = comp
617 .create_endpoint(
618 "controlbus:route?routeId=my-route&action=stop&authorizedRoutes=my-route",
619 &NoOpComponentContext,
620 )
621 .unwrap();
622 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
623
624 let exchange = Exchange::new(Message::default());
625 let result = producer.oneshot(exchange).await.unwrap();
626 assert!(matches!(result.input.body, Body::Empty));
627 }
628
629 #[tokio::test]
630 async fn test_producer_restart_maps_to_runtime_reload_command() {
631 let runtime = Arc::new(MockRuntime::new().with_status("my-route", "Started"));
632 let commands = runtime.commands();
633 let ctx = ProducerContext::new().with_runtime(runtime);
634 let comp = ControlBusComponent::new();
635 let endpoint = comp
636 .create_endpoint(
637 "controlbus:route?routeId=my-route&action=restart&authorizedRoutes=my-route",
638 &NoOpComponentContext,
639 )
640 .unwrap();
641 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
642
643 let exchange = Exchange::new(Message::default());
644 let result = producer.oneshot(exchange).await.unwrap();
645 assert!(matches!(result.input.body, Body::Empty));
646
647 let recorded = commands.lock().await.clone();
648 assert_eq!(recorded, vec!["reload:my-route".to_string()]);
649 }
650
651 #[tokio::test]
652 async fn test_producer_status_route() {
653 let ctx = test_producer_ctx_with_route("my-route", RouteStatus::Started);
654 let comp = ControlBusComponent::new();
655 let endpoint = comp
656 .create_endpoint(
657 "controlbus:route?routeId=my-route&action=status&authorizedRoutes=my-route",
658 &NoOpComponentContext,
659 )
660 .unwrap();
661 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
662
663 let exchange = Exchange::new(Message::default());
664 let result = producer.oneshot(exchange).await.unwrap();
665 assert!(matches!(result.input.body, Body::Text(_)));
666 if let Body::Text(status) = &result.input.body {
667 assert_eq!(status, "Started");
668 }
669 }
670
671 #[tokio::test]
672 async fn test_producer_status_failed_route() {
673 let ctx =
674 test_producer_ctx_with_route("my-route", RouteStatus::Failed("error msg".to_string()));
675 let comp = ControlBusComponent::new();
676 let endpoint = comp
677 .create_endpoint(
678 "controlbus:route?routeId=my-route&action=status&authorizedRoutes=my-route",
679 &NoOpComponentContext,
680 )
681 .unwrap();
682 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
683
684 let exchange = Exchange::new(Message::default());
685 let result = producer.oneshot(exchange).await.unwrap();
686 assert!(matches!(result.input.body, Body::Text(_)));
687 if let Body::Text(status) = &result.input.body {
688 assert_eq!(status, "Failed: error msg");
689 }
690 }
691
692 #[tokio::test]
693 async fn test_producer_status_uses_runtime_when_available() {
694 let ctx = test_producer_ctx_with_runtime_status("runtime-route", "Started");
695 let comp = ControlBusComponent::new();
696 let endpoint = comp
697 .create_endpoint(
698 "controlbus:route?routeId=runtime-route&action=status&authorizedRoutes=runtime-route",
699 &NoOpComponentContext,
700 )
701 .unwrap();
702 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
703
704 let exchange = Exchange::new(Message::default());
705 let result = producer.oneshot(exchange).await.unwrap();
706 assert!(matches!(result.input.body, Body::Text(_)));
707 if let Body::Text(status) = &result.input.body {
708 assert_eq!(status, "Started");
709 }
710 }
711
712 #[tokio::test]
713 async fn test_producer_status_errors_when_runtime_route_is_missing() {
714 let ctx = test_producer_ctx_with_empty_runtime();
715 let comp = ControlBusComponent::new();
716 let endpoint = comp
717 .create_endpoint(
718 "controlbus:route?routeId=my-route&action=status&authorizedRoutes=my-route",
719 &NoOpComponentContext,
720 )
721 .unwrap();
722 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
723
724 let exchange = Exchange::new(Message::default());
725 let err = producer.oneshot(exchange).await.unwrap_err().to_string();
726 assert!(
727 err.contains("not found"),
728 "runtime miss should not fallback to controller: {err}"
729 );
730 }
731
732 #[tokio::test]
733 async fn test_producer_denies_camel_route_id_header_override() {
734 let ctx = test_producer_ctx_with_route("from-uri", RouteStatus::Started);
739 let comp = ControlBusComponent::new();
740 let endpoint = comp
741 .create_endpoint(
742 "controlbus:route?routeId=from-uri&action=status&authorizedRoutes=from-uri",
743 &NoOpComponentContext,
744 )
745 .unwrap();
746 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
747
748 let mut exchange = Exchange::new(Message::default());
749 exchange.input.set_header(
752 "CamelRouteId",
753 serde_json::Value::String("from-header".to_string()),
754 );
755
756 let result = producer.oneshot(exchange).await.unwrap();
757 if let Body::Text(status) = &result.input.body {
758 assert_eq!(status, "Started", "URI routeId is source of truth");
759 } else {
760 panic!("expected Body::Text, got: {:?}", result.input.body);
761 }
762 }
763
764 #[tokio::test]
765 async fn test_producer_denies_header_only_route_id() {
766 let ctx = test_producer_ctx_with_route("from-header", RouteStatus::Started);
769 let comp = ControlBusComponent::new();
770 let endpoint = comp
771 .create_endpoint(
772 "controlbus:route?action=status&authorizedRoutes=from-header",
773 &NoOpComponentContext,
774 )
775 .unwrap();
776 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
777
778 let mut exchange = Exchange::new(Message::default());
779 exchange.input.set_header(
780 "CamelRouteId",
781 serde_json::Value::String("from-header".to_string()),
782 );
783
784 let err = producer
785 .oneshot(exchange)
786 .await
787 .expect_err("header must not satisfy routeId requirement");
788 assert!(
789 matches!(err, CamelError::Unauthorized(_)),
790 "expected Unauthorized, got: {err}"
791 );
792 }
793
794 #[tokio::test]
795 async fn test_producer_error_no_route_id() {
796 let ctx = test_producer_ctx();
797 let comp = ControlBusComponent::new();
798 let endpoint = comp
799 .create_endpoint(
800 "controlbus:route?action=status&authorizedRoutes=foo",
801 &NoOpComponentContext,
802 )
803 .unwrap();
804 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
805
806 let exchange = Exchange::new(Message::default());
807 let result = producer.oneshot(exchange).await;
808 assert!(result.is_err());
809 let err = result.unwrap_err().to_string();
810 assert!(
811 err.contains("routeId must be declared in URI"),
812 "Error should explain header override denied: {}",
813 err
814 );
815 }
816
817 #[tokio::test]
818 async fn test_producer_error_route_not_found() {
819 let ctx = test_producer_ctx(); let comp = ControlBusComponent::new();
821 let endpoint = comp
822 .create_endpoint(
823 "controlbus:route?routeId=nonexistent&action=status&authorizedRoutes=nonexistent",
824 &NoOpComponentContext,
825 )
826 .unwrap();
827 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
828
829 let exchange = Exchange::new(Message::default());
830 let result = producer.oneshot(exchange).await;
831 assert!(result.is_err());
832 let err = result.unwrap_err().to_string();
833 assert!(
834 err.contains("not found"),
835 "Error should mention not found: {}",
836 err
837 );
838 }
839
840 #[test]
841 fn test_endpoint_parses_suspend_action() {
842 let comp = ControlBusComponent::new();
843 let endpoint = comp
844 .create_endpoint(
845 "controlbus:route?routeId=foo&action=suspend",
846 &NoOpComponentContext,
847 )
848 .unwrap();
849 assert_eq!(
850 endpoint.uri(),
851 "controlbus:route?routeId=foo&action=suspend"
852 );
853 }
854
855 #[test]
856 fn test_endpoint_parses_resume_action() {
857 let comp = ControlBusComponent::new();
858 let endpoint = comp
859 .create_endpoint(
860 "controlbus:route?routeId=foo&action=resume",
861 &NoOpComponentContext,
862 )
863 .unwrap();
864 assert_eq!(endpoint.uri(), "controlbus:route?routeId=foo&action=resume");
865 }
866
867 #[test]
868 fn test_endpoint_parses_restart_action() {
869 let comp = ControlBusComponent::new();
870 let endpoint = comp
871 .create_endpoint(
872 "controlbus:route?routeId=foo&action=restart",
873 &NoOpComponentContext,
874 )
875 .unwrap();
876 assert_eq!(
877 endpoint.uri(),
878 "controlbus:route?routeId=foo&action=restart"
879 );
880 }
881
882 #[test]
883 fn test_endpoint_rejects_unknown_command() {
884 let comp = ControlBusComponent::new();
885 let result = comp.create_endpoint("controlbus:unknown?action=start", &NoOpComponentContext);
886 assert!(result.is_err(), "Should error for unknown command");
887 }
888
889 #[tokio::test]
900 async fn test_authz_unauthorized_when_no_authorized_routes_param() {
901 let ctx = test_producer_ctx_with_route("target-route", RouteStatus::Started);
903 let comp = ControlBusComponent::new();
904 let endpoint = comp
905 .create_endpoint(
906 "controlbus:route?routeId=target-route&action=status",
907 &NoOpComponentContext,
908 )
909 .unwrap();
910 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
911
912 let exchange = Exchange::new(Message::default());
913 let result = producer.oneshot(exchange).await;
914 let err = result.expect_err("must be denied when authorizedRoutes missing");
915 assert!(
916 matches!(err, CamelError::Unauthorized(_)),
917 "expected Unauthorized, got: {err}"
918 );
919 assert!(
920 err.to_string().contains("authorizedRoutes not configured"),
921 "error should mention fail-closed default: {err}"
922 );
923 }
924
925 #[tokio::test]
926 async fn test_authz_unauthorized_when_route_id_not_in_allowlist() {
927 let ctx = test_producer_ctx_with_route("route-b", RouteStatus::Started);
929 let comp = ControlBusComponent::new();
930 let endpoint = comp
931 .create_endpoint(
932 "controlbus:route?routeId=route-b&action=status&authorizedRoutes=route-a",
933 &NoOpComponentContext,
934 )
935 .unwrap();
936 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
937
938 let exchange = Exchange::new(Message::default());
939 let result = producer.oneshot(exchange).await;
940 let err = result.expect_err("must be denied when target not in allowlist");
941 assert!(
942 matches!(err, CamelError::Unauthorized(_)),
943 "expected Unauthorized, got: {err}"
944 );
945 assert!(
946 err.to_string().contains("not in authorizedRoutes"),
947 "error should mention allowlist miss: {err}"
948 );
949 }
950
951 #[tokio::test]
952 async fn test_authz_authorized_when_route_id_in_allowlist() {
953 let ctx = test_producer_ctx_with_route("target-route", RouteStatus::Started);
955 let comp = ControlBusComponent::new();
956 let endpoint = comp
957 .create_endpoint(
958 "controlbus:route?routeId=target-route&action=status&authorizedRoutes=target-route",
959 &NoOpComponentContext,
960 )
961 .unwrap();
962 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
963
964 let exchange = Exchange::new(Message::default());
965 let result = producer.oneshot(exchange).await.expect("must succeed");
966 if let Body::Text(status) = &result.input.body {
967 assert_eq!(status, "Started");
968 } else {
969 panic!("expected Body::Text(Started), got: {:?}", result.input.body);
970 }
971 }
972
973 #[tokio::test]
974 async fn test_authz_self_restart_rejected() {
975 let runtime_status = "Started".to_string();
977 let runtime = Arc::new(MockRuntime::new().with_status("self-route", &runtime_status));
978 let ctx = ProducerContext::new()
979 .with_runtime(runtime)
980 .with_route_id("self-route");
981 let comp = ControlBusComponent::new();
982 let endpoint = comp
983 .create_endpoint(
984 "controlbus:route?routeId=self-route&action=restart&authorizedRoutes=self-route",
985 &NoOpComponentContext,
986 )
987 .unwrap();
988 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
989
990 let exchange = Exchange::new(Message::default());
991 let result = producer.oneshot(exchange).await;
992 let err = result.expect_err("self-restart must be denied");
993 assert!(
994 matches!(err, CamelError::Unauthorized(_)),
995 "expected Unauthorized, got: {err}"
996 );
997 assert!(
998 err.to_string().contains("self-restart"),
999 "error should mention self-restart: {err}"
1000 );
1001 }
1002}