camel_component_api/consumer.rs
1use std::sync::Arc;
2
3use async_trait::async_trait;
4use tokio::sync::{mpsc, oneshot, watch};
5use tokio::task::JoinHandle;
6use tokio_util::sync::CancellationToken;
7
8use camel_api::security_policy::SecurityPolicy;
9use camel_api::{CamelError, Exchange};
10use camel_auth::CredentialSource;
11
12/// A message sent from a consumer to the route pipeline.
13///
14/// Fire-and-forget exchanges use `reply_tx = None`.
15/// Request-reply exchanges (e.g. `direct:`) provide a `reply_tx` so the
16/// pipeline result can be sent back to the consumer.
17pub struct ExchangeEnvelope {
18 pub exchange: Exchange,
19 pub reply_tx: Option<oneshot::Sender<Result<Exchange, CamelError>>>,
20}
21
22/// Declares when the runtime may consider a Consumer "started".
23///
24/// `Immediate` (default) preserves the classic fire-and-forget semantics:
25/// `spawn_consumer_task` returns as soon as the consumer task is spawned,
26/// matching the behaviour of timer, file, direct and similar polling
27/// consumers whose `start()` IS the lifetime loop.
28///
29/// `Explicit` is for resource-binding consumers (HTTP, WebSocket, …) whose
30/// `start()` returns control only after the resource (e.g. `TcpListener`)
31/// is bound and ready. The consumer MUST call `ConsumerContext::mark_ready`
32/// after a successful bind so the runtime can await readiness and propagate
33/// pre-ready `start()` errors as proper startup failures.
34///
35/// Adding this as a default-returning trait method keeps every existing
36/// `Consumer` impl backward compatible.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38#[non_exhaustive]
39pub enum ConsumerStartupMode {
40 /// Consumer's `start()` IS the lifetime loop. The runtime treats the
41 /// consumer as ready the moment `start()` is invoked. (Default)
42 #[default]
43 Immediate,
44 /// Consumer binds/registers a resource inside `start()` and signals
45 /// readiness explicitly via `ConsumerContext::mark_ready()`.
46 Explicit,
47}
48
49/// Internal state of a [`StartupSignal`].
50#[derive(Clone, Debug)]
51enum StartupState {
52 /// Consumer has not yet signalled readiness or failure.
53 Pending,
54 /// Consumer signalled readiness via `mark_ready()`.
55 Ready,
56 /// Consumer's `start()` returned an `Err` before signalling readiness.
57 Failed(String),
58}
59
60/// Shared handle used by a Consumer to signal readiness (or failure) to the
61/// runtime's [`StartupReceiver`].
62///
63/// Constructed in a pair via [`StartupSignal::pair`]. The signal is held by
64/// the consumer side (via [`ConsumerContext`]); the receiver is returned to
65/// the route controller.
66#[derive(Clone)]
67pub struct StartupSignal {
68 tx: watch::Sender<StartupState>,
69}
70
71impl StartupSignal {
72 /// Create a `(signal, receiver)` pair seeded in the `Pending` state.
73 pub fn pair() -> (Self, StartupReceiver) {
74 let (tx, rx) = watch::channel(StartupState::Pending);
75 (Self { tx }, StartupReceiver { rx })
76 }
77
78 /// Mark the consumer as ready. Idempotent — subsequent calls are no-ops
79 /// once the state has transitioned out of `Pending`.
80 ///
81 /// Returns `true` if this call transitioned `Pending → Ready`, `false`
82 /// if the state was already `Ready` or `Failed`. The runtime uses the
83 /// return value to detect Explicit consumers that returned `Ok` from
84 /// `start()` without calling `mark_ready` (a contract violation that
85 /// would hang the controller without the defensive fallback in
86 /// `spawn_consumer_task`).
87 pub fn mark_ready(&self) -> bool {
88 self.tx.send_if_modified(|s| {
89 if matches!(*s, StartupState::Pending) {
90 *s = StartupState::Ready;
91 true
92 } else {
93 false
94 }
95 })
96 }
97
98 /// Mark the consumer's startup as failed with `err`. Idempotent.
99 pub fn mark_failed(&self, err: String) {
100 self.tx.send_if_modified(|s| {
101 if matches!(*s, StartupState::Pending) {
102 *s = StartupState::Failed(err);
103 true
104 } else {
105 false
106 }
107 });
108 }
109}
110
111impl std::fmt::Debug for StartupSignal {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct("StartupSignal")
114 .field("state", &self.tx.borrow())
115 .finish()
116 }
117}
118
119/// Receiver half of the consumer startup handshake. Resolves once the
120/// consumer calls [`ConsumerContext::mark_ready`] (Ok) or its `start()`
121/// returns an `Err` first (Err).
122///
123/// For [`ConsumerStartupMode::Immediate`] consumers the receiver is
124/// pre-resolved at construction time (see [`StartupReceiver::immediate`]).
125pub struct StartupReceiver {
126 rx: watch::Receiver<StartupState>,
127}
128
129impl StartupReceiver {
130 /// Construct a receiver that is already resolved as `Ok`. Used for
131 /// [`ConsumerStartupMode::Immediate`] consumers so the controller can
132 /// uniformly `await` every receiver without changing behaviour.
133 pub fn immediate() -> Self {
134 let (tx, rx) = watch::channel(StartupState::Ready);
135 // Drop the sender — state is fixed at Ready. Receiver will never
136 // observe a closure error since it already holds Ready.
137 let _ = tx;
138 Self { rx }
139 }
140
141 /// Wait for the consumer to become ready or fail. Resolves:
142 /// - `Ok(())` if the consumer signalled readiness.
143 /// - `Err(CamelError::RouteError(_))` if the consumer's `start()`
144 /// returned an error before signalling readiness.
145 /// - `Err(CamelError::RouteError(_))` if the signal sender was dropped
146 /// without either transition (programming-contract violation).
147 pub async fn await_ready(mut self) -> Result<(), CamelError> {
148 loop {
149 match &*self.rx.borrow() {
150 StartupState::Pending => {}
151 StartupState::Ready => return Ok(()),
152 StartupState::Failed(msg) => {
153 return Err(CamelError::RouteError(msg.clone()));
154 }
155 }
156 if self.rx.changed().await.is_err() {
157 return Err(CamelError::RouteError(
158 "consumer startup signal dropped without resolving".to_string(),
159 ));
160 }
161 }
162 }
163}
164
165impl std::fmt::Debug for StartupReceiver {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("StartupReceiver")
168 .field("state", &self.rx.borrow())
169 .finish()
170 }
171}
172
173/// Context provided to a Consumer, allowing it to send exchanges into the route.
174#[derive(Clone)]
175pub struct ConsumerContext {
176 sender: mpsc::Sender<ExchangeEnvelope>,
177 cancel_token: CancellationToken,
178 route_id: String,
179 startup: StartupSignal,
180}
181
182impl ConsumerContext {
183 /// Create a new consumer context wrapping the given channel sender.
184 ///
185 /// The `route_id` identifies the route this consumer is bound to, enabling
186 /// ADR-0012 per-route metrics and health observations.
187 ///
188 /// The startup signal defaults to a fresh `Pending` pair; the consumer
189 /// can call [`Self::mark_ready`] once it has bound its resource. For
190 /// [`ConsumerStartupMode::Immediate`] consumers the runtime ignores
191 /// the signal (it constructs an already-resolved receiver instead).
192 pub fn new(
193 sender: mpsc::Sender<ExchangeEnvelope>,
194 cancel_token: CancellationToken,
195 route_id: String,
196 ) -> Self {
197 let (startup, _unused_receiver) = StartupSignal::pair();
198 // The receiver is dropped here: `spawn_consumer_task` constructs its
199 // own `(signal, receiver)` pair and replaces this one via
200 // `with_startup` so the controller holds the matching receiver.
201 let _ = _unused_receiver;
202 Self {
203 sender,
204 cancel_token,
205 route_id,
206 startup,
207 }
208 }
209
210 /// Replace the startup signal carried by this context. Used by
211 /// `spawn_consumer_task` to install the signal whose matching receiver
212 /// is returned to the route controller.
213 pub fn with_startup(mut self, startup: StartupSignal) -> Self {
214 self.startup = startup;
215 self
216 }
217
218 /// Returns a clone of the internal [`StartupSignal`] so callers (e.g.
219 /// `spawn_consumer_task`) can drive failure propagation independently of
220 /// the consumer's own `mark_ready()` call.
221 pub fn startup_signal(&self) -> StartupSignal {
222 self.startup.clone()
223 }
224
225 /// Mark this consumer's startup as complete. Only meaningful for
226 /// [`ConsumerStartupMode::Explicit`] consumers — `Immediate` consumers
227 /// never need to call this because the runtime resolves their startup
228 /// receiver at construction time.
229 ///
230 /// Idempotent.
231 pub fn mark_ready(&self) {
232 let _ = self.startup.mark_ready();
233 }
234
235 /// Mark this consumer's startup as FAILED with `err`. Only meaningful for
236 /// [`ConsumerStartupMode::Explicit`] consumers whose readiness is gated on
237 /// an asynchronous event that may never arrive (e.g. Kafka partition
238 /// assignment). Calling this resolves the runtime's startup await with a
239 /// startup error instead of hanging.
240 ///
241 /// Idempotent — the first transition out of `Pending` wins, so a later
242 /// `mark_ready()` cannot override an earlier `mark_failed()` and vice
243 /// versa.
244 pub fn mark_failed(&self, err: String) {
245 self.startup.mark_failed(err);
246 }
247
248 /// Returns a future that resolves when shutdown is requested.
249 /// Use in `tokio::select!` inside consumer loops.
250 pub async fn cancelled(&self) {
251 self.cancel_token.cancelled().await
252 }
253
254 /// Returns true if shutdown has been requested.
255 pub fn is_cancelled(&self) -> bool {
256 self.cancel_token.is_cancelled()
257 }
258
259 /// Returns the route_id this consumer is bound to.
260 ///
261 /// Available for ADR-0012 metrics/health calls that require a route_id
262 /// (categories (b′), (e), (g)). Set at construction time by the route
263 /// controller when spawning the consumer task.
264 pub fn route_id(&self) -> &str {
265 &self.route_id
266 }
267
268 /// Returns a clone of the `CancellationToken`.
269 ///
270 /// Useful for consumers that spawn per-request tasks and need to propagate
271 /// shutdown to each task. See `HttpConsumer` for an example.
272 pub fn cancel_token(&self) -> CancellationToken {
273 self.cancel_token.clone()
274 }
275
276 /// Returns a clone of the channel sender for manual exchange submission.
277 ///
278 /// Useful for consumers that spawn per-request tasks (e.g., `HttpConsumer`)
279 /// where each task independently sends exchanges into the pipeline.
280 /// For simple consumers, prefer `send()` or `send_and_wait()` instead.
281 pub fn sender(&self) -> mpsc::Sender<ExchangeEnvelope> {
282 self.sender.clone()
283 }
284
285 /// Send an exchange into the route pipeline (fire-and-forget).
286 pub async fn send(&self, exchange: Exchange) -> Result<(), CamelError> {
287 self.sender
288 .send(ExchangeEnvelope {
289 exchange,
290 reply_tx: None,
291 })
292 .await
293 .map_err(|_| CamelError::ChannelClosed)
294 }
295
296 /// Send an exchange and wait for the pipeline result (request-reply).
297 ///
298 /// Returns `Ok(exchange)` on success or `Err(e)` if the pipeline failed
299 /// without an error handler absorbing the error.
300 pub async fn send_and_wait(&self, exchange: Exchange) -> Result<Exchange, CamelError> {
301 let (reply_tx, reply_rx) = oneshot::channel();
302 self.sender
303 .send(ExchangeEnvelope {
304 exchange,
305 reply_tx: Some(reply_tx),
306 })
307 .await
308 .map_err(|_| CamelError::ChannelClosed)?;
309 reply_rx.await.map_err(|_| CamelError::ChannelClosed)?
310 }
311}
312
313/// Security context passed to a consumer before `start()`.
314///
315/// Carries the route's security classification so consumers can register
316/// auth state before accepting connections. Declared routes carry the
317/// `SecurityPolicy` from the route controller; plan-only contexts (routes
318/// without a policy declaration) carry just the compiled
319/// [`RouteSecurityPlan`] (`policy = None`). Authentication itself runs
320/// through the kernel fields (`plan` + `providers`).
321pub struct SecurityContext {
322 /// Route policy, when the route declared one. `None` for plan-only
323 /// contexts built via [`SecurityContext::from_plan`].
324 pub policy: Option<Arc<dyn SecurityPolicy>>,
325 pub credential_sources: Vec<CredentialSource>,
326 /// Compiled `RouteSecurityPlan` for the route, when one has been compiled.
327 ///
328 /// Phase-2 transports read the plan from here to drive per-route dispatch
329 /// enforcement. `None` until Task 1.8 compiles plans into the context.
330 pub plan: Option<camel_api::security_policy::RouteSecurityPlan>,
331 /// Provider registry carrying the route's named authenticators.
332 ///
333 /// Phase-2 transports read providers from here (grpc 2.1, mcp 2.6, ws 2.8,
334 /// http 2.9) instead of holding their own authenticator. `None` for routes
335 /// built before the registry injection path lands.
336 pub providers: Option<std::sync::Arc<camel_auth::ProviderRegistry>>,
337}
338
339impl SecurityContext {
340 pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
341 Self {
342 policy: Some(Arc::new(policy)),
343 credential_sources: vec![CredentialSource::AuthorizationHeader],
344 plan: None,
345 providers: None,
346 }
347 }
348
349 pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
350 Self {
351 policy: Some(policy),
352 credential_sources: vec![CredentialSource::AuthorizationHeader],
353 plan: None,
354 providers: None,
355 }
356 }
357
358 /// Build a plan-only context: no policy, no providers, empty credential
359 /// sources. The controller delivers a route's compiled classification
360 /// (e.g. the `Public` default for an undeclared server route) through
361 /// this form.
362 pub fn from_plan(plan: camel_api::security_policy::RouteSecurityPlan) -> Self {
363 Self {
364 policy: None,
365 credential_sources: Vec::new(),
366 plan: Some(plan),
367 providers: None,
368 }
369 }
370
371 pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
372 self.credential_sources = sources;
373 self
374 }
375
376 /// Attach the compiled `RouteSecurityPlan` for this route.
377 pub fn with_plan(mut self, plan: camel_api::security_policy::RouteSecurityPlan) -> Self {
378 self.plan = Some(plan);
379 self
380 }
381
382 /// Attach the provider registry carrying this route's named authenticators.
383 pub fn with_providers(
384 mut self,
385 providers: std::sync::Arc<camel_auth::ProviderRegistry>,
386 ) -> Self {
387 self.providers = Some(providers);
388 self
389 }
390}
391
392impl Clone for SecurityContext {
393 fn clone(&self) -> Self {
394 Self {
395 policy: self.policy.clone(),
396 credential_sources: self.credential_sources.clone(),
397 plan: self.plan.clone(),
398 providers: self.providers.as_ref().map(Arc::clone),
399 }
400 }
401}
402
403impl std::fmt::Debug for SecurityContext {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 f.debug_struct("SecurityContext")
406 .field("policy", &self.policy.as_ref().map(|_| "<SecurityPolicy>"))
407 .field("credential_sources", &self.credential_sources)
408 .field("plan", &self.plan)
409 .field(
410 "providers",
411 &self.providers.as_ref().map(|_| "<ProviderRegistry>"),
412 )
413 .finish()
414 }
415}
416
417/// How a consumer's exchanges should be processed by the pipeline.
418#[derive(Debug, Clone, PartialEq, Eq)]
419#[non_exhaustive]
420pub enum ConcurrencyModel {
421 /// Exchanges are processed one at a time, in order. Default for polling
422 /// consumers (timer, file) and synchronous consumers (direct).
423 Sequential,
424 /// Exchanges are processed concurrently via `tokio::spawn`. Optional
425 /// semaphore limit (`max`). `None` means unbounded (channel buffer is
426 /// the only backpressure).
427 Concurrent { max: Option<usize> },
428}
429
430/// A Consumer receives data from an external system and submits Exchanges
431/// to the Route's Pipeline via the [`ConsumerContext`].
432///
433/// # Shutdown Contract
434///
435/// The Runtime guarantees the following lifecycle:
436///
437/// 1. `start()` is called once. The Runtime spawns a task that owns the Consumer.
438/// 2. On route stop, the Runtime cancels the [`ConsumerContext`] cancel token.
439/// 3. The spawned task calls `stop()` on ALL exit paths after `start()` succeeds
440/// (clean exit, crash, cancellation, natural completion).
441/// 4. `background_task_handle()` is a supervision hook for crash propagation (ADR-0007),
442/// NOT the shutdown API.
443///
444/// Component authors MUST ensure:
445///
446/// - `stop()` cancels all component-owned inner tasks and cleans up registrations/resources.
447/// - If inner tasks use a private `CancellationToken`, `stop()` MUST cancel it.
448/// - Best practice: inner tasks should use the [`ConsumerContext`] cancel token (or a child)
449/// so they respond to runtime shutdown without waiting for `stop()`.
450/// - If using a private token, `stop()` must cancel it to ensure prompt cleanup.
451/// - `background_task_handle()` returns the `JoinHandle` of the primary background task,
452/// if any. The Runtime monitors this handle for unexpected exits (crash propagation).
453#[async_trait]
454pub trait Consumer: Send + Sync {
455 /// Start consuming messages, sending them through the provided context.
456 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError>;
457
458 /// Stop consuming messages and clean up all resources.
459 ///
460 /// Called by the Runtime on every exit path after `start()` succeeds.
461 /// See the [Shutdown Contract](#shutdown-contract) above.
462 async fn stop(&mut self) -> Result<(), CamelError>;
463
464 /// Temporarily suspend consuming messages without fully stopping.
465 ///
466 /// Default: no-op, returns `Ok(())`.
467 async fn suspend(&self) -> Result<(), CamelError> {
468 Ok(())
469 }
470
471 /// Resume consuming after a previous suspension.
472 ///
473 /// Default: no-op, returns `Ok(())`.
474 async fn resume(&self) -> Result<(), CamelError> {
475 Ok(())
476 }
477
478 /// Declares this consumer's natural concurrency model.
479 ///
480 /// The runtime uses this to decide whether to process exchanges
481 /// sequentially or spawn per-exchange. Consumers that accept inbound
482 /// connections (HTTP, WebSocket, Kafka) should override this to return
483 /// `ConcurrencyModel::Concurrent`.
484 ///
485 /// Default: `Sequential`.
486 fn concurrency_model(&self) -> ConcurrencyModel {
487 ConcurrencyModel::Sequential
488 }
489
490 /// Declares how the runtime should wait for this consumer's startup.
491 ///
492 /// - [`ConsumerStartupMode::Immediate`] (default): the consumer's
493 /// `start()` IS the lifetime loop. The runtime treats the route as
494 /// started as soon as `start()` is invoked, preserving the existing
495 /// fire-and-forget semantics for timer/file/direct/… consumers.
496 /// - [`ConsumerStartupMode::Explicit`]: the consumer binds/registers a
497 /// resource inside `start()` and MUST call
498 /// [`ConsumerContext::mark_ready`] after a successful bind. The
499 /// runtime awaits this signal (or an early `start()` error) before
500 /// treating the route as started, so HTTP/WebSocket listeners fail
501 /// fast when the bind fails instead of crashing the background task.
502 ///
503 /// Default: `Immediate`.
504 fn startup_mode(&self) -> ConsumerStartupMode {
505 ConsumerStartupMode::Immediate
506 }
507
508 /// Return a handle to the consumer's long-running background task so the
509 /// runtime can monitor it for unexpected exits after `start()` returns `Ok`.
510 ///
511 /// Default: `None` — consumer's work completes entirely within `start()`.
512 /// Override: return `Some(handle)` if `start()` spawns a detached task.
513 ///
514 /// **Contract:** the task must observe `ConsumerContext::cancelled()` so
515 /// runtime shutdown is distinguishable from crash exits.
516 ///
517 /// This method is called at most once; implementations should use `.take()`
518 /// to transfer ownership of the handle.
519 fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
520 None
521 }
522
523 /// Set the security context for this consumer.
524 ///
525 /// Called by the route controller before `start()` so the consumer
526 /// can register auth state (e.g. WebSocket auth in `WsAppState`).
527 ///
528 /// Default: no-op, returns `Ok(())`.
529 fn set_security_context(&mut self, _ctx: SecurityContext) {}
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn consumer_context_exposes_route_id() {
538 let (tx, _rx) = mpsc::channel(1);
539 let ctx = ConsumerContext::new(tx, CancellationToken::new(), "test-route".to_string());
540 assert_eq!(ctx.route_id(), "test-route");
541 }
542
543 #[tokio::test]
544 async fn test_consumer_context_cancelled() {
545 let (tx, _rx) = mpsc::channel(16);
546 let token = CancellationToken::new();
547 let ctx = ConsumerContext::new(tx, token.clone(), "test-route".to_string());
548
549 assert!(!ctx.is_cancelled());
550 token.cancel();
551 ctx.cancelled().await;
552 assert!(ctx.is_cancelled());
553 }
554
555 #[test]
556 fn test_concurrency_model_default_is_sequential() {
557 use super::ConcurrencyModel;
558
559 struct DummyConsumer;
560
561 #[async_trait::async_trait]
562 impl super::Consumer for DummyConsumer {
563 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
564 Ok(())
565 }
566 async fn stop(&mut self) -> Result<(), CamelError> {
567 Ok(())
568 }
569 }
570
571 let consumer = DummyConsumer;
572 assert_eq!(consumer.concurrency_model(), ConcurrencyModel::Sequential);
573 }
574
575 #[test]
576 fn test_concurrency_model_concurrent_override() {
577 use super::ConcurrencyModel;
578
579 struct ConcurrentConsumer;
580
581 #[async_trait::async_trait]
582 impl super::Consumer for ConcurrentConsumer {
583 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
584 Ok(())
585 }
586 async fn stop(&mut self) -> Result<(), CamelError> {
587 Ok(())
588 }
589 fn concurrency_model(&self) -> ConcurrencyModel {
590 ConcurrencyModel::Concurrent { max: Some(16) }
591 }
592 }
593
594 let consumer = ConcurrentConsumer;
595 assert_eq!(
596 consumer.concurrency_model(),
597 ConcurrencyModel::Concurrent { max: Some(16) }
598 );
599 }
600
601 // --- ConsumerStartupMode tests ---
602
603 #[test]
604 fn test_default_startup_mode_is_immediate() {
605 struct DummyConsumer;
606
607 #[async_trait::async_trait]
608 impl super::Consumer for DummyConsumer {
609 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
610 Ok(())
611 }
612 async fn stop(&mut self) -> Result<(), CamelError> {
613 Ok(())
614 }
615 }
616
617 let consumer = DummyConsumer;
618 assert_eq!(
619 consumer.startup_mode(),
620 super::ConsumerStartupMode::Immediate
621 );
622 }
623
624 #[test]
625 fn test_startup_mode_explicit_override() {
626 struct ExplicitConsumer;
627
628 #[async_trait::async_trait]
629 impl super::Consumer for ExplicitConsumer {
630 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
631 Ok(())
632 }
633 async fn stop(&mut self) -> Result<(), CamelError> {
634 Ok(())
635 }
636 fn startup_mode(&self) -> super::ConsumerStartupMode {
637 super::ConsumerStartupMode::Explicit
638 }
639 }
640
641 let consumer = ExplicitConsumer;
642 assert_eq!(
643 consumer.startup_mode(),
644 super::ConsumerStartupMode::Explicit
645 );
646 }
647
648 #[tokio::test]
649 async fn test_startup_signal_mark_ready_resolves_receiver_ok() {
650 let (signal, receiver) = StartupSignal::pair();
651 // Not yet signalled — receiver should still be pending.
652 assert!(matches!(*receiver.rx.borrow(), StartupState::Pending));
653
654 // Mark ready — receiver must observe Ok.
655 signal.mark_ready();
656 let result = receiver.await_ready().await;
657 assert!(result.is_ok(), "expected Ok after mark_ready");
658 }
659
660 #[tokio::test]
661 async fn test_startup_signal_mark_failed_propagates_error() {
662 let (signal, receiver) = StartupSignal::pair();
663 signal.mark_failed("bind failed".to_string());
664 let err = receiver
665 .await_ready()
666 .await
667 .expect_err("expected Err after mark_failed");
668 match err {
669 CamelError::RouteError(msg) => assert!(msg.contains("bind failed")),
670 other => panic!("expected RouteError, got {other:?}"),
671 }
672 }
673
674 #[tokio::test]
675 async fn test_startup_signal_idempotent_first_wins() {
676 let (signal, receiver) = StartupSignal::pair();
677 signal.mark_ready();
678 // mark_failed after mark_ready must NOT override.
679 signal.mark_failed("late failure".to_string());
680 let result = receiver.await_ready().await;
681 assert!(result.is_ok(), "first transition (Ready) wins");
682 }
683
684 #[tokio::test]
685 async fn test_startup_receiver_immediate_is_pre_resolved_ok() {
686 let receiver = StartupReceiver::immediate();
687 let result = receiver.await_ready().await;
688 assert!(result.is_ok(), "immediate receiver must resolve Ok");
689 }
690
691 #[tokio::test]
692 async fn test_consumer_context_mark_ready_drives_signal() {
693 let (tx, _rx) = mpsc::channel(1);
694 let ctx = ConsumerContext::new(
695 tx,
696 CancellationToken::new(),
697 "startup-test-route".to_string(),
698 );
699 let (signal, receiver) = StartupSignal::pair();
700 let ctx = ctx.with_startup(signal);
701 ctx.mark_ready();
702 let result = receiver.await_ready().await;
703 assert!(result.is_ok(), "ctx.mark_ready must resolve the receiver");
704 }
705
706 #[tokio::test]
707 async fn test_consumer_context_mark_failed_drives_signal() {
708 let (tx, _rx) = mpsc::channel(1);
709 let ctx = ConsumerContext::new(
710 tx,
711 CancellationToken::new(),
712 "startup-fail-route".to_string(),
713 );
714 let (signal, receiver) = StartupSignal::pair();
715 let ctx = ctx.with_startup(signal);
716 ctx.mark_failed("assignment window elapsed".to_string());
717 let err = receiver
718 .await_ready()
719 .await
720 .expect_err("ctx.mark_failed must resolve the receiver as Err");
721 match err {
722 CamelError::RouteError(msg) => assert!(msg.contains("assignment window elapsed")),
723 other => panic!("expected RouteError, got {other:?}"),
724 }
725 }
726
727 #[tokio::test]
728 async fn test_startup_receiver_dropped_sender_returns_err() {
729 // Build a signal/receiver pair and drop the signal without ever
730 // transitioning — receiver must surface a contract-violation error.
731 let (_signal, receiver) = StartupSignal::pair();
732 drop(_signal);
733 let err = receiver
734 .await_ready()
735 .await
736 .expect_err("dropped signal must surface as Err");
737 match err {
738 CamelError::RouteError(msg) => assert!(msg.contains("dropped")),
739 other => panic!("expected RouteError, got {other:?}"),
740 }
741 }
742
743 #[tokio::test]
744 async fn test_consumer_default_suspend_resume() {
745 struct DummyConsumer;
746
747 #[async_trait::async_trait]
748 impl super::Consumer for DummyConsumer {
749 async fn start(&mut self, _ctx: super::ConsumerContext) -> Result<(), CamelError> {
750 Ok(())
751 }
752 async fn stop(&mut self) -> Result<(), CamelError> {
753 Ok(())
754 }
755 }
756
757 let consumer = DummyConsumer;
758 assert!(consumer.suspend().await.is_ok());
759 assert!(consumer.resume().await.is_ok());
760 }
761
762 // --- SecurityContext tests ---
763
764 struct StubPolicy;
765
766 #[async_trait::async_trait]
767 impl SecurityPolicy for StubPolicy {
768 async fn evaluate(
769 &self,
770 _exchange: &mut Exchange,
771 _auth: &camel_api::security_policy::AuthContext<'_>,
772 ) -> Result<camel_api::security_policy::AuthorizationDecision, CamelError> {
773 Ok(camel_api::security_policy::AuthorizationDecision::Granted {
774 principal: camel_api::security_policy::Principal {
775 subject: "stub".into(),
776 issuer: "stub".into(),
777 audience: vec![],
778 scopes: vec![],
779 roles: vec![],
780 claims: serde_json::json!({}),
781 },
782 })
783 }
784 }
785
786 struct StubAuthenticator;
787
788 #[async_trait::async_trait]
789 impl camel_auth::TokenAuthenticator for StubAuthenticator {
790 async fn authenticate_bearer(
791 &self,
792 _token: &str,
793 ) -> Result<camel_api::security_policy::Principal, CamelError> {
794 Ok(camel_api::security_policy::Principal {
795 subject: "stub".into(),
796 issuer: "stub".into(),
797 audience: vec![],
798 scopes: vec![],
799 roles: vec![],
800 claims: serde_json::json!({}),
801 })
802 }
803 }
804
805 #[test]
806 fn test_security_context_new() {
807 let ctx = SecurityContext::new(StubPolicy);
808 assert!(Arc::strong_count(ctx.policy.as_ref().expect("new sets policy")) == 1);
809 assert_eq!(
810 ctx.credential_sources,
811 vec![camel_auth::CredentialSource::AuthorizationHeader]
812 );
813 }
814
815 #[test]
816 fn test_security_context_from_arc() {
817 let policy: Arc<dyn SecurityPolicy> = Arc::new(StubPolicy);
818 let ctx = SecurityContext::from_arc(Arc::clone(&policy));
819 assert!(Arc::ptr_eq(
820 ctx.policy.as_ref().expect("from_arc sets policy"),
821 &policy
822 ));
823 assert_eq!(
824 ctx.credential_sources,
825 vec![camel_auth::CredentialSource::AuthorizationHeader]
826 );
827 }
828
829 #[test]
830 fn test_security_context_clone_independent() {
831 let ctx = SecurityContext::new(StubPolicy);
832 let cloned = ctx.clone();
833 assert!(Arc::ptr_eq(
834 ctx.policy.as_ref().expect("source policy"),
835 cloned.policy.as_ref().expect("cloned policy"),
836 ));
837 assert_eq!(ctx.credential_sources, cloned.credential_sources);
838 }
839
840 #[test]
841 fn test_security_context_debug_redacts_traits() {
842 let ctx = SecurityContext::new(StubPolicy);
843 let debug_str = format!("{ctx:?}");
844 assert!(debug_str.contains("<SecurityPolicy>"));
845 assert!(debug_str.contains("credential_sources"));
846 }
847
848 #[test]
849 fn test_security_context_with_credential_sources() {
850 let ctx = SecurityContext::new(StubPolicy).with_credential_sources(vec![
851 camel_auth::CredentialSource::Cookie {
852 name: "session".into(),
853 },
854 camel_auth::CredentialSource::AuthorizationHeader,
855 ]);
856 assert_eq!(ctx.credential_sources.len(), 2);
857 assert!(matches!(
858 &ctx.credential_sources[0],
859 camel_auth::CredentialSource::Cookie { .. }
860 ));
861 }
862
863 #[test]
864 fn security_context_holds_plan_and_providers() {
865 use camel_api::security_policy::{AccessMode, RouteSecurityPlan, TransportId};
866
867 let plan = RouteSecurityPlan {
868 access_mode: AccessMode::Public,
869 provider_ref: None,
870 transport: TransportId::Http,
871 credential_sources: vec![],
872 audience_binding: None,
873 };
874
875 let registry = camel_auth::ProviderRegistry::new();
876 registry.register(
877 "default",
878 camel_auth::ProviderEntry {
879 authenticator: Arc::new(StubAuthenticator),
880 audience_binding: None,
881 },
882 );
883
884 let ctx = SecurityContext::new(StubPolicy)
885 .with_plan(plan)
886 .with_providers(Arc::new(registry));
887
888 let ctx_plan = ctx.plan.as_ref().expect("plan must be attached");
889 assert!(matches!(ctx_plan.access_mode, AccessMode::Public));
890 assert_eq!(ctx_plan.transport, TransportId::Http);
891
892 let providers = ctx.providers.as_ref().expect("providers must be attached");
893 let resolved = providers.resolve("default").expect("provider must resolve");
894 assert!(resolved.audience_binding.is_none());
895 }
896
897 /// `finish-auth-flip` Task 1.3: the `SecurityContext.authenticator`
898 /// hand-off is deleted — transports authenticate through the kernel
899 /// fields (`plan` + `providers`). Registry/doc mentions of the word
900 /// `authenticator` (e.g. `ProviderEntry`) are allowed; the struct
901 /// field is not.
902 #[test]
903 fn security_context_authenticator_deleted_source_scan() {
904 let src = include_str!("consumer.rs");
905 // Assembled at runtime so this scan's own text cannot match.
906 let needle = format!("pub {}authenticator", "");
907 assert!(
908 !src.contains(&needle),
909 "SecurityContext.authenticator must stay deleted: field found in consumer.rs"
910 );
911 }
912}