From 888215b382670b50586175145a1507427779aecd Mon Sep 17 00:00:00 2001 From: yeoleobun Date: Mon, 29 Dec 2025 12:03:08 +0800 Subject: [PATCH 1/3] add test for cancel --- src/dialog/tests/test_client_dialog.rs | 144 ++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/src/dialog/tests/test_client_dialog.rs b/src/dialog/tests/test_client_dialog.rs index f0be7b1..97a147a 100644 --- a/src/dialog/tests/test_client_dialog.rs +++ b/src/dialog/tests/test_client_dialog.rs @@ -3,7 +3,7 @@ //! Tests for client-side dialog behavior and state management use crate::transaction::{endpoint::EndpointBuilder, key::TransactionRole}; -use crate::transport::{SipAddr, TransportLayer}; +use crate::transport::{udp::UdpConnection, SipAddr, TransportLayer}; use crate::{ dialog::{ client_dialog::ClientInviteDialog, @@ -12,7 +12,7 @@ use crate::{ }, rsip_ext::destination_from_request, }; -use rsip::{headers::*, Request, Response, StatusCode, Uri}; +use rsip::{headers::*, prelude::HeadersExt, Request, Response, StatusCode, Uri}; use std::sync::Arc; use tokio::sync::mpsc::unbounded_channel; use tokio_util::sync::CancellationToken; @@ -473,3 +473,143 @@ async fn test_route_set_updates_from_200_ok_response() -> crate::Result<()> { Ok(()) } + +/// Verifies CANCEL request construction per RFC 3261 Section 9.1. +/// +/// RFC 3261 9.1 states: +/// - Request-URI, Call-ID, To, the numeric part of CSeq, and From header +/// fields in the CANCEL request MUST be identical to those in the +/// request being cancelled, including tags. +/// - A CANCEL constructed by a client MUST have only a single Via header +/// field value matching the top Via value in the request being cancelled. +#[tokio::test] +async fn test_cancel_conforms_to_rfc3261_section_9_1() -> crate::Result<()> { + use crate::dialog::{dialog_layer::DialogLayer, invitation::InviteOption}; + + // Start a UDP listener to capture SIP messages + let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await?; + let local_port = socket.local_addr()?.port(); + + let endpoint = create_test_endpoint().await?; + + // Setup outbound transport for client + let udp = UdpConnection::create_connection( + "127.0.0.1:0".parse().unwrap(), + None, + Some( + endpoint + .inner + .transport_layer + .inner + .cancel_token + .child_token(), + ), + ) + .await?; + endpoint.inner.transport_layer.add_transport(udp.into()); + + let dialog_layer = DialogLayer::new(endpoint.inner.clone()); + + let invite_option = InviteOption { + caller: Uri::try_from("sip:alice@example.com")?, + callee: Uri::try_from(format!("sip:bob@127.0.0.1:{};transport=udp", local_port).as_str())?, + contact: Uri::try_from("sip:alice@alice.example.com:5060")?, + ..Default::default() + }; + + let (state_sender, _) = unbounded_channel(); + + // Use create_client_invite_dialog - creates dialog and transaction without sending + let (client_dialog, mut tx) = + dialog_layer.create_client_invite_dialog(invite_option, state_sender)?; + + tx.send().await?; + + // Receive the INVITE request first + let mut buf = [0u8; 2048]; + let (len, _) = tokio::time::timeout( + std::time::Duration::from_secs(1), + socket.recv_from(&mut buf), + ) + .await + .map_err(|_| rsip::Error::Unexpected("Timeout receiving INVITE".into()))??; + + let invite_msg = std::str::from_utf8(&buf[..len]).unwrap(); + let invite_req: Request = rsip::SipMessage::try_from(invite_msg)?.try_into()?; + assert_eq!(invite_req.method, rsip::Method::Invite); + + let dialog_clone = client_dialog.clone(); + tokio::spawn(async move { dialog_clone.cancel().await }); + + // Receive the CANCEL request + let (len, _) = tokio::time::timeout( + std::time::Duration::from_secs(1), + socket.recv_from(&mut buf), + ) + .await + .map_err(|_| rsip::Error::Unexpected("Timeout receiving CANCEL".into()))??; + + let cancel_msg = std::str::from_utf8(&buf[..len]).unwrap(); + let cancel_req: Request = rsip::SipMessage::try_from(cancel_msg)?.try_into()?; + let cancel_vias = cancel_req + .headers + .iter() + .filter_map(|header| { + if let Header::Via(via) = header { + Some(via) + } else { + None + } + }) + .collect::>(); + + assert_eq!(cancel_req.method, rsip::Method::Cancel); + + assert_eq!( + cancel_req.uri, invite_req.uri, + "CANCEL Request-URI must match INVITE" + ); + + assert_eq!( + cancel_req.call_id_header()?.value().to_string(), + invite_req.call_id_header()?.value().to_string(), + "CANCEL Call-ID must match INVITE" + ); + + assert_eq!( + cancel_req.from_header()?.value().to_string(), + invite_req.from_header()?.value().to_string(), + "CANCEL From header must match INVITE (including tag)" + ); + + assert_eq!( + cancel_req.to_header()?.value().to_string(), + invite_req.to_header()?.value().to_string(), + "CANCEL To header must match INVITE" + ); + + assert!( + cancel_req.to_header()?.tag()?.is_none(), + "CANCEL should not have To tag, because the invite does not have" + ); + + assert_eq!( + cancel_req.cseq_header()?.seq()?, + invite_req.cseq_header()?.seq()?, + "CANCEL CSeq number must match INVITE" + ); + + assert_eq!( + cancel_vias.len(), + 1, + "CANCEL must have exactly one Via header" + ); + + assert_eq!( + cancel_req.via_header()?.value(), + invite_req.via_header()?.value(), + "CANCEL Via must match top Via in INVITE" + ); + + Ok(()) +} From 0713e385ed2d3d21329bd0cc7b865a2ffca88f66 Mon Sep 17 00:00:00 2001 From: yeoleobun Date: Mon, 29 Dec 2025 15:31:37 +0800 Subject: [PATCH 2/3] add test for domain name Contact --- .github/workflows/ci.yml | 16 +- Cargo.toml | 1 + src/dialog/tests/test_client_dialog.rs | 204 ++++++++++++++++++++++++- 3 files changed, 208 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f4c13..1c0abf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: Rust on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] env: CARGO_TERM_COLOR: always @@ -14,11 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose lint: name: Rustfmt / Clippy @@ -32,7 +32,7 @@ jobs: run: cargo fmt --all -- --check cargo-shear: - name: 'cargo shear' + name: "cargo shear" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index e172c3d..fef7514 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ axum = { version = "0.8.7", features = ["ws"] } tower = "0.5.2" tower-http = { version = "0.6.7", features = ["fs", "cors"] } http = "1.4.0" +tokio-stream = "0.1.17" [[example]] name = "client" diff --git a/src/dialog/tests/test_client_dialog.rs b/src/dialog/tests/test_client_dialog.rs index 97a147a..4ee276e 100644 --- a/src/dialog/tests/test_client_dialog.rs +++ b/src/dialog/tests/test_client_dialog.rs @@ -1,9 +1,7 @@ -//! Client dialog tests -//! -//! Tests for client-side dialog behavior and state management - -use crate::transaction::{endpoint::EndpointBuilder, key::TransactionRole}; +use crate::transaction::key::TransactionRole; +use crate::transport::transport_layer::DomainResolver; use crate::transport::{udp::UdpConnection, SipAddr, TransportLayer}; +use crate::EndpointBuilder; use crate::{ dialog::{ client_dialog::ClientInviteDialog, @@ -12,9 +10,11 @@ use crate::{ }, rsip_ext::destination_from_request, }; +use async_trait::async_trait; use rsip::{headers::*, prelude::HeadersExt, Request, Response, StatusCode, Uri}; use std::sync::Arc; use tokio::sync::mpsc::unbounded_channel; +use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; async fn create_test_endpoint() -> crate::Result { @@ -613,3 +613,197 @@ async fn test_cancel_conforms_to_rfc3261_section_9_1() -> crate::Result<()> { Ok(()) } + +/// Mock domain resolver that redirects all domain lookups to a local address +struct MockDomainResolver { + local_addr: SipAddr, +} + +#[async_trait] +impl DomainResolver for MockDomainResolver { + async fn resolve(&self, target: &SipAddr) -> crate::Result { + // Redirect domain lookups to our local test address, preserving transport + Ok(SipAddr { + r#type: target.r#type, + addr: self.local_addr.addr.clone(), + }) + } +} + +async fn create_test_endpoint_with_resolver( + local_addr: SipAddr, +) -> crate::Result { + let token = CancellationToken::new(); + let resolver = Box::new(MockDomainResolver { local_addr }); + let tl = TransportLayer::new_with_domain_resolver(token.child_token(), resolver); + let endpoint = EndpointBuilder::new() + .with_user_agent("rsipstack-test") + .with_transport_layer(tl) + .build(); + Ok(endpoint) +} + +/// Verifies that when the 200 OK contains a Contact header with a domain name, +/// the ACK is sent to that domain (resolved via the DomainResolver). +/// +/// This tests the full invite flow with two separate endpoints: +/// 1. UAC sends INVITE to UAS endpoint +/// 2. UAS responds with 200 OK containing Contact with domain name +/// 3. UAC sends ACK to the domain (which gets resolved to UAS endpoint) +#[tokio::test] +async fn test_ack_sent_to_domain_name_from_contact() -> crate::Result<()> { + use crate::dialog::{dialog_layer::DialogLayer, invitation::InviteOption}; + + // ========== Create UAS endpoint ========== + let uas_token = CancellationToken::new(); + let uas_transport_layer = TransportLayer::new(uas_token.child_token()); + + let uas_udp = UdpConnection::create_connection( + "127.0.0.1:0".parse().unwrap(), + None, + Some(uas_token.child_token()), + ) + .await?; + + let uas_port = uas_udp + .get_addr() + .addr + .port + .map(|p| u16::from(p)) + .unwrap_or(0); + uas_transport_layer.add_transport(uas_udp.into()); + + let uas_endpoint = EndpointBuilder::new() + .with_user_agent("rsipstack-uas") + .with_transport_layer(uas_transport_layer) + .build(); + + uas_endpoint.inner.transport_layer.serve_listens().await?; + let uas_endpoint_inner = uas_endpoint.inner.clone(); + tokio::spawn(async move { + let _ = uas_endpoint_inner.serve().await; + }); + + // ========== Create UAC endpoint with mock resolver ========== + let domain_target_addr = SipAddr { + r#type: Some(rsip::Transport::Udp), + addr: rsip::HostWithPort { + host: rsip::Host::IpAddr("127.0.0.1".parse().unwrap()), + port: Some(uas_port.into()), + }, + }; + + let uac_endpoint = create_test_endpoint_with_resolver(domain_target_addr).await?; + + let uac_udp = UdpConnection::create_connection( + "127.0.0.1:0".parse().unwrap(), + None, + Some( + uac_endpoint + .inner + .transport_layer + .inner + .cancel_token + .child_token(), + ), + ) + .await?; + let uac_port = uac_udp + .get_addr() + .addr + .port + .map(|p| u16::from(p)) + .unwrap_or(0); + uac_endpoint + .inner + .transport_layer + .add_transport(uac_udp.into()); + + uac_endpoint.inner.transport_layer.serve_listens().await?; + let uac_endpoint_inner = uac_endpoint.inner.clone(); + tokio::spawn(async move { + let _ = uac_endpoint_inner.serve().await; + }); + + // ========== Create dialog layers ========== + let uac_dialog_layer = DialogLayer::new(uac_endpoint.inner.clone()); + let uas_dialog_layer = DialogLayer::new(uas_endpoint.inner.clone()); + + // ========== UAS: Start listening for incoming transactions ========== + let mut uas_incoming = uas_endpoint.incoming_transactions()?; + + let (uac_state_sender, _) = unbounded_channel(); + let (uas_state_sender, _) = unbounded_channel(); + + // Oneshot channel to receive the ACK for verification + let (ack_sender, ack_receiver) = oneshot::channel::(); + + // UAS handler - wait for INVITE, respond with 200 OK containing domain Contact + tokio::spawn(async move { + let mut invite_tx = uas_incoming.recv().await.expect("failed to get the INVITE"); + assert!(matches!(invite_tx.original.method, rsip::Method::Invite)); + + let contact_uri = Uri::try_from(format!( + "sip:bob@uas.example.com:{};transport=udp", + uas_port + )) + .unwrap(); + + let dialog = uas_dialog_layer + .get_or_create_server_invite(&invite_tx, uas_state_sender, None, Some(contact_uri)) + .expect("failed to create dialog"); + + dialog.accept(None, None).expect("accept failed"); + + if let Some(msg) = invite_tx.receive().await { + if let rsip::SipMessage::Request(ack) = msg { + if ack.method == rsip::Method::Ack { + let _ = ack_sender.send(ack); + } + } + } + }); + + // ========== UAC: Create and process INVITE ========== + let invite_option = InviteOption { + caller: Uri::try_from("sip:alice@example.com")?, + callee: Uri::try_from(format!("sip:bob@127.0.0.1:{};transport=udp", uas_port).as_str())?, + contact: Uri::try_from(format!("sip:alice@127.0.0.1:{}", uac_port).as_str())?, + ..Default::default() + }; + + let (client_dialog, _) = uac_dialog_layer + .do_invite(invite_option, uac_state_sender) + .await?; + + // ========== Verify ACK was received by UAS with domain in Request-URI ========== + let ack_req = tokio::time::timeout(std::time::Duration::from_secs(2), ack_receiver) + .await + .expect("timeout receiving ACK") + .expect("fail to receiving ACK"); + + // Verify ACK Request-URI contains the domain from Contact header + assert_eq!(ack_req.method, rsip::Method::Ack, "Expected ACK request"); + + assert_eq!( + ack_req.uri.host_with_port.host, + rsip::Host::Domain("uas.example.com".into()), + "ACK Request-URI host should be the domain from Contact header" + ); + + assert_eq!( + ack_req.uri.host_with_port.port, + Some(uas_port.into()), + "ACK Request-URI port should match Contact port" + ); + + // Verify dialog was confirmed + assert!( + client_dialog.inner.is_confirmed(), + "Dialog should be confirmed after 200 OK" + ); + + uas_token.cancel(); + + Ok(()) +} From 5affb26ed660dc515bcd744013cd571fd2533b4f Mon Sep 17 00:00:00 2001 From: yeoleobun Date: Mon, 29 Dec 2025 20:32:10 +0800 Subject: [PATCH 3/3] add test for ack to sip over websocket --- .github/workflows/ci.yml | 16 +- Cargo.toml | 1 - src/dialog/tests/test_client_dialog.rs | 202 +++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c0abf2..f5f4c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: Rust on: push: - branches: ["main"] + branches: [ "main" ] pull_request: - branches: ["main"] + branches: [ "main" ] env: CARGO_TERM_COLOR: always @@ -14,11 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose lint: name: Rustfmt / Clippy @@ -32,7 +32,7 @@ jobs: run: cargo fmt --all -- --check cargo-shear: - name: "cargo shear" + name: 'cargo shear' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index fef7514..e172c3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ axum = { version = "0.8.7", features = ["ws"] } tower = "0.5.2" tower-http = { version = "0.6.7", features = ["fs", "cors"] } http = "1.4.0" -tokio-stream = "0.1.17" [[example]] name = "client" diff --git a/src/dialog/tests/test_client_dialog.rs b/src/dialog/tests/test_client_dialog.rs index 4ee276e..dd2b6b1 100644 --- a/src/dialog/tests/test_client_dialog.rs +++ b/src/dialog/tests/test_client_dialog.rs @@ -1,5 +1,7 @@ +use crate::transaction::endpoint::TargetLocator; use crate::transaction::key::TransactionRole; use crate::transport::transport_layer::DomainResolver; +use crate::transport::SipConnection; use crate::transport::{udp::UdpConnection, SipAddr, TransportLayer}; use crate::EndpointBuilder; use crate::{ @@ -807,3 +809,203 @@ async fn test_ack_sent_to_domain_name_from_contact() -> crate::Result<()> { Ok(()) } + +/// Mock target locator that maps Contact URIs to WebSocket address +struct WebSocketChannelLocator { + /// Map from Contact URI host to the channel's SipAddr + contact: String, + ws_addr: SipAddr, +} + +#[async_trait] +impl TargetLocator for WebSocketChannelLocator { + async fn locate(&self, uri: &rsip::Uri) -> crate::Result { + if let rsip::Host::Domain(domain) = &uri.host_with_port.host { + if domain.to_string().contains(&self.contact) { + return Ok(self.ws_addr.clone()); + } + } + SipAddr::try_from(uri) + } +} + +/// Verifies ACK to sip over websocket, it will use channel and have a contact like "bmf9p1ekfdar.invalid" +/// +/// This simulates the scenario where: +/// 1. A WebSocket client registers with Contact: +/// 2. The proxy forwards messages through a ChannelConnection +/// 3. When the UAC receives a 200 OK with this Contact, the ACK should be sent to the channel +#[tokio::test] +async fn test_ack_sent_to_websocket_channel_via_locator() -> crate::Result<()> { + use crate::dialog::{dialog_layer::DialogLayer, invitation::InviteOption}; + use crate::transport::channel::ChannelConnection; + use crate::transport::connection::TransportEvent; + + // ========== Setup channel connection to simulate WebSocket ========== + let (to_channel_tx, to_channel_rx) = unbounded_channel(); + let (from_channel_tx, mut from_channel_rx) = unbounded_channel(); + + let contact_host = "nbs1t4oqh57u.invalid"; + let contact_user = "kr9e8brl"; + // address used by sipjs + let ws_contact_uri = format!("sip:{}@{};transport=ws", contact_user, contact_host); + + // websocket address register into locator + let ws_addr = SipAddr { + r#type: Some(rsip::Transport::Ws), + addr: rsip::HostWithPort { + host: rsip::Host::IpAddr("127.0.0.1".parse().unwrap()), + port: Some(8080.into()), + }, + }; + + let chan_conn = + ChannelConnection::create_connection(to_channel_rx, from_channel_tx, ws_addr.clone(), None) + .await?; + + let sip_conn = SipConnection::Channel(chan_conn.clone()); + + let uac_token = CancellationToken::new(); + let locator = Box::new(WebSocketChannelLocator { + contact: contact_host.to_string(), + ws_addr: ws_addr.clone(), + }); + + let uac_transport_layer = TransportLayer::new(uac_token.child_token()); + + // Add UDP transport (provides addresses for Via/Contact headers, like in proxy) + let uac_udp = UdpConnection::create_connection( + "127.0.0.1:0".parse().unwrap(), + None, + Some(uac_token.child_token()), + ) + .await?; + let uac_port = uac_udp + .get_addr() + .addr + .port + .map(|p| u16::from(p)) + .unwrap_or(0); + uac_transport_layer.add_transport(uac_udp.into()); + + // Add WebSocket channel connection (like proxy's handle_websocket does) + uac_transport_layer.add_connection(sip_conn.clone()); + + let uac_endpoint = EndpointBuilder::new() + .with_user_agent("rsipstack-uac") + .with_transport_layer(uac_transport_layer) + .with_target_locator(locator) + .build(); + + uac_endpoint.inner.transport_layer.serve_listens().await?; + let uac_endpoint_inner = uac_endpoint.inner.clone(); + tokio::spawn(async move { + let _ = uac_endpoint_inner.serve().await; + }); + + // Create channels for dialog state + let (uac_state_sender, _uac_state_receiver) = unbounded_channel(); + + // ========== Start UAC INVITE in background ========== + // INVITE is sent to the WebSocket contact domain which will be routed via the channel + let invite_option = InviteOption { + caller: Uri::try_from("sip:alice@example.com")?, + callee: Uri::try_from(format!("sip:bob@{};transport=ws", contact_host).as_str())?, + contact: Uri::try_from(format!("sip:alice@127.0.0.1:{}", uac_port).as_str())?, + ..Default::default() + }; + + let uac_endpoint_inner = uac_endpoint.inner.clone(); + let dialog_handle = tokio::spawn(async move { + let uac_dialog_layer = DialogLayer::new(uac_endpoint_inner); + uac_dialog_layer + .do_invite(invite_option, uac_state_sender) + .await + }); + + // ========== UAS: Receive INVITE from channel and respond with 200 OK ========== + let invite_req = + tokio::time::timeout(std::time::Duration::from_secs(1), from_channel_rx.recv()) + .await + .unwrap() + .unwrap(); + + let TransportEvent::Incoming(rsip::SipMessage::Request(invite_req), _, _) = invite_req else { + panic!("Expected INVITE request"); + }; + + assert_eq!(invite_req.method, rsip::Method::Invite); + + // Build 200 OK with WebSocket Contact + let ws_contact = rsip::headers::Contact::new(&format!("<{}>", ws_contact_uri)); + let to_with_tag: rsip::Header = invite_req + .to_header()? + .clone() + .with_tag("uas-tag-123".into())? + .into(); + + let ok_response = Response { + status_code: StatusCode::OK, + version: rsip::Version::V2, + headers: vec![ + invite_req.via_header()?.clone().into(), + invite_req.from_header()?.clone().into(), + to_with_tag, + invite_req.call_id_header()?.clone().into(), + invite_req.cseq_header()?.clone().into(), + ws_contact.into(), + rsip::headers::ContentLength::from(0u32).into(), + ] + .into(), + body: vec![], + }; + + // Send 200 OK back through the channel (simulating response from WebSocket peer) + to_channel_tx + .send(TransportEvent::Incoming( + rsip::SipMessage::Response(ok_response), + sip_conn.clone(), + ws_addr.clone(), + )) + .unwrap(); + + let ack_event = tokio::time::timeout(std::time::Duration::from_secs(1), from_channel_rx.recv()) + .await + .unwrap() + .unwrap(); + + let TransportEvent::Incoming(rsip::SipMessage::Request(ack_req), _, _) = ack_event else { + panic!("Expected ACK request"); + }; + + // Cleanup + uac_token.cancel(); + dialog_handle.abort(); + + assert_eq!(ack_req.method, rsip::Method::Ack, "Expected ACK request"); + + // The Request-URI should match the Contact from the 200 OK + assert!( + ack_req + .uri + .host_with_port + .host + .to_string() + .contains(contact_host), + "ACK Request-URI host should contain the WebSocket contact domain, got: {}", + ack_req.uri.host_with_port.host + ); + + // Verify transport parameter is ws + let has_ws_transport = ack_req + .uri + .params + .iter() + .any(|p| matches!(p, rsip::Param::Transport(t) if *t == rsip::Transport::Ws)); + assert!( + has_ws_transport, + "ACK Request-URI should have transport=ws parameter" + ); + + Ok(()) +}