Skip to main content

camel_component_controlbus/
lib.rs

1//! ControlBus component for managing route lifecycle.
2//!
3//! This component allows routes to control other routes in the Camel context
4//! using the ControlBus EIP pattern. It provides operations like start, stop,
5//! suspend, resume, and restart for routes.
6//!
7//! TODO(CTRL-001): `SuspendRoute` and `ResumeRoute` commands are dispatched to
8//!   the runtime but route-level suspension/resumption semantics (pausing message
9//!   consumption without stopping the route) depend on runtime support that may
10//!   not be fully implemented in all components.
11//!
12//! TODO(CTRL-002): The `Status` action returns only the route lifecycle status
13//!   string. Full route statistics (total exchange count, failure count, mean
14//!   processing time, last exchange timestamp) are not yet populated. A future
15//!   `Stats` command should return a structured JSON body with these metrics.
16//!
17//! # URI Format
18//!
19//! `controlbus:route?routeId=my-route&action=start&authorizedRoutes=my-route,other-route`
20//!
21//! # Parameters
22//!
23//! - `routeId`: The ID of the route to operate on. **Required** — declared statically in the
24//!   URI. The `CamelRouteId` exchange header override is denied (exchange data is untrusted;
25//!   see ADR-0032 and ADR-0034).
26//! - `action`: The action to perform: `start`, `stop`, `suspend`, `resume`, `restart`, `status`
27//! - `authorizedRoutes`: Comma-separated allowlist of route IDs this endpoint may target.
28//!   **Required** — when absent the endpoint fails closed (every command rejected).
29//!
30//! # Example
31//!
32//! ```ignore
33//! // Start a route
34//! from("timer:start?period=60000")
35//!     .to("controlbus:route?routeId=my-route&action=start");
36//!
37//! // Get route status
38//! from("direct:getStatus")
39//!     .to("controlbus:route?routeId=my-route&action=status")
40//!     .log("Status: ${body}");
41//! ```
42
43use 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
63// ---------------------------------------------------------------------------
64// ControlBusComponent
65// ---------------------------------------------------------------------------
66
67/// The ControlBus component for managing route lifecycle.
68pub struct ControlBusComponent;
69
70impl ControlBusComponent {
71    /// Create a new ControlBus component.
72    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        // Parse command (path portion after controlbus:)
103        let command = parts.path.clone();
104
105        // Validate command - only "route" is supported
106        if command != "route" {
107            return Err(CamelError::EndpointCreationFailed(format!(
108                "controlbus: unknown command '{}', only 'route' is supported",
109                command
110            )));
111        }
112
113        // Parse routeId parameter
114        let route_id = parts.params.get("routeId").cloned();
115
116        // Parse action parameter
117        let action = if let Some(action_str) = parts.params.get("action") {
118            Some(parse_action(action_str)?)
119        } else {
120            None
121        };
122
123        // Validate: for "route" command, action is required
124        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        // Parse authorizedRoutes (R4-H1: capability authz allowlist).
131        // The CamelRouteId header override is denied — target routeId must
132        // be declared statically in the URI. Without authorizedRoutes the
133        // endpoint fails closed (no route may be controlled).
134        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
148/// Parse an action string into a RouteAction.
149fn 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
164// ---------------------------------------------------------------------------
165// ControlBusEndpoint
166// ---------------------------------------------------------------------------
167
168/// Endpoint for the ControlBus component.
169struct ControlBusEndpoint {
170    uri: String,
171    route_id: Option<String>,
172    action: Option<RouteAction>,
173    /// Allowlist of route IDs this endpoint may target. `None` ⇒ fail-closed.
174    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        // Capture the calling route ID for self-restart detection (R4-H1).
208        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// ---------------------------------------------------------------------------
221// ControlBusProducer
222// ---------------------------------------------------------------------------
223
224/// Producer that executes control bus actions on routes.
225#[derive(Clone)]
226struct ControlBusProducer {
227    /// Route ID from URI params only. Header override is denied (R4-H1).
228    route_id: Option<String>,
229    /// Action to perform on the route.
230    action: RouteAction,
231    /// Runtime command/query handle.
232    runtime: Arc<dyn RuntimeHandle>,
233    /// Allowlist of route IDs that may be targeted. `None` ⇒ fail-closed.
234    authorized_routes: Option<Vec<String>>,
235    /// ID of the route that owns this producer (for self-restart deny).
236    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        // R4-H1: routeId MUST come from the URI only. The CamelRouteId
250        // exchange header is untrusted data (ADR-0032) and may not override
251        // the statically-declared target.
252        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        // Capability authz gate. Fail-closed if no allowlist; deny self-restart.
265        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    /// Capability authz check. Three gates, all must pass:
302    /// 1. `authorized_routes` is configured (fail-closed by default).
303    /// 2. `route_id` is in the allowlist.
304    /// 3. `route_id` is not the calling route (self-restart denied).
305    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// ---------------------------------------------------------------------------
412// Tests
413// ---------------------------------------------------------------------------
414
415#[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        // R4-H1: the CamelRouteId exchange header is untrusted. Even when
735        // a routeId is declared in the URI, the header must NOT be
736        // consulted as a source of truth. The URI value is the only one
737        // that counts for dispatch.
738        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        // Adversary-controlled header would target a different route if
750        // the override were honored. URI is the source of truth.
751        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        // R4-H1: when routeId is missing from the URI, the header MUST
767        // NOT be consulted. The endpoint must be denied.
768        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(); // No routes
820        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    // -----------------------------------------------------------------------
890    // R4-H1: ControlBus capability authz
891    //
892    // The `controlbus:` endpoint MUST declare the authorized target routes
893    // explicitly via the `authorizedRoutes` URI param. Without it, all
894    // commands fail-closed. The `CamelRouteId` header override is denied
895    // (target MUST be declared statically in URI). Self-restart
896    // (routeId == calling route) is also rejected.
897    // -----------------------------------------------------------------------
898
899    #[tokio::test]
900    async fn test_authz_unauthorized_when_no_authorized_routes_param() {
901        // authorizedRoutes not set → fail-closed
902        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        // authorizedRoutes=route-a, but target is route-b → denied
928        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        // authorizedRoutes=target-route, routeId=target-route, caller=other
954        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        // target=calling route → self-restart denied
976        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}