- 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/
437 lines
15 KiB
Rust
437 lines
15 KiB
Rust
//! Integration tests for the producer half: the open-op path
|
|
//! (establisher + pump handler + registration) over the duplex
|
|
//! harness, ported from the reverse POC's suite (16 tests rode the
|
|
//! topology) with the dial closure standing in for real sockets.
|
|
//!
|
|
//! Covered: establishment success + the typed-error table
|
|
//! (`unknown_resource` / `dial_failed` / `FORBIDDEN`), the plan flow
|
|
//! under same-resource concurrency (R-01, no handoff race), the
|
|
//! per-call opener identity (CF-006 witness), late-registration
|
|
//! visibility (W2), pump round-trip through `pump_bidi` (R-02, the
|
|
//! wrapper-managed pump, no spawn-and-forget), and out-of-band
|
|
//! `channel/close`.
|
|
|
|
mod harness;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use alkcall::protocol::wire::CallError;
|
|
use alktunnels::params::{Substrate, TUNNEL_OPEN_SCOPE};
|
|
use alktunnels::producer::ResourceRegistry;
|
|
|
|
use harness::{echo_dial, failing_dial, wire, wire_with, Topology, TEST_AUTH_TOKEN};
|
|
|
|
fn params(resource: &str) -> alktunnels::params::TunnelParams {
|
|
alktunnels::params::TunnelParams {
|
|
resource: resource.to_string(),
|
|
substrate: Substrate::Tcp,
|
|
}
|
|
}
|
|
|
|
/// Call the producer's open op on channel 0 (the consumer's surface),
|
|
/// returning the wire `CallError` on failure.
|
|
async fn call_open(
|
|
topo: &Topology,
|
|
input: serde_json::Value,
|
|
auth_token: Option<&str>,
|
|
) -> Result<serde_json::Value, CallError> {
|
|
let mut payload = serde_json::json!({
|
|
"operationId": "channels/tunnel/sub",
|
|
"input": input,
|
|
});
|
|
if let Some(token) = auth_token {
|
|
payload["auth_token"] = serde_json::Value::String(token.to_string());
|
|
}
|
|
let response = topo.consumer_call.call_with_payload(payload).await;
|
|
response.result
|
|
}
|
|
|
|
async fn call_open_params(
|
|
topo: &Topology,
|
|
p: &alktunnels::params::TunnelParams,
|
|
auth_token: Option<&str>,
|
|
) -> Result<serde_json::Value, CallError> {
|
|
call_open(
|
|
topo,
|
|
serde_json::json!({
|
|
"resource": p.resource,
|
|
"substrate": match p.substrate {
|
|
Substrate::Tcp => "tcp",
|
|
Substrate::Udp => "udp",
|
|
Substrate::Unix => "unix",
|
|
},
|
|
}),
|
|
auth_token,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Adopt the producer-allocated channel and pump it against in-process
|
|
/// halves (the consumer-side data plane, the session's take_halves
|
|
/// shape, driven manually until the consumer session task lands).
|
|
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 (the pre-negotiated call channel) 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)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_round_trips_through_pump_bidi() {
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
let out = call_open_params(&topo, ¶ms("echo"), None)
|
|
.await
|
|
.expect("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").await.expect("write");
|
|
write.flush().await.expect("flush");
|
|
let mut buf = vec![0u8; 4];
|
|
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");
|
|
|
|
// EOF propagates: closing the local write half shuts the loop
|
|
// down through both pumps (the two-pump contract), and the
|
|
// wrapper reaps the producer-side channel on completion (R-02).
|
|
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 unknown_resource_is_typed_and_leaves_no_channel() {
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
let err = call_open_params(&topo, ¶ms("ghost"), None)
|
|
.await
|
|
.expect_err("unknown resource must fail the open");
|
|
assert_eq!(err.code, "channel:open_failed");
|
|
assert_eq!(establishment_reason_of(&err), Some("unknown_resource"));
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
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 dial_failure_is_typed_and_leaves_no_channel() {
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("dead", Substrate::Tcp, "unreachable")
|
|
.await;
|
|
let topo = wire(registry, failing_dial("no route to target")).await;
|
|
|
|
let err = call_open_params(&topo, ¶ms("dead"), None)
|
|
.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 schema_gate_rejects_missing_substrate_before_the_establisher() {
|
|
// The registry's input-schema gate runs before the establisher:
|
|
// a missing substrate is `INVALID_INPUT` with no reason code, and
|
|
// the establisher's own `handler_error` parse branch is
|
|
// unreachable past this gate (the schema pins both fields).
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
let err = call_open(&topo, serde_json::json!({"resource": "echo"}), None)
|
|
.await
|
|
.expect_err("missing substrate must fail the schema gate");
|
|
assert_eq!(err.code, "INVALID_INPUT");
|
|
assert!(establishment_reason_of(&err).is_none());
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_denied_without_any_identity() {
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire_with(
|
|
registry,
|
|
harness::RegistrationMode::Dial(echo_dial()),
|
|
None,
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
let err = call_open_params(&topo, ¶ms("echo"), None)
|
|
.await
|
|
.expect_err("identity-less open must be denied");
|
|
assert_eq!(err.code, "FORBIDDEN");
|
|
assert!(no_data_channels(&topo));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_on_transport_identity_alone() {
|
|
// CF-005 (b): the transport identity (set before dialing)
|
|
// propagates to the serving dispatch and authorizes the scope
|
|
// gate. No token payload at all.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
let out = call_open_params(&topo, ¶ms("echo"), None)
|
|
.await
|
|
.expect("transport-identity open must be authorized");
|
|
assert!(out["channel_id"].is_u64());
|
|
|
|
// CF-006: the establisher's per-call auth carried the END CALLER's
|
|
// identity (the consumer), not a synthetic install-time value.
|
|
let witness = topo.identity_witness.lock().await.clone();
|
|
assert_eq!(
|
|
witness.as_deref(),
|
|
Some("consumer"),
|
|
"establisher saw the per-call opener identity (CF-006)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_token_overrides_transport_identity() {
|
|
// CF-005 precedence: the payload token wins. The provider resolves
|
|
// the scoped token to the consumer identity; the witness proves
|
|
// the establisher saw it end-to-end.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire_with(
|
|
registry,
|
|
harness::RegistrationMode::Dial(echo_dial()),
|
|
None,
|
|
None,
|
|
Arc::new(harness::TestIdProvider),
|
|
)
|
|
.await;
|
|
|
|
let out = call_open_params(&topo, ¶ms("echo"), Some(TEST_AUTH_TOKEN))
|
|
.await
|
|
.expect("token open authorized");
|
|
assert!(out["channel_id"].is_u64());
|
|
let witness = topo.identity_witness.lock().await.clone();
|
|
assert_eq!(witness.as_deref(), Some("consumer"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_opens_same_resource_no_plan_race() {
|
|
// The R-01 plan flow under concurrency: two opens of the SAME
|
|
// resource; the establisher returns each dialed handle via ITS
|
|
// plan; no handoff map to race. Each round-trips its own marker.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let mut sessions = Vec::new();
|
|
for i in 0..2 {
|
|
let out = call_open_params(&topo, ¶ms("echo"), None)
|
|
.await
|
|
.expect("open must succeed");
|
|
let channel_id = out["channel_id"].as_u64().unwrap() as u32;
|
|
let (mut read, mut write, _pump) = adopt_and_pump(&topo, channel_id).await;
|
|
let msg = format!("marker {i}");
|
|
write.write_all(msg.as_bytes()).await.expect("write");
|
|
write.flush().await.expect("flush");
|
|
let mut buf = vec![0u8; msg.len()];
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
|
.await
|
|
.expect("round trip")
|
|
.expect("read");
|
|
assert_eq!(buf, msg.as_bytes());
|
|
sessions.push(channel_id);
|
|
}
|
|
assert_ne!(sessions[0], sessions[1]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn late_registration_is_visible() {
|
|
// W2: the harness registers the openable AFTER
|
|
// from_connection_with_serving. If the dispatcher cached
|
|
// registrations, opens would fail with unknown-operation, which
|
|
// successful opens elsewhere prove it; assert registry state too.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("late", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
assert!(topo
|
|
.producer_registry
|
|
.registration("channels/tunnel/sub")
|
|
.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_rejects_unknown_substrate_loudly() {
|
|
// The registry schema-validates input before the establisher
|
|
// (ADR-001's loud posture): an unknown substrate value is a schema
|
|
// failure, not a silent pass-through.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
let err = call_open(
|
|
&topo,
|
|
serde_json::json!({"resource": "echo", "substrate": "sctp"}),
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("unknown substrate must fail the open");
|
|
assert_eq!(err.code, "INVALID_INPUT");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn producer_outbound_calls_still_resolve_while_serving() {
|
|
// ADR-022 §2 both-sides sanity: the producer is the connect side
|
|
// AND the serving side; its outbound calling still resolves while
|
|
// it serves tunnel opens.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire(registry, echo_dial()).await;
|
|
|
|
let response = topo
|
|
.producer
|
|
.call_open_op("consumer/serves/nothing", serde_json::json!({}))
|
|
.await;
|
|
assert!(
|
|
response.result.is_err(),
|
|
"consumer serves nothing; the point is the reply RESOLVED"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_lookup_respects_substrate_key() {
|
|
// The registry is keyed by (resource, substrate): a resource
|
|
// produced for TCP is unknown for UDP: the same name on a
|
|
// different substrate is a different resource (ADR-001).
|
|
let registry = ResourceRegistry::new();
|
|
registry.register("svc", Substrate::Tcp, "in-process").await;
|
|
let topo = wire(registry, failing_dial("unreachable")).await;
|
|
|
|
let mut p = params("svc");
|
|
p.substrate = Substrate::Udp;
|
|
let err = call_open_params(&topo, &p, None)
|
|
.await
|
|
.expect_err("udp lookup of a tcp resource must miss");
|
|
assert_eq!(establishment_reason_of(&err), Some("unknown_resource"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tunnel_establisher_no_witness_wrapper_opens() {
|
|
// The no-witness wrapper (`tunnel_establisher`, the public
|
|
// default shape, producer.rs's witness-less establisher body):
|
|
// a full open through the wrapper round-trips. The wrapper
|
|
// differs from `tunnel_establisher_with_witness` ONLY in the
|
|
// witness (the CF-006 probe seam), so the default-registration
|
|
// shape is exercised end to end here.
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("echo", Substrate::Tcp, "in-process")
|
|
.await;
|
|
let topo = wire_with(
|
|
registry,
|
|
harness::RegistrationMode::DialNoWitness(echo_dial()),
|
|
Some(harness::consumer_identity()),
|
|
None,
|
|
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
|
)
|
|
.await;
|
|
|
|
let out = call_open_params(&topo, ¶ms("echo"), None)
|
|
.await
|
|
.expect("the wrapper establisher opens");
|
|
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
|
let (mut read, mut write, pump) = adopt_and_pump(&topo, channel_id).await;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
write.write_all(b"no-witness").await.expect("write");
|
|
write.flush().await.expect("flush");
|
|
let mut buf = vec![0u8; 10];
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
|
.await
|
|
.expect("round trip")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"no-witness");
|
|
|
|
drop(write);
|
|
drop(read);
|
|
tokio::time::timeout(std::time::Duration::from_secs(5), pump)
|
|
.await
|
|
.expect("pump completion")
|
|
.expect("pump task");
|
|
}
|
|
|
|
/// Type-shape assertions kept for harness parity.
|
|
#[allow(dead_code)]
|
|
fn type_assertions() {
|
|
let _dial = echo_dial();
|
|
let _scope: &str = TUNNEL_OPEN_SCOPE;
|
|
}
|