use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use axum::Router;
use camel_api::AuthPrincipal;
use camel_api::security_policy::{
AccessMode, Principal, RouteSecurityPlan, SecurityPolicy, TransportId,
};
use camel_auth::native_auth::{NativeCredential, NativeCredentialSecret, NativeCredentialStore};
use camel_auth::{
CredentialSource, ProviderEntry, ProviderRegistry, RolePolicy, StaticTokenAuthenticator,
read_carrier,
};
use camel_component_api::test_support::NoopRuntimeObservability;
use camel_component_api::{ExchangeEnvelope, SecurityContext};
use camel_component_ws::{WsAppState, WsPathConfig, dispatch_handler};
use futures::SinkExt;
use tokio::sync::{RwLock, mpsc};
use tokio_tungstenite::tungstenite::ClientRequestBuilder;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::tungstenite::protocol::Message as ClientMessage;
const PATH: &str = "/ws/kernel";
const PROVIDER: &str = "idp-ws";
const TOKEN: &str = "SENTINEL_KERNEL_TOKEN";
const SUBJECT: &str = "svc-kernel-ws";
fn native_principal() -> Principal {
Principal {
subject: SUBJECT.into(),
issuer: "ws-kernel-test".into(),
audience: vec![],
scopes: vec![],
roles: vec![],
claims: serde_json::Value::Null,
}
}
fn provider_registry() -> Arc<ProviderRegistry> {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: TOKEN.to_string().into(),
},
principal: native_principal(),
}])
.unwrap();
let registry = ProviderRegistry::new();
registry.register(
PROVIDER,
ProviderEntry {
authenticator: Arc::new(StaticTokenAuthenticator::new(store)),
audience_binding: None,
},
);
Arc::new(registry)
}
fn authenticated_plan() -> RouteSecurityPlan {
RouteSecurityPlan {
access_mode: AccessMode::Authenticated,
provider_ref: Some(PROVIDER.into()),
transport: TransportId::Ws,
credential_sources: vec![CredentialSource::AuthorizationHeader],
audience_binding: None,
}
}
fn public_plan() -> RouteSecurityPlan {
RouteSecurityPlan {
access_mode: AccessMode::Public,
provider_ref: None,
transport: TransportId::Ws,
credential_sources: vec![],
audience_binding: None,
}
}
fn kernel_security_context(
plan: RouteSecurityPlan,
providers: Arc<ProviderRegistry>,
) -> SecurityContext {
let policy: Arc<dyn SecurityPolicy> = Arc::new(RolePolicy::new(vec![], true));
SecurityContext::from_arc(policy)
.with_plan(plan)
.with_providers(providers)
}
fn make_app_state(
path: &str,
sec_ctx: SecurityContext,
) -> (WsAppState, mpsc::Receiver<ExchangeEnvelope>) {
let (tx, rx) = mpsc::channel::<ExchangeEnvelope>(64);
let dispatch: Arc<RwLock<HashMap<String, mpsc::Sender<ExchangeEnvelope>>>> =
Arc::new(RwLock::new([(path.to_string(), tx)].into_iter().collect()));
let path_configs = Arc::new(dashmap::DashMap::new());
path_configs.insert(
path.to_string(),
WsPathConfig {
max_connections: 100,
max_message_size: 65536,
heartbeat_interval: Duration::ZERO,
idle_timeout: Duration::ZERO,
allow_origin: "*".into(),
},
);
let path_policies = Arc::new(dashmap::DashMap::new());
path_policies.insert(path.to_string(), sec_ctx);
(
WsAppState {
dispatch,
path_configs,
path_policies,
server_error: Arc::new(AtomicBool::new(false)),
runtime: Arc::new(NoopRuntimeObservability),
route_id: "ws-kernel-test-route".to_string(),
},
rx,
)
}
async fn spawn_server(state: WsAppState) -> (u16, tokio::task::JoinHandle<()>) {
let app = Router::new().fallback(dispatch_handler).with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(port, handle)
}
fn upgrade_uri(port: u16) -> http::Uri {
format!("ws://127.0.0.1:{port}{PATH}").parse().unwrap()
}
#[tokio::test]
async fn ws_kernel_handshake_grants() {
let (state, mut rx) = make_app_state(
PATH,
kernel_security_context(authenticated_plan(), provider_registry()),
);
let (port, server) = spawn_server(state).await;
let builder = ClientRequestBuilder::new(upgrade_uri(port))
.with_header("Authorization", format!("Bearer {TOKEN}"));
let (mut client, response) = tokio_tungstenite::connect_async(builder).await.unwrap();
assert_eq!(response.status(), 101, "kernel handshake must authorize");
client
.send(ClientMessage::Text("first".into()))
.await
.unwrap();
client
.send(ClientMessage::Binary("second".as_bytes().to_vec().into()))
.await
.unwrap();
let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("first message envelope dispatched")
.expect("dispatch channel open");
assert_eq!(
envelope.exchange.input.body.as_text(),
Some("first"),
"first message body must reach the route"
);
let carrier =
read_carrier(&envelope.exchange).expect("typed carrier missing on FIRST message exchange");
assert_eq!(carrier.provider_id(), PROVIDER);
assert_eq!(carrier.principal().subject, SUBJECT);
let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("second message envelope dispatched")
.expect("dispatch channel open");
assert!(matches!(
envelope.exchange.input.body,
camel_api::Body::Bytes(ref b) if b.as_ref() == b"second"
));
let carrier =
read_carrier(&envelope.exchange).expect("typed carrier missing on SECOND message exchange");
assert_eq!(carrier.provider_id(), PROVIDER);
assert_eq!(carrier.principal().subject, SUBJECT);
server.abort();
}
#[tokio::test]
async fn ws_kernel_handshake_denies() {
let (state, _rx) = make_app_state(
PATH,
kernel_security_context(authenticated_plan(), provider_registry()),
);
let (port, server) = spawn_server(state).await;
let builder = ClientRequestBuilder::new(upgrade_uri(port))
.with_header("Authorization", "Bearer wrong-token");
match tokio_tungstenite::connect_async(builder).await {
Err(WsError::Http(response)) => {
assert_eq!(response.status().as_u16(), 401, "kernel denial status");
}
Err(e) => panic!("expected HTTP denial, got: {e}"),
Ok(_) => panic!("invalid token must not complete the handshake"),
}
server.abort();
}
#[tokio::test]
async fn ws_public_route_passes_without_extraction() {
let (state, mut rx) = make_app_state(
PATH,
kernel_security_context(public_plan(), provider_registry()),
);
let (port, server) = spawn_server(state).await;
let builder = ClientRequestBuilder::new(upgrade_uri(port));
let (mut client, response) = tokio_tungstenite::connect_async(builder).await.unwrap();
assert_eq!(response.status(), 101, "public plan must pass through");
client
.send(ClientMessage::Text("public-body".into()))
.await
.unwrap();
let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("public route body must run")
.expect("dispatch channel open");
assert_eq!(envelope.exchange.input.body.as_text(), Some("public-body"));
assert!(
read_carrier(&envelope.exchange).is_none(),
"public pass-through must not mint a carrier"
);
server.abort();
}