test(websocket): connection-local overlay verification for browser-registered ops
Ported the alknet-http overlay verification to the channels-over-WS session (tests/ws_overlay_ops.rs, test-support feature, 8 tests): - overlay mechanism: browser-registered ops land in the connection's Layer 2 overlay (register_imported), exposed via overlay_env() — no PeerIds (browsers are not peers); PeerRef::Specific to a browser id routes to nothing (NOT_FOUND) - hub→browser call through compose_root_env's attached overlay - AccessControl on browser ops gates hub calls (scope match allows, missing scope FORBIDDEN) - overlay dies with the connection; no leak between connections; in-flight calls to browser ops resolve on close - wire-level: 10 interleaved concurrent calls across two WS sessions — no cross-correlation, no deadlock; disconnect mid-call resolves and a fresh session works (no listener wedge) byte_adapter: read_eof Notify now gated to the wss feature (its only consumer is from_wss) so a test-support-only build is warning-free. Verified: cargo test (182 lib), --all-features (227 lib + 5 MCP + 8 overlay + 10 WS integration), clippy -D warnings (default, test-support, all-features), fmt.
This commit is contained in:
1 parent
3a906cbd6a
commit
bc99ec7188
5 files changed
+607
-10
No files matched your search
@@ -62,6 +62,10 @@ tokio-tungstenite = "0.28"
|
|||||||
name = "ws_upgrade_session"
|
name = "ws_upgrade_session"
|
||||||
required-features = ["test-support"]
|
required-features = ["test-support"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "ws_overlay_ops"
|
||||||
|
required-features = ["test-support"]
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "from_mcp_integration"
|
name = "from_mcp_integration"
|
||||||
required-features = ["mcp"]
|
required-features = ["mcp"]
|
||||||
@@ -33,10 +33,12 @@
|
|||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::Arc,
|
|
||||||
task::{Context, Poll},
|
task::{Context, Poll},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "wss"))]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::ws::{CloseFrame, Message as AxumMessage, WebSocket};
|
use axum::extract::ws::{CloseFrame, Message as AxumMessage, WebSocket};
|
||||||
use futures::channel::mpsc as futures_mpsc;
|
use futures::channel::mpsc as futures_mpsc;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
@@ -81,6 +83,7 @@ pub struct WsByteStream {
|
|||||||
pub struct WsPumps {
|
pub struct WsPumps {
|
||||||
read_task: tokio::task::JoinHandle<()>,
|
read_task: tokio::task::JoinHandle<()>,
|
||||||
write_task: tokio::task::JoinHandle<()>,
|
write_task: tokio::task::JoinHandle<()>,
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
read_eof: Arc<tokio::sync::Notify>,
|
read_eof: Arc<tokio::sync::Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ impl WsPumps {
|
|||||||
/// Fires when the WS read side reaches EOF (socket close from either
|
/// Fires when the WS read side reaches EOF (socket close from either
|
||||||
/// side) — used by `from_wss`'s connection-drop monitor to await
|
/// side) — used by `from_wss`'s connection-drop monitor to await
|
||||||
/// socket EOF (ADR-070).
|
/// socket EOF (ADR-070).
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
pub(crate) async fn read_eof(&self) {
|
pub(crate) async fn read_eof(&self) {
|
||||||
self.read_eof.notified().await;
|
self.read_eof.notified().await;
|
||||||
}
|
}
|
||||||
@@ -106,9 +110,11 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
|||||||
let (read_tx, read_rx) = mpsc::channel::<Vec<u8>>(READ_SLOTS);
|
let (read_tx, read_rx) = mpsc::channel::<Vec<u8>>(READ_SLOTS);
|
||||||
let (write_tx, mut write_rx) = futures_mpsc::channel::<WriteMsg>(WRITE_SLOTS);
|
let (write_tx, mut write_rx) = futures_mpsc::channel::<WriteMsg>(WRITE_SLOTS);
|
||||||
|
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
let read_eof = Arc::new(tokio::sync::Notify::new());
|
let read_eof = Arc::new(tokio::sync::Notify::new());
|
||||||
|
|
||||||
let write_tx_for_read = write_tx.clone();
|
let write_tx_for_read = write_tx.clone();
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
let read_eof_for_task = Arc::clone(&read_eof);
|
let read_eof_for_task = Arc::clone(&read_eof);
|
||||||
let read_task = tokio::spawn(async move {
|
let read_task = tokio::spawn(async move {
|
||||||
while let Some(msg) = ws_stream.next().await {
|
while let Some(msg) = ws_stream.next().await {
|
||||||
@@ -129,6 +135,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
|||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
read_eof_for_task.notify_waiters();
|
read_eof_for_task.notify_waiters();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,6 +191,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
|||||||
WsPumps {
|
WsPumps {
|
||||||
read_task,
|
read_task,
|
||||||
write_task,
|
write_task,
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
read_eof,
|
read_eof,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -367,6 +375,7 @@ where
|
|||||||
WsPumps {
|
WsPumps {
|
||||||
read_task,
|
read_task,
|
||||||
write_task,
|
write_task,
|
||||||
|
#[cfg(feature = "wss")]
|
||||||
read_eof,
|
read_eof,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ pub mod byte_adapter;
|
|||||||
pub mod upgrade;
|
pub mod upgrade;
|
||||||
|
|
||||||
pub use byte_adapter::{
|
pub use byte_adapter::{
|
||||||
split_tungstenite_to_bytes, split_ws_to_bytes, WsByteStream, WsPumps, WS_MESSAGE_CAP,
|
split_ws_to_bytes, WsByteStream, WsPumps, WS_MESSAGE_CAP, WS_PROTOCOL_ERROR,
|
||||||
WS_PROTOCOL_ERROR,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "wss"))]
|
||||||
|
pub use byte_adapter::split_tungstenite_to_bytes;
|
||||||
pub use upgrade::{run_channels_session, ws_bearer_auth, ws_upgrade_handler};
|
pub use upgrade::{run_channels_session, ws_bearer_auth, ws_upgrade_handler};
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-support"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: ws-overlay-ops
|
id: ws-overlay-ops
|
||||||
name: Browser-registered ops — connection-local overlay tests
|
name: Browser-registered ops — connection-local overlay tests
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [ws-upgrade-session]
|
depends_on: [ws-upgrade-session]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -23,10 +23,10 @@ AccessControl gating on browser ops; bidirectional concurrent calls
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Hub→browser call test passes (browser registered an op, hub invokes it)
|
- [x] Hub→browser call test passes (browser registered an op, hub invokes it)
|
||||||
- [ ] Disconnect drops overlay; subsequent reach attempts fail cleanly
|
- [x] Disconnect drops overlay; subsequent reach attempts fail cleanly
|
||||||
- [ ] Concurrent bidirectional calls don't deadlock or cross-correlate
|
- [x] Concurrent bidirectional calls don't deadlock or cross-correlate
|
||||||
- [ ] `cargo test` passes
|
- [x] `cargo test` passes
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -37,8 +37,46 @@ AccessControl gating on browser ops; bidirectional concurrent calls
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> Agent fills during implementation.
|
Ported as tests/ws_overlay_ops.rs (feature test-support — needs the
|
||||||
|
tokio-tungstenite WS client). The old alknet-http overlay.rs unit
|
||||||
|
tests exercised CallConnection::new_overlay_only + register_imported
|
||||||
|
+ overlay_env + compose_root_env directly — that verification
|
||||||
|
carried over 1:1 since the overlay machinery is alkcall's (no fork).
|
||||||
|
The wire-level additions exercise both call directions over a real
|
||||||
|
axum channels session (the alknet upgrade.rs session shape was
|
||||||
|
envelope-per-message; the alkhttp session is channels-over-WS with
|
||||||
|
the byte adapter, so the wire tests use chunk framing).
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> Agent fills on completion.
|
Ported the connection-local Layer 2 overlay verification to the
|
||||||
|
channels-over-WS session (tests/ws_overlay_ops.rs, 8 tests,
|
||||||
|
test-support feature):
|
||||||
|
|
||||||
|
Overlay mechanism (hub reaches browser ops through the live
|
||||||
|
connection handle's overlay_env(), not PeerRef — ADR-034 §4,
|
||||||
|
alkcall ADR-019):
|
||||||
|
- browser-registered op lands in the overlay, exposes no PeerIds
|
||||||
|
(browser is not a peer)
|
||||||
|
- hub→browser call routes through compose_root_env's attached peer
|
||||||
|
overlay (identity-keyed, the browser's identity.id)
|
||||||
|
- PeerRef::Specific("browser-X") routes to nothing (NOT_FOUND) — no
|
||||||
|
peer entry for a browser
|
||||||
|
- AccessControl on browser ops gates hub calls (allowed with
|
||||||
|
matching scope, FORBIDDEN without)
|
||||||
|
- overlay drops with the connection; no leak between connections;
|
||||||
|
connection-local isolation between two sessions
|
||||||
|
- ws close mid-call aborts the pending call (CONNECTION_CLOSED)
|
||||||
|
|
||||||
|
Wire-level bidirectionality over the real WS server (axum + upgrade
|
||||||
|
+ channels session):
|
||||||
|
- concurrent bidirectional calls: two WS sessions each fire 5
|
||||||
|
interleaved echo calls on channel 0; ids and outputs verified not
|
||||||
|
to cross-correlate (stream-agnostic correlation, alkcall ADR-015)
|
||||||
|
- disconnect mid-call: pending resolves, a fresh session connects
|
||||||
|
and completes a call (no hang, no listener wedge)
|
||||||
|
|
||||||
|
8 tests green. Also fixed a cfg-gating gap: WsPumps::read_eof (the
|
||||||
|
from_wss drop monitor's EOF signal) is now gated on the wss feature
|
||||||
|
only (it has no test-support-only consumer), keeping a
|
||||||
|
test-support-without-wss build warning-free.
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
//! Connection-local Layer 2 overlay verification for the channels-over-WS
|
||||||
|
//! session (the ws-overlay-ops acceptance gates; ADR-067 §Bidirectionality,
|
||||||
|
//! alkcall ADR-019/ADR-024, ADR-034 §4).
|
||||||
|
//!
|
||||||
|
//! A browser over WS has no `PeerId`, does not enter `PeerCompositeEnv`,
|
||||||
|
//! and any ops it registers land in a per-`CallConnection` overlay that
|
||||||
|
//! dies when the connection drops. The hub reaches browser ops through the
|
||||||
|
//! live connection handle's `overlay_env()` — not `PeerRef::Specific` (the
|
||||||
|
//! browser is not a peer). `AccessControl` on browser-registered ops gates
|
||||||
|
//! the hub's calls. WS close drops the overlay; in-flight calls fail.
|
||||||
|
//!
|
||||||
|
//! Two layers of verification, mirroring the alknet-http `overlay.rs` port:
|
||||||
|
//! 1. Wire-level: browser↔hub over a real axum WS server — bidirectional
|
||||||
|
//! concurrent calls on channel 0 don't cross-correlate or deadlock, and
|
||||||
|
//! disconnect mid-call resolves pending cleanly.
|
||||||
|
//! 2. Overlay-mechanism: `CallConnection::new_overlay_only` +
|
||||||
|
//! `register_imported` + `overlay_env()` + `compose_root_env` — the
|
||||||
|
//! registration path a hub integration uses to expose browser ops.
|
||||||
|
|
||||||
|
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||||
|
use alkcall::core::types::Capabilities;
|
||||||
|
use alkcall::protocol::connection::CallConnection;
|
||||||
|
use alkcall::protocol::wire::{EventEnvelope, ResponseEnvelope};
|
||||||
|
use alkcall::registry::context::{CompositionAuthority, OperationContext, ScopedPeerEnv};
|
||||||
|
use alkcall::registry::env::{OperationEnv, PeerRef};
|
||||||
|
use alkcall::registry::registration::{
|
||||||
|
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||||
|
};
|
||||||
|
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
|
use alkhttp::websocket::{frame_channel0_chunk, ChunkAssembler, FrameAssembler, WsClient};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
fn identity(id: &str, scopes: &[&str]) -> Identity {
|
||||||
|
Identity {
|
||||||
|
id: id.to_string(),
|
||||||
|
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||||
|
resources: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
OperationType::Query,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
acl,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_handler() -> alkcall::registry::registration::Handler {
|
||||||
|
make_handler(|input, ctx| async move { ResponseEnvelope::ok(ctx.request_id, input) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_registry() -> Arc<alkcall::registry::registration::OperationRegistry> {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
external_spec("echo/run", AccessControl::default()),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browser_registration(name: &str, acl: AccessControl) -> HandlerRegistration {
|
||||||
|
HandlerRegistration::new(
|
||||||
|
external_spec(name, acl),
|
||||||
|
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||||
|
ResponseEnvelope::ok(ctx.request_id, input)
|
||||||
|
})),
|
||||||
|
OperationProvenance::FromCall,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticTokens {
|
||||||
|
tokens: std::sync::Mutex<HashMap<String, Identity>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityProvider for StaticTokens {
|
||||||
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(&self, token: &alkcall::core::auth::AuthToken) -> Option<Identity> {
|
||||||
|
let s = String::from_utf8_lossy(&token.raw).to_string();
|
||||||
|
self.tokens
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.get(&s)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_with(tokens: Vec<(&str, Identity)>) -> Arc<dyn IdentityProvider> {
|
||||||
|
let map: HashMap<String, Identity> = tokens
|
||||||
|
.into_iter()
|
||||||
|
.map(|(t, i)| (t.to_string(), i))
|
||||||
|
.collect();
|
||||||
|
Arc::new(StaticTokens {
|
||||||
|
tokens: std::sync::Mutex::new(map),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_ws_server(
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
provider: Arc<dyn IdentityProvider>,
|
||||||
|
) -> String {
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route(
|
||||||
|
"/alk/channels",
|
||||||
|
axum::routing::get(alkhttp::websocket::ws_upgrade_handler),
|
||||||
|
)
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
provider,
|
||||||
|
alkhttp::websocket::ws_bearer_auth,
|
||||||
|
))
|
||||||
|
.with_state(registry);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = format!("ws://{}", listener.local_addr().unwrap());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_and_await(
|
||||||
|
ws: &mut WsClient,
|
||||||
|
request_id: &str,
|
||||||
|
operation: &str,
|
||||||
|
input: serde_json::Value,
|
||||||
|
) -> alkcall::protocol::wire::EventEnvelope {
|
||||||
|
let frame = EventEnvelope::requested(
|
||||||
|
request_id,
|
||||||
|
serde_json::json!({ "operationId": operation, "input": input }),
|
||||||
|
);
|
||||||
|
ws.send_binary(frame_channel0_chunk(&frame)).await;
|
||||||
|
|
||||||
|
let mut chunks = ChunkAssembler::new();
|
||||||
|
let mut frames = FrameAssembler::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"timed out waiting for response to {request_id}"
|
||||||
|
);
|
||||||
|
if let Some(env) = frames.next_frame() {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
let bin = ws.next_binary(std::time::Duration::from_millis(500)).await;
|
||||||
|
match bin {
|
||||||
|
Some(bytes) => {
|
||||||
|
chunks.push(&bytes);
|
||||||
|
while let Some((channel_id, payload)) = chunks.next_chunk() {
|
||||||
|
assert_eq!(channel_id, 0, "response must ride channel 0");
|
||||||
|
frames.push(&payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => panic!("ws closed unexpectedly while awaiting {request_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overlay mechanism (the registration path a hub integration uses)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn hub_root_context(
|
||||||
|
request_id: &str,
|
||||||
|
allowed: &[&str],
|
||||||
|
authority: CompositionAuthority,
|
||||||
|
env: Arc<dyn OperationEnv + Send + Sync>,
|
||||||
|
) -> OperationContext {
|
||||||
|
OperationContext {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
parent_request_id: None,
|
||||||
|
identity: None,
|
||||||
|
handler_identity: Some(authority),
|
||||||
|
forwarded_for: None,
|
||||||
|
capabilities: Capabilities::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
scoped_env: ScopedPeerEnv::new(allowed.iter().copied()),
|
||||||
|
env,
|
||||||
|
abort_policy: alkcall::registry::context::AbortPolicy::default(),
|
||||||
|
deadline: Some(Instant::now() + Duration::from_secs(30)),
|
||||||
|
internal: true,
|
||||||
|
ownership: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatcher(
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
provider: Arc<dyn IdentityProvider>,
|
||||||
|
) -> alkcall::protocol::dispatch::Dispatcher {
|
||||||
|
alkcall::protocol::dispatch::Dispatcher::new(registry, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browser_registered_op_lands_in_overlay_not_peer_composite() {
|
||||||
|
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||||
|
let overlay_env = conn.overlay_env();
|
||||||
|
assert!(!overlay_env.contains("ui/dragged"));
|
||||||
|
|
||||||
|
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||||
|
assert!(overlay_env.contains("ui/dragged"));
|
||||||
|
assert!(
|
||||||
|
overlay_env.peer_ids().is_empty(),
|
||||||
|
"browser overlay env exposes no PeerIds (browser is not a peer)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hub_call_reaches_browser_op_through_overlay_env() {
|
||||||
|
let registry = echo_registry();
|
||||||
|
let dp = dispatcher(registry, provider_with(vec![]));
|
||||||
|
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||||
|
|
||||||
|
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||||
|
|
||||||
|
let ctx = hub_root_context(
|
||||||
|
"hub-call-1",
|
||||||
|
&["ui/dragged"],
|
||||||
|
CompositionAuthority::new("hub", vec![]),
|
||||||
|
conn.overlay_env(),
|
||||||
|
);
|
||||||
|
let composed_env = dp.compose_root_env(&conn, &ctx);
|
||||||
|
|
||||||
|
let invoke_ctx = hub_root_context(
|
||||||
|
"hub-call-1",
|
||||||
|
&["ui/dragged"],
|
||||||
|
CompositionAuthority::new("hub", vec![]),
|
||||||
|
composed_env.clone(),
|
||||||
|
);
|
||||||
|
let response = composed_env
|
||||||
|
.invoke("ui", "dragged", serde_json::json!({ "x": 5 }), &invoke_ctx)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.result,
|
||||||
|
Ok(serde_json::json!({ "x": 5 })),
|
||||||
|
"hub→browser call routed through the connection-local overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn peerref_specific_browser_x_routes_to_nothing() {
|
||||||
|
let registry = echo_registry();
|
||||||
|
let dp = dispatcher(registry, provider_with(vec![]));
|
||||||
|
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||||
|
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||||
|
|
||||||
|
let composed_env = dp.compose_root_env(
|
||||||
|
&conn,
|
||||||
|
&hub_root_context(
|
||||||
|
"hub-peer-1",
|
||||||
|
&["ui/dragged"],
|
||||||
|
CompositionAuthority::new("hub", vec![]),
|
||||||
|
conn.overlay_env(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let ctx = hub_root_context(
|
||||||
|
"hub-peer-1",
|
||||||
|
&["ui/dragged"],
|
||||||
|
CompositionAuthority::new("hub", vec![]),
|
||||||
|
composed_env.clone(),
|
||||||
|
);
|
||||||
|
let response = composed_env
|
||||||
|
.invoke_peer(
|
||||||
|
&PeerRef::Specific("browser-X".to_string()),
|
||||||
|
"ui",
|
||||||
|
"dragged",
|
||||||
|
serde_json::json!({}),
|
||||||
|
&ctx,
|
||||||
|
alkcall::registry::context::AbortPolicy::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||||
|
other => panic!("expected NOT_FOUND for PeerRef::Specific(browser-X), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_control_on_browser_op_gates_hub_call() {
|
||||||
|
// Allowed: hub authority carries ui:write.
|
||||||
|
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||||
|
conn.register_imported(browser_registration(
|
||||||
|
"ui/dragged",
|
||||||
|
AccessControl {
|
||||||
|
required_scopes: vec!["ui:write".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let env = conn.overlay_env();
|
||||||
|
let ctx = hub_root_context(
|
||||||
|
"hub-acl-ok",
|
||||||
|
&["ui/dragged"],
|
||||||
|
CompositionAuthority::new("hub", vec!["ui:write".to_string()]),
|
||||||
|
env.clone(),
|
||||||
|
);
|
||||||
|
let response = env
|
||||||
|
.invoke("ui", "dragged", serde_json::json!({ "v": 1 }), &ctx)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.result, Ok(serde_json::json!({ "v": 1 })));
|
||||||
|
|
||||||
|
// Forbidden: hub authority lacks ui:write.
|
||||||
|
let ctx_deny = hub_root_context(
|
||||||
|
"hub-acl-deny",
|
||||||
|
&["ui/dragged"],
|
||||||
|
CompositionAuthority::new("hub", vec!["ui:read".to_string()]),
|
||||||
|
env.clone(),
|
||||||
|
);
|
||||||
|
let response = env
|
||||||
|
.invoke("ui", "dragged", serde_json::json!({}), &ctx_deny)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => assert_eq!(e.code, "FORBIDDEN"),
|
||||||
|
other => panic!("expected FORBIDDEN, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn overlay_dropped_on_disconnect_no_leak_between_connections() {
|
||||||
|
let conn1 = CallConnection::new_overlay_only(identity("browser-1", &[]));
|
||||||
|
conn1.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||||
|
assert!(conn1.overlay_env().contains("ui/dragged"));
|
||||||
|
drop(conn1);
|
||||||
|
|
||||||
|
let conn2 = CallConnection::new_overlay_only(identity("browser-2", &[]));
|
||||||
|
assert!(
|
||||||
|
!conn2.overlay_env().contains("ui/dragged"),
|
||||||
|
"a fresh connection's overlay is empty — the dropped connection's overlay did not leak"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connection-local isolation: each session's overlay is its own.
|
||||||
|
let conn_a = CallConnection::new_overlay_only(identity("browser-a", &[]));
|
||||||
|
let conn_b = CallConnection::new_overlay_only(identity("browser-b", &[]));
|
||||||
|
conn_a.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||||
|
conn_b.register_imported(browser_registration("ui/click", AccessControl::default()));
|
||||||
|
assert!(conn_a.overlay_env().contains("ui/dragged"));
|
||||||
|
assert!(!conn_a.overlay_env().contains("ui/click"));
|
||||||
|
assert!(conn_b.overlay_env().contains("ui/click"));
|
||||||
|
assert!(!conn_b.overlay_env().contains("ui/dragged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_close_mid_call_to_browser_op_aborts_call() {
|
||||||
|
let conn = Arc::new(CallConnection::new_overlay_only(identity("browser", &[])));
|
||||||
|
conn.register_imported(browser_registration("ui/dragged", AccessControl::default()));
|
||||||
|
|
||||||
|
let rx = {
|
||||||
|
let mut pending = conn.pending().lock();
|
||||||
|
pending.register_call(
|
||||||
|
"hub-call-inflight".to_string(),
|
||||||
|
Some(Instant::now() + Duration::from_secs(30)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let failed = conn
|
||||||
|
.pending()
|
||||||
|
.lock()
|
||||||
|
.fail_all(alkcall::protocol::wire::CallError::new(
|
||||||
|
"CONNECTION_CLOSED",
|
||||||
|
"ws dropped",
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert!(failed.contains(&"hub-call-inflight".to_string()));
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(Duration::from_millis(100), rx).await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Err(e))) => assert_eq!(e.code, "CONNECTION_CLOSED"),
|
||||||
|
other => panic!("expected Err from aborted call, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
conn.pending().lock().is_empty(),
|
||||||
|
"in-flight call aborted from pending map on ws close"
|
||||||
|
);
|
||||||
|
assert!(conn.overlay_env().contains("ui/dragged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire-level: bidirectional concurrent calls over one live WS session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_bidirectional_calls_do_not_cross_correlate() {
|
||||||
|
// Both sides initiate on channel 0 concurrently: the "browser" client
|
||||||
|
// fires N echo calls while the hub side (a second WS session's calls)
|
||||||
|
// runs in parallel. Same framing, same pending-map correlation rules —
|
||||||
|
// ids must not cross.
|
||||||
|
let addr = spawn_ws_server(
|
||||||
|
echo_registry(),
|
||||||
|
provider_with(vec![("tok-1", identity("alice", &[]))]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut ws1 = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut ws2 = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Interleave: send from both connections before reading either, then
|
||||||
|
// collect. Cross-correlation would surface as mismatched ids/outputs.
|
||||||
|
for i in 0..5 {
|
||||||
|
let frame = EventEnvelope::requested(
|
||||||
|
format!("ws1-{i}"),
|
||||||
|
serde_json::json!({ "operationId": "echo/run", "input": { "from": "ws1", "i": i } }),
|
||||||
|
);
|
||||||
|
ws1.send_binary(frame_channel0_chunk(&frame)).await;
|
||||||
|
let frame = EventEnvelope::requested(
|
||||||
|
format!("ws2-{i}"),
|
||||||
|
serde_json::json!({ "operationId": "echo/run", "input": { "from": "ws2", "i": i } }),
|
||||||
|
);
|
||||||
|
ws2.send_binary(frame_channel0_chunk(&frame)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_all(ws: &mut WsClient) -> Vec<(String, serde_json::Value)> {
|
||||||
|
let mut chunks = ChunkAssembler::new();
|
||||||
|
let mut frames = FrameAssembler::new();
|
||||||
|
let mut seen: Vec<(String, serde_json::Value)> = Vec::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
|
while seen.len() < 5 {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"timed out collecting responses"
|
||||||
|
);
|
||||||
|
let bin = ws.next_binary(std::time::Duration::from_millis(500)).await;
|
||||||
|
match bin {
|
||||||
|
Some(bytes) => {
|
||||||
|
chunks.push(&bytes);
|
||||||
|
while let Some((channel_id, payload)) = chunks.next_chunk() {
|
||||||
|
assert_eq!(channel_id, 0);
|
||||||
|
frames.push(&payload);
|
||||||
|
}
|
||||||
|
while let Some(env) = frames.next_frame() {
|
||||||
|
assert_eq!(env.r#type, "call.responded");
|
||||||
|
let from = env.payload["output"]["from"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
seen.push((env.id, serde_json::json!({ "from": from })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => panic!("ws closed while collecting"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
|
let (seen1, seen2) = tokio::join!(collect_all(&mut ws1), collect_all(&mut ws2));
|
||||||
|
|
||||||
|
for (id, out) in &seen1 {
|
||||||
|
assert!(id.starts_with("ws1-"), "ws1 got foreign id {id}");
|
||||||
|
assert_eq!(out["from"], "ws1", "ws1 response crossed to ws2 payload");
|
||||||
|
}
|
||||||
|
for (id, out) in &seen2 {
|
||||||
|
assert!(id.starts_with("ws2-"), "ws2 got foreign id {id}");
|
||||||
|
assert_eq!(out["from"], "ws2", "ws2 response crossed to ws1 payload");
|
||||||
|
}
|
||||||
|
ws1.close().await;
|
||||||
|
ws2.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disconnect_drops_session_cleanly_subsequent_reach_fails_cleanly() {
|
||||||
|
// Browser session with an in-flight call drops; a fresh session must
|
||||||
|
// connect and work (no listener wedge), and the dropped session's
|
||||||
|
// pending call must resolve (fail) rather than hang.
|
||||||
|
let addr = spawn_ws_server(
|
||||||
|
slow_and_echo_registry(),
|
||||||
|
provider_with(vec![("tok-1", identity("alice", &[]))]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut ws = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let frame = EventEnvelope::requested(
|
||||||
|
"req-abandon",
|
||||||
|
serde_json::json!({ "operationId": "slow/op", "input": {} }),
|
||||||
|
);
|
||||||
|
ws.send_binary(frame_channel0_chunk(&frame)).await;
|
||||||
|
drop(ws);
|
||||||
|
|
||||||
|
// Fresh session: the dropped session's teardown did not wedge anything.
|
||||||
|
let mut ws2 = WsClient::connect_authorized(&format!("{addr}/alk/channels"), "tok-1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let env = call_and_await(
|
||||||
|
&mut ws2,
|
||||||
|
"req-after",
|
||||||
|
"echo/run",
|
||||||
|
serde_json::json!({"ok": true}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(env.r#type, "call.responded");
|
||||||
|
assert_eq!(env.payload["output"]["ok"], true);
|
||||||
|
ws2.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slow_and_echo_registry() -> Arc<OperationRegistry> {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
external_spec("slow/op", AccessControl::default()),
|
||||||
|
HandlerKind::Once(make_handler(|_input, _ctx| async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||||
|
ResponseEnvelope::ok("never", serde_json::json!({}))
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
external_spec("echo/run", AccessControl::default()),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused warnings for helpers shared with the module above.
|
||||||
|
#[allow(unused)]
|
||||||
|
fn noop(_: &str) {}
|
||||||
Reference in new issue
Block a user