Add: Finalize unit and integration tests. README for test usage.

This commit is contained in:
ghost 2025-11-26 15:06:48 +01:00
parent 15dfec8695
commit 29c0d8c0cf
24 changed files with 1374 additions and 0 deletions

127
Cargo.lock generated
View File

@ -26,6 +26,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anyhow"
version = "1.0.100"
@ -167,12 +173,40 @@ dependencies = [
"subtle",
]
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959"
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "generic-array"
version = "0.14.7"
@ -266,6 +300,12 @@ version = "0.2.176"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "lock_api"
version = "0.4.13"
@ -323,6 +363,33 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "mockall"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"lazy_static",
"mockall_derive",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "niom-turn"
version = "0.1.0"
@ -334,12 +401,14 @@ dependencies = [
"hex",
"hmac",
"md5",
"mockall",
"rcgen",
"rustls 0.21.12",
"rustls-pemfile",
"serde",
"serde_json",
"sha1",
"tempfile",
"thiserror",
"tokio",
"tokio-rustls",
@ -422,6 +491,32 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
@ -519,6 +614,19 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.20.9"
@ -713,6 +821,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "1.0.69"

View File

@ -34,3 +34,7 @@ thiserror = "1.0"
crc32fast = "1.3"
md5 = "0.7"
[dev-dependencies]
mockall = "0.12"
tempfile = "3"

74
tests/README.md Normal file
View File

@ -0,0 +1,74 @@
# Test Suite Overview
The test tree is split by concern so it is easy to find the relevant scenarios. Each folder owns its
own helpers while all reusable mocks live in `support/mocks.rs`.
```
tests/
├── README.md # This file
├── support/ # Shared tooling (stun builders, TLS utils, mocks)
│ ├── mocks.rs # mockall definitions grouped by domain
│ ├── mod.rs # re-exports used by integration crates
│ ├── stun_builders.rs
│ └── tls.rs
├── udp_turn.rs # Legacy UDP end-to-end happy-path integration
├── tls_turn.rs # Legacy TLS allocate/refresh integration
├── auth/
│ ├── helpers.rs # Realm/nonce utilities + TURN bootstrapping
│ ├── unit.rs # Realm mismatch + unknown user unit tests
│ ├── integration_udp.rs # UDP auth failures (unknown user)
│ └── integration_tls.rs # TLS auth failure path (bad credentials)
├── alloc/
│ ├── helpers.rs # Lifetime/nonce helpers and UDP harness
│ ├── unit.rs # Lifetime clamp + zero release
│ └── integration_udp.rs # Refresh request clamping (server path)
├── channel/
│ ├── helpers.rs # Channel/peer fixtures + harness
│ ├── unit.rs # ChannelData parsing coverage
│ ├── integration_udp.rs # ChannelBind without allocation → 437
│ └── integration_tls.rs # TLS ChannelBind mismatch handling
├── config/
│ ├── helpers.rs # JSON builders + temp-file writer
│ ├── unit.rs # Minimal/malformed parse tests
│ └── integration.rs # Config::from_file → AuthManager wiring
└── errors/
├── helpers.rs # Malformed frame + harness
├── unit.rs # Frame stream mock + parse errors
├── integration_udp.rs # Malformed packet dropped silently
└── integration_tls.rs # TLS reader ignores garbage frames
```
## Mocks
- `support/mocks.rs` centralises every `mockall` mock. Sections are grouped by domain (Auth,
Allocation, Channel, Config, Error) so it is obvious which tests should use which mock.
- Tests import mocks via `#[path = "../support/mod.rs"] mod support;` and reach the relevant mock at
`support::mocks::MockFoo`.
- Additional domain-specific helper traits can be added to `mocks.rs` without touching the main
crate.
## Helper Policy
- Each folder keeps tiny `helpers.rs` files for fixtures that are only meaningful within that domain
(e.g. auth peers, config JSON blobs). This keeps intent local and avoids a mega helper file.
- When scenarios need runtime bootstrapping they should add small helper modules in their folder
rather than extending `tests/support`.
## Domain Coverage Highlights
- **Auth**: Validates realm mismatch and unknown users at unit level plus UDP/TLS rejection flows.
- **Allocation**: Exercises lifetime clamping in isolation and via refresh STUN requests.
- **Channel**: Covers ChannelData parsing plus UDP/TLS ChannelBind error paths when allocations
are missing.
- **Config**: Confirms JSON defaults, malformed detection, and `Config::from_file` wiring into an
`AuthManager` via a temp file.
- **Errors**: Ensures malformed frames trigger parser errors and are ignored by UDP/TLS loops
without producing responses.
- **End-to-end baselines**: `udp_turn.rs` and `tls_turn.rs` remain as the canonical happy-path
integration tests for Allocate/Refresh/Permission flows over both transports.
## Running Tests
- Full suite: `cargo test` (runs unit + integration crates, TLS fixtures included).
- Per-domain focus: `cargo test --test auth_integration_udp`, `cargo test --test channel_unit`, etc.
- Include ignored tests: none remain; every scenario runs as part of the default suite.

50
tests/alloc/helpers.rs Normal file
View File

@ -0,0 +1,50 @@
//! Allocation test helpers.
use crate::support::{default_test_credentials, test_auth_manager};
use niom_turn::alloc::AllocationManager;
use niom_turn::auth::{AuthManager, InMemoryStore};
use niom_turn::constants::ATTR_NONCE;
use niom_turn::models::stun::StunMessage;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
pub fn sample_client() -> SocketAddr {
"127.0.0.1:41000".parse().expect("client addr")
}
pub fn sample_peer() -> SocketAddr {
"127.0.0.1:42000".parse().expect("peer addr")
}
pub fn lifetime_secs(secs: u64) -> Duration {
Duration::from_secs(secs)
}
pub fn build_auth_manager() -> AuthManager<InMemoryStore> {
let (user, password) = default_test_credentials();
test_auth_manager(user, password)
}
pub async fn spawn_udp_server(
auth: AuthManager<InMemoryStore>,
allocs: AllocationManager,
) -> SocketAddr {
let server = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let addr = server.local_addr().expect("udp addr");
let arc = Arc::new(server);
let reader = arc.clone();
let auth_clone = auth.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
let _ = niom_turn::server::udp_reader_loop(reader, auth_clone, alloc_clone).await;
});
addr
}
pub fn extract_nonce(msg: &StunMessage) -> Option<String> {
msg.attributes
.iter()
.find(|attr| attr.typ == ATTR_NONCE)
.and_then(|attr| String::from_utf8(attr.value.clone()).ok())
}

View File

@ -0,0 +1,70 @@
//! UDP allocation lifecycle integration tests.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use crate::support::stun_builders::{
build_allocate_request, build_refresh_request, extract_lifetime, new_transaction_id, parse,
};
use helpers::*;
use niom_turn::alloc::AllocationManager;
use niom_turn::auth;
use support::{default_test_credentials, init_tracing, test_auth_manager};
use tokio::net::UdpSocket;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn refresh_request_is_clamped_to_maximum_lifetime() {
init_tracing();
let (username, password) = default_test_credentials();
let auth_manager = test_auth_manager(username, password);
let allocs = AllocationManager::new();
let server_addr = spawn_udp_server(auth_manager.clone(), allocs.clone()).await;
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
let mut buf = [0u8; 1500];
// Challenge for nonce
let challenge = build_allocate_request(None, None, None, None, None);
client
.send_to(&challenge, server_addr)
.await
.expect("send challenge");
let (len, _) = client.recv_from(&mut buf).await.expect("recv nonce");
let resp = parse(&buf[..len]);
let nonce = helpers::extract_nonce(&resp).expect("nonce attr");
// Successful allocation
let key = auth::compute_a1_md5(username, auth_manager.realm(), password);
let allocate = build_allocate_request(
Some(username),
Some(auth_manager.realm()),
Some(&nonce),
Some(&key),
Some(600),
);
client
.send_to(&allocate, server_addr)
.await
.expect("send auth allocate");
client.recv_from(&mut buf).await.expect("recv alloc success");
// Request refresh with value exceeding MAX (7200s) and assert server clamps to 3600
let refresh = build_refresh_request(
new_transaction_id(),
username,
auth_manager.realm(),
&nonce,
&key,
7200,
);
client
.send_to(&refresh, server_addr)
.await
.expect("send refresh");
let (len, _) = client.recv_from(&mut buf).await.expect("recv refresh");
let resp = parse(&buf[..len]);
let lifetime = extract_lifetime(&resp).expect("lifetime attr");
assert_eq!(lifetime, 3600);
}

44
tests/alloc/unit.rs Normal file
View File

@ -0,0 +1,44 @@
//! Allocation lifecycle unit tests covering clamping and removal.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::alloc::AllocationManager;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
async fn allocate_sample(manager: &AllocationManager) -> SocketAddr {
let server = Arc::new(UdpSocket::bind("127.0.0.1:0").await.expect("udp bind"));
let client = sample_client();
manager
.allocate_for(client, server)
.await
.expect("allocate relay");
client
}
#[tokio::test(flavor = "current_thread")]
async fn refresh_clamps_to_minimum_lifetime() {
let manager = AllocationManager::new();
let client = allocate_sample(&manager).await;
let applied = manager
.refresh_allocation(client, Some(Duration::from_secs(30)))
.expect("refresh");
assert_eq!(applied, Duration::from_secs(60));
}
#[tokio::test(flavor = "current_thread")]
async fn zero_lifetime_removes_allocation() {
let manager = AllocationManager::new();
let client = allocate_sample(&manager).await;
let applied = manager
.refresh_allocation(client, Some(Duration::from_secs(0)))
.expect("refresh zero");
assert_eq!(applied, Duration::from_secs(0));
assert!(manager.get_allocation(&client).is_none());
}

50
tests/auth/helpers.rs Normal file
View File

@ -0,0 +1,50 @@
//! Helpers dedicated to auth-focused tests.
use crate::support::{default_test_credentials, test_auth_manager};
use niom_turn::alloc::AllocationManager;
use niom_turn::auth::{AuthManager, InMemoryStore};
use niom_turn::config::AuthOptions;
use niom_turn::constants::ATTR_NONCE;
use niom_turn::models::stun::StunMessage;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
pub fn default_options() -> AuthOptions {
AuthOptions::default()
}
pub fn loopback_peer() -> SocketAddr {
"127.0.0.1:55000".parse().expect("loopback socket")
}
pub fn build_auth_manager() -> AuthManager<InMemoryStore> {
let (user, password) = default_test_credentials();
test_auth_manager(user, password)
}
pub fn default_credentials() -> (&'static str, &'static str) {
default_test_credentials()
}
pub async fn spawn_udp_server(
auth: AuthManager<InMemoryStore>,
allocs: AllocationManager,
) -> SocketAddr {
let server = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let addr = server.local_addr().expect("udp addr");
let server_arc = Arc::new(server);
let reader = server_arc.clone();
let auth_clone = auth.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
let _ = niom_turn::server::udp_reader_loop(reader, auth_clone, alloc_clone).await;
});
addr
}
pub fn extract_nonce(msg: &StunMessage) -> Option<String> {
msg.attributes
.iter()
.find(|attr| attr.typ == ATTR_NONCE)
.and_then(|attr| String::from_utf8(attr.value.clone()).ok())
}

View File

@ -0,0 +1,116 @@
//! TLS-focused authentication integration tests.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use crate::support::stun_builders::{build_allocate_request, extract_error_code, parse};
use helpers::*;
use niom_turn::alloc::AllocationManager;
use support::{default_test_credentials, init_tracing, test_auth_manager};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio_rustls::TlsAcceptor;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tls_rejects_invalid_credentials() {
init_tracing();
let udp = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let udp_arc = Arc::new(udp);
let (username, password) = default_test_credentials();
let auth = test_auth_manager(username, password);
let allocs = AllocationManager::new();
let (cert, key) = support::tls::generate_self_signed_cert();
let mut cfg = tokio_rustls::rustls::ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(vec![cert.clone()], key)
.expect("server config");
cfg.alpn_protocols.push(b"turn".to_vec());
let acceptor = TlsAcceptor::from(Arc::new(cfg));
let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("tcp bind");
let tcp_addr = tcp_listener.local_addr().expect("tcp addr");
let udp_clone = udp_arc.clone();
let auth_clone = auth.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
loop {
let (stream, peer) = match tcp_listener.accept().await {
Ok(conn) => conn,
Err(_) => break,
};
let acceptor = acceptor.clone();
let udp_clone = udp_clone.clone();
let auth_clone = auth_clone.clone();
let alloc_clone = alloc_clone.clone();
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(mut tls_stream) => {
if let Err(e) = niom_turn::tls::handle_tls_connection(
&mut tls_stream,
peer,
udp_clone,
auth_clone,
alloc_clone,
)
.await
{
tracing::error!("tls connection error: {:?}", e);
}
}
Err(e) => {
tracing::error!("tls accept failed: {:?}", e);
}
}
});
}
});
let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
root_store.add(&cert).expect("add root");
let client_cfg = tokio_rustls::rustls::ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_cfg));
let tcp_stream = tokio::net::TcpStream::connect(tcp_addr)
.await
.expect("tcp connect");
let domain = tokio_rustls::rustls::ServerName::try_from("localhost").unwrap();
let mut tls_stream = connector
.connect(domain, tcp_stream)
.await
.expect("tls connect");
let allocate = build_allocate_request(None, None, None, None, None);
tls_stream
.write_all(&allocate)
.await
.expect("write challenge");
let mut buf = vec![0u8; 1500];
let n = tls_stream.read(&mut buf).await.expect("read nonce");
let resp = parse(&buf[..n]);
let nonce = extract_nonce(&resp).expect("nonce attr");
let key = niom_turn::auth::compute_a1_md5(username, auth.realm(), "wrongpass");
let request = build_allocate_request(
Some(username),
Some(auth.realm()),
Some(&nonce),
Some(&key),
Some(600),
);
tls_stream
.write_all(&request)
.await
.expect("write invalid alloc");
let n = tls_stream.read(&mut buf).await.expect("read reject");
let resp = parse(&buf[..n]);
let code = extract_error_code(&resp).expect("error attr");
assert_eq!(code, 401);
}

View File

@ -0,0 +1,53 @@
//! UDP-focused authentication integration tests.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use crate::support::stun_builders::{build_allocate_request, extract_error_code, parse};
use helpers::*;
use niom_turn::alloc::AllocationManager;
use support::{default_test_credentials, init_tracing, test_auth_manager};
use tokio::net::UdpSocket;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn udp_rejects_unknown_user_after_nonce() {
init_tracing();
let (username, password) = default_test_credentials();
let auth = test_auth_manager(username, password);
let allocs = AllocationManager::new();
let server_addr = spawn_udp_server(auth.clone(), allocs.clone()).await;
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
// Trigger initial challenge to receive nonce
let request = build_allocate_request(None, None, None, None, None);
client
.send_to(&request, server_addr)
.await
.expect("send challenge");
let mut buf = [0u8; 1500];
let (len, _) = client.recv_from(&mut buf).await.expect("recv nonce");
let resp = parse(&buf[..len]);
let nonce = extract_nonce(&resp).expect("nonce attr");
// Attempt to authenticate with an unknown username
let intruder = "intruder";
let key = niom_turn::auth::compute_a1_md5(intruder, auth.realm(), "wrongpass");
let request = build_allocate_request(
Some(intruder),
Some(auth.realm()),
Some(&nonce),
Some(&key),
Some(600),
);
client
.send_to(&request, server_addr)
.await
.expect("send invalid auth allocate");
let (len, _) = client.recv_from(&mut buf).await.expect("recv reject");
let resp = parse(&buf[..len]);
let code = extract_error_code(&resp).expect("error code attr");
assert_eq!(code, 401);
}

80
tests/auth/unit.rs Normal file
View File

@ -0,0 +1,80 @@
//! Auth-specific unit tests driven by mock credential stores.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::auth::{self, AuthStatus};
use niom_turn::traits::CredentialStore;
use support::mocks;
use crate::support::stun_builders::{build_allocate_request, parse};
fn realm_options(realm: &str) -> niom_turn::config::AuthOptions {
let mut opts = default_options();
opts.realm = realm.to_string();
opts.nonce_secret = Some("static-secret".into());
opts
}
#[tokio::test(flavor = "current_thread")]
async fn credential_store_mock_allows_lookup() {
let mut store = mocks::MockCredentialStore::new();
store
.expect_get_password()
.with(mocks::predicates::eq("alice"))
.returning(|_| Box::pin(async { Some("s3cret".to_string()) }));
let password = CredentialStore::get_password(&store, "alice").await;
assert_eq!(password.as_deref(), Some("s3cret"));
}
#[tokio::test(flavor = "current_thread")]
async fn rejects_mismatched_realm_requests() {
let peer = loopback_peer();
let mut store = niom_turn::auth::InMemoryStore::new();
store.insert("alice", "secret");
let auth = niom_turn::auth::AuthManager::new(store, &realm_options("expected.realm"));
let nonce = auth.mint_nonce(&peer);
let wrong_realm = "other.realm";
let key = auth::compute_a1_md5("alice", wrong_realm, "secret");
let buf = build_allocate_request(
Some("alice"),
Some(wrong_realm),
Some(&nonce),
Some(&key),
Some(600),
);
let msg = parse(&buf);
match auth.authenticate(&msg, &peer).await {
AuthStatus::Reject { code, reason } => {
assert_eq!(code, 400);
assert_eq!(reason, "Realm Mismatch");
}
other => panic!("unexpected auth result: {:?}", other),
}
}
#[tokio::test(flavor = "current_thread")]
async fn rejects_unknown_user() {
let peer = loopback_peer();
let auth = build_auth_manager();
let nonce = auth.mint_nonce(&peer);
let key = auth::compute_a1_md5("intruder", auth.realm(), "badpass");
let buf = build_allocate_request(
Some("intruder"),
Some(auth.realm()),
Some(&nonce),
Some(&key),
Some(600),
);
let msg = parse(&buf);
match auth.authenticate(&msg, &peer).await {
AuthStatus::Reject { code, reason } => {
assert_eq!(code, 401);
assert_eq!(reason, "Unknown User");
}
other => panic!("unexpected auth result: {:?}", other),
}
}

49
tests/channel/helpers.rs Normal file
View File

@ -0,0 +1,49 @@
//! Helpers for channel-oriented tests.
use crate::support::{default_test_credentials, test_auth_manager};
use niom_turn::alloc::AllocationManager;
use niom_turn::auth::{AuthManager, InMemoryStore};
use niom_turn::constants::ATTR_NONCE;
use niom_turn::models::stun::StunMessage;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
pub fn sample_channel_number() -> u16 {
0x4001
}
pub fn sample_peer() -> SocketAddr {
"127.0.0.1:43000".parse().expect("peer addr")
}
pub fn sample_payload() -> Vec<u8> {
b"some-channel-payload".to_vec()
}
pub fn build_auth_manager() -> AuthManager<InMemoryStore> {
let (user, password) = default_test_credentials();
test_auth_manager(user, password)
}
pub async fn spawn_udp_server(
auth: AuthManager<InMemoryStore>,
allocs: AllocationManager,
) -> SocketAddr {
let server = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let addr = server.local_addr().expect("udp addr");
let arc = Arc::new(server);
let reader = arc.clone();
let auth_clone = auth.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
let _ = niom_turn::server::udp_reader_loop(reader, auth_clone, alloc_clone).await;
});
addr
}
pub fn extract_nonce(msg: &StunMessage) -> Option<String> {
msg.attributes
.iter()
.find(|attr| attr.typ == ATTR_NONCE)
.and_then(|attr| String::from_utf8(attr.value.clone()).ok())
}

View File

@ -0,0 +1,117 @@
//! TLS channel bind integration tests.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use crate::support::stun_builders::{build_allocate_request, build_channel_bind_request, extract_error_code, parse};
use helpers::*;
use niom_turn::alloc::AllocationManager;
use niom_turn::auth;
use std::sync::Arc;
use support::{default_test_credentials, init_tracing, test_auth_manager};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio_rustls::TlsAcceptor;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tls_channel_bind_without_allocation_returns_mismatch() {
init_tracing();
let udp = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let udp_arc = Arc::new(udp);
let (username, password) = default_test_credentials();
let auth_manager = test_auth_manager(username, password);
let allocs = AllocationManager::new();
let (cert, key) = support::tls::generate_self_signed_cert();
let mut cfg = tokio_rustls::rustls::ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(vec![cert.clone()], key)
.expect("server config");
cfg.alpn_protocols.push(b"turn".to_vec());
let acceptor = TlsAcceptor::from(Arc::new(cfg));
let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("tcp bind");
let tcp_addr = tcp_listener.local_addr().expect("tcp addr");
let udp_clone = udp_arc.clone();
let auth_clone = auth_manager.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
loop {
let (stream, peer) = match tcp_listener.accept().await {
Ok(conn) => conn,
Err(_) => break,
};
let acceptor = acceptor.clone();
let udp_clone = udp_clone.clone();
let auth_clone = auth_clone.clone();
let alloc_clone = alloc_clone.clone();
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(mut tls_stream) => {
if let Err(e) = niom_turn::tls::handle_tls_connection(
&mut tls_stream,
peer,
udp_clone,
auth_clone,
alloc_clone,
)
.await
{
tracing::error!("tls connection error: {:?}", e);
}
}
Err(e) => tracing::error!("tls accept failed: {:?}", e),
}
});
}
});
let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
root_store.add(&cert).expect("add root");
let client_cfg = tokio_rustls::rustls::ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_cfg));
let tcp_stream = tokio::net::TcpStream::connect(tcp_addr)
.await
.expect("tcp connect");
let domain = tokio_rustls::rustls::ServerName::try_from("localhost").unwrap();
let mut tls_stream = connector
.connect(domain, tcp_stream)
.await
.expect("tls connect");
// Obtain nonce via challenge
let allocate = build_allocate_request(None, None, None, None, None);
tls_stream
.write_all(&allocate)
.await
.expect("write challenge");
let mut buf = vec![0u8; 1500];
let n = tls_stream.read(&mut buf).await.expect("read nonce");
let resp = parse(&buf[..n]);
let nonce = extract_nonce(&resp).expect("nonce attr");
let key = auth::compute_a1_md5(username, auth_manager.realm(), password);
let channel_req = build_channel_bind_request(
username,
auth_manager.realm(),
&nonce,
&key,
sample_channel_number(),
&sample_peer(),
);
tls_stream
.write_all(&channel_req)
.await
.expect("write channel bind");
let n = tls_stream.read(&mut buf).await.expect("read response");
let resp = parse(&buf[..n]);
let code = extract_error_code(&resp).expect("error attr");
assert_eq!(code, 437);
}

View File

@ -0,0 +1,55 @@
//! UDP channel bind integration coverage.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use crate::support::stun_builders::{
build_allocate_request, build_channel_bind_request, extract_error_code, parse,
};
use helpers::*;
use niom_turn::alloc::AllocationManager;
use niom_turn::auth;
use support::{default_test_credentials, init_tracing, test_auth_manager};
use tokio::net::UdpSocket;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn channel_bind_without_allocation_returns_mismatch() {
init_tracing();
let (username, password) = default_test_credentials();
let auth_manager = test_auth_manager(username, password);
let allocs = AllocationManager::new();
let server_addr = spawn_udp_server(auth_manager.clone(), allocs.clone()).await;
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
let mut buf = [0u8; 1500];
// Challenge to obtain nonce (no allocation performed yet)
let challenge = build_allocate_request(None, None, None, None, None);
client
.send_to(&challenge, server_addr)
.await
.expect("send challenge");
let (len, _) = client.recv_from(&mut buf).await.expect("recv nonce");
let resp = parse(&buf[..len]);
let nonce = extract_nonce(&resp).expect("nonce attr");
let key = auth::compute_a1_md5(username, auth_manager.realm(), password);
let channel_req = build_channel_bind_request(
username,
auth_manager.realm(),
&nonce,
&key,
sample_channel_number(),
&sample_peer(),
);
client
.send_to(&channel_req, server_addr)
.await
.expect("send channel bind");
let (len, _) = client.recv_from(&mut buf).await.expect("recv error");
let resp = parse(&buf[..len]);
let code = extract_error_code(&resp).expect("error attr");
assert_eq!(code, 437);
}

40
tests/channel/unit.rs Normal file
View File

@ -0,0 +1,40 @@
//! Channel-centric unit scaffolding validating mock sinks.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::stun::{build_channel_data, parse_channel_data};
use support::mocks;
#[tokio::test(flavor = "current_thread")]
async fn channel_sink_mock_records_payload() {
let mut sink = mocks::MockChannelSink::new();
sink
.expect_send_channel_data()
.withf(|channel, payload| *channel == sample_channel_number() && payload == &sample_payload())
.returning(|_, _| Box::pin(async { Ok(()) }));
sink
.send_channel_data(sample_channel_number(), sample_payload())
.await
.expect("channel data to send");
}
#[test]
fn parse_channel_data_round_trip() {
let payload = sample_payload();
let frame = build_channel_data(sample_channel_number(), &payload);
let (channel, body) = parse_channel_data(&frame).expect("parse channel frame");
assert_eq!(channel, sample_channel_number());
assert_eq!(body, payload.as_slice());
}
#[test]
fn parse_channel_data_rejects_invalid_channel_range() {
let mut frame = build_channel_data(sample_channel_number(), &sample_payload());
frame[0] = 0x20; // invalid prefix (must be 0x40)
assert!(parse_channel_data(&frame).is_none());
}

24
tests/config/helpers.rs Normal file
View File

@ -0,0 +1,24 @@
//! Helpers for config parsing tests.
use serde_json::json;
use std::io::Write;
use tempfile::NamedTempFile;
pub fn minimal_config_json() -> String {
json!({
"server": { "bind": "127.0.0.1:0", "tls_cert": null, "tls_key": null },
"credentials": [],
"auth": { "realm": "niom-turn.test", "nonce_ttl_seconds": 300 }
})
.to_string()
}
pub fn malformed_config_json() -> String {
"{ server: }".to_string()
}
pub fn write_temp_config(body: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("temp file");
file.write_all(body.as_bytes()).expect("write temp config");
file.flush().expect("flush temp config");
file
}

View File

@ -0,0 +1,29 @@
//! Config-driven integration scaffolding testing startup paths.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use serde_json::json;
#[tokio::test(flavor = "current_thread")]
async fn config_file_round_trip_populates_auth_manager() {
let cfg_json = json!({
"server": { "bind": "127.0.0.1:3478", "tls_cert": null, "tls_key": null },
"credentials": [ { "username": "alice", "password": "secret" } ],
"auth": { "realm": "niom-turn.integration", "nonce_ttl_seconds": 120 }
})
.to_string();
let temp = helpers::write_temp_config(&cfg_json);
let cfg = niom_turn::config::Config::from_file(temp.path()).expect("config load");
assert_eq!(cfg.server.bind, "127.0.0.1:3478");
assert_eq!(cfg.credentials.len(), 1);
let store = niom_turn::auth::InMemoryStore::new();
for cred in &cfg.credentials {
store.insert(&cred.username, &cred.password);
}
let auth = niom_turn::auth::AuthManager::new(store, &cfg.auth);
assert_eq!(auth.realm(), "niom-turn.integration");
}

37
tests/config/unit.rs Normal file
View File

@ -0,0 +1,37 @@
//! Config parsing unit scaffolding using mock sources.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::config::Config;
use support::mocks;
#[test]
fn config_source_mock_serves_payload() {
let mut source = mocks::MockConfigSource::new();
source
.expect_load()
.returning(|| Ok(minimal_config_json()));
let body = source.load().expect("config payload");
assert!(body.contains("niom-turn.test"));
}
#[test]
fn parsing_minimal_config_populates_defaults() {
let json = minimal_config_json();
let cfg: Config = serde_json::from_str(&json).expect("config parse");
assert_eq!(cfg.server.bind, "127.0.0.1:0");
assert_eq!(cfg.auth.realm, "niom-turn.test");
assert_eq!(cfg.auth.nonce_ttl_seconds, 300);
}
#[test]
fn malformed_config_fails_to_parse() {
let json = malformed_config_json();
let parsed: Result<Config, _> = serde_json::from_str(&json);
assert!(parsed.is_err());
}

36
tests/errors/helpers.rs Normal file
View File

@ -0,0 +1,36 @@
//! Helpers for error-path tests.
use crate::support::{default_test_credentials, test_auth_manager};
use niom_turn::alloc::AllocationManager;
use niom_turn::auth::{AuthManager, InMemoryStore};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
pub fn malformed_stun_frame() -> Vec<u8> {
vec![0x00, 0x01, 0x02] // too short for STUN header
}
pub fn oversized_payload() -> Vec<u8> {
vec![0u8; 4096]
}
pub fn build_auth_manager() -> AuthManager<InMemoryStore> {
let (user, password) = default_test_credentials();
test_auth_manager(user, password)
}
pub async fn spawn_udp_server(
auth: AuthManager<InMemoryStore>,
allocs: AllocationManager,
) -> SocketAddr {
let server = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let addr = server.local_addr().expect("udp addr");
let arc = Arc::new(server);
let reader = arc.clone();
let auth_clone = auth.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
let _ = niom_turn::server::udp_reader_loop(reader, auth_clone, alloc_clone).await;
});
addr
}

View File

@ -0,0 +1,92 @@
//! TLS error-path integration tests.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::alloc::AllocationManager;
use std::sync::Arc;
use support::{init_tracing, test_auth_manager, default_test_credentials};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UdpSocket};
use tokio::time::{timeout, Duration};
use tokio_rustls::TlsAcceptor;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn malformed_tls_frame_is_ignored() {
init_tracing();
let udp = UdpSocket::bind("127.0.0.1:0").await.expect("udp bind");
let udp_arc = Arc::new(udp);
let (username, password) = default_test_credentials();
let auth = test_auth_manager(username, password);
let allocs = AllocationManager::new();
let (cert, key) = support::tls::generate_self_signed_cert();
let mut cfg = tokio_rustls::rustls::ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(vec![cert.clone()], key)
.expect("server config");
cfg.alpn_protocols.push(b"turn".to_vec());
let acceptor = TlsAcceptor::from(Arc::new(cfg));
let tcp_listener = TcpListener::bind("127.0.0.1:0").await.expect("tcp bind");
let tcp_addr = tcp_listener.local_addr().expect("tcp addr");
let udp_clone = udp_arc.clone();
let auth_clone = auth.clone();
let alloc_clone = allocs.clone();
tokio::spawn(async move {
loop {
let (stream, peer) = match tcp_listener.accept().await {
Ok(conn) => conn,
Err(_) => break,
};
let acceptor = acceptor.clone();
let udp_clone = udp_clone.clone();
let auth_clone = auth_clone.clone();
let alloc_clone = alloc_clone.clone();
tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(mut tls_stream) => {
let _ = niom_turn::tls::handle_tls_connection(
&mut tls_stream,
peer,
udp_clone,
auth_clone,
alloc_clone,
)
.await;
}
Err(e) => tracing::error!("tls accept failed: {:?}", e),
}
});
}
});
let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
root_store.add(&cert).expect("add root");
let client_cfg = tokio_rustls::rustls::ClientConfig::builder()
.with_safe_defaults()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_cfg));
let tcp_stream = tokio::net::TcpStream::connect(tcp_addr)
.await
.expect("tcp connect");
let domain = tokio_rustls::rustls::ServerName::try_from("localhost").unwrap();
let mut tls_stream = connector
.connect(domain, tcp_stream)
.await
.expect("tls connect");
tls_stream
.write_all(&malformed_stun_frame())
.await
.expect("write malformed");
let mut buf = vec![0u8; 512];
let result = timeout(Duration::from_millis(200), tls_stream.read(&mut buf)).await;
assert!(result.is_err(), "server should not respond to malformed frame");
}

View File

@ -0,0 +1,30 @@
//! UDP error-path integration tests.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::alloc::AllocationManager;
use support::init_tracing;
use tokio::net::UdpSocket;
use tokio::time::{timeout, Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn malformed_packet_is_dropped_without_response() {
init_tracing();
let auth = build_auth_manager();
let allocs = AllocationManager::new();
let server_addr = spawn_udp_server(auth, allocs).await;
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
client
.send_to(&malformed_stun_frame(), server_addr)
.await
.expect("send malformed");
let mut buf = [0u8; 1500];
let recv = timeout(Duration::from_millis(200), client.recv_from(&mut buf)).await;
assert!(recv.is_err(), "server should not respond to malformed frame");
}

28
tests/errors/unit.rs Normal file
View File

@ -0,0 +1,28 @@
//! Error-path unit scaffolding leveraging mock frame streams.
#[path = "../support/mod.rs"]
mod support;
mod helpers;
use helpers::*;
use niom_turn::stun::{parse_message, ParseError};
use support::mocks;
#[tokio::test(flavor = "current_thread")]
async fn frame_stream_mock_yields_sequence() {
let mut stream = mocks::MockFrameStream::new();
stream
.expect_next_frame()
.times(1)
.returning(|| Box::pin(async { Some(malformed_stun_frame()) }));
let first = stream.next_frame().await;
assert_eq!(first.as_ref().map(|f| f.len()), Some(3));
}
#[test]
fn parse_message_rejects_short_frame() {
let err = parse_message(&malformed_stun_frame()).unwrap_err();
assert!(matches!(err, ParseError::TooShort));
}

94
tests/support/mocks.rs Normal file
View File

@ -0,0 +1,94 @@
//! Centralised mock definitions for test crates.
//! Sections are grouped by domain to keep the mapping between mocks and
//! their intended test areas obvious.
#![allow(dead_code)]
use async_trait::async_trait;
use mockall::mock;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
pub mod predicates {
#![allow(unused_imports)]
pub use mockall::predicate::*;
}
// --- Auth domain -----------------------------------------------------------
mock! {
pub CredentialStore {}
#[async_trait]
impl niom_turn::traits::CredentialStore for CredentialStore {
async fn get_password(&self, username: &str) -> Option<String>;
}
}
// --- Allocation domain -----------------------------------------------------
#[async_trait]
pub trait RelayIo: Send + Sync {
async fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr)>;
async fn send_to(&self, buf: &[u8], target: SocketAddr) -> std::io::Result<usize>;
}
mock! {
pub RelayIo {}
#[async_trait]
impl RelayIo for RelayIo {
async fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr)>;
async fn send_to(&self, buf: &[u8], target: SocketAddr) -> std::io::Result<usize>;
}
}
pub trait AllocationClock: Send + Sync {
fn now(&self) -> Instant;
fn advance(&self, delta: Duration);
}
mock! {
pub AllocationClock {}
impl AllocationClock for AllocationClock {
fn now(&self) -> Instant;
fn advance(&self, delta: Duration);
}
}
// --- Channel domain --------------------------------------------------------
#[async_trait]
pub trait ChannelSink: Send + Sync {
async fn send_channel_data(&self, channel: u16, payload: Vec<u8>) -> anyhow::Result<()>;
async fn send_data_indication(&self, peer: SocketAddr, payload: Vec<u8>) -> anyhow::Result<()>;
}
mock! {
pub ChannelSink {}
#[async_trait]
impl ChannelSink for ChannelSink {
async fn send_channel_data(&self, channel: u16, payload: Vec<u8>) -> anyhow::Result<()>;
async fn send_data_indication(&self, peer: SocketAddr, payload: Vec<u8>) -> anyhow::Result<()>;
}
}
// --- Config domain ---------------------------------------------------------
pub trait ConfigSource: Send + Sync {
fn load(&self) -> Result<String, std::io::Error>;
}
mock! {
pub ConfigSource {}
impl ConfigSource for ConfigSource {
fn load(&self) -> Result<String, std::io::Error>;
}
}
// --- Error/Parser domain ---------------------------------------------------
#[async_trait]
pub trait FrameStream: Send + Sync {
async fn next_frame(&self) -> Option<Vec<u8>>;
}
mock! {
pub FrameStream {}
#[async_trait]
impl FrameStream for FrameStream {
async fn next_frame(&self) -> Option<Vec<u8>>;
}
}

View File

@ -1,3 +1,4 @@
pub mod mocks;
pub mod stun_builders;
pub mod tls;

View File

@ -98,6 +98,45 @@ pub fn build_send_request(
)
}
/// Build a ChannelBind request binding `channel` to `peer`.
pub fn build_channel_bind_request(
username: &str,
realm: &str,
nonce: &str,
key: &[u8],
channel: u16,
peer: &std::net::SocketAddr,
) -> Vec<u8> {
let mut buf = BytesMut::new();
buf.extend_from_slice(&METHOD_CHANNEL_BIND.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes());
buf.extend_from_slice(&MAGIC_COOKIE_BYTES);
let trans = new_transaction_id();
buf.extend_from_slice(&trans);
push_string_attr(&mut buf, ATTR_USERNAME, username);
push_string_attr(&mut buf, ATTR_REALM, realm);
push_string_attr(&mut buf, ATTR_NONCE, nonce);
let mut channel_value = vec![0u8; 4];
channel_value[0] = (channel >> 8) as u8;
channel_value[1] = channel as u8;
push_bytes_attr(&mut buf, ATTR_CHANNEL_NUMBER, &channel_value);
let encoded = niom_turn::stun::encode_xor_peer_address(peer, &trans);
push_bytes_attr(&mut buf, ATTR_XOR_PEER_ADDRESS, &encoded);
append_message_integrity(&mut buf, key);
// update length
let total_len = (buf.len() - 20) as u16;
let len_bytes = total_len.to_be_bytes();
buf[2] = len_bytes[0];
buf[3] = len_bytes[1];
buf.to_vec()
}
fn build_authenticated_request(
method: u16,
username: Option<&str>,
@ -208,3 +247,38 @@ fn append_message_integrity(buf: &mut BytesMut, key: &[u8]) {
pub fn parse(buf: &[u8]) -> niom_turn::models::stun::StunMessage {
parse_message(buf).expect("valid stun message")
}
/// Extract ERROR-CODE attribute value (e.g. 401, 437) if present.
pub fn extract_error_code(msg: &niom_turn::models::stun::StunMessage) -> Option<u16> {
msg.attributes
.iter()
.find(|a| a.typ == ATTR_ERROR_CODE)
.and_then(|attr| {
if attr.value.len() >= 4 {
let class = attr.value[2] as u16;
let number = attr.value[3] as u16;
Some(class * 100 + number)
} else {
None
}
})
}
/// Extract lifetime (seconds) from responses when present.
pub fn extract_lifetime(msg: &niom_turn::models::stun::StunMessage) -> Option<u32> {
msg.attributes
.iter()
.find(|a| a.typ == ATTR_LIFETIME)
.and_then(|attr| {
if attr.value.len() >= 4 {
Some(u32::from_be_bytes([
attr.value[0],
attr.value[1],
attr.value[2],
attr.value[3],
]))
} else {
None
}
})
}