use active_call::{
app::{AppState, AppStateBuilder},
config::{Config, OptionsResponseConfig},
};
use std::time::Duration;
use tokio::net::{TcpListener, UdpSocket};
use tokio::time::timeout;
use tracing::info;
struct SipNode {
sip_port: u16,
}
async fn spawn_node(mutate_config: impl FnOnce(&mut Config)) -> SipNode {
let probe_socket = UdpSocket::bind(("127.0.0.1", 0)).await.unwrap();
let sip_port = probe_socket.local_addr().unwrap().port();
drop(probe_socket);
let http_listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let http_port = http_listener.local_addr().unwrap().port();
let mut config = Config {
addr: "127.0.0.1".to_string(),
udp_port: sip_port,
http_addr: format!("127.0.0.1:{http_port}"),
log_level: Some("info".to_string()),
useragent: Some("ActiveCallTest".to_string()),
media_cache_path: "./target/tmp_media".to_string(),
..Default::default()
};
mutate_config(&mut config);
let app: AppState = AppStateBuilder::new()
.with_config(config)
.build()
.await
.expect("failed to build app state");
let sip_app = app.clone();
tokio::spawn(async move {
let _ = sip_app.serve().await;
});
tokio::time::sleep(Duration::from_millis(300)).await;
SipNode { sip_port }
}
struct Probe {
socket: UdpSocket,
server: std::net::SocketAddr,
}
impl Probe {
async fn new(server: std::net::SocketAddr) -> Self {
let socket = UdpSocket::bind(("127.0.0.1", 0)).await.unwrap();
Self { socket, server }
}
fn options(&self, call_id: &str) -> String {
format!(
"OPTIONS sip:bot@{server} SIP/2.0\r\n\
Via: SIP/2.0/UDP 127.0.0.1:{port};branch=z9hG4bKopt{call_id};rport\r\n\
From: <sip:prober@127.0.0.1>;tag=probetag{call_id}\r\n\
To: <sip:bot@{server}>\r\n\
Call-ID: options-{call_id}@127.0.0.1\r\n\
CSeq: 1 OPTIONS\r\n\
Max-Forwards: 70\r\n\
Content-Length: 0\r\n\
\r\n",
server = self.server,
port = self.socket.local_addr().unwrap().port(),
)
}
fn invite(&self, call_id: &str) -> String {
let sdp = "v=0\r\n\
o=- 123456 1 IN IP4 127.0.0.1\r\n\
s=-\r\n\
c=IN IP4 127.0.0.1\r\n\
t=0 0\r\n\
m=audio 40000 RTP/AVP 0 101\r\n\
a=rtpmap:0 PCMU/8000\r\n\
a=rtpmap:101 telephone-event/8000\r\n\
a=sendrecv\r\n";
format!(
"INVITE sip:bot@{server} SIP/2.0\r\n\
Via: SIP/2.0/UDP 127.0.0.1:{port};branch=z9hG4bKinv{call_id};rport\r\n\
From: <sip:caller@127.0.0.1>;tag=invttag{call_id}\r\n\
To: <sip:bot@{server}>\r\n\
Call-ID: invite-{call_id}@127.0.0.1\r\n\
CSeq: 1 INVITE\r\n\
Contact: <sip:caller@127.0.0.1:{port}>\r\n\
Max-Forwards: 70\r\n\
Content-Type: application/sdp\r\n\
Content-Length: {len}\r\n\
\r\n\
{sdp}",
server = self.server,
port = self.socket.local_addr().unwrap().port(),
len = sdp.len(),
)
}
async fn send(&self, msg: String) {
self.socket.send_to(msg.as_bytes(), self.server).await.unwrap();
}
async fn collect(&self, wait: Duration) -> Vec<String> {
let mut messages = Vec::new();
let deadline = tokio::time::Instant::now() + wait;
while let Some(remaining) =
deadline.checked_duration_since(tokio::time::Instant::now())
{
let mut buf = [0u8; 4096];
match timeout(remaining, self.socket.recv_from(&mut buf)).await {
Ok(Ok((n, _))) => {
messages.push(String::from_utf8_lossy(&buf[..n]).to_string());
}
_ => break,
}
}
messages
}
async fn received_ok(&self, wait: Duration) -> Option<String> {
for msg in self.collect(wait).await {
if msg.starts_with("SIP/2.0 200") {
return Some(msg);
}
info!(status = %msg.lines().next().unwrap_or(""), "probe received non-200 response");
}
None
}
}
#[tokio::test]
async fn options_auto_learn_answers_after_call_traffic() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_test_writer()
.try_init()
.ok();
let node = spawn_node(|config| {
config.options_response = Some(OptionsResponseConfig {
enabled: Some(true),
auto_learn: Some(true),
allow: vec![],
allow_registered_servers: Some(false),
..Default::default()
});
})
.await;
let probe = Probe::new(format!("127.0.0.1:{}", node.sip_port).parse().unwrap()).await;
probe.send(probe.options("before-call")).await;
assert!(
probe.received_ok(Duration::from_secs(1)).await.is_none(),
"OPTIONS must be dropped before any call traffic is learned"
);
probe.send(probe.invite("learn-1")).await;
tokio::time::sleep(Duration::from_millis(300)).await;
probe.send(probe.options("after-call")).await;
let msg = probe
.received_ok(Duration::from_secs(3))
.await
.expect("OPTIONS must be answered after call traffic was learned");
assert!(msg.contains("Allow:"), "200 OK must carry Allow: {msg}");
}
#[tokio::test]
async fn options_static_allow_and_extra_headers() {
let node = spawn_node(|config| {
config.options_response = Some(OptionsResponseConfig {
enabled: Some(true),
auto_learn: Some(false),
allow: vec!["127.0.0.1".to_string()],
allow_registered_servers: Some(false),
extra_headers: vec![("X-Node-Id".to_string(), "test-node".to_string())],
..Default::default()
});
})
.await;
let probe = Probe::new(format!("127.0.0.1:{}", node.sip_port).parse().unwrap()).await;
probe.send(probe.options("static-1")).await;
let msg = probe
.received_ok(Duration::from_secs(3))
.await
.expect("OPTIONS from an ACL-allowed source must be answered");
assert!(
msg.contains("X-Node-Id: test-node"),
"200 OK must carry the configured extra header: {msg}"
);
assert!(msg.contains("Allow:"), "200 OK must carry Allow: {msg}");
assert!(
msg.contains("Accept: application/sdp"),
"200 OK must carry Accept: {msg}"
);
}
#[tokio::test]
async fn options_disabled_stays_silent() {
let node = spawn_node(|config| {
config.options_response = Some(OptionsResponseConfig {
enabled: Some(false),
..Default::default()
});
})
.await;
let probe = Probe::new(format!("127.0.0.1:{}", node.sip_port).parse().unwrap()).await;
probe.send(probe.options("disabled-1")).await;
assert!(
probe.collect(Duration::from_secs(1)).await.is_empty(),
"OPTIONS must not be answered when options_response is disabled"
);
}