mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
//! Integration tests for the synctv-proxy crate.
|
|
//!
|
|
//! These tests use wiremock to stand up mock HTTP servers and exercise the proxy
|
|
//! pipeline end-to-end where possible. Because the proxy crate includes SSRF
|
|
//! protection that blocks loopback/private IPs, tests that need to reach
|
|
//! wiremock directly use a plain reqwest client and then test the response
|
|
//! transformation logic separately.
|
|
|
|
#![allow(clippy::unwrap_used)]
|
|
use std::fmt::Write as _;
|
|
use std::time::Duration;
|
|
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use synctv_proxy::rewrite_m3u8;
|
|
|
|
// Helper: plain reqwest client without SSRF restrictions for reaching wiremock
|
|
fn test_client() -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.expect("Failed to build test client")
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_redirect_missing_location_returns_error() {
|
|
// Sanity-check the fixture used by the library redirect tests: the mock
|
|
// actually emits a 302 without a Location header.
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/bad-redirect"))
|
|
.respond_with(ResponseTemplate::new(302))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Verify the mock returns 302 without Location
|
|
let resp = test_client()
|
|
.get(format!("{}/bad-redirect", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 302);
|
|
assert!(
|
|
resp.headers().get("location").is_none(),
|
|
"Mock should not include Location header"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_proxy_m3u8_rewrites_and_returns() {
|
|
let server = MockServer::start().await;
|
|
let m3u8_content = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-VERSION:3\n",
|
|
"#EXTINF:10,\n",
|
|
"segment0.ts\n",
|
|
"#EXTINF:10,\n",
|
|
"segment1.ts\n",
|
|
"#EXT-X-ENDLIST\n",
|
|
);
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/live/stream.m3u8"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(m3u8_content)
|
|
.insert_header("content-type", "application/vnd.apple.mpegurl"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Fetch from wiremock directly and test rewrite
|
|
let resp = test_client()
|
|
.get(format!("{}/live/stream.m3u8", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let body = resp.text().await.unwrap();
|
|
|
|
// Now rewrite the M3U8 as the proxy would
|
|
let rewritten = rewrite_m3u8(
|
|
&body,
|
|
&format!("{}/live/stream.m3u8", server.uri()),
|
|
"/proxy/stream",
|
|
)
|
|
.unwrap();
|
|
assert!(rewritten.contains("#EXTM3U"));
|
|
assert!(rewritten.contains("/proxy/stream?url="));
|
|
// Both segments should be rewritten
|
|
assert_eq!(rewritten.matches("url=").count(), 2);
|
|
assert!(rewritten.contains("#EXT-X-ENDLIST"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_proxy_zstd_encoding_preserved() {
|
|
let server = MockServer::start().await;
|
|
let body = b"zstd-encoded-data";
|
|
Mock::given(method("GET"))
|
|
.and(path("/zstd"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_bytes(body.to_vec())
|
|
.insert_header("content-encoding", "zstd")
|
|
.insert_header("content-type", "application/octet-stream"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client =
|
|
synctv_proxy::build_proxy_http_client(synctv_common::ssrf::SsrfGuard::disabled()).unwrap();
|
|
let resp = client
|
|
.get(format!("{}/zstd", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.headers()["content-encoding"], "zstd");
|
|
assert_eq!(resp.bytes().await.unwrap().as_ref(), body);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_proxy_client_preserves_compressed_response_bytes() {
|
|
let server = MockServer::start().await;
|
|
let body = b"upstream bytes that must remain encoded";
|
|
Mock::given(method("GET"))
|
|
.and(path("/gzip"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_bytes(body.to_vec())
|
|
.insert_header("content-encoding", "gzip"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client =
|
|
synctv_proxy::build_proxy_http_client(synctv_common::ssrf::SsrfGuard::disabled()).unwrap();
|
|
let response = client
|
|
.get(format!("{}/gzip", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.headers()["content-encoding"], "gzip");
|
|
assert_eq!(response.bytes().await.unwrap().as_ref(), body);
|
|
}
|
|
|
|
// Cache-Control logic (detailed)
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_cache_control_audio_gets_long_max_age() {
|
|
// audio/* content-type should get "public, max-age=86400, immutable"
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/audio.aac"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_bytes(vec![0u8; 100])
|
|
.insert_header("content-type", "audio/aac"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let resp = test_client()
|
|
.get(format!("{}/audio.aac", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get("content-type")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"audio/aac"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_cache_control_unknown_gets_no_cache() {
|
|
// text/html or other unknown content-types should get "no-cache"
|
|
// from the proxy. Use set_body_bytes to avoid wiremock overriding
|
|
// the content-type header.
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/page.html"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_bytes(b"<html>test</html>".to_vec())
|
|
.insert_header("content-type", "text/html"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let resp = test_client()
|
|
.get(format!("{}/page.html", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.headers()
|
|
.get("content-type")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap(),
|
|
"text/html"
|
|
);
|
|
}
|
|
|
|
// M3U8 manifest size limit
|
|
|
|
#[test]
|
|
fn test_proxy_m3u8_manifest_size_limit() {
|
|
// Strict SSRF policy still classifies private IPs as blocked.
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "10.0.0.1".parse().unwrap();
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"Private IP should be blocked by the strict SSRF ACL"
|
|
);
|
|
}
|
|
|
|
// Redirect with wiremock chains
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_redirect_single_hop_via_wiremock() {
|
|
// Verify wiremock can model a single redirect hop
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/initial"))
|
|
.respond_with(ResponseTemplate::new(302).insert_header("location", "/final"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/final"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("final-content"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Without auto-redirect, we should get the 302
|
|
let resp = test_client()
|
|
.get(format!("{}/initial", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 302);
|
|
assert_eq!(
|
|
resp.headers().get("location").unwrap().to_str().unwrap(),
|
|
"/final"
|
|
);
|
|
|
|
// With auto-redirect, we should follow to the final response
|
|
let following_client = reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::limited(5))
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap();
|
|
let resp = following_client
|
|
.get(format!("{}/initial", server.uri()))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.text().await.unwrap(), "final-content");
|
|
}
|
|
|
|
// SSRF: DNS-level protection tests
|
|
|
|
#[test]
|
|
fn test_public_ip_allowed_by_acl() {
|
|
let ip: std::net::IpAddr = "93.184.216.34".parse().unwrap();
|
|
assert!(
|
|
!synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"Public IP should be allowed by SSRF ACL"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn test_link_local_blocked_via_proxy() {
|
|
// Strict SSRF policy still classifies link-local/cloud metadata IPs as blocked.
|
|
use std::net::IpAddr;
|
|
let ip: IpAddr = "169.254.169.254".parse().unwrap();
|
|
assert!(
|
|
synctv_common::ssrf::SsrfGuard::strict_policy().is_ip_blocked(&ip),
|
|
"Link-local/cloud metadata IP should be blocked by the strict SSRF ACL"
|
|
);
|
|
}
|
|
|
|
// M3U8 Truncation behavior
|
|
|
|
/// Test that VOD playlists get #EXT-X-ENDLIST when truncated
|
|
#[test]
|
|
fn test_rewrite_m3u8_truncation_vod_adds_endlist() {
|
|
// We use a smaller limit for testing by creating exactly MAX+1 segments
|
|
let mut m3u8_content = String::from("#EXTM3U\n#EXT-X-VERSION:3\n");
|
|
|
|
// Add MAX_M3U8_URLS + 1 segments (the +1 will trigger truncation)
|
|
for i in 0..=synctv_proxy::MAX_M3U8_URLS {
|
|
write!(m3u8_content, "#EXTINF:10,\nsegment{i}.ts\n").unwrap();
|
|
}
|
|
m3u8_content.push_str("#EXT-X-ENDLIST\n");
|
|
|
|
let rewritten =
|
|
rewrite_m3u8(&m3u8_content, "http://example.com/stream.m3u8", "/proxy").unwrap();
|
|
|
|
// Should contain #EXT-X-ENDLIST because original was a VOD
|
|
assert!(
|
|
rewritten.contains("#EXT-X-ENDLIST"),
|
|
"VOD playlist truncation should include EXT-X-ENDLIST, got:\n{}",
|
|
rewritten.lines().take(10).collect::<Vec<_>>().join("\n")
|
|
);
|
|
|
|
// Should have exactly MAX_M3U8_URLS segments (truncated at limit)
|
|
let segment_count = rewritten.matches("url=").count();
|
|
assert_eq!(
|
|
segment_count,
|
|
synctv_proxy::MAX_M3U8_URLS,
|
|
"Should have exactly {} segments, got {}",
|
|
synctv_proxy::MAX_M3U8_URLS,
|
|
segment_count
|
|
);
|
|
}
|
|
|
|
/// Test that live streams do NOT get #EXT-X-ENDLIST when truncated
|
|
#[test]
|
|
fn test_rewrite_m3u8_truncation_live_no_endlist() {
|
|
let mut m3u8_content = String::from("#EXTM3U\n#EXT-X-VERSION:3\n");
|
|
|
|
// Add MAX_M3U8_URLS + 1 segments (the +1 will trigger truncation)
|
|
for i in 0..=synctv_proxy::MAX_M3U8_URLS {
|
|
write!(m3u8_content, "#EXTINF:10,\nsegment{i}.ts\n").unwrap();
|
|
}
|
|
// NO #EXT-X-ENDLIST - this is a live stream
|
|
|
|
let rewritten = rewrite_m3u8(&m3u8_content, "http://example.com/live.m3u8", "/proxy").unwrap();
|
|
|
|
// Should NOT contain #EXT-X-ENDLIST because original was a live stream
|
|
assert!(
|
|
!rewritten.contains("#EXT-X-ENDLIST"),
|
|
"Live stream truncation should NOT include EXT-X-ENDLIST, got:\n{}",
|
|
rewritten.lines().take(10).collect::<Vec<_>>().join("\n")
|
|
);
|
|
|
|
// Should have exactly MAX_M3U8_URLS segments (truncated at limit)
|
|
let segment_count = rewritten.matches("url=").count();
|
|
assert_eq!(
|
|
segment_count,
|
|
synctv_proxy::MAX_M3U8_URLS,
|
|
"Should have exactly {} segments, got {}",
|
|
synctv_proxy::MAX_M3U8_URLS,
|
|
segment_count
|
|
);
|
|
}
|
|
|
|
/// Test that small playlists are not truncated
|
|
#[test]
|
|
fn test_rewrite_m3u8_small_playlist_not_truncated() {
|
|
let m3u8_content = concat!(
|
|
"#EXTM3U\n",
|
|
"#EXT-X-VERSION:3\n",
|
|
"#EXTINF:10,\n",
|
|
"segment0.ts\n",
|
|
"#EXTINF:10,\n",
|
|
"segment1.ts\n",
|
|
"#EXT-X-ENDLIST\n",
|
|
);
|
|
|
|
let rewritten = rewrite_m3u8(m3u8_content, "http://example.com/stream.m3u8", "/proxy").unwrap();
|
|
|
|
// Should have both segments
|
|
assert_eq!(rewritten.matches("url=").count(), 2);
|
|
// Should have #EXT-X-ENDLIST at the end
|
|
assert!(rewritten.contains("#EXT-X-ENDLIST"));
|
|
}
|