- Rewrote the remaining em dashes outside docs/reviews/ (preserved as historical record, matching the alktty precedent): tests/ (313), fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in fences), docs/research + sdd_process + plans (525 plus 25 in fences), docs/architecture/overview.md fence labels (13), README, CHANGELOG, AGENTS.md, fuzz/README.md (176), and the .opencode/agents prompts (110) - Rewrote fenced content too: ASCII tree and module-map label separators, Rust snippet comments, and template placeholders; wire data, JSON/BAST examples, and transcripts survive verbatim - Rewrote 6 assert/expect message literals in tests/ and 2 format strings in fuzz/shared/src; no test data changed - Added Project Convention 18: no em dashes in prose (doc comments, markdown including docs/ and tasks/, agent prompts, commit messages); fence content rule, en-dash clause rule, and the no-spaced-hyphen prohibition follow alktty's convention 15 Follows6b20924(src doc comments) andcd0afe0(docs/architecture). Verification: cargo test (141 pass); cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/
560 lines
19 KiB
Rust
560 lines
19 KiB
Rust
//! Integration tests for the direct op (`channels/tunnel/direct`,
|
|
//! ADR-007) end to end over the duplex harness: the params → spec →
|
|
//! establisher → registration → `TunnelSession::open_direct` chain.
|
|
//!
|
|
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp, with the
|
|
//! framed adapter wraps at the dial closure, datagram boundary
|
|
//! preserved), the schema-class failure for malformed targets (no
|
|
//! phantom session), the `NOT_FOUND` posture on a producer without
|
|
//! the direct op registered, the structural scope separation
|
|
//! (`tunnel:open` does NOT imply `tunnel:direct`: FORBIDDEN, and
|
|
//! vice versa), and the CF-006 per-call opener identity on the
|
|
//! direct establisher (witness).
|
|
|
|
mod harness;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use alkcall::protocol::wire::CallError;
|
|
use alktunnels::params::{Substrate, SubstrateAddr};
|
|
use alktunnels::{TunnelEstablishError, TunnelSession};
|
|
|
|
use harness::{
|
|
all_substrate_dial, consumer_identity, echo_dial, failing_dial, framed_udp_echo_dial, wire,
|
|
wire_direct, wire_direct_forward, wire_direct_with_identity, Topology,
|
|
};
|
|
|
|
fn target_hostport(host: &str, port: u16) -> SubstrateAddr {
|
|
SubstrateAddr::HostPort {
|
|
host: host.to_string(),
|
|
port,
|
|
}
|
|
}
|
|
|
|
/// Call the producer's direct op on channel 0 (the raw call surface),
|
|
/// returning the wire `CallError` on failure.
|
|
async fn call_direct(
|
|
topo: &Topology,
|
|
input: serde_json::Value,
|
|
) -> Result<serde_json::Value, CallError> {
|
|
let payload = serde_json::json!({
|
|
"operationId": "channels/tunnel/direct",
|
|
"input": input,
|
|
});
|
|
let response = topo.consumer_call.call_with_payload(payload).await;
|
|
response.result
|
|
}
|
|
|
|
/// Adopt the producer-allocated channel and pump it against in-process
|
|
/// halves (the consumer-side data plane, driven manually).
|
|
async fn adopt_and_pump(
|
|
topo: &Topology,
|
|
channel_id: u32,
|
|
) -> (
|
|
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
|
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
|
tokio::task::JoinHandle<(u64, u64)>,
|
|
) {
|
|
let (send, recv) = topo
|
|
.consumer_manager
|
|
.adopt_channel(channel_id, "alk/tunnel", None)
|
|
.await
|
|
.expect("adopt producer-allocated channel");
|
|
let bidi = alkcall::core::types::BiStream::from_joined(recv, send);
|
|
let (channel_end, local_end) = tokio::io::duplex(64 * 1024);
|
|
let (c_read, c_write) = tokio::io::split(channel_end);
|
|
let pump = tokio::spawn(alkcall::channels::pump::pump_bidi(bidi, c_read, c_write));
|
|
let (l_read, l_write) = tokio::io::split(local_end);
|
|
(l_read, l_write, pump)
|
|
}
|
|
|
|
/// Only channel 0 may remain: a failed open leaves no data channel
|
|
/// on either side (the phantom-channel property).
|
|
fn no_data_channels(topo: &Topology) -> bool {
|
|
let consumer = topo.consumer_manager.channel_ids();
|
|
let producer = topo.producer.manager().channel_ids();
|
|
consumer.iter().all(|&id| id == 0) && producer.iter().all(|&id| id == 0)
|
|
}
|
|
|
|
fn establishment_reason_of(err: &CallError) -> Option<&str> {
|
|
err.details
|
|
.as_ref()
|
|
.and_then(|d| d.get("reason"))
|
|
.and_then(|r| r.as_str())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_tcp_round_trips_through_pump_bidi() {
|
|
let topo = wire_direct(echo_dial()).await;
|
|
|
|
let out = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "tcp",
|
|
"target": {"host": "db.internal", "port": 5432}
|
|
}),
|
|
)
|
|
.await
|
|
.expect("direct open must succeed");
|
|
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
|
assert!(topo.producer.manager().has_channel(channel_id));
|
|
|
|
let (mut read, mut write, pump) = adopt_and_pump(&topo, channel_id).await;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
write.write_all(b"ping-direct").await.expect("write");
|
|
write.flush().await.expect("flush");
|
|
let mut buf = vec![0u8; 11];
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
|
.await
|
|
.expect("round trip timed out")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"ping-direct");
|
|
|
|
// EOF propagates and the wrapper reaps the producer-side channel.
|
|
drop(write);
|
|
drop(read);
|
|
let (c2p, p2c) = tokio::time::timeout(std::time::Duration::from_secs(5), pump)
|
|
.await
|
|
.expect("pump completion timed out")
|
|
.expect("pump task");
|
|
assert!(c2p > 0 && p2c > 0, "both pumps moved bytes: {c2p}, {p2c}");
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
assert!(
|
|
!topo.producer.manager().has_channel(channel_id),
|
|
"wrapper reaped the producer-side channel after pump completion"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_udp_round_trips_framed() {
|
|
// The UDP dial closure wraps the framed adapter (ADR-003) at the
|
|
// boundary, and the establisher stays substrate-agnostic; the
|
|
// session's datagram plane sees boundary-preserved frames.
|
|
let topo = wire_direct(all_substrate_dial()).await;
|
|
|
|
let out = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "udp",
|
|
"target": {"host": "10.0.0.7", "port": 53}
|
|
}),
|
|
)
|
|
.await
|
|
.expect("udp direct open must succeed");
|
|
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
|
|
|
let mut session = TunnelSession::adopt(
|
|
&topo.consumer_manager,
|
|
channel_id,
|
|
Substrate::Udp,
|
|
"alk/tunnel",
|
|
)
|
|
.await
|
|
.expect("adopt into a udp session");
|
|
session
|
|
.send_datagram(b"datagram-direct")
|
|
.await
|
|
.expect("send");
|
|
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
|
.await
|
|
.expect("round trip timed out")
|
|
.expect("recv")
|
|
.expect("datagram");
|
|
assert_eq!(dg.as_ref(), b"datagram-direct");
|
|
session.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_target_is_schema_class_and_leaves_no_channel() {
|
|
// The registry's input-schema gate runs before the establisher: a
|
|
// unix target without `path` (and a tcp target without `port`)
|
|
// fails as `INVALID_INPUT`; never a phantom session, never a
|
|
// establisher dial.
|
|
let topo = wire_direct(echo_dial()).await;
|
|
|
|
let err = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "unix",
|
|
"target": {"host": "nope", "port": 1}
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("unix target without path must fail the schema gate");
|
|
assert_eq!(err.code, "INVALID_INPUT");
|
|
assert!(establishment_reason_of(&err).is_none());
|
|
assert!(no_data_channels(&topo));
|
|
|
|
let err = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "tcp",
|
|
"target": {"host": "nope"}
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("tcp target without port must fail the schema gate");
|
|
assert_eq!(err.code, "INVALID_INPUT");
|
|
assert!(no_data_channels(&topo));
|
|
|
|
// Missing target entirely.
|
|
let err = call_direct(&topo, serde_json::json!({"substrate": "tcp"}))
|
|
.await
|
|
.expect_err("missing target must fail the schema gate");
|
|
assert_eq!(err.code, "INVALID_INPUT");
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dial_failure_passes_through_typed() {
|
|
// The dial closure's own typed errors pass through unchanged
|
|
// (ADR-007 §4's table minus the registry row).
|
|
let topo = wire_direct(failing_dial("no route to target")).await;
|
|
|
|
let err = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "tcp",
|
|
"target": {"host": "blackhole.internal", "port": 9}
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("failed dial must fail the open");
|
|
assert_eq!(err.code, "channel:open_failed");
|
|
assert_eq!(establishment_reason_of(&err), Some("dial_failed"));
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn producer_without_direct_op_rejects_loudly() {
|
|
// An old producer (only the base op registered) rejects the
|
|
// unknown op loudly (NOT_FOUND, the SSH "unknown channel type"
|
|
// posture.
|
|
let registry = alktunnels::producer::ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
assert!(topo
|
|
.producer_registry
|
|
.registration("channels/tunnel/direct")
|
|
.is_none());
|
|
|
|
let err = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "tcp",
|
|
"target": {"host": "db.internal", "port": 5432}
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("unknown op must be rejected loudly");
|
|
assert_eq!(err.code, "NOT_FOUND");
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tunnel_open_scope_does_not_imply_tunnel_direct() {
|
|
// ADR-006 Amendment 1: the scopes are structurally separate
|
|
// grants (an identity holding `tunnel:open` but not
|
|
// `tunnel:direct`) is FORBIDDEN on the direct op.
|
|
let topo = wire_direct_with_identity(consumer_identity()).await;
|
|
|
|
let err = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "tcp",
|
|
"target": {"host": "db.internal", "port": 5432}
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("tunnel:open must NOT authorize the direct op");
|
|
assert_eq!(err.code, "FORBIDDEN");
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_scope_does_not_imply_tunnel_open() {
|
|
// The separation is symmetric: `tunnel:direct` does not authorize
|
|
// the base open op either (two capabilities; a deployment
|
|
// granting both grants both explicitly). A tunnel:direct-only
|
|
// identity is FORBIDDEN on `channels/tunnel/sub` even though the
|
|
// producer registered it.
|
|
let registry = alktunnels::producer::ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = harness::wire_with(
|
|
registry,
|
|
harness::RegistrationMode::Dial(echo_dial()),
|
|
Some(harness::direct_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
let payload = serde_json::json!({
|
|
"operationId": "channels/tunnel/sub",
|
|
"input": {"resource": "echo", "substrate": "tcp"},
|
|
});
|
|
let response = topo.consumer_call.call_with_payload(payload).await;
|
|
let err = response
|
|
.result
|
|
.expect_err("tunnel:direct must NOT authorize the base op");
|
|
assert_eq!(err.code, "FORBIDDEN");
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_establisher_witnesses_per_call_identity() {
|
|
// CF-006: the direct establisher's per-call auth carried the END
|
|
// CALLER's identity (the direct op is the arbitrary-egress
|
|
// capability, so identity visibility matters more, not less).
|
|
let topo = wire_direct(echo_dial()).await;
|
|
|
|
let out = call_direct(
|
|
&topo,
|
|
serde_json::json!({
|
|
"substrate": "tcp",
|
|
"target": {"host": "db.internal", "port": 5432}
|
|
}),
|
|
)
|
|
.await
|
|
.expect("direct open authorized (both-scopes identity)");
|
|
assert!(out["channel_id"].is_u64());
|
|
|
|
let witness = topo.identity_witness.lock().await.clone();
|
|
assert_eq!(
|
|
witness.as_deref(),
|
|
Some("consumer-both"),
|
|
"direct establisher saw the per-call opener identity (CF-006)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn establisher_parse_backstop_maps_handler_error() {
|
|
// The establisher's serde parse is the backstop behind the schema
|
|
// gate: feed an input the schema would have rejected but a direct
|
|
// establisher call never schema-checks (the raw-call shape:
|
|
// assembly variants, or a registry whose schema gate is looser)
|
|
// and observe the typed `handler_error` mapping (ADR-007 §4).
|
|
// An unknown substrate value fails the enum parse.
|
|
let establisher = alktunnels::producer::direct_establisher(echo_dial());
|
|
let err = establisher(
|
|
serde_json::json!({
|
|
"substrate": "sctp",
|
|
"target": {"host": "h", "port": 1}
|
|
}),
|
|
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
|
)
|
|
.await;
|
|
match err {
|
|
Err(alkcall::channels::operations::EstablishmentError::HandlerError { message }) => {
|
|
assert!(message.contains("invalid direct params"), "{message}");
|
|
}
|
|
Err(other) => panic!("expected HandlerError backstop, got {other:?}"),
|
|
Ok(_) => panic!("expected HandlerError backstop, got Ok"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_establisher_renders_target_to_backing_string() {
|
|
// The design pin: the establisher renders the target to the SAME
|
|
// backing-string form the registry backings use and calls the
|
|
// injected DialFn with it, and the dial closure observes the exact
|
|
// rendered form ("host:port" / path), per ADR-007 §4.
|
|
let observed: Arc<tokio::sync::Mutex<Vec<(Substrate, String)>>> =
|
|
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
|
let obs = Arc::clone(&observed);
|
|
let dial: alktunnels::producer::DialFn =
|
|
Arc::new(move |substrate: Substrate, backing: &str| {
|
|
let obs = Arc::clone(&obs);
|
|
let backing = backing.to_string();
|
|
Box::pin(async move {
|
|
obs.lock().await.push((substrate, backing));
|
|
Err(TunnelEstablishError::DialFailed("stop here".to_string()))
|
|
})
|
|
});
|
|
let establisher = alktunnels::producer::direct_establisher(dial);
|
|
let auth = alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel");
|
|
|
|
let _ = establisher(
|
|
serde_json::json!({"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}),
|
|
auth.clone(),
|
|
)
|
|
.await;
|
|
let _ = establisher(
|
|
serde_json::json!({"substrate": "unix", "target": {"path": "/run/pg/.s.PGSQL.5432"}}),
|
|
auth,
|
|
)
|
|
.await;
|
|
|
|
let seen = observed.lock().await.clone();
|
|
assert_eq!(
|
|
seen,
|
|
vec![
|
|
(Substrate::Tcp, "db.internal:5432".to_string()),
|
|
(Substrate::Unix, "/run/pg/.s.PGSQL.5432".to_string()),
|
|
],
|
|
"targets rendered to the registry backing-string form"
|
|
);
|
|
}
|
|
|
|
// --- consumer session level (TunnelSession::open_direct) ---------------
|
|
|
|
#[tokio::test]
|
|
async fn open_direct_session_round_trip_and_teardown() {
|
|
let topo = wire_direct_forward(echo_dial()).await;
|
|
|
|
let mut session = TunnelSession::open_direct(
|
|
&topo.consumer,
|
|
target_hostport("db.internal", 5432),
|
|
Substrate::Tcp,
|
|
)
|
|
.await
|
|
.expect("open_direct");
|
|
assert!(topo.producer_manager.has_channel(session.channel_id));
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let (read, write) = session.stream_halves().expect("stream session has halves");
|
|
write.write_all(b"ping-direct").await.expect("write");
|
|
write.flush().await.expect("flush");
|
|
let mut buf = vec![0u8; 11];
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
|
.await
|
|
.expect("round trip")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"ping-direct");
|
|
|
|
// Teardown semantics identical to `open` (ADR-007 §5).
|
|
let channel_id = session.channel_id;
|
|
let reaped = session.close().await;
|
|
assert!(reaped, "close reaped the adopted entry");
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
assert!(
|
|
!topo.producer_manager.has_channel(channel_id),
|
|
"producer side reaped after pump completion"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_direct_session_datagram_boundary_preserved() {
|
|
let topo = wire_direct_forward(framed_udp_echo_dial()).await;
|
|
|
|
let mut session = TunnelSession::open_direct(
|
|
&topo.consumer,
|
|
target_hostport("10.0.0.7", 53),
|
|
Substrate::Udp,
|
|
)
|
|
.await
|
|
.expect("udp open_direct");
|
|
session.send_datagram(b"one").await.expect("send");
|
|
session.send_datagram(b"").await.expect("send empty");
|
|
let first = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
|
.await
|
|
.expect("round trip")
|
|
.expect("recv")
|
|
.expect("datagram");
|
|
assert_eq!(first.as_ref(), b"one");
|
|
let second = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
|
.await
|
|
.expect("round trip")
|
|
.expect("recv")
|
|
.expect("datagram");
|
|
assert!(
|
|
second.is_empty(),
|
|
"the empty datagram crosses framed (F-2 layering)"
|
|
);
|
|
session.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_direct_malformed_target_no_phantom_session() {
|
|
// The session-level failure surface: a malformed target is the
|
|
// schema/invalid_input-class failure (registry-side); a failed
|
|
// open never yields a session.
|
|
let topo = wire_direct_forward(echo_dial()).await;
|
|
|
|
let err = match TunnelSession::open_direct(
|
|
&topo.consumer,
|
|
SubstrateAddr::Path {
|
|
path: "/nope".to_string(),
|
|
},
|
|
Substrate::Tcp,
|
|
)
|
|
.await
|
|
{
|
|
Err(e) => e,
|
|
Ok(_) => panic!("path-shaped target on tcp must fail"),
|
|
};
|
|
let call = err.open_ref().call_error().expect("wire error");
|
|
assert_eq!(call.code, "INVALID_INPUT");
|
|
|
|
// Both managers hold only channel 0 (the phantom-channel property).
|
|
assert!(
|
|
topo.consumer
|
|
.manager()
|
|
.channel_ids()
|
|
.iter()
|
|
.all(|&id| id == 0)
|
|
&& topo
|
|
.producer_manager
|
|
.channel_ids()
|
|
.iter()
|
|
.all(|&id| id == 0),
|
|
"no phantom session"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_direct_scope_separated_session_level() {
|
|
// Session-level scope separation (ADR-006 Amendment 1): a
|
|
// tunnel:open-only consumer client is FORBIDDEN on open_direct
|
|
// (the scopes are separate grants, and the session constructor
|
|
// states which capability it invokes.
|
|
let topo = harness::wire_direct_forward_with_identity(echo_dial(), consumer_identity()).await;
|
|
|
|
let err = match TunnelSession::open_direct(
|
|
&topo.consumer,
|
|
target_hostport("db.internal", 5432),
|
|
Substrate::Tcp,
|
|
)
|
|
.await
|
|
{
|
|
Err(e) => e,
|
|
Ok(_) => panic!("tunnel:open-only identity must NOT open_direct"),
|
|
};
|
|
let call = err.open_ref().call_error().expect("wire error");
|
|
assert_eq!(call.code, "FORBIDDEN");
|
|
assert!(
|
|
topo.consumer
|
|
.manager()
|
|
.channel_ids()
|
|
.iter()
|
|
.all(|&id| id == 0)
|
|
&& topo
|
|
.producer_manager
|
|
.channel_ids()
|
|
.iter()
|
|
.all(|&id| id == 0),
|
|
"no phantom session"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_direct_witness_carries_session_identity() {
|
|
let topo = wire_direct_forward(echo_dial()).await;
|
|
let _session = TunnelSession::open_direct(
|
|
&topo.consumer,
|
|
target_hostport("db.internal", 5432),
|
|
Substrate::Tcp,
|
|
)
|
|
.await
|
|
.expect("open_direct");
|
|
let witness = topo.identity_witness.lock().await.clone();
|
|
assert_eq!(
|
|
witness.as_deref(),
|
|
Some("consumer-both"),
|
|
"the session path's establisher saw the per-call opener identity"
|
|
);
|
|
}
|