//! Shared test wiring for the channels-based tests (the producer //! harness in `channels.rs::tests` and the consumer end-to-end tests //! in `session.rs::tests` both need the same `ChannelClient` ↔ //! server wiring). `#[cfg(test)]`-only — never part of the public //! API. use std::collections::HashMap; use std::sync::Arc; use alkcall::channels::client::ChannelClient; use alkcall::channels::operations::ChannelCore; use alkcall::channels::policy::default_policy; use alkcall::core::auth::{AuthContext, Identity}; use alkcall::core::types::Connection as CoreConnection; use alkcall::registry::registration::OperationRegistry; use crate::backend::TtyBackend; use crate::channels::register_openable; /// Build a `ChannelClient` and a server-side `ChannelCore` + registry /// with `channels/tty/sub` registered, wired over a `tokio::io::duplex` /// carrying the channels 8-byte chunk header wire format. Returns the /// client. The server-side dispatch loop is spawned and runs until the /// client drops or the test ends. /// /// This mirrors the alkcall `channel_0_end_to_end_register_openable` /// test's wiring pattern but uses alktty's `register_openable` helper /// so the registration is the code under test. pub(crate) async fn wire_client_and_server( backends: Arc>>, ownership: Option>, identity: Option, ) -> ChannelClient { use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero}; use alkcall::channels::policy::NoCap; use alkcall::core::auth::IdentityProvider; use alkcall::protocol::connection::split_single_stream; use alkcall::protocol::dispatch::Dispatcher; struct NoopIdProvider; impl IdentityProvider for NoopIdProvider { fn resolve_from_fingerprint(&self, _: &str) -> Option { None } fn resolve_from_token(&self, _: &alkcall::core::auth::AuthToken) -> Option { None } } let policy = default_policy(); let policy_for_hook = Arc::clone(&policy); let identity_for_conn = identity.clone(); let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, auth| { let backends = Arc::clone(&backends); let ownership = ownership.clone(); let _identity = identity.clone(); let policy = Arc::clone(&policy_for_hook); tokio::spawn(async move { let channel0_bidi = match channel0_conn.accept_bi().await { Ok(s) => s, Err(_) => return, }; let (writer, reader) = split_single_stream(channel0_bidi); // Propagate the identity to the channel-0 // connection so the call dispatch's // `resolve_identity` sees it. The // `ChannelsAdapter` builds `channel0_conn` fresh // from `channel_source` and does NOT inherit the // outer connection's identity — we set it here // so the `AccessControl` scope-gate can check it. if let Some(id) = _identity { let _ = channel0_conn.set_identity(id); } let core = ChannelCore::new(manager, policy); let mut registry = OperationRegistry::new(); register_openable( &core, Arc::clone(&backends), ownership.clone(), &mut registry, auth.clone(), ) .expect("register_openable"); let registry = Arc::new(registry); let provider: Arc = Arc::new(NoopIdProvider); let call_connection = Arc::new( alkcall::protocol::connection::CallConnection::new_single_stream( channel0_conn, Arc::clone(&writer), ), ); let dp = Dispatcher::new(registry, provider); dp.run_loop_single_stream(call_connection, reader, writer) .await; }) }); let (client_end, server_end) = tokio::io::duplex(64 * 1024); let client_conn = CoreConnection::from_bidi(client_end, b"alk/channels".to_vec(), None); let server_conn = CoreConnection::from_bidi(server_end, b"alk/channels".to_vec(), None); // Set the identity on the server connection so the call // dispatch sees it (the ACL check runs against the // connection's identity, not the `install_channel_zero` // hook's `auth`). if let Some(id) = &identity_for_conn { let _ = server_conn.set_identity(id.clone()); } let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap)); let auth = AuthContext::anonymous(b"alk/channels"); let _server_handle = tokio::spawn(async move { let _ = alkcall::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await; }); ChannelClient::from_connection(client_conn) .await .expect("channel client init") } /// An identity with the `tty:open` scope (the gate /// `channels/tty/sub` requires). pub(crate) fn tty_identity(id: &str) -> Identity { Identity { id: id.to_string(), scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()], resources: HashMap::new(), } } #[cfg(test)] mod tests { use super::*; use crate::backend::MockBackend; use crate::channels::OP_TTY_OPEN; #[test] fn tty_identity_has_tty_open_scope() { let id = tty_identity("carol"); assert_eq!(id.id, "carol"); assert!(id .scopes .iter() .any(|s| s == crate::adapter::TTY_OPEN_SCOPE)); } #[tokio::test] async fn harness_open_op_returns_channel_id() { let mut backends: HashMap> = HashMap::new(); backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0))); let client = wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await; let response = tokio::time::timeout( std::time::Duration::from_secs(5), client.call_open_op( OP_TTY_OPEN, serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }), ), ) .await .expect("open op timed out"); assert!( response.result.is_ok(), "open op failed: {:?}", response.result ); } #[tokio::test] async fn harness_handler_to_client_data_flows() { use tokio::io::AsyncReadExt; let mut backends: HashMap> = HashMap::new(); backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0))); let client = wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await; let (channel_id, send, mut recv) = tokio::time::timeout( std::time::Duration::from_secs(5), client.open_channel( OP_TTY_OPEN, serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }), crate::channels::TTY_ALPN, ), ) .await .expect("open_channel timed out") .expect("open_channel"); assert!(channel_id > 0); // The producer writes a 5-byte TTY header (stdout sentinel) // almost immediately after the open (MockBackend resolves // exit right away) — a push-first producer whose first write // races the consumer's adopt. Read it: alkcall 0.4.1's // early-arrival park guarantees the first chunks are not lost. let mut first = [0u8; 5]; tokio::time::timeout( std::time::Duration::from_secs(5), recv.read_exact(&mut first), ) .await .expect("no first chunk from producer (handler→client direction)") .expect("read first chunk header"); assert_eq!(first[0], crate::wire::STREAM_STDOUT); drop(send); } /// End-to-end (R4, post-ADR-010): `input` that passes the registry's /// (partial) schema but fails the establisher's full /// `NegotiateRequest` parse — `cwd` typed as a number. The /// establisher rejects it before the reply: the open op fails with /// `channel:open_failed` + `details.reason == "handler_error"` (the /// malformed-negotiation mapping), the channel is torn down, and no /// `channel_id` is ever returned. Uses `ChannelClient::open_channel` /// directly (the consumer's local fail-fast parse in /// `open_via_channels` would reject these params before the open op /// runs — R5's fail-fast path). #[tokio::test] async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() { use alkcall::channels::client::ChannelOpenError; let mut backends: HashMap> = HashMap::new(); backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0))); let client = wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await; let result = tokio::time::timeout( std::time::Duration::from_secs(5), client.open_channel( OP_TTY_OPEN, serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"], "cwd": 42 }), crate::channels::TTY_ALPN, ), ) .await .expect("open_channel timed out"); match result { Err(ChannelOpenError::CallFailed { error }) => { assert_eq!(error.code, "channel:open_failed"); let details = error.details.expect("details carry the reason"); assert_eq!(details["reason"], "handler_error"); assert!( details["message"] .as_str() .is_some_and(|m| m.contains("malformed negotiation")), "message carries the malformed-negotiation detail, got {details}" ); } Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"), Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"), } // The SSH contract: no data channel survives a failed // establishment (channel 0 is the call channel). assert!( client.manager().channel_ids().iter().all(|&id| id == 0), "no channel survives a failed establishment" ); } /// Post-alkcall-0.6 (ADR-010 as amended): allocation failure no /// longer arrives in-band. `backend.allocate` runs in the /// establisher (the allocated `TtyHandle` crosses to the pump /// handler via the `Establishment` plan payload), so its failure /// fails the open op itself — `channel:open_failed` with /// `details.reason == "dial_failed"` — and no channel survives /// (the SSH contract: no `channel_id`, no `0x00`-prefixed /// in-band frame on a channel that was reported as succeeding). #[tokio::test] async fn allocate_failure_fails_open_as_dial_failed() { use alkcall::channels::client::ChannelOpenError; struct AllocFailBackend; #[async_trait::async_trait] impl TtyBackend for AllocFailBackend { async fn allocate( &self, _params: &crate::backend::TtyParams, ) -> Result { Err(crate::backend::TtyError::AllocFailed { message: "out of ptys".to_string(), }) } } let mut backends: HashMap> = HashMap::new(); backends.insert("mock".to_string(), Arc::new(AllocFailBackend)); let client = wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await; let result = tokio::time::timeout( std::time::Duration::from_secs(5), client.open_channel( OP_TTY_OPEN, serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }), crate::channels::TTY_ALPN, ), ) .await .expect("open_channel timed out"); match result { Err(ChannelOpenError::CallFailed { error }) => { assert_eq!(error.code, "channel:open_failed"); let details = error.details.expect("details carry the reason"); assert_eq!(details["reason"], "dial_failed"); assert!( details["message"] .as_str() .is_some_and(|m| m.contains("out of ptys")), "message carries the backend allocation failure detail, got {details}" ); } Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"), Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"), } // The SSH contract: no data channel survives a failed // establishment (channel 0 is the call channel), and no // in-band frame was ever written (the channel never existed). assert!( client.manager().channel_ids().iter().all(|&id| id == 0), "no channel survives a failed allocation" ); } }