phase 2: channels integration + TtySession consumer client
New code (not a port) — the producer/consumer halves of the channels integration per alkcall's protocol-crate pattern. Producer half (src/channels.rs): - register_openable helper: builds the OperationSpec for channels/tty/sub (Sub-typed, channel_open marker for alk/tty, AccessControl with tty:open scope gate) and calls ChannelCore::register_openable - TtyOpenHandler factory: receives the channel Connection, calls accept_bi(), runs drive_session on the BiStream — same code path as the direct-ALPN TtyAdapter (ADR-093) - 8 tests: spec shape, registration, end-to-end open op returns channel_id, ACL denial without tty:open scope Consumer half (src/session.rs): - TtySession::connect_direct(connection, negotiate) — direct alk/tty - TtySession::open_via_channels(client, params) — opens a channel via ChannelClient::open_channel, builds a Connection from the reassembled halves, runs the same negotiation + typed-methods flow - Methods: send_stdin, close_stdin, resize, signal, recv_stdout, recv_stderr, wait - Read pump: spawns a task that reads chunks off the BiStream and routes stdout/stderr to mpsc channels, parses Exit control chunks and resolves a watch channel for wait() - Drop aborts the read pump - 6 tests: negotiation frame write, stdin round-trip, resize/signal, stdout stream, NoExitChunk on early close, broken-stream error Also: added Serialize to NegotiateRequest + TerminalParamsWire (was Deserialize-only; the session client needs to serialize the negotiation frame). 80/80 tests pass, wasm32-unknown-unknown clean, clippy clean.
This commit is contained in:
1 parent
29dfa1a6af
commit
765f40ae34
4 files changed
+1204
-2
No files matched your search
+517
@@ -0,0 +1,517 @@
|
||||
//! Producer half of the channels integration — the `register_openable`
|
||||
//! helper and the `TtyOpenHandler` factory (ADR-047 §3).
|
||||
//!
|
||||
//! This is the path where TTY rides inside `alk/channels` rather than
|
||||
//! directly on a `alk/tty` ALPN connection. The assembly layer (an
|
||||
//! `alk/channels` acceptor's `install_channel_zero` hook) builds a
|
||||
//! per-connection [`ChannelCore`], then calls
|
||||
//! [`register_openable`] to register the `channels/tty/sub` open op
|
||||
//! on the per-connection overlay registry. When a consumer (a
|
||||
//! [`crate::session::TtySession`] or any `ChannelClient` caller)
|
||||
//! invokes `channels/tty/sub`, the alkcall channels wrapper does
|
||||
//! `check_open` → `open_channel` → spawn the [`TtyOpenHandler`] →
|
||||
//! respond with `{ channel_id }`. The `TtyOpenHandler` receives the
|
||||
//! channel's `Connection` (data-plane ALPN `alk/tty`), calls
|
||||
//! `accept_bi()` to get the channel's `BiStream`, and runs
|
||||
//! [`crate::adapter::drive_session`] on it — the same code path the
|
||||
//! direct-ALPN `TtyAdapter` uses (ADR-093: TTY always uses its 5-byte
|
||||
//! format; the channels layer strips its 8-byte header and hands TTY
|
||||
//! the payload transparently).
|
||||
//!
|
||||
//! The access control (scope-gate + ownership) is wired into the
|
||||
//! `OperationSpec`'s `AccessControl` and enforced by the registry's
|
||||
//! `invoke`/`invoke_streaming` before the `TtyOpenHandler` runs — the
|
||||
//! handler itself only validates that the negotiated backend exists
|
||||
//! and spawns the protocol. This is the difference from the
|
||||
//! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check;
|
||||
//! the channels path gets ACL for free from alkcall's registry.
|
||||
//!
|
||||
//! [`ChannelCore`]: alkcall::channels::operations::ChannelCore
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::channels::operations::{ChannelCore, OpenHandler};
|
||||
use alkcall::core::auth::AuthContext;
|
||||
use alkcall::core::ownership::OwnershipProvider;
|
||||
use alkcall::core::Connection;
|
||||
use alkcall::registry::spec::{
|
||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapter::{drive_session, TTY_OPEN_SCOPE};
|
||||
use crate::backend::TtyBackend;
|
||||
|
||||
/// The per-ALPN open operation name (`channels/<alpn>/sub` convention,
|
||||
/// ADR-047). TTY is consumer-opens (the client requests a shell), so
|
||||
/// the op is `sub` (subscribe), not `pub` (publish).
|
||||
pub const OP_TTY_OPEN: &str = "channels/tty/sub";
|
||||
|
||||
/// The data-plane ALPN the channel carries. Matches the constant in
|
||||
/// [`crate::adapter::TtyAdapter::alpn`].
|
||||
pub const TTY_ALPN: &str = "alk/tty";
|
||||
|
||||
/// Register the `channels/tty/sub` open op on a per-connection
|
||||
/// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13 —
|
||||
/// per-connection registration).
|
||||
///
|
||||
/// The assembly layer (an `alk/channels` acceptor's
|
||||
/// `install_channel_zero` hook) builds a [`ChannelCore`] from the
|
||||
/// adapter-supplied `ChannelManager` + `ChannelLifecyclePolicy`,
|
||||
/// constructs a fresh per-connection `OperationRegistry`, and calls
|
||||
/// this helper. The helper builds the [`OperationSpec`] for
|
||||
/// `channels/tty/sub` with the `channel_open` marker set
|
||||
/// (`ChannelOpenSpec::new("alk/tty")`), an `AccessControl` carrying
|
||||
/// the `tty:open` scope gate, and a permissive input schema (the
|
||||
/// `NegotiateRequest` shape — JSON, validated by the
|
||||
/// `drive_session` negotiation reader). It then wraps the
|
||||
/// [`TtyOpenHandler`] factory and calls
|
||||
/// [`ChannelCore::register_openable`].
|
||||
///
|
||||
/// `backends` is the same backend map the direct-ALPN `TtyAdapter`
|
||||
/// holds — `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
|
||||
/// negotiation frame's `backend` string. The assembly layer typically
|
||||
/// shares one backend map between the direct-ALPN adapter and the
|
||||
/// channels `register_openable` registration.
|
||||
///
|
||||
/// `ownership` is the optional `OwnershipProvider` for the ADR-050
|
||||
/// resource-ownership check. `None` = scope-gate only. The provider
|
||||
/// is consulted inside `drive_session` (after the backend is selected
|
||||
/// and `resource_id` is extracted from `backend_params`), not by the
|
||||
/// channels wrapper — the wrapper's `AccessControl` carries only the
|
||||
/// scope gate. This mirrors the direct-ALPN `TtyAdapter` shape: the
|
||||
/// scope gate is enforced once at the entry point, the ownership
|
||||
/// check is enforced once at the backend-selection point.
|
||||
///
|
||||
/// `auth` is the peer's `AuthContext`, captured at
|
||||
/// `install_channel_zero` time and closed over by the wrapper so the
|
||||
/// `TtyOpenHandler` receives it without the wrapper having to reach
|
||||
/// into `OperationContext` for it (per the ADR-047 §4 amendment).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `ChannelCore::register_openable` rejects the
|
||||
/// spec (e.g., the spec has no `channel_open` marker — won't happen
|
||||
/// with the spec this helper builds, but the underlying call is
|
||||
/// fallible).
|
||||
pub fn register_openable(
|
||||
core: &ChannelCore,
|
||||
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||
registry: &mut alkcall::registry::registration::OperationRegistry,
|
||||
auth: AuthContext,
|
||||
) -> Result<(), String> {
|
||||
let spec = tty_open_spec();
|
||||
let open_handler = make_tty_open_handler(backends, ownership, auth.identity.clone());
|
||||
core.register_openable(spec, open_handler, registry, auth)
|
||||
}
|
||||
|
||||
/// Build the [`OperationSpec`] for `channels/tty/sub`.
|
||||
///
|
||||
/// The op is `Sub`-typed (consumer-opens — the initiator opens a
|
||||
/// channel and the data plane flows on the channel's `BiStream`, not
|
||||
/// in the call response stream). The `channel_open` marker is set to
|
||||
/// `ChannelOpenSpec::new("alk/tty")` so the channels wrapper allocates
|
||||
/// a data channel. The `AccessControl` carries the `tty:open` scope
|
||||
/// gate (the same scope the direct-ALPN `TtyAdapter` checks).
|
||||
///
|
||||
/// The input schema is permissive (`type: object`) — the
|
||||
/// `NegotiateRequest` JSON shape is validated by `drive_session`'s
|
||||
/// negotiation reader, not by the registry's schema validator. This
|
||||
/// keeps the wire-format definition in one place (`wire.rs` +
|
||||
/// `negotiation.rs`) and avoids duplicating the schema in the
|
||||
/// `OperationSpec`. A future tightening could add the full
|
||||
/// `NegotiateRequest` JSON schema here; the permissive shape is the
|
||||
/// starting point.
|
||||
pub fn tty_open_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
OP_TTY_OPEN,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"carriage": { "type": "string" },
|
||||
"backend": { "type": "string" },
|
||||
"cmd": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["carriage", "backend", "cmd"]
|
||||
}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}),
|
||||
vec![],
|
||||
AccessControl {
|
||||
required_scopes: vec![TTY_OPEN_SCOPE.to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new(TTY_ALPN))
|
||||
}
|
||||
|
||||
/// Build the [`OpenHandler`] for `channels/tty/sub`.
|
||||
///
|
||||
/// The handler receives the open op's `input` (the `NegotiateRequest`
|
||||
/// params — ignored here; `drive_session` reads it from the channel's
|
||||
/// `BiStream` as the first frame, same as the direct-ALPN path), the
|
||||
/// channel's [`Connection`] (data-plane ALPN `alk/tty`), and the
|
||||
/// peer's [`AuthContext`]. It calls `accept_bi()` to get the channel's
|
||||
/// [`BiStream`], splits it into read/write halves (the stdlib
|
||||
/// `tokio::io::split` idiom — the same split the direct-ALPN
|
||||
/// `TtyAdapter::handle` does), and runs [`drive_session`] on them.
|
||||
///
|
||||
/// The handler's `JoinHandle` is recorded by the channels wrapper for
|
||||
/// teardown (abort on `channel/close` / connection drop). When the
|
||||
/// session ends (exit chunk sent, stream closed, or stream reset),
|
||||
/// `drive_session` returns and the spawned task completes; the
|
||||
/// wrapper's teardown task then calls `manager.teardown_channel` and
|
||||
/// `policy.on_close` (ADR-047 §7).
|
||||
fn make_tty_open_handler(
|
||||
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||
identity: Option<alkcall::core::auth::Identity>,
|
||||
) -> OpenHandler {
|
||||
Arc::new(move |input: Value, channel_conn: Connection, auth: AuthContext| {
|
||||
let backends = Arc::clone(&backends);
|
||||
let ownership = ownership.clone();
|
||||
let identity = identity.clone().or_else(|| auth.identity.clone());
|
||||
let _ = input;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let stream = match channel_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!("tty: channels open: accept_bi failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (client_read, client_write) = tokio::io::split(stream);
|
||||
drive_session(client_write, client_read, backends, ownership, identity).await;
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::{MockBackend, TtyError};
|
||||
use alkcall::channels::operations::ChannelCore;
|
||||
use alkcall::channels::policy::default_policy;
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::core::types::Connection as CoreConnection;
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use std::collections::HashMap as StdHashMap;
|
||||
use tokio::io::duplex;
|
||||
|
||||
/// 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 and the per-connection `ChannelCore` (for any teardown
|
||||
/// assertions). 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.
|
||||
async fn wire_client_and_server(
|
||||
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||
identity: Option<Identity>,
|
||||
) -> ChannelClient {
|
||||
use alkcall::channels::adapter::{
|
||||
ChannelsAdapter, InstallChannelZero,
|
||||
};
|
||||
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<alkcall::core::auth::Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(
|
||||
&self,
|
||||
_: &alkcall::core::auth::AuthToken,
|
||||
) -> Option<alkcall::core::auth::Identity> {
|
||||
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<dyn IdentityProvider> = 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) = 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")
|
||||
}
|
||||
|
||||
/// A `ChannelLifecyclePolicy` with no cap (for tests).
|
||||
use alkcall::channels::policy::NoCap;
|
||||
|
||||
#[test]
|
||||
fn op_tty_open_is_channels_tty_sub() {
|
||||
assert_eq!(OP_TTY_OPEN, "channels/tty/sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tty_alpn_is_alk_tty() {
|
||||
assert_eq!(TTY_ALPN, "alk/tty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tty_open_spec_has_channel_open_marker_for_alk_tty() {
|
||||
let spec = tty_open_spec();
|
||||
assert_eq!(spec.name, OP_TTY_OPEN);
|
||||
assert_eq!(spec.op_type, OperationType::Sub);
|
||||
assert_eq!(spec.visibility, Visibility::External);
|
||||
let marker = spec.channel_open.expect("channel_open marker set");
|
||||
assert_eq!(marker.alpn, TTY_ALPN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tty_open_spec_requires_tty_open_scope() {
|
||||
let spec = tty_open_spec();
|
||||
assert_eq!(spec.access_control.required_scopes, vec![TTY_OPEN_SCOPE]);
|
||||
assert!(spec.access_control.has_restrictions());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tty_open_spec_input_schema_has_required_fields() {
|
||||
let spec = tty_open_spec();
|
||||
let schema = spec.input_schema;
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("required array");
|
||||
let required_names: Vec<&str> =
|
||||
required.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(required_names.contains(&"carriage"));
|
||||
assert!(required_names.contains(&"backend"));
|
||||
assert!(required_names.contains(&"cmd"));
|
||||
}
|
||||
|
||||
/// `register_openable` registers the op on the registry. The op
|
||||
/// must be discoverable by name afterwards.
|
||||
#[tokio::test]
|
||||
async fn register_openable_registers_op_on_registry() {
|
||||
let (_client, server) = duplex(1024);
|
||||
let (_reader, writer) = tokio::io::split(server);
|
||||
let (handle, _runner) = alkcall::channels::mux::MuxRunner::new(Box::new(writer));
|
||||
let manager = alkcall::channels::manager::ChannelManager::with_defaults(handle, None);
|
||||
let core = ChannelCore::new(manager, default_policy());
|
||||
|
||||
let backends: Arc<HashMap<String, Arc<dyn TtyBackend>>> =
|
||||
Arc::new(HashMap::new());
|
||||
let mut registry = OperationRegistry::new();
|
||||
register_openable(
|
||||
&core,
|
||||
backends,
|
||||
None,
|
||||
&mut registry,
|
||||
AuthContext::anonymous(b"alk/channels"),
|
||||
)
|
||||
.expect("register_openable");
|
||||
assert!(registry.registration(OP_TTY_OPEN).is_some());
|
||||
}
|
||||
|
||||
/// End-to-end: `ChannelClient::call_open_op("channels/tty/sub")`
|
||||
/// returns `{ channel_id }` when the server-side has registered via
|
||||
/// `register_openable` with a `MockBackend`. The open op increments
|
||||
/// the channel count; the `channel_id` is non-zero (channel 0 is
|
||||
/// the call channel, ADR-036).
|
||||
#[tokio::test]
|
||||
async fn end_to_end_open_op_returns_channel_id() {
|
||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
backends.insert(
|
||||
"mock".to_string(),
|
||||
Arc::new(MockBackend::with_exit_code(0)),
|
||||
);
|
||||
let backends = Arc::new(backends);
|
||||
|
||||
let identity = Identity {
|
||||
id: "alice".to_string(),
|
||||
scopes: vec![TTY_OPEN_SCOPE.to_string()],
|
||||
resources: StdHashMap::new(),
|
||||
};
|
||||
let client = wire_client_and_server(backends, None, Some(identity)).await;
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op(
|
||||
OP_TTY_OPEN,
|
||||
json!({
|
||||
"carriage": "raw",
|
||||
"backend": "mock",
|
||||
"cmd": ["true"],
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("open op timed out");
|
||||
|
||||
let out = response.result.expect("open op should succeed");
|
||||
let channel_id = out
|
||||
.get("channel_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.expect("channel_id in response");
|
||||
assert!(
|
||||
channel_id > 0,
|
||||
"channel_id should be non-zero (channel 0 is the call channel), got {channel_id}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The open op denies when the caller lacks `tty:open`. The
|
||||
/// `AccessControl::required_scopes` gate runs in the registry's
|
||||
/// `invoke_streaming` before the `TtyOpenHandler` spawns.
|
||||
#[tokio::test]
|
||||
async fn end_to_end_open_op_denies_without_tty_open_scope() {
|
||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
backends.insert(
|
||||
"mock".to_string(),
|
||||
Arc::new(MockBackend::with_exit_code(0)),
|
||||
);
|
||||
let backends = Arc::new(backends);
|
||||
|
||||
let identity = Identity {
|
||||
id: "alice".to_string(),
|
||||
scopes: vec![],
|
||||
resources: StdHashMap::new(),
|
||||
};
|
||||
let client = wire_client_and_server(backends, None, Some(identity)).await;
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.call_open_op(
|
||||
OP_TTY_OPEN,
|
||||
json!({
|
||||
"carriage": "raw",
|
||||
"backend": "mock",
|
||||
"cmd": ["true"],
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("open op timed out");
|
||||
|
||||
let err = response.result.expect_err("open op should be denied");
|
||||
assert!(
|
||||
err.message.contains("scope") || err.code.contains("FORBIDDEN") || err.code.contains("AUTH"),
|
||||
"error should mention scope/forbidden/auth, got code={} message={}",
|
||||
err.code,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanity that the `MockBackend` we use in the channels tests
|
||||
/// compiles as a `TtyBackend` and resolves to the configured exit
|
||||
/// code. Belt-and-suspenders — the backend module already tests
|
||||
/// this, but the channels tests depend on it.
|
||||
#[tokio::test]
|
||||
async fn mock_backend_resolves_configured_exit_code() {
|
||||
let backend = MockBackend::with_exit_code(7);
|
||||
let params = crate::backend::TtyParams {
|
||||
terminal: None,
|
||||
cmd: vec!["true".to_string()],
|
||||
cwd: None,
|
||||
env: HashMap::new(),
|
||||
backend_params: serde_json::Map::new(),
|
||||
};
|
||||
let handle = backend.allocate(¶ms).await.expect("allocate");
|
||||
let code = handle.exit_code.await.expect("exit_code resolves");
|
||||
assert_eq!(code, 7);
|
||||
let _: Result<i32, TtyError> = Ok(code);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user