Files
tuliprox/backend/app/src/api/api_utils/tests.rs
T
euzuandGitHub 696060bca6 Fix M3U alias credential rewriting on provider fallback (#851)
* Fix M3U alias credential rewriting on provider fallback

  Rewrite opaque authentication query parameters when stream allocation
  switches from the primary M3U provider to an alias.

  Support unambiguous cross-key mappings such as token to api_key while
  preserving unrelated query parameters and rejecting ambiguous mappings.

  Add regression coverage for URL rewriting and provider allocation.
2026-09-01 09:32:58 +02:00

5645 lines
221 KiB
Rust

use super::*;
use crate::{
api::model::{
ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, EventManager,
MetadataUpdateManager, PlaylistStorageState, ProviderConfig as RuntimeProviderConfig, ProviderConfigConnection,
SharedStreamManager,
},
auth::Fingerprint,
model::{
AppConfig, Config, ConfigInput, ConfigInputAlias, ConfigProvider, ConfigTarget, GracePeriodOptions,
MediaToolCapabilities, NetworkAccess, ProcessTargets, ProxyUserCredentials, SourcesConfig, StreamHistoryConfig,
},
repository::GeoIp,
utils::FileLockManager,
};
use arc_swap::{ArcSwap, ArcSwapOption};
use axum::http::{HeaderMap, Response, StatusCode};
use bytes::Bytes;
use futures::stream;
use http_body_util::BodyExt;
use shared::{
defaults::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs},
foundation::Filter,
model::{
AdmissionStrategy, ClusterFlags, ConfigPaths, ConfigProviderDto, ConfigTargetOptions, GeoIpUnavailablePolicy,
InputFetchMethod, InputType, PlaylistItemType, ProcessingOrder, ProviderUrlSelectionPolicy, ProxyType,
StreamChannel, XtreamCluster,
},
utils::Internable,
};
use std::{borrow::Cow, collections::HashMap, net::SocketAddr, sync::Arc};
use tokio::{
io::AsyncWriteExt,
net::TcpListener,
sync::{mpsc, RwLock},
};
use tuliprox_core::utils::response_compression::should_compress_response;
use tuliprox_session::{
admission::{
evaluate_remaining_strategies_after_grace, get_effective_admission_strategies, RECENT_EVICTION_REENTRY_TTL_SECS,
},
GraceResolutionContext,
};
#[test]
fn stalker_playback_refreshes_invalid_or_rejected_urls() {
assert!(should_refresh_stalker_playback(InputType::Stalker, false, None));
assert!(should_refresh_stalker_playback(InputType::Stalker, true, Some(StatusCode::UNAUTHORIZED)));
assert!(!should_refresh_stalker_playback(InputType::Stalker, true, Some(StatusCode::OK)));
assert!(!should_refresh_stalker_playback(InputType::Xtream, false, None));
}
#[test]
fn initial_stalker_playback_resolves_only_empty_urls() {
assert!(needs_initial_stalker_resolution(InputType::Stalker, ""));
assert!(!needs_initial_stalker_resolution(InputType::Stalker, "https://stream.example/live.ts"));
assert!(!needs_initial_stalker_resolution(InputType::Xtream, ""));
assert_eq!(stalker_stream_kind(XtreamCluster::Live, PlaylistItemType::Catchup), StalkerStreamKind::Archive);
}
fn test_runtime_provider(url: &str, username: &str, password: &str) -> Arc<RuntimeProviderConfig> {
test_runtime_provider_with_type(url, username, password, InputType::Xtream)
}
#[tokio::test]
async fn streamed_json_array_coalesces_small_entries() {
let response = stream_json_array_stream(stream::iter(0..4_096u32));
let mut body = response.into_body();
let mut frames = 0usize;
let mut bytes = Vec::new();
while let Some(frame) = body.frame().await {
let Ok(frame) = frame else {
return;
};
if let Ok(data) = frame.into_data() {
frames += 1;
bytes.extend_from_slice(&data);
}
}
assert!(frames <= 2, "small JSON entries should be coalesced, got {frames} frames");
let decoded = serde_json::from_slice::<Vec<u32>>(&bytes);
assert!(decoded.is_ok_and(|values| values.len() == 4_096));
}
#[tokio::test]
async fn catchup_payload_probe_detects_fragmented_hls() {
let source =
stream::iter([Ok::<_, StreamError>(Bytes::from_static(b"#EX")), Ok(Bytes::from_static(b"TM3U\nsegment.ts\n"))])
.boxed();
let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await;
assert!(matches!(&result, Ok(CatchupPayload::HlsManifest(_))));
if let Ok(CatchupPayload::HlsManifest(manifest)) = result {
assert_eq!(manifest, b"#EXTM3U\nsegment.ts\n".as_slice());
}
}
#[tokio::test]
async fn catchup_payload_probe_replays_ts_bytes() {
let expected = Bytes::from_static(b"\x47direct-ts-payload");
let source = stream::iter([Ok::<_, StreamError>(expected.clone())]).boxed();
let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await;
assert!(matches!(&result, Ok(CatchupPayload::Direct(_))));
if let Ok(CatchupPayload::Direct(mut stream)) = result {
let mut actual = Vec::new();
while let Some(chunk) = stream.next().await {
if let Ok(chunk) = chunk {
actual.extend_from_slice(&chunk);
}
}
assert_eq!(actual, expected.as_ref());
}
}
#[tokio::test]
async fn catchup_payload_probe_replays_partial_signature_at_eof() {
let expected = Bytes::from_static(b"#EXT");
let source = stream::iter([Ok::<_, StreamError>(expected.clone())]).boxed();
let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await;
assert!(matches!(&result, Ok(CatchupPayload::Direct(_))));
if let Ok(CatchupPayload::Direct(mut stream)) = result {
let actual = stream.next().await.and_then(Result::ok);
assert_eq!(actual.as_ref(), Some(&expected));
assert!(stream.next().await.is_none());
}
}
#[tokio::test]
async fn catchup_payload_probe_rejects_oversized_manifest() {
let oversized = vec![b'x'; MAX_HLS_MANIFEST_BYTES];
let source =
stream::iter([Ok::<_, StreamError>(Bytes::from_static(b"#EXTM3U")), Ok(Bytes::from(oversized))]).boxed();
let result = probe_catchup_payload(source, std::time::Duration::from_secs(1)).await;
assert!(result.is_err());
}
#[tokio::test]
async fn coalesced_stream_remains_finished_when_polled_again() {
let stream = coalesce_byte_stream(stream::empty::<Result<Bytes, ()>>());
futures::pin_mut!(stream);
assert!(stream.next().await.is_none());
assert!(stream.next().await.is_none());
}
fn test_runtime_provider_with_type(
url: &str,
username: &str,
password: &str,
input_type: InputType,
) -> Arc<RuntimeProviderConfig> {
let url = if input_type == InputType::M3u {
format!("{url}/playlist.m3u8?username={username}&password={password}")
} else {
url.to_string()
};
let input = ConfigInput {
name: "provider".intern(),
url,
username: Some(username.to_string()),
password: Some(password.to_string()),
input_type,
..ConfigInput::default()
};
Arc::new(RuntimeProviderConfig::new(
&input,
Arc::new(RwLock::new(ProviderConfigConnection::default())),
Arc::new(|_, _| {}),
))
}
fn test_runtime_provider_without_credentials(url: &str, input_type: InputType) -> Arc<RuntimeProviderConfig> {
let input = ConfigInput { name: "provider".intern(), url: url.to_string(), input_type, ..ConfigInput::default() };
Arc::new(RuntimeProviderConfig::new(
&input,
Arc::new(RwLock::new(ProviderConfigConnection::default())),
Arc::new(|_, _| {}),
))
}
#[test]
fn test_is_seek_request() {
let mut headers = HeaderMap::new();
// No range header
assert!(!is_seek_request(XtreamCluster::Video, &headers));
// Range: bytes=0- (Should be true now to allow session takeover on restart)
headers.insert("range", "bytes=0-".parse().unwrap());
assert!(is_seek_request(XtreamCluster::Video, &headers));
// Range: bytes=100- (Should be true)
headers.insert("range", "bytes=100-".parse().unwrap());
assert!(is_seek_request(XtreamCluster::Video, &headers));
// Range: bytes=100-200 (Should be true)
headers.insert("range", "bytes=100-200".parse().unwrap());
assert!(is_seek_request(XtreamCluster::Video, &headers));
// Live cluster should always return false
headers.insert("range", "bytes=100-".parse().unwrap());
assert!(!is_seek_request(XtreamCluster::Live, &headers));
}
#[test]
fn hls_manifests_are_not_forced_as_seek_responses() {
let mut headers = HeaderMap::new();
headers.insert("range", HeaderValue::from_static("bytes=0-"));
assert!(!is_seekable_media_request(XtreamCluster::Video, &headers, Some(HLS_EXT)));
assert!(is_seekable_media_request(XtreamCluster::Video, &headers, Some(".ts")));
}
#[test]
fn resolve_redirect_location_resolves_provider_scheme_urls() {
let provider = ConfigProvider::from(&ConfigProviderDto {
name: "develop".intern(),
urls: vec!["https://provider.example".intern()],
provider_url_selection_policy: ProviderUrlSelectionPolicy::ResumeLastWorking,
dns: None,
});
let input = ConfigInput {
name: "provider".intern(),
provider_configs: Some(vec![Arc::new(provider)]),
..ConfigInput::default()
};
let resolved =
resolve_redirect_location(Some(&input), "provider://develop/live/provider-user/provider-pass/33486.m3u8")
.expect("provider url should resolve");
assert_eq!(resolved, "https://provider.example/live/provider-user/provider-pass/33486.m3u8");
}
#[test]
fn stream_alternative_url_keeps_unmatched_urls_unchanged() {
let input = ConfigInput {
name: "source".intern(),
url: "http://source.example".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::Xtream,
..ConfigInput::default()
};
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
let stream_url = "http://other.example/live/source-user/source-pass/123.ts";
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
assert_eq!(rewritten, None);
}
#[test]
fn stream_alternative_url_rewrites_only_query_auth_fields() {
let input = ConfigInput {
name: "source".intern(),
url: "http://source.example".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::Xtream,
..ConfigInput::default()
};
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
let stream_url = "http://source.example/player?token=source-user&username=source-user&password=source-pass";
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
assert_eq!(
rewritten,
Some("http://alias.example/player?token=source-user&username=alias-user&password=alias-pass".to_string())
);
}
#[test]
fn stream_url_matches_provider_requires_base_url_and_account_identity() {
let provider = test_runtime_provider("http://same.example", "selected-user", "selected-pass");
assert!(stream_url_matches_provider("http://same.example/live/selected-user/selected-pass/123.ts", &provider));
assert!(stream_url_matches_provider(
"http://same.example/timeshift/selected-user/selected-pass/30/2026-06-15:20-00/123.ts",
&provider
));
assert!(stream_url_matches_provider(
"http://same.example/future-route/selected-user/selected-pass/opaque/123.ts",
&provider
));
assert!(!stream_url_matches_provider("http://same.example/live/other-user/other-pass/123.ts", &provider));
assert!(!stream_url_matches_provider(
"http://same.example/timeshift/other-user/other-pass/30/2026-06-15:20-00/123.ts",
&provider
));
assert!(!stream_url_matches_provider(
"http://same.example/future-route/other-user/other-pass/opaque/123.ts",
&provider
));
}
#[test]
fn stream_url_matches_provider_accepts_external_playlist_url_for_m3u_without_account_signature() {
let provider =
test_runtime_provider_with_type("http://provider.example", "selected-user", "selected-pass", InputType::M3u);
assert!(stream_url_matches_provider(
"https://hlspackager.akamaized.net/live/DB/ALYAUM_TV/HLS/ALYAUM_TV.m3u8",
&provider
));
assert!(stream_url_matches_provider(
"https://shd-gcp-live.edgenextcdn.net/live/bitmovin-mbc-1/15cf99af5de54063fdabfefe66adc075/index.m3u8",
&provider
));
}
#[test]
fn stream_url_matches_provider_rejects_external_cdn_url_with_wrong_account_signature() {
let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass");
assert!(!stream_url_matches_provider("http://cdn.example/live/other-user/other-pass/123.ts", &provider));
assert!(!stream_url_matches_provider(
"http://cdn.example/segment.ts?username=other-user&password=other-pass",
&provider
));
}
#[test]
fn stream_url_matches_provider_rejects_external_cdn_url_with_wrong_account_signature_for_m3u() {
let provider =
test_runtime_provider_with_type("http://provider.example", "selected-user", "selected-pass", InputType::M3u);
assert!(!stream_url_matches_provider(
"http://cdn.example/segment.ts?username=other-user&password=other-pass",
&provider
));
}
#[test]
fn stream_url_matches_provider_detects_m3u_path_credentials_against_alias_account() {
// Regression: a cross-host M3U URL whose path embeds the alias's
// account credentials must be detected as an account signature and
// validated, not silently allowed as an open URL.
let provider =
test_runtime_provider_with_type("http://provider.example", "selected-user", "selected-pass", InputType::M3u);
// Matching path credentials -> allowed (account matches).
assert!(stream_url_matches_provider("http://cdn.example/live/selected-user/selected-pass/123.ts", &provider));
}
#[test]
fn stream_url_matches_provider_rejects_open_external_cdn_url_without_account_signature_for_xtream() {
let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass");
assert!(!stream_url_matches_provider("http://cdn.example/open/playlist.m3u8", &provider));
assert!(!stream_url_matches_provider("http://cdn.example/open/segment.ts?key=signedopaque", &provider));
}
#[test]
fn stream_url_matches_provider_rejects_external_cdn_url_for_xtream_even_with_valid_account_signature() {
let provider = test_runtime_provider("http://provider.example", "selected-user", "selected-pass");
assert!(!stream_url_matches_provider("http://cdn.example/live/selected-user/selected-pass/123.ts", &provider));
assert!(!stream_url_matches_provider(
"http://cdn.example/segment.ts?username=selected-user&password=selected-pass",
&provider
));
}
#[test]
fn find_input_account_by_signature_matches_main_input_and_alias_accounts() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example".to_string(),
username: Some("main-user".to_string()),
password: Some("main-pass".to_string()),
input_type: InputType::Xtream,
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "alias".intern(),
url: "http://alias.example".to_string(),
username: Some("alias-user".to_string()),
password: Some("alias-pass".to_string()),
max_connections: 1,
priority: 0,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
};
let main = find_input_account_by_signature("http://cdn.example/live/main-user/main-pass/1.ts", &input);
assert_eq!(
main,
Some(("http://provider.example".to_string(), Some("main-user".to_string()), Some("main-pass".to_string()),))
);
let alias = find_input_account_by_signature("http://cdn.example/live/alias-user/alias-pass/1.ts", &input);
assert_eq!(
alias,
Some(("http://alias.example".to_string(), Some("alias-user".to_string()), Some("alias-pass".to_string()),))
);
assert_eq!(find_input_account_by_signature("http://cdn.example/live/other/other/1.ts", &input), None);
assert_eq!(find_input_account_by_signature("http://cdn.example/open/playlist.m3u8", &input), None);
}
#[test]
fn get_stream_alternative_url_rewrites_external_cdn_url_with_valid_account_signature_for_alias_account() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u);
let stream_url = "http://cdn.example/live/source-user/source-pass/123.ts";
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
assert_eq!(rewritten, Some("http://cdn.example/live/alias-user/alias-pass/123.ts".to_string()));
}
#[test]
fn get_stream_alternative_url_rewrites_opaque_m3u_token_for_alias_account() {
let input = ConfigInput {
name: "source".intern(),
url: "http://playlist.example/a.m3u?token=provider-a-token".to_string(),
input_type: InputType::M3u,
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "alias".intern(),
url: "http://playlist.example/b.m3u?token=provider-b-token".to_string(),
username: None,
password: None,
max_connections: 0,
priority: 0,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
};
let alias = test_runtime_provider_without_credentials(
"http://playlist.example/b.m3u?token=provider-b-token",
InputType::M3u,
);
let rewritten =
get_stream_alternative_url("http://stream.example/channel/segment.ts?token=provider-a-token", &input, &alias);
assert_eq!(rewritten, Some("http://stream.example/channel/segment.ts?token=provider-b-token".to_string()));
}
#[test]
fn get_stream_alternative_url_rewrites_single_opaque_m3u_credential_with_different_key() {
let input = ConfigInput {
name: "source".intern(),
url: "http://playlist.example/a.m3u?token=provider-a-token".to_string(),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_without_credentials(
"http://playlist.example/b.m3u?api_key=provider-b-key",
InputType::M3u,
);
let rewritten = get_stream_alternative_url(
"http://stream.example/segment.ts?token=provider-a-token&quality=hd",
&input,
&alias,
);
assert_eq!(rewritten, Some("http://stream.example/segment.ts?api_key=provider-b-key&quality=hd".to_string()));
}
#[test]
fn get_stream_alternative_url_rejects_ambiguous_cross_key_credential_mapping() {
let input = ConfigInput {
name: "source".intern(),
url: "http://playlist.example/a.m3u?token=provider-a-token".to_string(),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_without_credentials(
"http://playlist.example/b.m3u?api_key=provider-b-key&auth=provider-b-auth",
InputType::M3u,
);
assert_eq!(
get_stream_alternative_url("http://stream.example/segment.ts?token=provider-a-token", &input, &alias),
None
);
}
#[test]
fn get_stream_alternative_url_rejects_partial_source_credential_mapping() {
let input = ConfigInput {
name: "source".intern(),
url: "http://playlist.example/a.m3u?token=provider-a-token&api_key=provider-a-key".to_string(),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_without_credentials(
"http://playlist.example/b.m3u?token=provider-b-token",
InputType::M3u,
);
assert_eq!(
get_stream_alternative_url(
"http://stream.example/segment.ts?token=provider-a-token&api_key=provider-a-key&quality=hd",
&input,
&alias,
),
None
);
}
#[test]
fn select_provider_stream_url_rewrites_opaque_m3u_token_for_allocated_alias() {
let input = ConfigInput {
name: "provider-a".intern(),
url: "http://playlist.example/a.m3u?token=provider-a-token".to_string(),
input_type: InputType::M3u,
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "provider-b".intern(),
url: "http://playlist.example/b.m3u?token=provider-b-token".to_string(),
username: None,
password: None,
max_connections: 0,
priority: 0,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
};
let alias = test_runtime_provider_without_credentials(
"http://playlist.example/b.m3u?token=provider-b-token",
InputType::M3u,
);
let selected = select_provider_stream_url(
"http://stream.example/channel/segment.ts?token=provider-a-token",
&input,
&alias,
false,
);
assert_eq!(
selected,
Some(("provider".intern(), "http://stream.example/channel/segment.ts?token=provider-b-token".to_string(),))
);
}
#[test]
fn get_stream_alternative_url_rewrites_timeshift_path_credentials_for_alias_account() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::Xtream,
..ConfigInput::default()
};
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
let stream_url = "http://provider.example/timeshift/source-user/source-pass/30/2026-06-15:20-00/123.ts";
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
assert_eq!(
rewritten,
Some("http://alias.example/timeshift/alias-user/alias-pass/30/2026-06-15:20-00/123.ts".to_string())
);
}
#[test]
fn get_stream_alternative_url_rewrites_future_route_path_credentials_for_alias_account() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::Xtream,
..ConfigInput::default()
};
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
let stream_url = "http://provider.example/future-route/source-user/source-pass/opaque/123.ts";
let rewritten = get_stream_alternative_url(stream_url, &input, &alias);
assert_eq!(rewritten, Some("http://alias.example/future-route/alias-user/alias-pass/opaque/123.ts".to_string()));
}
#[test]
fn get_stream_alternative_url_keeps_open_external_playlist_url_for_m3u() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u);
let stream_url = "https://cnbc-live.akamaized.net/cnbc/master.m3u8";
assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), Some(stream_url.to_string()));
}
#[test]
fn get_stream_alternative_url_keeps_open_external_multisegment_playlist_url_for_m3u() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u);
let stream_url = "https://hnpsechtsc.turknet.ercdn.net/xpnvudnlsv/cnbc-e/cnbc-e.m3u8";
assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), Some(stream_url.to_string()));
}
#[test]
fn get_stream_alternative_url_keeps_open_external_m3u_url_for_provider_without_credentials() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8".to_string(),
input_type: InputType::M3u,
..ConfigInput::default()
};
let provider = test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u);
let stream_url = "http://s.only4.tv/17113/video.m3u8?token=abc";
assert_eq!(get_stream_alternative_url(stream_url, &input, &provider), Some(stream_url.to_string()));
}
#[test]
fn get_stream_alternative_url_rejects_query_credentials_for_provider_without_credentials() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8".to_string(),
input_type: InputType::M3u,
..ConfigInput::default()
};
let provider = test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u);
assert_eq!(
get_stream_alternative_url(
"http://cdn.example/segment.ts?username=other-user&password=other-pass",
&input,
&provider
),
None
);
}
#[test]
fn get_stream_alternative_url_rejects_basic_auth_credentials_for_provider_without_credentials() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8".to_string(),
input_type: InputType::M3u,
..ConfigInput::default()
};
let provider = test_runtime_provider_without_credentials("http://provider.example/playlist.m3u8", InputType::M3u);
assert_eq!(get_stream_alternative_url("http://user:pass@cdn.example/segment.ts", &input, &provider), None);
}
#[test]
fn get_stream_alternative_url_rejects_external_m3u_url_with_unmatched_account_signature() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example/playlist.m3u8?username=source-user&password=source-pass".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::M3u,
..ConfigInput::default()
};
let alias = test_runtime_provider_with_type("http://alias.example", "alias-user", "alias-pass", InputType::M3u);
assert_eq!(
get_stream_alternative_url(
"http://cdn.example/segment.ts?username=other-user&password=other-pass",
&input,
&alias,
),
None
);
}
#[test]
fn get_stream_alternative_url_does_not_passthrough_arbitrary_open_external_url_for_xtream() {
let input = ConfigInput {
name: "source".intern(),
url: "http://provider.example".to_string(),
username: Some("source-user".to_string()),
password: Some("source-pass".to_string()),
input_type: InputType::Xtream,
..ConfigInput::default()
};
let alias = test_runtime_provider("http://alias.example", "alias-user", "alias-pass");
let stream_url = "http://cdn.example/open/playlist.m3u8";
assert_eq!(get_stream_alternative_url(stream_url, &input, &alias), None);
}
#[test]
fn media_server_proxy_response_header_filter_drops_hop_by_hop_headers() {
for name in [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
] {
assert!(is_hop_by_hop_response_header(&HeaderName::from_static(name)));
}
assert!(!is_hop_by_hop_response_header(&header::CONTENT_TYPE));
}
#[test]
fn media_server_image_error_status_classifies_client_and_upstream_failures() {
let parse_error = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
.detail("media server image URL is missing required path parts");
assert_eq!(media_server_image_error_status(&parse_error), StatusCode::BAD_REQUEST);
let not_found = MediaServerError::new(MediaServerErrorKind::MediaServerItemNotFound)
.detail("plex media-server image URL is missing image_path");
assert_eq!(media_server_image_error_status(&not_found), StatusCode::NOT_FOUND);
let upstream = MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed)
.detail("media-server image request failed");
assert_eq!(media_server_image_error_status(&upstream), StatusCode::BAD_GATEWAY);
}
#[test]
fn media_server_playback_urls_are_proxy_only_redirect_guard_candidates() {
let plex_input = ConfigInput { input_type: InputType::Plex, ..ConfigInput::default() };
let emby_input = ConfigInput { input_type: InputType::Emby, ..ConfigInput::default() };
let m3u_input = ConfigInput { input_type: InputType::M3u, ..ConfigInput::default() };
assert!(is_media_server_playback_url(
&plex_input,
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
));
assert!(is_media_server_playback_url(
&m3u_input,
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
));
assert!(is_media_server_playback_url(&plex_input, "https://plex.example/stream.mkv"));
assert!(!is_media_server_playback_url(&emby_input, "https://emby.example/stream.mkv"));
assert!(!is_media_server_playback_url(&m3u_input, "https://provider.example/stream.mkv"));
assert!(!is_media_server_stream_ref_url("https://provider.example/stream.mkv"));
assert!(is_media_server_stream_ref_url("media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"));
assert_eq!(
resolve_request_url_for_logging(
&plex_input,
"media-server://plex/server/rating?part_key=%2Flibrary%2Fparts%2Fredacted"
)
.as_ref(),
"media-server://<redacted>"
);
}
#[test]
fn test_streaming_response_extension_disables_compression() {
let mut response = Response::new(());
mark_response_as_uncompressed(&mut response);
assert!(!should_compress_response(&response));
}
async fn spawn_legacy_hls_test_origin(
response_head: String,
response_body: Vec<u8>,
) -> (SocketAddr, tokio::task::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("test origin binds");
let origin_addr = listener.local_addr().expect("test origin address");
let origin_task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("test origin accepts request");
let mut request = Vec::new();
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let mut chunk = [0_u8; 1024];
let read = socket.read(&mut chunk).await.expect("test origin reads request");
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
}
socket.write_all(response_head.as_bytes()).await.expect("test origin writes response headers");
socket.write_all(&response_body).await.expect("test origin writes response body");
String::from_utf8_lossy(&request).into_owned()
});
(origin_addr, origin_task)
}
async fn forced_legacy_hls_test_response(
origin_addr: SocketAddr,
request_headers: &HeaderMap,
client_port: u16,
) -> axum::response::Response {
let origin_url = format!("http://{origin_addr}/segment.ts");
let input = Arc::new(ConfigInput {
id: 1,
name: "provider_1".intern(),
input_type: InputType::Xtream,
headers: HashMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]),
url: format!("http://{origin_addr}"),
enabled: true,
priority: 0,
max_connections: 1,
method: InputFetchMethod::default(),
..ConfigInput::default()
});
let app_config = create_test_provider_app_config();
app_config.sources.store(Arc::new(SourcesConfig { inputs: vec![Arc::clone(&input)], ..SourcesConfig::default() }));
let app_state = create_test_app_state_for_config(Arc::new(app_config));
let client_addr = SocketAddr::from(([127, 0, 0, 1], client_port));
let fingerprint = create_test_fingerprint(client_addr);
let mut user = ProxyUserCredentials::default();
user.username = "viewer".to_string();
let session = UserSession {
token: format!("legacy-hls-marker-{client_port}"),
transition_version: 1,
virtual_id: 41,
provider: Arc::clone(&input.name),
stream_url: origin_url.as_str().intern(),
provider_session_headers: HashMap::new(),
addr: client_addr,
socket_bound: false,
active_addrs: vec![client_addr],
ts: 1,
started_at: 1,
permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
lifecycle: crate::api::model::PlaybackLifecycle::Active,
};
let mut stream_channel = create_test_local_channel(&origin_url);
stream_channel.provider_id = u32::from(input.id);
stream_channel.input_name = Arc::clone(&input.name);
stream_channel.item_type = PlaylistItemType::Catchup;
stream_channel.cluster = XtreamCluster::Live;
stream_channel.url = origin_url.as_str().intern();
force_provider_stream_response(
&fingerprint,
&app_state,
&session,
stream_channel,
ForceStreamRequestContext {
req_headers: request_headers,
input: &input,
user: &user,
session_reservation_ttl_secs: 0,
content_representation: crate::api::model::ProviderContentRepresentationMode::Identity,
},
None,
)
.await
.into_response()
}
#[tokio::test]
async fn forced_hls_provider_response_disables_compression_and_streams_identity_bytes() {
const IDENTITY_BODY: &[u8] = b"legacy hls identity segment";
let mut encoder = async_compression::tokio::write::GzipEncoder::new(Vec::new());
encoder.write_all(IDENTITY_BODY).await.expect("gzip test body encodes");
encoder.shutdown().await.expect("gzip test encoder finishes");
let encoded_body = encoder.into_inner();
let response_head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
encoded_body.len()
);
let (origin_addr, origin_task) = spawn_legacy_hls_test_origin(response_head, encoded_body).await;
let mut request_headers = HeaderMap::new();
request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
let response = forced_legacy_hls_test_response(origin_addr, &request_headers, 55_310).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(!should_compress_response(&response));
assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
assert!(!response.headers().contains_key(header::CONTENT_LENGTH));
let body = response.into_body().collect().await.expect("legacy HLS response body").to_bytes();
assert_eq!(body.as_ref(), IDENTITY_BODY);
let request = origin_task.await.expect("test origin task completes").to_ascii_lowercase();
assert!(request.contains("\r\naccept-encoding: identity\r\n"));
}
#[tokio::test]
async fn forced_hls_unencoded_partial_response_preserves_range_and_disables_compression() {
const PARTIAL_BODY: &[u8] = b"cdef";
let response_head = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Type: video/mp2t\r\nContent-Range: bytes 2-5/10\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
PARTIAL_BODY.len()
);
let (origin_addr, origin_task) = spawn_legacy_hls_test_origin(response_head, PARTIAL_BODY.to_vec()).await;
let mut request_headers = HeaderMap::new();
request_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
request_headers.insert(header::RANGE, HeaderValue::from_static("bytes=2-"));
let response = forced_legacy_hls_test_response(origin_addr, &request_headers, 55_311).await;
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert!(!should_compress_response(&response));
assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes 2-5/10");
assert_eq!(response.headers()[header::CONTENT_LENGTH], "4");
assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
let body = response.into_body().collect().await.expect("legacy HLS partial body").to_bytes();
assert_eq!(body.as_ref(), PARTIAL_BODY);
let request = origin_task.await.expect("test origin task completes").to_ascii_lowercase();
assert!(request.contains("\r\naccept-encoding: identity\r\n"));
assert!(request.contains("\r\nrange: bytes=2-\r\n"));
}
#[test]
fn test_regular_response_keeps_compression_enabled() {
let response = Response::new(());
assert!(should_compress_response(&response));
}
#[test]
fn test_get_stream_config_u64_uses_default_when_stream_config_missing() {
assert_eq!(
resolve_stream_config_u64(None, |stream| stream.hls_session_ttl_secs, default_hls_session_ttl_secs()),
default_hls_session_ttl_secs()
);
assert_eq!(
resolve_stream_config_u64(None, |stream| stream.catchup_session_ttl_secs, default_catchup_session_ttl_secs()),
default_catchup_session_ttl_secs()
);
}
#[tokio::test]
async fn test_get_session_reservation_ttl_secs_uses_hls_ttl_for_live_dash() {
let app_state = create_test_app_state();
assert_eq!(
get_session_reservation_ttl_secs(&app_state, PlaylistItemType::LiveDash),
default_hls_session_ttl_secs()
);
}
#[test]
fn provider_affinity_policy_matches_stream_types() {
assert!(!PlaylistItemType::Live.requires_provider_affinity());
assert!(!PlaylistItemType::LiveUnknown.requires_provider_affinity());
assert!(PlaylistItemType::LiveHls.requires_provider_affinity());
assert!(PlaylistItemType::LiveDash.requires_provider_affinity());
assert!(PlaylistItemType::Video.requires_provider_affinity());
assert!(PlaylistItemType::Series.requires_provider_affinity());
assert!(PlaylistItemType::Catchup.requires_provider_affinity());
}
#[tokio::test]
async fn resolve_streaming_strategy_honors_forced_provider_fallback_policy() {
let app_state = create_test_dual_provider_app_state();
let input_name = "provider_1".intern();
let input =
app_state.app_config.sources.load().get_input_by_name(&input_name).cloned().unwrap_or_else(|| unreachable!());
let pinned_provider = "provider_1".intern();
let busy_addr: SocketAddr = "127.0.0.1:55301".parse().unwrap_or_else(|_| unreachable!());
let strict_addr: SocketAddr = "127.0.0.1:55302".parse().unwrap_or_else(|_| unreachable!());
let fallback_addr: SocketAddr = "127.0.0.1:55303".parse().unwrap_or_else(|_| unreachable!());
let stream_url = "http://provider-1.example/movie/user1/pass1/1.mkv";
let busy = app_state
.active_provider
.acquire_exact_connection_with_grace(
&pinned_provider,
&busy_addr,
false,
0,
crate::api::model::ConnectionKind::Normal,
)
.await;
assert!(busy.is_some(), "setup should occupy the pinned provider");
let strict = resolve_streaming_strategy(
&app_state,
stream_url,
&create_test_fingerprint(strict_addr),
&input,
StreamingAcquireOptions {
force_provider: Some(&pinned_provider),
allow_forced_provider_fallback: false,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("vod-session"),
accept_requested_stream_url: false,
},
)
.await;
assert!(strict.provider_handle.is_none(), "strict provider affinity should not allocate a different provider");
assert!(
matches!(
strict.provider_stream_state,
ProviderStreamState::Custom { reason: ProviderStreamCustomReason::ProviderExhausted, .. }
),
"strict provider affinity should fail closed when the pinned provider is unavailable"
);
let fallback = resolve_streaming_strategy(
&app_state,
stream_url,
&create_test_fingerprint(fallback_addr),
&input,
StreamingAcquireOptions {
force_provider: Some(&pinned_provider),
allow_forced_provider_fallback: true,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("live-session"),
accept_requested_stream_url: false,
},
)
.await;
let (ProviderStreamState::Available(Some(fallback_provider), _)
| ProviderStreamState::GracePeriod(Some(fallback_provider), _)) = fallback.provider_stream_state
else {
panic!("fallback-enabled request should allocate a provider")
};
assert_eq!(fallback_provider.as_ref(), "provider_2");
app_state.active_provider.release_connection(&busy_addr).await;
app_state.active_provider.release_connection(&strict_addr).await;
app_state.active_provider.release_connection(&fallback_addr).await;
}
#[tokio::test]
async fn resolve_streaming_strategy_rewrites_stale_alias_url_to_selected_main_provider() {
let app_state = create_test_dual_provider_app_state();
let input_name = "provider_1".intern();
let input =
app_state.app_config.sources.load().get_input_by_name(&input_name).cloned().unwrap_or_else(|| unreachable!());
let addr: SocketAddr = "127.0.0.1:55304".parse().unwrap_or_else(|_| unreachable!());
let strategy = resolve_streaming_strategy(
&app_state,
"http://provider-2.example/live/user2/pass2/100.ts",
&create_test_fingerprint(addr),
&input,
StreamingAcquireOptions {
force_provider: None,
allow_forced_provider_fallback: false,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("live-session"),
accept_requested_stream_url: false,
},
)
.await;
let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else {
panic!("request should allocate the main provider")
};
assert_eq!(provider.as_ref(), "provider_1");
assert_eq!(url.as_ref(), "http://provider-1.example/live/user1/pass1/100.ts");
app_state.active_provider.release_connection(&addr).await;
}
#[tokio::test]
async fn resolve_streaming_strategy_rewrites_opaque_m3u_token_after_alias_allocation() {
let app_config = create_test_dual_provider_app_config();
let input = Arc::new(ConfigInput {
id: 1,
name: "provider-a".intern(),
input_type: InputType::M3u,
url: "http://playlist.example/a.m3u?token=provider-a-token".to_string(),
enabled: true,
max_connections: 1,
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "provider-b".intern(),
url: "http://playlist.example/b.m3u?token=provider-b-token".to_string(),
username: None,
password: None,
max_connections: 0,
priority: 1,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
});
app_config.sources.store(Arc::new(SourcesConfig { inputs: vec![Arc::clone(&input)], ..SourcesConfig::default() }));
let app_state = create_test_app_state_for_config(Arc::new(app_config));
let provider_a = "provider-a".intern();
let busy_addr: SocketAddr = "127.0.0.1:55306".parse().unwrap_or_else(|_| unreachable!());
let alias_addr: SocketAddr = "127.0.0.1:55307".parse().unwrap_or_else(|_| unreachable!());
let busy = app_state
.active_provider
.acquire_exact_connection_with_grace(
&provider_a,
&busy_addr,
false,
0,
crate::api::model::ConnectionKind::Normal,
)
.await;
assert!(busy.is_some(), "setup should occupy provider A");
let strategy = resolve_streaming_strategy(
&app_state,
"http://stream.example/channel/segment.ts?token=provider-a-token",
&create_test_fingerprint(alias_addr),
&input,
StreamingAcquireOptions {
force_provider: None,
allow_forced_provider_fallback: false,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("live-session"),
accept_requested_stream_url: false,
},
)
.await;
let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else {
panic!("request should allocate provider B")
};
assert_eq!(provider.as_ref(), "provider-b");
assert_eq!(url.as_ref(), "http://stream.example/channel/segment.ts?token=provider-b-token");
app_state.active_provider.release_connection(&busy_addr).await;
app_state.active_provider.release_connection(&alias_addr).await;
}
#[tokio::test]
async fn resolve_streaming_strategy_rejects_unmapped_provider_url() {
let app_state = create_test_dual_provider_app_state();
let input_name = "provider_1".intern();
let input =
app_state.app_config.sources.load().get_input_by_name(&input_name).cloned().unwrap_or_else(|| unreachable!());
let addr: SocketAddr = "127.0.0.1:55305".parse().unwrap_or_else(|_| unreachable!());
let strategy = resolve_streaming_strategy(
&app_state,
"http://unmapped.example/live/user1/pass1/100.ts",
&create_test_fingerprint(addr),
&input,
StreamingAcquireOptions {
force_provider: None,
allow_forced_provider_fallback: false,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("live-session"),
accept_requested_stream_url: false,
},
)
.await;
assert!(strategy.provider_handle.is_none());
assert!(matches!(
strategy.provider_stream_state,
ProviderStreamState::Custom { reason: ProviderStreamCustomReason::UnmappedProviderUrl, .. }
));
app_state.active_provider.release_connection(&addr).await;
}
#[tokio::test]
async fn resolve_streaming_strategy_accepts_stalker_portal_url() {
let app_config = create_test_provider_app_config();
let Some(configured_input) = app_config.sources.load().inputs.first().cloned() else { unreachable!() };
let mut stalker_input = (*configured_input).clone();
stalker_input.input_type = InputType::Stalker;
stalker_input.username = None;
stalker_input.password = None;
app_config
.sources
.store(Arc::new(SourcesConfig { inputs: vec![Arc::new(stalker_input)], ..SourcesConfig::default() }));
let app_state = create_test_app_state_for_config(Arc::new(app_config));
let input_name = "provider_1".intern();
let input =
app_state.app_config.sources.load().get_input_by_name(&input_name).cloned().unwrap_or_else(|| unreachable!());
let addr: SocketAddr = "127.0.0.1:55307".parse().unwrap_or_else(|_| unreachable!());
let stream_url = "http://line.example/play/live.php?mac=00:11:22:33:44:55&stream=347&extension=ts&play_token=abc";
let strategy = resolve_streaming_strategy(
&app_state,
stream_url,
&create_test_fingerprint(addr),
&input,
StreamingAcquireOptions {
force_provider: None,
allow_forced_provider_fallback: false,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("live-session"),
accept_requested_stream_url: false,
},
)
.await;
let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else { unreachable!() };
assert_eq!(provider.as_ref(), "provider_1");
assert_eq!(url.as_ref(), stream_url);
app_state.active_provider.release_connection(&addr).await;
}
#[tokio::test]
async fn resolve_streaming_strategy_accepts_session_requested_stream_url() {
let app_state = create_test_dual_provider_app_state();
let input_name = "provider_1".intern();
let input =
app_state.app_config.sources.load().get_input_by_name(&input_name).cloned().unwrap_or_else(|| unreachable!());
let addr: SocketAddr = "127.0.0.1:55306".parse().unwrap_or_else(|_| unreachable!());
let trusted_url = "http://unmapped.example/live/user1/pass1/100.ts";
let strategy = resolve_streaming_strategy(
&app_state,
trusted_url,
&create_test_fingerprint(addr),
&input,
StreamingAcquireOptions {
force_provider: None,
allow_forced_provider_fallback: false,
allow_provider_grace: false,
user_priority: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
session_owner: Some("live-session"),
accept_requested_stream_url: true,
},
)
.await;
let ProviderStreamState::Available(Some(provider), url) = strategy.provider_stream_state else {
panic!("session-requested URL should be accepted for the pinned provider")
};
assert_eq!(provider.as_ref(), "provider_1");
assert_eq!(url.as_ref(), trusted_url);
app_state.active_provider.release_connection(&addr).await;
}
#[test]
fn test_should_allow_exhausted_shared_reconnect_only_for_matching_shared_session() {
let session = UserSession {
transition_version: 1,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
token: "tok".to_string(),
virtual_id: 282,
provider: Arc::<str>::from("provider"),
stream_url: Arc::<str>::from("http://provider/live/449924.ts"),
provider_session_headers: HashMap::new(),
addr: "127.0.0.1:1234".parse().unwrap_or_else(|_| unreachable!()),
socket_bound: false,
active_addrs: vec!["127.0.0.1:1234".parse().unwrap_or_else(|_| unreachable!())],
ts: 1,
started_at: 1,
permission: UserConnectionPermission::Allowed,
lifecycle: crate::api::model::PlaybackLifecycle::Active,
};
assert!(should_allow_exhausted_shared_reconnect(true, Some(&session), 282, "http://provider/live/449924.ts"));
assert!(!should_allow_exhausted_shared_reconnect(false, Some(&session), 282, "http://provider/live/449924.ts"));
assert!(!should_allow_exhausted_shared_reconnect(true, Some(&session), 999, "http://provider/live/449924.ts"));
assert!(!should_allow_exhausted_shared_reconnect(true, Some(&session), 282, "http://provider/live/other.ts"));
}
fn create_test_app_config() -> AppConfig {
let input = Arc::new(ConfigInput {
id: 1,
name: "local_media".intern(),
input_type: InputType::Library,
headers: HashMap::default(),
url: "file:///tmp".to_string(),
enabled: true,
priority: 0,
max_connections: 1,
method: InputFetchMethod::default(),
aliases: None,
..ConfigInput::default()
});
let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() };
AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config::default())),
sources: Arc::new(ArcSwap::from_pointee(sources)),
hdhomerun: Arc::new(ArcSwapOption::default()),
api_proxy: Arc::new(ArcSwapOption::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
fn create_test_provider_app_config() -> AppConfig {
let input = Arc::new(ConfigInput {
id: 1,
name: "provider_1".intern(),
input_type: InputType::Xtream,
headers: HashMap::default(),
url: "http://provider-1.example".to_string(),
username: Some("user1".to_string()),
password: Some("pass1".to_string()),
enabled: true,
priority: 0,
max_connections: 1,
method: InputFetchMethod::default(),
aliases: None,
..ConfigInput::default()
});
let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() };
AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config::default())),
sources: Arc::new(ArcSwap::from_pointee(sources)),
hdhomerun: Arc::new(ArcSwapOption::default()),
api_proxy: Arc::new(ArcSwapOption::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
fn create_test_dual_provider_app_config() -> AppConfig {
let input = Arc::new(ConfigInput {
id: 1,
name: "provider_1".intern(),
input_type: InputType::Xtream,
headers: HashMap::default(),
url: "http://provider-1.example".to_string(),
username: Some("user1".to_string()),
password: Some("pass1".to_string()),
enabled: true,
priority: 0,
max_connections: 1,
method: InputFetchMethod::default(),
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "provider_2".intern(),
url: "http://provider-2.example".to_string(),
username: Some("user2".to_string()),
password: Some("pass2".to_string()),
priority: 1,
max_connections: 1,
exp_date: None,
enabled: true,
stalker: None,
}]),
..ConfigInput::default()
});
let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() };
AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config::default())),
sources: Arc::new(ArcSwap::from_pointee(sources)),
hdhomerun: Arc::new(ArcSwapOption::default()),
api_proxy: Arc::new(ArcSwapOption::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
fn create_test_app_state() -> Arc<AppState> { create_test_app_state_for_config(Arc::new(create_test_app_config())) }
#[tokio::test]
async fn create_api_proxy_user_defaults_output_clusters_to_all() {
let app_state = create_test_app_state();
let user = create_api_proxy_user(&app_state);
assert_eq!(user.output_clusters, ClusterFlags::all());
}
fn create_test_provider_app_state() -> Arc<AppState> {
create_test_app_state_for_config(Arc::new(create_test_provider_app_config()))
}
fn create_test_dual_provider_app_state() -> Arc<AppState> {
create_test_app_state_for_config(Arc::new(create_test_dual_provider_app_config()))
}
fn create_test_app_state_for_config(app_cfg: Arc<AppConfig>) -> Arc<AppState> {
let event_manager = Arc::new(EventManager::new());
let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager));
let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider)));
let history_config = Some(StreamHistoryConfig::default());
active_provider.set_shared_stream_manager(Arc::clone(&shared_stream_manager));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
let config = app_cfg.config.load();
let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager));
let connection_manager = Arc::new(ConnectionManager::new(
&active_users,
&active_provider,
&shared_stream_manager,
&event_manager,
history_config.as_ref(),
));
let tokens = CancelTokens::default();
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
let (manual_update_sender, _) = mpsc::channel::<crate::api::model::ManualPlaylistUpdateRequest>(1);
Arc::new(AppState {
forced_targets: Arc::new(ArcSwap::from_pointee(ProcessTargets {
enabled: false,
inputs: Vec::new(),
targets: Vec::new(),
target_names: Vec::new(),
})),
app_config: app_cfg,
http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())),
http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())),
public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())),
downloads: Arc::new(crate::api::model::DownloadQueue::new()),
cache: Arc::new(ArcSwapOption::default()),
shared_stream_manager,
hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()),
hls_provisioning: Arc::new(crate::api::model::HlsProvisioningState::new()),
stalker_resolve_coordinator: Arc::default(),
active_users,
active_provider,
connection_manager,
event_manager,
cancel_tokens: Arc::new(ArcSwap::from_pointee(tokens)),
playlists: Arc::new(PlaylistStorageState::new()),
geoip,
update_guard: crate::api::model::UpdateGuard::new(),
metadata_manager,
identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty(
std::path::PathBuf::new(),
)),
login_throttle: Arc::new(crate::auth::LoginThrottle::new()),
token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty(
std::path::PathBuf::new(),
)),
manual_update_sender,
})
}
fn create_test_fingerprint(addr: std::net::SocketAddr) -> Fingerprint {
Fingerprint::new(format!("fp-{addr}"), addr.ip().to_string(), addr)
}
fn create_test_fingerprint_with_user_agent(addr: std::net::SocketAddr, user_agent: &str) -> Fingerprint {
Fingerprint::new(format!("{}|{user_agent}", addr.ip()), addr.ip().to_string(), addr)
}
fn create_test_app_state_with_stream_config(stream: crate::model::StreamConfig) -> Arc<AppState> {
let config = Config {
reverse_proxy: Some(crate::model::ReverseProxyConfig {
resource_rewrite_disabled: false,
rewrite_secret: [0; 16],
resource_retry: crate::model::ResourceRetryConfig::default(),
disabled_header: None,
stream: Some(stream),
cache: None,
rate_limit: None,
geoip: None,
stream_history: None,
qos_aggregation: None,
hls_cache: None,
}),
user_access_control: true,
..Config::default()
};
let mut app_cfg = create_test_app_config();
app_cfg.config = Arc::new(ArcSwap::from_pointee(config));
create_test_app_state_for_config(Arc::new(app_cfg))
}
fn create_test_local_channel(url: &str) -> StreamChannel {
StreamChannel {
target_id: 1,
virtual_id: 41,
provider_id: 0,
input_name: "library".intern(),
item_type: PlaylistItemType::LocalVideo,
cluster: XtreamCluster::Video,
group: "Local Movies".intern(),
title: "Local Test".intern(),
url: url.into(),
shared: false,
shared_joined_existing: None,
shared_stream_id: None,
technical: None,
epg_channel_id: None,
epg_reference_ts: None,
upstream_user_agent: None,
}
}
fn create_test_live_channel(url: &str) -> StreamChannel {
StreamChannel {
target_id: 1,
virtual_id: 42,
provider_id: 1,
input_name: "provider_1".intern(),
item_type: PlaylistItemType::Live,
cluster: XtreamCluster::Live,
group: "Live".intern(),
title: "Shared Live".intern(),
url: url.into(),
shared: false,
shared_joined_existing: None,
shared_stream_id: None,
technical: None,
epg_channel_id: None,
epg_reference_ts: None,
upstream_user_agent: None,
}
}
fn create_test_session(
token: &str,
item_type: PlaylistItemType,
lifecycle: crate::api::model::PlaybackLifecycle,
) -> UserSession {
UserSession {
token: token.to_string(),
transition_version: 1,
virtual_id: 42,
provider: Arc::<str>::from("provider-a"),
stream_url: Arc::<str>::from(match item_type {
PlaylistItemType::LiveHls => "http://provider-1.example/live/42.m3u8",
_ => "http://provider-1.example/live/42.ts",
}),
provider_session_headers: HashMap::new(),
addr: "127.0.0.1:55555".parse().unwrap_or_else(|_| unreachable!()),
socket_bound: item_type.uses_socket_bound_session(),
active_addrs: Vec::new(),
ts: 1,
started_at: 1,
permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
lifecycle,
}
}
#[test]
fn classify_playback_request_marks_adaptive_playlist_request_as_prepare() {
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: None,
prepare_only: true,
terminate: false,
});
assert_eq!(request_class, PlaybackRequestClass::Prepare);
}
#[test]
fn classify_playback_request_marks_preserved_session_as_activate() {
let session = create_test_session(
"tok-preserved",
PlaylistItemType::LiveHls,
crate::api::model::PlaybackLifecycle::Preserved,
);
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: Some(&session),
prepare_only: false,
terminate: false,
});
assert_eq!(request_class, PlaybackRequestClass::Activate);
}
#[test]
fn classify_playback_request_marks_counted_session_as_follow_up() {
let session =
create_test_session("tok-active", PlaylistItemType::LiveHls, crate::api::model::PlaybackLifecycle::Active);
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: Some(&session),
prepare_only: false,
terminate: false,
});
assert_eq!(request_class, PlaybackRequestClass::FollowUp);
}
/// `PendingProvider` must NOT be classified as `FollowUp`.
/// `PendingProvider` has no counted lease yet — the session is still waiting
/// for a provider slot. A new request on a `PendingProvider` session should
/// be `Activate` so that full admission evaluation happens, not a cheap
/// `FollowUp` skip.
#[test]
fn classify_playback_request_marks_pending_provider_as_activate_not_follow_up() {
let session = create_test_session(
"tok-pending",
PlaylistItemType::LiveHls,
crate::api::model::PlaybackLifecycle::PendingProvider {
data: crate::api::model::PendingProviderState {
reason_code: crate::api::model::PendingProviderReason::GraceHold,
created_at: 1,
deadline: 30,
version: 1,
wake_source: None,
},
},
);
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: Some(&session),
prepare_only: false,
terminate: false,
});
assert_eq!(
request_class,
PlaybackRequestClass::Activate,
"PendingProvider should not be FollowUp - it has no counted lease yet"
);
}
/// `Active` without a counted lease must NOT be classified as `FollowUp`.
/// `FollowUp` should only be returned when the session actually owns a
/// counted admission lease. A session with `Active` lifecycle but no counted
/// lease should go through `Activate` so that the counted lease is reacquired.
#[test]
fn classify_playback_request_marks_active_without_counted_as_activate_not_follow_up() {
let mut session = create_test_session(
"tok-active-uncounted",
PlaylistItemType::LiveHls,
crate::api::model::PlaybackLifecycle::Active, // counted=false via is_counted()
);
// Manually force counted=false by setting to Prepared lifecycle, then restoring
// Note: is_counted() returns false for Prepared, true for Active
// For this test we need a session that is Active lifecycle but not counted
// The new model derives counted from lifecycle, so we must use a different lifecycle
// to represent "not counted". Use Prepared instead.
session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared;
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: Some(&session),
prepare_only: false,
terminate: false,
});
assert_eq!(
request_class,
PlaybackRequestClass::Activate,
"Active session with counted=false should not be FollowUp"
);
}
/// Prepared sessions must be classified as Activate.
#[test]
fn classify_playback_request_marks_prepared_session_as_activate() {
let session =
create_test_session("tok-prepared", PlaylistItemType::LiveHls, crate::api::model::PlaybackLifecycle::Prepared);
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: Some(&session),
prepare_only: false,
terminate: false,
});
assert_eq!(request_class, PlaybackRequestClass::Activate);
}
/// `GraceActive` without counted lease must NOT be classified as `FollowUp`.
#[test]
fn classify_playback_request_marks_grace_active_without_counted_as_activate() {
let mut session = create_test_session(
"tok-grace-uncounted",
PlaylistItemType::LiveHls,
crate::api::model::PlaybackLifecycle::Active, // is_counted() = true for GraceActive
);
// Test scenario: session has GraceActive lifecycle but we need it NOT counted
// This represents the edge case before grace task resolves. Use Prepared lifecycle
// to model "not counted" since is_counted() returns false for Prepared.
session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared;
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: Some(&session),
prepare_only: false,
terminate: false,
});
assert_eq!(
request_class,
PlaybackRequestClass::Activate,
"GraceActive session with counted=false should not be FollowUp"
);
}
#[tokio::test]
async fn activate_session_before_stream_open_skips_placeholder_for_follow_up_session() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]),
});
let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "follow-up-user".to_string();
user.max_connections = 1;
let mut channel = create_test_live_channel("http://provider-1.example/live/55220.m3u8");
channel.item_type = PlaylistItemType::LiveHls;
channel.virtual_id = 55220;
app_state.connection_manager.add_connection(&addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-follow-up",
virtual_id: channel.virtual_id,
provider: input.name.as_ref(),
stream_url: channel.url.as_ref(),
addr: &addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 10,
fingerprint: &fingerprint,
provider: input.name.clone(),
stream_channel: &channel,
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-follow-up"),
})
.await;
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-follow-up",
request_class: None,
virtual_id: VirtualId::new(channel.virtual_id),
item_type: PlaylistItemType::LiveHls,
stream_url: channel.url.as_ref(),
connection_permission: UserConnectionPermission::Allowed,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: true,
},
)
.await;
assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed);
assert_eq!(activation.admission.kind, Some(crate::api::model::ConnectionKind::Normal));
assert_eq!(activation.grace_mode, None);
assert!(
activation.placeholder_transition_version.is_none(),
"follow-up activation must not create a placeholder session"
);
}
#[tokio::test]
async fn activate_session_before_stream_open_revalidates_precomputed_follow_up_request_class() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]),
});
let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "precomputed-follow-up-user".to_string();
user.max_connections = 1;
let mut channel = create_test_live_channel("http://provider-1.example/live/55221.m3u8");
channel.item_type = PlaylistItemType::LiveHls;
channel.virtual_id = 55221;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-precomputed-follow-up",
virtual_id: channel.virtual_id,
provider: input.name.as_ref(),
stream_url: channel.url.as_ref(),
addr: &addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-precomputed-follow-up",
request_class: Some(PlaybackRequestClass::FollowUp),
virtual_id: VirtualId::new(channel.virtual_id),
item_type: PlaylistItemType::LiveHls,
stream_url: channel.url.as_ref(),
connection_permission: UserConnectionPermission::Allowed,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: true,
},
)
.await;
assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed);
assert_eq!(activation.admission.kind, Some(crate::api::model::ConnectionKind::Normal));
assert_eq!(activation.grace_mode, None);
assert!(
activation.placeholder_transition_version.is_some(),
"precomputed FollowUp must be revalidated against the current uncounted lifecycle"
);
}
// stale FollowUp revalidation
#[tokio::test]
async fn activate_session_before_stream_open_stale_follow_up_reclassified_on_counted_lease_release() {
// Scenario: pre-computed FollowUp, but session's counted lease was released before
// the guard was acquired. Must reclassify to Activate so admission runs.
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]),
});
let addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "stale-followup-user".to_string();
user.max_connections = 1;
let mut channel = create_test_live_channel("http://provider-1.example/live/55230.m3u8");
channel.item_type = PlaylistItemType::LiveHls;
channel.virtual_id = 55230;
// Session created in Active (counted) state.
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-stale-followup",
virtual_id: channel.virtual_id,
provider: input.name.as_ref(),
stream_url: channel.url.as_ref(),
addr: &addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
// Simulate the counted lease being released before activation:
// expire the session so it no longer has a counted lease.
app_state.active_users.terminate_session(&user.username, "tok-stale-followup").await;
// Call activate with stale FollowUp. Must NOT skip admission — reclassification
// to Activate must run so the placeholder is created.
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-stale-followup",
request_class: Some(PlaybackRequestClass::FollowUp),
virtual_id: VirtualId::new(channel.virtual_id),
item_type: PlaylistItemType::LiveHls,
stream_url: channel.url.as_ref(),
connection_permission: UserConnectionPermission::Allowed,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: true,
},
)
.await;
// Must NOT skip — placeholder must be created since session is expired.
assert!(
activation.placeholder_transition_version.is_some(),
"stale FollowUp with expired session must run admission and create placeholder"
);
}
// pre-resolved Grace materialization
#[tokio::test]
async fn activate_session_before_stream_open_pre_resolved_grace_period_materializes_pending_provider() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
});
let addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "pre-resolved-grace-user".to_string();
user.max_connections = 1;
let mut channel = create_test_live_channel("http://provider-1.example/live/55231.m3u8");
channel.item_type = PlaylistItemType::LiveHls;
channel.virtual_id = 55231;
// Session in Prepared state (no grace lifecycle yet).
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-pre-resolved-grace",
virtual_id: channel.virtual_id,
provider: input.name.as_ref(),
stream_url: channel.url.as_ref(),
addr: &addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
// Call activation with pre-resolved GracePeriod permission.
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-pre-resolved-grace",
request_class: None,
virtual_id: VirtualId::new(channel.virtual_id),
item_type: PlaylistItemType::LiveHls,
stream_url: channel.url.as_ref(),
connection_permission: UserConnectionPermission::GracePeriod,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: true,
},
)
.await;
assert_eq!(activation.admission.permission, UserConnectionPermission::GracePeriod);
assert_eq!(activation.grace_mode, Some(crate::api::model::GraceMode::Hold));
let session = app_state.active_users.get_and_update_user_session(&user.username, "tok-pre-resolved-grace").await;
assert!(
session.is_some_and(|s| matches!(s.lifecycle, crate::api::model::PlaybackLifecycle::PendingProvider { .. })),
"pre-resolved GracePeriod must materialize as PendingProvider lifecycle"
);
}
/// `activate_session_before_stream_open` skips placeholder for Prepare class.
#[tokio::test]
async fn activate_session_before_stream_open_skips_placeholder_for_prepare() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]),
});
let addr: SocketAddr = "127.0.0.1:55222".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "prepare-user".to_string();
user.max_connections = 1;
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-prepare",
// Explicitly pass Prepare class — placeholder and admission should be skipped.
request_class: Some(PlaybackRequestClass::Prepare),
virtual_id: VirtualId::new(55222),
item_type: PlaylistItemType::LiveHls,
stream_url: "http://provider.example/live/test.ts",
connection_permission: UserConnectionPermission::Allowed,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: true,
},
)
.await;
// Prepare returns Allowed without running admission strategies.
assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed);
assert_eq!(activation.grace_mode, None);
assert!(
activation.placeholder_transition_version.is_none(),
"Prepare activation must not create a placeholder session"
);
}
/// `resolve_playback_request_admission` with `prepare_only = true` returns `Prepare` class.
#[tokio::test]
async fn resolve_playback_request_admission_prepare_only_returns_prepare_class() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
});
let addr: SocketAddr = "127.0.0.1:55223".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let mut user = ProxyUserCredentials::default();
user.username = "prepare-only-user".to_string();
user.max_connections = 1;
let (admission, grace_mode, request_class) = resolve_playback_request_admission(
&app_state.admission_ctx(),
&user,
&fingerprint,
None,
"tok-prepare-only",
false,
EvictionReentryGuard::Session("tok-prepare-only"),
true, // prepare_only
false, // terminate
)
.await;
assert_eq!(request_class, PlaybackRequestClass::Prepare);
// Prepare returns Allowed without running strategies.
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert_eq!(grace_mode, None);
}
/// `resolve_playback_request_admission` with `terminate = true` returns `Terminate` class
/// and calls `terminate_session` on the existing session.
#[tokio::test]
async fn resolve_playback_request_admission_terminate_returns_terminate_class() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
});
let addr: SocketAddr = "127.0.0.1:55224".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let mut user = ProxyUserCredentials::default();
user.username = "terminate-user".to_string();
user.max_connections = 2;
// First create a session.
let session_token = "tok-terminate";
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token,
virtual_id: 55224,
provider: "test-provider",
stream_url: "http://provider.example/test.ts",
addr: &addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
// Verify session exists.
let before = app_state.active_users.get_and_update_user_session(&user.username, session_token).await;
assert!(before.is_some(), "session should exist before terminate");
let (admission, grace_mode, request_class) = resolve_playback_request_admission(
&app_state.admission_ctx(),
&user,
&fingerprint,
before.as_ref(),
session_token,
false,
EvictionReentryGuard::Session(session_token),
false, // prepare_only
true, // terminate
)
.await;
assert_eq!(request_class, PlaybackRequestClass::Terminate);
assert_eq!(admission.permission, UserConnectionPermission::Exhausted);
assert_eq!(grace_mode, None);
// Session should be expired after terminate.
let after = app_state.active_users.get_and_update_user_session(&user.username, session_token).await;
assert!(after.is_none(), "session should be removed after terminate");
}
/// `classify_playback_request` returns `Terminate` when `terminate = true`.
#[test]
fn classify_playback_request_returns_terminate_when_flag_set() {
let request_class = classify_playback_request(PlaybackRequestFacts {
existing_session: None,
prepare_only: false,
terminate: true,
});
assert_eq!(request_class, PlaybackRequestClass::Terminate);
}
#[tokio::test]
async fn activate_session_before_stream_open_marks_pending_provider_for_grace_hold() {
let stream_cfg = crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
};
let mut app_cfg = create_test_app_config();
app_cfg.config = Arc::new(ArcSwap::from_pointee(Config {
user_access_control: true,
reverse_proxy: Some(crate::model::ReverseProxyConfig {
resource_rewrite_disabled: false,
rewrite_secret: [0; 16],
resource_retry: crate::model::ResourceRetryConfig::default(),
disabled_header: None,
stream: Some(stream_cfg),
cache: None,
rate_limit: None,
geoip: None,
stream_history: None,
qos_aggregation: None,
hls_cache: None,
}),
..Config::default()
}));
let app_state = create_test_app_state_for_config(Arc::new(app_cfg));
let first_addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!());
let second_addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!());
let first_fingerprint = create_test_fingerprint(first_addr);
let second_fingerprint = create_test_fingerprint(second_addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "grace-hold-user".to_string();
user.max_connections = 1;
let first_channel = create_test_live_channel("http://provider-1.example/live/1.ts");
let mut second_channel = create_test_live_channel("http://provider-1.example/live/2.m3u8");
second_channel.item_type = PlaylistItemType::LiveHls;
second_channel.virtual_id = 55231;
app_state.connection_manager.add_connection(&first_addr).await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 10,
fingerprint: &first_fingerprint,
provider: input.name.clone(),
stream_channel: &first_channel,
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-first"),
})
.await;
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &second_fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-grace-hold",
request_class: None,
virtual_id: VirtualId::new(second_channel.virtual_id),
item_type: PlaylistItemType::LiveHls,
stream_url: second_channel.url.as_ref(),
connection_permission: UserConnectionPermission::Allowed,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: false,
},
)
.await;
assert_eq!(activation.admission.permission, UserConnectionPermission::GracePeriod);
assert_eq!(activation.grace_mode, Some(crate::api::model::GraceMode::Hold));
let session = app_state
.active_users
.get_and_update_user_session(&user.username, "tok-grace-hold")
.await
.expect("placeholder session should exist");
let crate::api::model::PlaybackLifecycle::PendingProvider { data: pending } = &session.lifecycle else {
panic!("grace hold should mark pending provider state")
};
assert!(matches!(pending.reason_code, crate::api::model::PendingProviderReason::GraceHold));
assert!(pending.deadline >= pending.created_at);
assert_eq!(app_state.active_users.user_connections(&user.username).await, 1);
assert!(
!session.lifecycle.is_counted(),
"pending provider placeholder must not consume an active user lease before commit"
);
}
#[tokio::test]
async fn activate_session_before_stream_open_does_not_commit_user_lease_before_provider_success() {
let stream_cfg = crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 0,
grace_period_timeout_secs: 8,
grace_period_hold_stream: false,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: None,
};
let mut app_cfg = create_test_app_config();
app_cfg.config = Arc::new(ArcSwap::from_pointee(Config {
user_access_control: true,
reverse_proxy: Some(crate::model::ReverseProxyConfig {
resource_rewrite_disabled: false,
rewrite_secret: [0; 16],
resource_retry: crate::model::ResourceRetryConfig::default(),
disabled_header: None,
stream: Some(stream_cfg),
cache: None,
rate_limit: None,
geoip: None,
stream_history: None,
qos_aggregation: None,
hls_cache: None,
}),
..Config::default()
}));
let app_state = create_test_app_state_for_config(Arc::new(app_cfg));
let addr: SocketAddr = "127.0.0.1:55232".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input = app_state.app_config.sources.load().inputs[0].clone();
let mut user = ProxyUserCredentials::default();
user.username = "atomic-commit-user".to_string();
user.max_connections = 1;
let channel = create_test_live_channel("http://provider-1.example/live/3.ts");
let activation = activate_session_before_stream_open(
&app_state,
SessionActivationRequest {
fingerprint: &fingerprint,
input: input.as_ref(),
user: &user,
session_token: "tok-atomic-commit",
request_class: None,
virtual_id: VirtualId::new(channel.virtual_id),
item_type: channel.item_type,
stream_url: channel.url.as_ref(),
connection_permission: UserConnectionPermission::Allowed,
connection_kind: crate::api::model::ConnectionKind::Normal,
socket_bound: false,
},
)
.await;
assert_eq!(activation.admission.permission, UserConnectionPermission::Allowed);
assert_eq!(activation.grace_mode, None);
let session = app_state
.active_users
.get_and_update_user_session(&user.username, "tok-atomic-commit")
.await
.expect("placeholder session should exist");
assert_eq!(
app_state.active_users.user_connections(&user.username).await,
0,
"allowed activation should stay provisional until provider acquisition and stream commit succeed"
);
assert!(
!session.lifecycle.is_counted(),
"placeholder session must stay uncounted until the provider side has been committed"
);
assert!(!matches!(session.lifecycle, crate::api::model::PlaybackLifecycle::PendingProvider { .. }));
}
fn create_test_shared_target() -> ConfigTarget {
ConfigTarget {
id: 1,
enabled: true,
name: "shared".to_string(),
options: Some(ConfigTargetOptions {
share_live_streams: shared::model::ConfigTargetShareLiveStreams { mpeg_ts: true, ..Default::default() },
..ConfigTargetOptions::default()
}),
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
}
}
#[test]
fn admission_failure_reason_maps_to_custom_video_type() {
assert!(matches!(
admission_failure_video_type(ConnectFailureReason::UserAccountExpired),
Some(CustomVideoStreamType::UserAccountExpired)
));
assert!(matches!(
admission_failure_video_type(ConnectFailureReason::UserConnectionsExhausted),
Some(CustomVideoStreamType::UserConnectionsExhausted)
));
assert!(matches!(
admission_failure_video_type(ConnectFailureReason::ProviderConnectionsExhausted),
Some(CustomVideoStreamType::ProviderConnectionsExhausted)
));
assert!(admission_failure_video_type(ConnectFailureReason::ProviderError).is_none());
}
#[tokio::test]
async fn effective_admission_strategies_use_legacy_grace_when_field_missing() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: None,
});
assert_eq!(
get_effective_admission_strategies(&app_state.admission_ctx()).as_ref(),
&[shared::model::AdmissionStrategy::GraceHoldStream][..]
);
}
#[tokio::test]
async fn effective_admission_strategies_respect_explicit_empty_list() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![]),
});
assert!(get_effective_admission_strategies(&app_state.admission_ctx()).is_empty());
}
#[tokio::test]
async fn grace_context_is_populated_when_grace_strategy_is_actually_granted() {
// Use a DIFFERENT session token than the pre-existing counted session.
// Otherwise session-admission may treat it as a valid reopen and skip the exhausted path.
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::GraceHoldStream,
AdmissionStrategy::EvictUserOldest,
]),
});
let addr1: SocketAddr = "127.0.0.1:55401".parse().unwrap_or_else(|_| unreachable!());
let addr2: SocketAddr = "10.0.0.5:55402".parse().unwrap_or_else(|_| unreachable!());
let fingerprint1 = create_test_fingerprint(addr1);
let fingerprint2 = create_test_fingerprint(addr2);
// addr1 and addr2 have DIFFERENT IPs.
// EvictUserSameIpOldest will NOT match (different IP), so GraceHoldStream is evaluated.
let mut user = ProxyUserCredentials::default();
user.username = "user-grace-ctx".to_string();
user.max_connections = 1;
// Register the connection first so update_connection succeeds
app_state.connection_manager.add_connection(&addr1).await;
// Create the session — lifecycle starts as Prepared (uncounted)
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-existing-counted",
virtual_id: 55401,
provider: "provider_1",
stream_url: "http://provider-1.example/live/55401.m3u8",
addr: &addr1,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
// update_connection promotes the session to Active (counted) and creates a stream.
// This exhausts the user's single slot (max_connections = 1).
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 55401,
meter_uid: 55401,
username: "user-grace-ctx",
max_connections: 1,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &fingerprint1,
provider: "provider_1".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/live/55401.m3u8"),
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-existing-counted"),
})
.await
.expect("stream should be created");
// Now the new request finds the slot exhausted and the grace strategy kicks in.
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
client_ip: &fingerprint2.client_ip,
request_addr: &fingerprint2.addr,
use_session_admission: true,
session_token: Some("tok-new-request"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-request"),
},
)
.await;
assert_eq!(result.admission.permission, UserConnectionPermission::GracePeriod, "grace should be granted");
assert!(matches!(result.grace_mode, Some(crate::api::model::GraceMode::Hold)));
let ctx = result.grace_context.expect("grace_context must be present when grace is granted");
assert_eq!(ctx.strategy_index, 1, "GraceHoldStream is at index 1");
assert_eq!(ctx.strategies.len(), 3);
assert!(matches!(ctx.strategies[ctx.strategy_index], AdmissionStrategy::GraceHoldStream));
}
#[tokio::test]
async fn evaluate_remaining_strategies_evicts_after_used_grace() {
// Strategies: [GraceHoldStream, EvictUserOldest]
// Grace was used at index 0, so only EvictUserOldest (index 1) is evaluated.
// Eviction frees the slot -> Allowed.
let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest];
let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None };
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]),
});
let addr1: SocketAddr = "127.0.0.1:55701".parse().unwrap_or_else(|_| unreachable!());
let addr2: SocketAddr = "10.0.0.5:55702".parse().unwrap_or_else(|_| unreachable!());
let fingerprint1 = create_test_fingerprint(addr1);
let fingerprint2 = create_test_fingerprint(addr2);
app_state.connection_manager.add_connection(&addr1).await;
app_state.connection_manager.add_connection(&addr2).await;
let mut user = ProxyUserCredentials::default();
user.username = "remaining-evict".to_string();
user.max_connections = 1;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-counted",
virtual_id: 55701,
provider: "provider-evict",
stream_url: "http://provider.example/live/1.ts",
addr: &addr1,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 55701,
meter_uid: 55701,
username: "remaining-evict",
max_connections: 1,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &fingerprint1,
provider: "provider-evict".intern(),
stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"),
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-counted"),
})
.await
.expect("stream should be created");
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "remaining-evict",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint2.client_ip,
request_addr: &fingerprint2.addr,
use_session_admission: true,
session_token: Some("tok-new"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"),
},
&grace_context,
Some(crate::api::model::ConnectionKind::Normal),
)
.await;
assert_eq!(result.admission.permission, UserConnectionPermission::Allowed, "EvictUserOldest should free the slot");
assert!(result.grace_context.is_none(), "no grace context on eviction success");
}
#[tokio::test]
async fn evaluate_remaining_strategies_skips_no_match_and_uses_later_eviction() {
// Strategies: [GraceHoldStream, EvictUserSameIpOldest, EvictUserOldest]
// Grace was at index 0, remaining are EvictUserSameIpOldest (index 1) and EvictUserOldest (index 2).
// The existing counted session is at a DIFFERENT IP, so EvictUserSameIpOldest -> NoMatch.
// EvictUserOldest succeeds -> Allowed.
let strategies = vec![
AdmissionStrategy::GraceHoldStream,
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::EvictUserOldest,
];
let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None };
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![
AdmissionStrategy::GraceHoldStream,
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::EvictUserOldest,
]),
});
let addr1: SocketAddr = "127.0.0.1:55801".parse().unwrap_or_else(|_| unreachable!());
let addr2: SocketAddr = "10.0.0.5:55802".parse().unwrap_or_else(|_| unreachable!());
let fingerprint1 = create_test_fingerprint(addr1);
let fingerprint2 = create_test_fingerprint(addr2);
app_state.connection_manager.add_connection(&addr1).await;
app_state.connection_manager.add_connection(&addr2).await;
let mut user = ProxyUserCredentials::default();
user.username = "remaining-skip-no-match".to_string();
user.max_connections = 1;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-counted",
virtual_id: 55801,
provider: "provider-skip",
stream_url: "http://provider.example/live/1.ts",
addr: &addr1,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 55801,
meter_uid: 55801,
username: "remaining-skip-no-match",
max_connections: 1,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &fingerprint1,
provider: "provider-skip".intern(),
stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"),
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-counted"),
})
.await
.expect("stream should be created");
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "remaining-skip-no-match",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint2.client_ip,
request_addr: &fingerprint2.addr,
use_session_admission: true,
session_token: Some("tok-new"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"),
},
&grace_context,
Some(crate::api::model::ConnectionKind::Normal),
)
.await;
assert_eq!(
result.admission.permission,
UserConnectionPermission::Allowed,
"EvictUserSameIpOldest should NoMatch, EvictUserOldest should succeed"
);
}
#[tokio::test]
async fn evaluate_remaining_strategies_empty_slice_denies() {
// Strategies: [GraceHoldStream]
// Grace was at index 0, remaining slice is empty -> exhausted.
let strategies = vec![AdmissionStrategy::GraceHoldStream];
let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None };
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
});
let addr: SocketAddr = "10.0.0.5:55901".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "no-remaining-strategies",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint.client_ip,
request_addr: &fingerprint.addr,
use_session_admission: true,
session_token: Some("tok-new"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"),
},
&grace_context,
None,
)
.await;
assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted, "empty remaining slice should deny");
}
#[tokio::test]
async fn evaluate_remaining_strategies_preserves_soft_kind_on_exhausted() {
// Strategies: [GraceHoldStream]
// Grace was at index 0, remaining slice is empty -> exhausted.
// grace_context.kind is Soft — must be preserved in the exhausted result.
let strategies = vec![AdmissionStrategy::GraceHoldStream];
let grace_context = GraceResolutionContext {
strategy_index: 0,
strategies: strategies.into(),
kind: Some(crate::api::model::ConnectionKind::Soft),
};
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
});
let addr: SocketAddr = "10.0.0.6:55902".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "soft-kind-user",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint.client_ip,
request_addr: &fingerprint.addr,
use_session_admission: true,
session_token: Some("tok-soft"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-soft"),
},
&grace_context,
Some(crate::api::model::ConnectionKind::Soft),
)
.await;
assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted, "empty remaining slice should deny");
assert_eq!(
result.admission.kind,
Some(crate::api::model::ConnectionKind::Soft),
"exhausted result must preserve the original Soft connection kind"
);
}
#[tokio::test]
async fn evaluate_remaining_strategies_does_not_retry_used_prefix() {
// Strategies: [GraceHoldStream, GraceInstantStream, EvictUserOldest]
// Grace was at index 1 (GraceInstantStream).
// Remaining slice: [EvictUserOldest] (index 2).
// GraceHoldStream (index 0) must NOT be re-evaluated.
let strategies = vec![
AdmissionStrategy::GraceHoldStream,
AdmissionStrategy::GraceInstantStream,
AdmissionStrategy::EvictUserOldest,
];
let strategies_for_config = strategies.clone();
let grace_context = GraceResolutionContext { strategy_index: 1, strategies: strategies.into(), kind: None };
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(strategies_for_config),
});
let addr1: SocketAddr = "127.0.0.1:56001".parse().unwrap_or_else(|_| unreachable!());
let addr2: SocketAddr = "10.0.0.5:56002".parse().unwrap_or_else(|_| unreachable!());
let fingerprint1 = create_test_fingerprint(addr1);
let fingerprint2 = create_test_fingerprint(addr2);
app_state.connection_manager.add_connection(&addr1).await;
app_state.connection_manager.add_connection(&addr2).await;
let mut user = ProxyUserCredentials::default();
user.username = "remaining-no-retry".to_string();
user.max_connections = 1;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-counted",
virtual_id: 56001,
provider: "provider-no-retry",
stream_url: "http://provider.example/live/1.ts",
addr: &addr1,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 56001,
meter_uid: 56001,
username: "remaining-no-retry",
max_connections: 1,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &fingerprint1,
provider: "provider-no-retry".intern(),
stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"),
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-counted"),
})
.await
.expect("stream should be created");
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "remaining-no-retry",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint2.client_ip,
request_addr: &fingerprint2.addr,
use_session_admission: true,
session_token: Some("tok-new"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"),
},
&grace_context,
Some(crate::api::model::ConnectionKind::Normal),
)
.await;
assert_eq!(
result.admission.permission,
UserConnectionPermission::Allowed,
"only EvictUserOldest should be evaluated, not GraceHoldStream"
);
}
#[tokio::test]
async fn evaluate_remaining_strategies_empty_slice_uses_original_kind_not_context_kind() {
// grace_context.kind = Normal, original_kind = Soft
// remaining slice is empty -> exhausted result must use original_kind.
// This proves the empty-slice branch uses original_kind, not grace_context.kind.
let strategies = vec![AdmissionStrategy::GraceHoldStream];
let grace_context = GraceResolutionContext {
strategy_index: 0,
strategies: strategies.into(),
kind: Some(crate::api::model::ConnectionKind::Normal),
};
let original_kind = Some(crate::api::model::ConnectionKind::Soft);
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]),
});
let addr: SocketAddr = "10.0.0.7:55903".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "kind-mismatch-empty",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint.client_ip,
request_addr: &fingerprint.addr,
use_session_admission: true,
session_token: Some("tok-empty"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-empty"),
},
&grace_context,
original_kind,
)
.await;
assert_eq!(result.admission.permission, UserConnectionPermission::Exhausted);
assert_eq!(
result.admission.kind, original_kind,
"exhausted result must use original_kind (Soft), not grace_context.kind (Normal)"
);
}
#[tokio::test]
async fn evaluate_remaining_strategies_later_grace_uses_original_kind_not_context_kind() {
// grace_context.kind = Normal, original_kind = Soft
// Strategies: [GraceHoldStream, GraceInstantStream]
// Grace was used at index 0 (GraceHoldStream).
// Remaining slice contains GraceInstantStream (index 1).
// When the helper returns Grace for the remaining strategy, the new
// GraceResolutionContext.kind must be original_kind (Soft), not grace_context.kind (Normal).
// This proves build_grace_ctx uses original_kind as source of truth.
let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream];
let grace_context = GraceResolutionContext {
strategy_index: 0,
strategies: strategies.into(),
kind: Some(crate::api::model::ConnectionKind::Normal),
};
let original_kind = Some(crate::api::model::ConnectionKind::Soft);
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]),
});
let addr1: SocketAddr = "127.0.0.1:55710".parse().unwrap_or_else(|_| unreachable!());
let addr2: SocketAddr = "10.0.0.8:55711".parse().unwrap_or_else(|_| unreachable!());
let fingerprint1 = create_test_fingerprint(addr1);
let fingerprint2 = create_test_fingerprint(addr2);
app_state.connection_manager.add_connection(&addr1).await;
app_state.connection_manager.add_connection(&addr2).await;
let mut user = ProxyUserCredentials::default();
user.username = "kind-mismatch-grace".to_string();
user.max_connections = 1;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-counted-grace",
virtual_id: 55710,
provider: "provider-grace-kind",
stream_url: "http://provider.example/live/1.ts",
addr: &addr1,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 55710,
meter_uid: 55710,
username: "kind-mismatch-grace",
max_connections: 1,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &fingerprint1,
provider: "provider-grace-kind".intern(),
stream_channel: &create_test_live_channel("http://provider.example/live/1.ts"),
user_agent: Cow::Borrowed("ua"),
session_token: Some("tok-counted-grace"),
})
.await
.expect("stream should be created");
let result = evaluate_remaining_strategies_after_grace(
&app_state.admission_ctx(),
AdmissionRequest {
username: "kind-mismatch-grace",
max_connections: 1,
soft_connections: 0,
client_ip: &fingerprint2.client_ip,
request_addr: &fingerprint2.addr,
use_session_admission: true,
session_token: Some("tok-new-grace"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-grace"),
},
&grace_context,
original_kind,
)
.await;
assert_eq!(
result.admission.permission,
UserConnectionPermission::GracePeriod,
"remaining GraceInstantStream should grant GracePeriod"
);
assert!(result.grace_context.is_some(), "grace_context must be present when grace is granted");
assert_eq!(
result.grace_context.as_ref().unwrap().kind,
original_kind,
"GraceResolutionContext.kind in the result must be original_kind (Soft), not grace_context.kind (Normal)"
);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn resolve_admission_with_strategies_falls_through_after_failed_grace_grant() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]),
});
let first_addr: std::net::SocketAddr = "127.0.0.1:55151".parse().unwrap_or_else(|_| unreachable!());
let second_addr: std::net::SocketAddr = "127.0.0.1:55152".parse().unwrap_or_else(|_| unreachable!());
let first_fingerprint = create_test_fingerprint(first_addr);
let second_fingerprint = create_test_fingerprint(second_addr);
app_state.connection_manager.add_connection(&first_addr).await;
app_state.connection_manager.add_connection(&second_addr).await;
let mut session_user = ProxyUserCredentials::default();
session_user.username = "fallthrough".to_string();
session_user.max_connections = 1;
session_user.soft_connections = 1;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &session_user,
session_token: "tok-first",
virtual_id: 1,
provider: "provider-a",
stream_url: "http://provider-1.example/live/1.ts",
addr: &first_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: "fallthrough",
max_connections: 1,
soft_connections: 1,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 10,
fingerprint: &first_fingerprint,
provider: "provider-a".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/live/1.ts"),
user_agent: std::borrow::Cow::Borrowed("ua"),
session_token: Some("tok-first"),
})
.await;
assert!(app_state.active_users.grant_grace("fallthrough").await);
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &session_user,
session_token: "tok-second",
virtual_id: 2,
provider: "provider-a",
stream_url: "http://provider-1.example/live/2.ts",
addr: &second_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Soft),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 2,
username: "fallthrough",
max_connections: 1,
soft_connections: 1,
connection_kind: crate::api::model::ConnectionKind::Soft,
priority: 0,
soft_priority: 10,
fingerprint: &second_fingerprint,
provider: "provider-a".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/live/2.ts"),
user_agent: std::borrow::Cow::Borrowed("ua"),
session_token: Some("tok-second"),
})
.await;
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: "fallthrough",
max_connections: 1,
soft_connections: 1,
client_ip: "127.0.0.1",
request_addr: &"127.0.0.1:55153".parse().unwrap_or_else(|_| unreachable!()),
use_session_admission: true,
session_token: Some("tok-third"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-third"),
},
)
.await;
let admission = result.admission;
let grace_mode = result.grace_mode;
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Normal));
assert_eq!(grace_mode, None);
}
#[tokio::test]
async fn resolve_admission_with_strategies_allows_existing_session_even_when_user_is_at_limit() {
let app_state = create_test_app_state();
let addr = "127.0.0.1:55154".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let mut user = ProxyUserCredentials::default();
user.username = "session-admission".to_string();
user.max_connections = 1;
app_state.connection_manager.add_connection(&addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "vod-session",
virtual_id: 1,
provider: "provider-a",
stream_url: "http://provider-1.example/movie/1.mkv",
addr: &addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 10,
fingerprint: &fingerprint,
provider: "provider-a".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/movie/1.mkv"),
user_agent: std::borrow::Cow::Borrowed("ua"),
session_token: Some("vod-session"),
})
.await;
let session_based = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
client_ip: &fingerprint.client_ip,
request_addr: &fingerprint.addr,
use_session_admission: true,
session_token: Some("vod-session"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"),
},
)
.await;
assert_eq!(session_based.admission.permission, UserConnectionPermission::Allowed);
let connection_based = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
client_ip: &fingerprint.client_ip,
request_addr: &fingerprint.addr,
use_session_admission: false,
session_token: Some("vod-session"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"),
},
)
.await;
assert_eq!(connection_based.admission.permission, UserConnectionPermission::Exhausted);
}
#[tokio::test]
async fn resolve_admission_with_strategies_prevents_recently_evicted_playback_ping_pong() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 0,
grace_period_timeout_secs: 8,
grace_period_hold_stream: false,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]),
});
let victim_addr: std::net::SocketAddr = "127.0.0.1:55181".parse().unwrap_or_else(|_| unreachable!());
let reconnect_addr: std::net::SocketAddr = "127.0.0.1:55182".parse().unwrap_or_else(|_| unreachable!());
let winner_addr: std::net::SocketAddr = "127.0.0.1:55183".parse().unwrap_or_else(|_| unreachable!());
let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0");
let reconnect_fingerprint = create_test_fingerprint_with_user_agent(reconnect_addr, "player/1.0");
let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0");
let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9001.ts");
victim_channel.virtual_id = 9001;
let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9002.ts");
winner_channel.virtual_id = 9002;
app_state.connection_manager.add_connection(&victim_addr).await;
app_state.connection_manager.add_connection(&winner_addr).await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: "loop-user",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &victim_fingerprint,
provider: "provider-a".intern(),
stream_channel: &victim_channel,
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("session-victim"),
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 2,
username: "loop-user",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &winner_fingerprint,
provider: "provider-a".intern(),
stream_channel: &winner_channel,
user_agent: std::borrow::Cow::Borrowed("winner/1.0"),
session_token: Some("session-winner"),
})
.await;
app_state
.active_users
.mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS)
.await;
app_state.connection_manager.release_connection_as_kicked(&victim_addr).await;
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: "loop-user",
max_connections: 1,
soft_connections: 0,
client_ip: &reconnect_fingerprint.client_ip,
request_addr: &reconnect_fingerprint.addr,
use_session_admission: true,
session_token: Some("socket-reconnect"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9001) },
},
)
.await;
let admission = result.admission;
let grace_mode = result.grace_mode;
assert_eq!(admission.permission, UserConnectionPermission::Exhausted);
assert_eq!(grace_mode, None);
let active_streams = app_state.active_users.active_streams().await;
assert_eq!(active_streams.len(), 1);
assert_eq!(active_streams[0].channel.virtual_id, 9002);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn resolve_admission_with_strategies_allows_other_channel_after_recent_eviction() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 0,
grace_period_timeout_secs: 8,
grace_period_hold_stream: false,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]),
});
let victim_addr: std::net::SocketAddr = "127.0.0.1:55184".parse().unwrap_or_else(|_| unreachable!());
let winner_addr: std::net::SocketAddr = "127.0.0.1:55185".parse().unwrap_or_else(|_| unreachable!());
let new_addr: std::net::SocketAddr = "127.0.0.1:55186".parse().unwrap_or_else(|_| unreachable!());
let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0");
let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0");
let new_fingerprint = create_test_fingerprint_with_user_agent(new_addr, "player/1.0");
let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9101.ts");
victim_channel.virtual_id = 9101;
let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9102.ts");
winner_channel.virtual_id = 9102;
let mut session_user = ProxyUserCredentials::default();
session_user.username = "loop-user-2".to_string();
app_state.connection_manager.add_connection(&victim_addr).await;
app_state.connection_manager.add_connection(&winner_addr).await;
// Create sessions before update_connection so streams are linked to counted sessions
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &session_user,
session_token: "session-victim",
virtual_id: 9101,
provider: "provider-a",
stream_url: "http://provider-1.example/live/9101.ts",
addr: &victim_addr,
connection_permission: shared::model::UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &session_user,
session_token: "session-winner",
virtual_id: 9102,
provider: "provider-a",
stream_url: "http://provider-1.example/live/9102.ts",
addr: &winner_addr,
connection_permission: shared::model::UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: "loop-user-2",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &victim_fingerprint,
provider: "provider-a".intern(),
stream_channel: &victim_channel,
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("session-victim"),
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 2,
username: "loop-user-2",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &winner_fingerprint,
provider: "provider-a".intern(),
stream_channel: &winner_channel,
user_agent: std::borrow::Cow::Borrowed("winner/1.0"),
session_token: Some("session-winner"),
})
.await;
app_state
.active_users
.mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS)
.await;
app_state.connection_manager.release_connection_as_kicked(&victim_addr).await;
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: "loop-user-2",
max_connections: 1,
soft_connections: 0,
client_ip: &new_fingerprint.client_ip,
request_addr: &new_fingerprint.addr,
use_session_admission: true,
session_token: Some("session-new"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9103) },
},
)
.await;
let admission = result.admission;
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert!(app_state.active_users.active_streams().await.is_empty());
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn resolve_admission_with_strategies_does_not_suppress_different_session_on_same_channel() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 0,
grace_period_timeout_secs: 8,
grace_period_hold_stream: false,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]),
});
let victim_addr: std::net::SocketAddr = "127.0.0.1:55190".parse().unwrap_or_else(|_| unreachable!());
let winner_addr: std::net::SocketAddr = "127.0.0.1:55191".parse().unwrap_or_else(|_| unreachable!());
let new_addr: std::net::SocketAddr = "127.0.0.1:55192".parse().unwrap_or_else(|_| unreachable!());
let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0");
let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "player/1.0");
let new_fingerprint = create_test_fingerprint_with_user_agent(new_addr, "player/1.0");
let mut channel = create_test_live_channel("http://provider-1.example/live/9301.m3u8");
channel.virtual_id = 9301;
channel.item_type = PlaylistItemType::LiveHls;
let mut session_user = ProxyUserCredentials::default();
session_user.username = "loop-user-4".to_string();
app_state.connection_manager.add_connection(&victim_addr).await;
app_state.connection_manager.add_connection(&winner_addr).await;
// Create sessions before update_connection so streams are linked to counted sessions
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &session_user,
session_token: "session-victim",
virtual_id: 9301,
provider: "provider-a",
stream_url: "http://provider-1.example/live/9301.m3u8",
addr: &victim_addr,
connection_permission: shared::model::UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &session_user,
session_token: "session-winner",
virtual_id: 9301,
provider: "provider-a",
stream_url: "http://provider-1.example/live/9301.m3u8",
addr: &winner_addr,
connection_permission: shared::model::UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: "loop-user-4",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &victim_fingerprint,
provider: "provider-a".intern(),
stream_channel: &channel,
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("session-victim"),
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 2,
username: "loop-user-4",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &winner_fingerprint,
provider: "provider-a".intern(),
stream_channel: &channel,
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("session-winner"),
})
.await;
app_state
.active_users
.mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS)
.await;
app_state.connection_manager.release_connection_as_kicked(&victim_addr).await;
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: "loop-user-4",
max_connections: 1,
soft_connections: 0,
client_ip: &new_fingerprint.client_ip,
request_addr: &new_fingerprint.addr,
use_session_admission: true,
session_token: Some("session-other"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::Session("session-other"),
},
)
.await;
let admission = result.admission;
let grace_mode = result.grace_mode;
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert_eq!(grace_mode, None);
assert!(app_state.active_users.active_streams().await.is_empty());
}
#[tokio::test]
async fn resolve_admission_with_strategies_allows_recently_evicted_playback_when_soft_slot_is_free() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 0,
grace_period_timeout_secs: 8,
grace_period_hold_stream: false,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]),
});
let victim_addr: std::net::SocketAddr = "127.0.0.1:55187".parse().unwrap_or_else(|_| unreachable!());
let reconnect_addr: std::net::SocketAddr = "127.0.0.1:55188".parse().unwrap_or_else(|_| unreachable!());
let winner_addr: std::net::SocketAddr = "127.0.0.1:55189".parse().unwrap_or_else(|_| unreachable!());
let victim_fingerprint = create_test_fingerprint_with_user_agent(victim_addr, "player/1.0");
let reconnect_fingerprint = create_test_fingerprint_with_user_agent(reconnect_addr, "player/1.0");
let winner_fingerprint = create_test_fingerprint_with_user_agent(winner_addr, "winner/1.0");
let mut victim_channel = create_test_live_channel("http://provider-1.example/live/9201.ts");
victim_channel.virtual_id = 9201;
let mut winner_channel = create_test_live_channel("http://provider-1.example/live/9202.ts");
winner_channel.virtual_id = 9202;
app_state.connection_manager.add_connection(&victim_addr).await;
app_state.connection_manager.add_connection(&winner_addr).await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: "loop-user-3",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &victim_fingerprint,
provider: "provider-a".intern(),
stream_channel: &victim_channel,
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("session-victim"),
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 2,
username: "loop-user-3",
max_connections: 2,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &winner_fingerprint,
provider: "provider-a".intern(),
stream_channel: &winner_channel,
user_agent: std::borrow::Cow::Borrowed("winner/1.0"),
session_token: Some("session-winner"),
})
.await;
app_state
.active_users
.mark_recent_eviction_guard_for_addr(&victim_addr, winner_addr, RECENT_EVICTION_REENTRY_TTL_SECS)
.await;
app_state.connection_manager.release_connection_as_kicked(&victim_addr).await;
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: "loop-user-3",
max_connections: 1,
soft_connections: 1,
client_ip: &reconnect_fingerprint.client_ip,
request_addr: &reconnect_fingerprint.addr,
use_session_admission: true,
session_token: Some("socket-reconnect"),
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9201) },
},
)
.await;
let admission = result.admission;
let grace_mode = result.grace_mode;
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Soft));
assert_eq!(grace_mode, None);
let active_streams = app_state.active_users.active_streams().await;
assert_eq!(active_streams.len(), 1);
assert_eq!(active_streams[0].channel.virtual_id, 9202);
}
#[tokio::test]
async fn local_stream_response_registers_active_local_stream() {
let app_state = create_test_app_state();
let temp_dir = tempfile::tempdir().expect("tempdir");
let file_path = temp_dir.path().join("local-test.mkv");
tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file");
let addr = "127.0.0.1:55123".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let channel = create_test_local_channel(&format!("file://{}", file_path.display()));
let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() };
let user = ProxyUserCredentials::default();
let target = ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
};
let _response = local_stream_response(
&fingerprint,
&app_state,
channel,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
None,
None,
false,
)
.await
.into_response();
let active_streams = app_state.active_users.active_streams().await;
assert_eq!(active_streams.len(), 1, "local file streaming should register an active stream");
assert_eq!(active_streams[0].channel.item_type, PlaylistItemType::LocalVideo);
}
#[tokio::test]
async fn local_stream_response_rechecks_limits_before_registering_socket_bound_streams() {
let mut app_cfg = create_test_app_config();
let config = Config { user_access_control: true, ..Config::default() };
app_cfg.config = Arc::new(ArcSwap::from_pointee(config));
let app_state = create_test_app_state_for_config(Arc::new(app_cfg));
let temp_dir = tempfile::tempdir().expect("tempdir");
let file_path = temp_dir.path().join("local-race-test.mkv");
tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file");
let first_addr = "127.0.0.1:55131".parse().unwrap_or_else(|_| unreachable!());
let second_addr = "127.0.0.1:55132".parse().unwrap_or_else(|_| unreachable!());
let first_fingerprint = create_test_fingerprint(first_addr);
let second_fingerprint = create_test_fingerprint(second_addr);
let channel = create_test_local_channel(&format!("file://{}", file_path.display()));
let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() };
let mut user = ProxyUserCredentials::default();
user.username = "local-limit-user".to_string();
user.max_connections = 1;
let target = ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
};
let first_token = create_session_fingerprint(&first_fingerprint, &user.username, channel.virtual_id, true);
let second_token = create_session_fingerprint(&second_fingerprint, &user.username, channel.virtual_id, true);
let _first_response = local_stream_response(
&first_fingerprint,
&app_state,
channel.clone(),
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
Some(&first_token),
None,
false,
)
.await
.into_response();
let _second_response = local_stream_response(
&second_fingerprint,
&app_state,
channel,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
Some(&second_token),
None,
false,
)
.await
.into_response();
assert_eq!(app_state.active_users.user_connections(&user.username).await, 1);
assert_eq!(app_state.active_users.active_streams().await.len(), 1);
assert_eq!(
app_state
.active_users
.connection_admission_for_session(
&user.username,
user.max_connections,
user.soft_connections,
&second_token
)
.await
.permission,
UserConnectionPermission::Exhausted,
"failed second open must not leave a placeholder session that bypasses admission"
);
}
#[tokio::test]
async fn stream_response_preserves_soft_kind_for_shared_reuse() {
let app_state = create_test_provider_app_state();
let stream_url = "http://provider-1.example/live/shared.ts";
let input_name = "provider_1".intern();
let input = app_state.app_config.get_input_by_name(&input_name).expect("provider input should exist");
let target = Arc::new(create_test_shared_target());
let owner_addr = "127.0.0.1:55140".parse().unwrap_or_else(|_| unreachable!());
let owner_handle = app_state
.active_provider
.acquire_connection(&input.name, &owner_addr, 0, crate::api::model::ConnectionKind::Normal)
.await
.expect("owner allocation should exist");
let shared_stream = stream::pending::<Result<Bytes, std::io::Error>>();
let registered = SharedStreamManager::register_shared_stream(
SharedStreamCtx {
app_config: &app_state.app_config,
shared_stream_manager: &app_state.shared_stream_manager,
active_provider: &app_state.active_provider,
connection_manager: &app_state.connection_manager,
},
stream_url,
shared_stream,
&owner_addr,
Vec::new(),
1,
Some(owner_handle),
0,
crate::api::model::ConnectionKind::Normal,
)
.await;
assert!(registered.is_some(), "shared stream should register");
let mut user = ProxyUserCredentials::default();
user.username = "soft-user".to_string();
user.max_connections = 1;
user.soft_connections = 1;
user.priority = 0;
user.soft_priority = 9;
let normal_addr = "127.0.0.1:55141".parse().unwrap_or_else(|_| unreachable!());
let normal_fingerprint = create_test_fingerprint(normal_addr);
let normal_channel = create_test_live_channel("http://provider-1.example/live/normal.ts");
app_state.active_users.add_connection(&normal_addr).await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 1001,
meter_uid: 0,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: user.priority,
soft_priority: user.soft_priority,
fingerprint: &normal_fingerprint,
provider: input.name.clone(),
stream_channel: &normal_channel,
user_agent: Cow::Borrowed("ua"),
session_token: Some("normal-session"),
})
.await
.expect("normal stream should register");
let admission =
app_state.active_users.connection_admission(&user.username, user.max_connections, user.soft_connections).await;
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert_eq!(admission.kind, Some(crate::api::model::ConnectionKind::Soft));
let soft_addr = "127.0.0.1:55142".parse().unwrap_or_else(|_| unreachable!());
let soft_fingerprint = create_test_fingerprint(soft_addr);
let response = stream_response(
&soft_fingerprint,
&app_state,
"soft-session",
None,
create_test_live_channel(stream_url),
stream_url,
None,
&HeaderMap::default(),
&input,
&target,
&user,
admission.permission,
admission.kind.unwrap_or(crate::api::model::ConnectionKind::Normal),
false,
None,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let session_admission = app_state
.active_users
.connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, "soft-session")
.await;
assert_eq!(session_admission.kind, Some(crate::api::model::ConnectionKind::Soft));
}
#[tokio::test]
async fn stream_response_rolls_back_provisional_user_activation_when_provider_open_fails() {
let mut app_cfg = create_test_provider_app_config();
app_cfg.config = Arc::new(ArcSwap::from_pointee(Config {
user_access_control: true,
custom_stream_response_enabled: true,
..Config::default()
}));
let app_state = create_test_app_state_for_config(Arc::new(app_cfg));
let addr = "127.0.0.1:55143".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let input_name = "provider_1".intern();
let input = app_state.app_config.get_input_by_name(&input_name).expect("provider input should exist");
let target = Arc::new(ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
});
let mut user = ProxyUserCredentials::default();
user.username = "rollback-user".to_string();
user.max_connections = 1;
let stream_url = "provider://bad-url";
let channel = create_test_live_channel(stream_url);
let response = stream_response(
&fingerprint,
&app_state,
"rollback-session",
None,
channel,
stream_url,
None,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
false,
None,
)
.await
.into_response();
// Custom-video stream is enabled (`custom_stream_response_enabled: true`
// in this fixture), so a missing resource must return 400 — the
// Nginx `proxy_intercept_errors on;` contract requires 4xx so the
// socket is severed instead of looping on a 200 OK fallback body.
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
app_state.active_users.user_connections(&user.username).await,
0,
"failed provider open must rollback provisional user activation"
);
assert!(
app_state.active_users.get_and_update_user_session(&user.username, "rollback-session").await.is_none(),
"failed provider open must remove the provisional placeholder session"
);
}
/// Regression test for: when a catchup request fails upstream (e.g. provider returns
/// 4xx/5xx) the connection-slot is released, but the provider account was being
/// pinned via `refresh_provider_reservation` for `catchup_session_ttl_secs`. This
/// blocked other sessions of the same family from acquiring the same provider even
/// though the slot was already free. The fix delegates the pinning decision to
/// `should_pin_provider_for_session` and skips the reservation when the response
/// is a non-Provisioning custom video (failure fallback). Provisioning custom videos
/// must keep their reservation since they represent a successful provider handoff.
#[tokio::test]
async fn should_pin_provider_for_session_skips_reservation_on_failure_custom_video() {
let app_state = create_test_app_state();
let no_video_details = StreamDetails {
stream: None,
stream_info: Some((Vec::new(), StatusCode::OK, None, None)),
provider_name: Some("provider_1".intern()),
request_url: None,
session_headers: None,
provider_session_headers: HashMap::new(),
grace_period: GracePeriodOptions::default(),
provider_grace_active: false,
disable_provider_grace: false,
reconnect_flag: None,
provider_handle: None,
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
};
assert!(
should_pin_provider_for_session(&no_video_details, &app_state, PlaylistItemType::Catchup),
"a real provider stream (no CustomVideoStreamType) must pin the provider"
);
let provisioning_details = StreamDetails {
stream: None,
stream_info: Some((Vec::new(), StatusCode::OK, None, Some(CustomVideoStreamType::Provisioning))),
provider_name: Some("provider_1".intern()),
request_url: None,
session_headers: None,
provider_session_headers: HashMap::new(),
grace_period: GracePeriodOptions::default(),
provider_grace_active: false,
disable_provider_grace: false,
reconnect_flag: None,
provider_handle: None,
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
};
assert!(
should_pin_provider_for_session(&provisioning_details, &app_state, PlaylistItemType::Catchup),
"a Provisioning custom video represents a successful provider handoff and must pin"
);
for failure_type in [
CustomVideoStreamType::ChannelUnavailable,
CustomVideoStreamType::ProviderConnectionsExhausted,
CustomVideoStreamType::UserConnectionsExhausted,
CustomVideoStreamType::UserAccountExpired,
CustomVideoStreamType::LowPriorityPreempted,
] {
let failure_details = StreamDetails {
stream: None,
stream_info: Some((Vec::new(), StatusCode::BAD_REQUEST, None, Some(failure_type))),
provider_name: Some("provider_1".intern()),
request_url: None,
session_headers: None,
provider_session_headers: HashMap::new(),
grace_period: GracePeriodOptions::default(),
provider_grace_active: false,
disable_provider_grace: false,
reconnect_flag: None,
provider_handle: None,
content_representation: crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
grace_resolution_context: None,
};
assert!(
!should_pin_provider_for_session(&failure_details, &app_state, PlaylistItemType::Catchup),
"{failure_type:?} is a failure fallback — must NOT pin the provider"
);
}
}
#[tokio::test]
async fn local_stream_response_disables_response_compression() {
let app_state = create_test_app_state();
let temp_dir = tempfile::tempdir().expect("tempdir");
let file_path = temp_dir.path().join("local-test.mkv");
tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file");
let addr = "127.0.0.1:55124".parse().unwrap_or_else(|_| unreachable!());
let fingerprint = create_test_fingerprint(addr);
let channel = create_test_local_channel(&format!("file://{}", file_path.display()));
let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() };
let user = ProxyUserCredentials::default();
let target = ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
};
let response = local_stream_response(
&fingerprint,
&app_state,
channel,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
None,
None,
false,
)
.await
.into_response();
assert!(!should_compress_response(&response));
}
#[tokio::test]
async fn local_stream_response_reuses_stable_playback_session_token_across_reopens() {
let app_state = create_test_app_state();
let temp_dir = tempfile::tempdir().expect("tempdir");
let file_path = temp_dir.path().join("local-test.mkv");
tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file");
let channel = create_test_local_channel(&format!("file://{}", file_path.display()));
let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() };
let user = ProxyUserCredentials::default();
let target = ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
};
let playback_session_token = "local-playback-token";
let first_fingerprint = create_test_fingerprint("127.0.0.1:55125".parse().unwrap_or_else(|_| unreachable!()));
let second_fingerprint = create_test_fingerprint("127.0.0.1:55126".parse().unwrap_or_else(|_| unreachable!()));
let _first_response = local_stream_response(
&first_fingerprint,
&app_state,
channel.clone(),
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
Some(playback_session_token),
None,
false,
)
.await
.into_response();
let _second_response = local_stream_response(
&second_fingerprint,
&app_state,
channel,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
Some(playback_session_token),
None,
false,
)
.await
.into_response();
let active_streams = app_state.active_users.active_streams().await;
assert_eq!(active_streams.len(), 1, "stable playback token should reuse the tracked local connection");
assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token));
assert_eq!(active_streams[0].addr, second_fingerprint.addr);
}
#[tokio::test]
async fn local_stream_response_allows_exhausted_reopen_for_same_playback_session_token() {
let app_state = create_test_app_state();
let temp_dir = tempfile::tempdir().expect("tempdir");
let file_path = temp_dir.path().join("local-test.mkv");
tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file");
let channel = create_test_local_channel(&format!("file://{}", file_path.display()));
let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() };
let mut user = ProxyUserCredentials::default();
user.username = "user1".to_string();
user.max_connections = 1;
let target = ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
};
let playback_session_token = "local-playback-token";
let first_fingerprint = create_test_fingerprint("127.0.0.1:55127".parse().unwrap_or_else(|_| unreachable!()));
let second_fingerprint = create_test_fingerprint("127.0.0.1:55128".parse().unwrap_or_else(|_| unreachable!()));
let _first_response = local_stream_response(
&first_fingerprint,
&app_state,
channel.clone(),
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
Some(playback_session_token),
None,
false,
)
.await
.into_response();
let second_response = local_stream_response(
&second_fingerprint,
&app_state,
channel,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Exhausted,
crate::api::model::ConnectionKind::Normal,
Some(playback_session_token),
None,
false,
)
.await
.into_response();
assert_eq!(second_response.status(), StatusCode::OK);
let active_streams = app_state.active_users.active_streams().await;
assert_eq!(active_streams.len(), 1);
assert_eq!(active_streams[0].session_token.as_deref(), Some(playback_session_token));
assert_eq!(active_streams[0].addr, second_fingerprint.addr);
}
#[tokio::test]
async fn local_stream_response_preserves_soft_kind_across_reopens() {
let app_state = create_test_app_state();
let temp_dir = tempfile::tempdir().expect("tempdir");
let file_path = temp_dir.path().join("local-soft-test.mkv");
tokio::fs::write(&file_path, Bytes::from_static(b"local-stream")).await.expect("write local file");
let channel = create_test_local_channel(&format!("file://{}", file_path.display()));
let input = ConfigInput { input_type: InputType::Library, ..ConfigInput::default() };
let mut user = ProxyUserCredentials::default();
user.username = "soft-local-user".to_string();
user.max_connections = 1;
user.soft_connections = 1;
let target = ConfigTarget {
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(ArcSwapOption::default()),
favourites: None,
processing_order: ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
};
let playback_session_token = "local-soft-playback-token";
let first_fingerprint = create_test_fingerprint("127.0.0.1:55129".parse().unwrap_or_else(|_| unreachable!()));
let second_fingerprint = create_test_fingerprint("127.0.0.1:55130".parse().unwrap_or_else(|_| unreachable!()));
let _first_response = local_stream_response(
&first_fingerprint,
&app_state,
channel.clone(),
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Soft,
Some(playback_session_token),
None,
false,
)
.await
.into_response();
let second_response = local_stream_response(
&second_fingerprint,
&app_state,
channel,
&HeaderMap::default(),
&input,
&target,
&user,
UserConnectionPermission::Exhausted,
crate::api::model::ConnectionKind::Normal,
Some(playback_session_token),
None,
false,
)
.await
.into_response();
assert_eq!(second_response.status(), StatusCode::OK);
let session_admission = app_state
.active_users
.connection_admission_for_session(
&user.username,
user.max_connections,
user.soft_connections,
playback_session_token,
)
.await;
assert_eq!(session_admission.kind, Some(crate::api::model::ConnectionKind::Soft));
}
#[tokio::test]
async fn activated_session_admission_keeps_hls_placeholders_uncounted_via_api_utils() {
let app_state = create_test_app_state();
let mut user = ProxyUserCredentials::default();
user.username = "hls-user".to_string();
user.max_connections = 1;
let first_addr: std::net::SocketAddr = "127.0.0.1:55177".parse().unwrap_or_else(|_| unreachable!());
let second_addr: std::net::SocketAddr = "127.0.0.1:55178".parse().unwrap_or_else(|_| unreachable!());
let first_fingerprint = create_test_fingerprint(first_addr);
let second_fingerprint = create_test_fingerprint(second_addr);
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-hls-first",
virtual_id: 7101,
provider: "provider-a",
stream_url: "http://provider-1.example/live/7101.m3u8",
addr: &first_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-hls-second",
virtual_id: 7102,
provider: "provider-a",
stream_url: "http://provider-1.example/live/7102.m3u8",
addr: &second_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
let first_admission = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
client_ip: &first_fingerprint.client_ip,
request_addr: &first_fingerprint.addr,
use_session_admission: true,
session_token: Some("tok-hls-first"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-first"),
},
)
.await;
let second_admission = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
client_ip: &second_fingerprint.client_ip,
request_addr: &second_fingerprint.addr,
use_session_admission: true,
session_token: Some("tok-hls-second"),
activate_unbound_session: true,
eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-second"),
},
)
.await;
assert_eq!(first_admission.admission.permission, UserConnectionPermission::Allowed);
assert_eq!(second_admission.admission.permission, UserConnectionPermission::Allowed);
assert_eq!(app_state.active_users.user_connections(&user.username).await, 0);
}
#[tokio::test]
async fn socket_bound_playback_sessions_enforce_hard_limits_per_socket() {
let app_state = create_test_app_state();
let mut user = ProxyUserCredentials::default();
user.username = "user1".to_string();
user.max_connections = 1;
let first_addr: std::net::SocketAddr = "127.0.0.1:55171".parse().unwrap_or_else(|_| unreachable!());
let second_addr: std::net::SocketAddr = "127.0.0.1:55172".parse().unwrap_or_else(|_| unreachable!());
let first_fingerprint = create_test_fingerprint(first_addr);
let first_token = create_session_fingerprint(&first_fingerprint, &user.username, 5001, true);
let second_fingerprint = create_test_fingerprint(second_addr);
let second_token = create_session_fingerprint(&second_fingerprint, &user.username, 5001, true);
app_state.connection_manager.add_connection(&first_addr).await;
app_state.connection_manager.add_connection(&second_addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: &first_token,
virtual_id: 5001,
provider: "provider-a",
stream_url: "http://provider-1.example/vod/5001.ts",
addr: &first_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 5001,
meter_uid: 0,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &first_fingerprint,
provider: "provider-a".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/vod/5001.ts"),
user_agent: std::borrow::Cow::Borrowed("ua"),
session_token: Some(&first_token),
})
.await;
let admission = app_state
.active_users
.connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &second_token)
.await;
assert_eq!(admission.permission, UserConnectionPermission::Exhausted);
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn resolve_admission_with_strategies_evicts_preserved_hls_session_for_same_user_ts_request() {
let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::EvictUserSameIpLatest,
AdmissionStrategy::GraceHoldStream,
AdmissionStrategy::EvictUserOldest,
AdmissionStrategy::EvictUserLatest,
]),
});
let hls_addr: std::net::SocketAddr = "127.0.0.1:55176".parse().unwrap_or_else(|_| unreachable!());
let ts_addr: std::net::SocketAddr = "127.0.0.1:55177".parse().unwrap_or_else(|_| unreachable!());
let hls_fingerprint = create_test_fingerprint_with_user_agent(hls_addr, "player/1.0");
let ts_fingerprint = create_test_fingerprint_with_user_agent(ts_addr, "player/1.0");
let mut user = ProxyUserCredentials::default();
user.username = "same-user".to_string();
user.max_connections = 1;
app_state.connection_manager.add_connection(&hls_addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: "tok-hls-preserved",
virtual_id: 5001,
provider: "provider-a",
stream_url: "http://provider-1.example/live/5001.m3u8",
addr: &hls_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &hls_fingerprint,
provider: "provider-a".intern(),
stream_channel: &StreamChannel {
item_type: PlaylistItemType::LiveHls,
virtual_id: 5001,
..create_test_live_channel("http://provider-1.example/live/5001.m3u8")
},
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("tok-hls-preserved"),
})
.await;
app_state.connection_manager.release_connection(&hls_addr).await;
assert_eq!(app_state.active_users.user_connections(&user.username).await, 0);
assert!(app_state.active_users.active_streams().await.is_empty());
let mut close_rx = app_state.connection_manager.get_close_connection_channel();
let result = resolve_admission_with_strategies(
&app_state.admission_ctx(),
AdmissionRequest {
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
client_ip: &ts_fingerprint.client_ip,
request_addr: &ts_fingerprint.addr,
use_session_admission: false,
session_token: None,
activate_unbound_session: false,
eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(5001) },
},
)
.await;
let admission = result.admission;
let grace_mode = result.grace_mode;
assert_eq!(admission.permission, UserConnectionPermission::Allowed);
assert_eq!(grace_mode, None);
assert!(app_state.active_users.active_streams().await.is_empty());
assert_eq!(
tokio::time::timeout(std::time::Duration::from_millis(100), close_rx.recv()).await.ok().and_then(Result::ok),
Some(crate::api::model::CloseConnectionSignal::WithReason(
hls_addr,
shared::model::DisconnectReason::ClientKicked,
))
);
assert!(
app_state.active_users.get_and_update_user_session(&user.username, "tok-hls-preserved").await.is_none(),
"preserved session should be removed once the TS request evicts it"
);
}
#[tokio::test]
async fn socket_bound_playback_sessions_still_allow_soft_slots() {
let app_state = create_test_app_state();
let mut user = ProxyUserCredentials::default();
user.username = "soft-user".to_string();
user.max_connections = 1;
user.soft_connections = 1;
user.priority = 0;
user.soft_priority = 9;
let first_addr: std::net::SocketAddr = "127.0.0.1:55173".parse().unwrap_or_else(|_| unreachable!());
let second_addr: std::net::SocketAddr = "127.0.0.1:55174".parse().unwrap_or_else(|_| unreachable!());
let third_addr: std::net::SocketAddr = "127.0.0.1:55175".parse().unwrap_or_else(|_| unreachable!());
let first_fingerprint = create_test_fingerprint(first_addr);
let second_fingerprint = create_test_fingerprint(second_addr);
let first_token = create_session_fingerprint(&first_fingerprint, &user.username, 6001, true);
let second_token = create_session_fingerprint(&second_fingerprint, &user.username, 6001, true);
let third_fingerprint = create_test_fingerprint(third_addr);
let third_token = create_session_fingerprint(&third_fingerprint, &user.username, 6001, true);
app_state.connection_manager.add_connection(&first_addr).await;
app_state.connection_manager.add_connection(&second_addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: &first_token,
virtual_id: 6001,
provider: "provider-a",
stream_url: "http://provider-1.example/vod/6001.ts",
addr: &first_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: true,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 6001,
meter_uid: 0,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: user.priority,
soft_priority: user.soft_priority,
fingerprint: &first_fingerprint,
provider: "provider-a".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/vod/6001.ts"),
user_agent: std::borrow::Cow::Borrowed("ua"),
session_token: Some(&first_token),
})
.await;
let second_admission = app_state
.active_users
.connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &second_token)
.await;
assert_eq!(second_admission.permission, UserConnectionPermission::Allowed);
assert_eq!(second_admission.kind, Some(crate::api::model::ConnectionKind::Soft));
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: &second_token,
virtual_id: 6001,
provider: "provider-a",
stream_url: "http://provider-1.example/vod/6001.ts",
addr: &second_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Soft),
socket_bound: true,
})
.await;
app_state
.active_users
.update_connection(crate::api::model::ActiveUserConnectionParams {
uid: 6002,
meter_uid: 0,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Soft,
priority: user.priority,
soft_priority: user.soft_priority,
fingerprint: &second_fingerprint,
provider: "provider-a".intern(),
stream_channel: &create_test_live_channel("http://provider-1.example/vod/6002.ts"),
user_agent: std::borrow::Cow::Borrowed("ua"),
session_token: Some(&second_token),
})
.await;
let third_admission = app_state
.active_users
.connection_admission_for_session(&user.username, user.max_connections, user.soft_connections, &third_token)
.await;
assert_eq!(third_admission.permission, UserConnectionPermission::Exhausted);
}
#[test]
fn session_based_playback_matches_adaptive_types_and_extensions() {
assert!(is_session_based_playback(PlaylistItemType::LiveHls, None));
assert!(is_session_based_playback(PlaylistItemType::LiveDash, None));
assert!(is_session_based_playback(PlaylistItemType::Live, Some(HLS_EXT)));
assert!(is_session_based_playback(PlaylistItemType::Live, Some(DASH_EXT)));
assert!(!is_session_based_playback(PlaylistItemType::Video, None));
}
#[test]
fn create_session_fingerprint_switches_between_logical_and_socket_bound_keys() {
let fingerprint = create_test_fingerprint("127.0.0.1:55176".parse().unwrap_or_else(|_| unreachable!()));
let logical = create_session_fingerprint(&fingerprint, "user1", 7001, false);
let socket_bound = create_session_fingerprint(&fingerprint, "user1", 7001, true);
assert_ne!(logical, socket_bound);
assert!(logical.contains(&fingerprint.key));
assert!(socket_bound.contains(&fingerprint.addr.to_string()));
}
#[test]
fn adaptive_playback_session_fingerprint_is_logical_across_initial_sockets() {
let Some(first_addr) = "127.0.0.1:55177".parse().ok() else {
return;
};
let Some(second_addr) = "127.0.0.1:55178".parse().ok() else {
return;
};
let first = Fingerprint::new("10.0.0.6|player".to_string(), "10.0.0.6".to_string(), first_addr);
let second = Fingerprint::new(first.key.clone(), first.client_ip.clone(), second_addr);
let first_token = create_playback_session_fingerprint(&first, "user1", 7002, PlaylistItemType::Live, Some(HLS_EXT));
let second_token =
create_playback_session_fingerprint(&second, "user1", 7002, PlaylistItemType::Live, Some(HLS_EXT));
assert_eq!(first_token, second_token);
assert!(first_token.contains(&first.key));
assert!(!first_token.contains(&first.addr.to_string()));
assert!(!second_token.contains(&second.addr.to_string()));
}
#[test]
fn playback_session_fingerprint_keeps_ts_socket_bound_but_vod_logical() {
let first_addr: SocketAddr = "127.0.0.1:55179".parse().unwrap_or_else(|_| unreachable!());
let second_addr: SocketAddr = "127.0.0.1:55180".parse().unwrap_or_else(|_| unreachable!());
let first = Fingerprint::new("10.0.0.7|player".to_string(), "10.0.0.7".to_string(), first_addr);
let second = Fingerprint::new(first.key.clone(), first.client_ip.clone(), second_addr);
let first_ts = create_playback_session_fingerprint(&first, "user1", 7003, PlaylistItemType::Live, None);
let second_ts = create_playback_session_fingerprint(&second, "user1", 7003, PlaylistItemType::Live, None);
let first_vod = create_playback_session_fingerprint(&first, "user1", 7003, PlaylistItemType::Video, None);
let second_vod = create_playback_session_fingerprint(&second, "user1", 7003, PlaylistItemType::Video, None);
assert_ne!(first_ts, second_ts, "plain TS live remains socket-bound");
assert_eq!(first_vod, second_vod, "VOD remains logical across reopen/seek sockets");
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn xtream_hls_then_ts_uses_distinct_tokens_and_evicts_old_hls_session() {
let mut app_cfg = create_test_app_config();
let config = Config {
user_access_control: true,
reverse_proxy: Some(crate::model::ReverseProxyConfig {
resource_rewrite_disabled: false,
rewrite_secret: [0; 16],
resource_retry: crate::model::ResourceRetryConfig::default(),
disabled_header: None,
stream: Some(crate::model::StreamConfig {
retry: true,
metrics_enabled: true,
buffer: None,
grace_period_millis: 2_000,
grace_period_timeout_secs: 8,
grace_period_hold_stream: true,
hls_session_ttl_secs: 10,
catchup_session_ttl_secs: 10,
throttle_str: None,
throttle_kbps: 0,
shared_burst_buffer_mb: 1,
shared_subscriber_idle_timeout_secs: 300,
admission_strategies: Some(vec![
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::EvictUserSameIpLatest,
AdmissionStrategy::GraceHoldStream,
AdmissionStrategy::EvictUserOldest,
AdmissionStrategy::EvictUserLatest,
]),
}),
cache: None,
rate_limit: None,
geoip: None,
stream_history: None,
qos_aggregation: None,
hls_cache: None,
}),
..Config::default()
};
app_cfg.config = Arc::new(ArcSwap::from_pointee(config));
let app_state = create_test_app_state_for_config(Arc::new(app_cfg));
let hls_addr: SocketAddr = "127.0.0.1:55186".parse().unwrap_or_else(|_| unreachable!());
let ts_addr: SocketAddr = "127.0.0.1:55187".parse().unwrap_or_else(|_| unreachable!());
let hls_fingerprint = create_test_fingerprint_with_user_agent(hls_addr, "libmpv");
let ts_fingerprint = create_test_fingerprint_with_user_agent(ts_addr, "libmpv");
let mut user = ProxyUserCredentials::default();
user.username = "xtream-hls-ts".to_string();
user.max_connections = 1;
let virtual_id = 7811;
let hls_token = create_session_fingerprint(&hls_fingerprint, &user.username, virtual_id, false);
let ts_token = create_session_fingerprint(&ts_fingerprint, &user.username, virtual_id, true);
assert_ne!(hls_token, ts_token, "Xtream .m3u8 and .ts must not share the same playback token");
let mut hls_channel = create_test_live_channel("http://provider-1.example/live/7811.m3u8");
hls_channel.virtual_id = virtual_id;
hls_channel.item_type = PlaylistItemType::LiveHls;
let mut ts_channel = create_test_live_channel("http://provider-1.example/live/7811.ts");
ts_channel.virtual_id = virtual_id;
app_state.connection_manager.add_connection(&hls_addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: &hls_token,
virtual_id,
provider: "provider_1",
stream_url: hls_channel.url.as_ref(),
addr: &hls_addr,
connection_permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 1,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &hls_fingerprint,
provider: "provider_1".intern(),
stream_channel: &hls_channel,
user_agent: Cow::Borrowed("libmpv"),
session_token: Some(&hls_token),
})
.await;
app_state.connection_manager.release_connection(&hls_addr).await;
assert_eq!(
app_state.active_users.user_connections(&user.username).await,
0,
"the preserved HLS playback must reserve capacity only virtually"
);
assert_eq!(app_state.active_users.active_users_and_connections().await, (0, 0));
assert!(
app_state.active_users.get_and_update_user_session(&user.username, &hls_token).await.is_some(),
"preserved HLS session should still exist before the competing TS request"
);
assert_eq!(
app_state
.active_users
.connection_admission(&user.username, user.max_connections, user.soft_connections)
.await
.permission,
UserConnectionPermission::Exhausted,
"the preserved HLS playback must still reserve the user's only slot before the TS request is evaluated"
);
assert_eq!(
app_state.active_users.get_eviction_candidates(&user.username, &ts_fingerprint.client_ip).await.len(),
1,
"the preserved HLS playback should be the single eviction candidate for the competing TS request"
);
let (ts_admission, ts_grace_mode, request_class) = resolve_playback_request_admission(
&app_state.admission_ctx(),
&user,
&ts_fingerprint,
None,
&ts_token,
false,
EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) },
false,
false,
)
.await;
assert_eq!(request_class, PlaybackRequestClass::Activate);
assert_eq!(ts_admission.permission, UserConnectionPermission::Allowed);
assert_eq!(ts_grace_mode, None);
assert!(
app_state
.active_users
.get_and_update_user_session(&user.username, &hls_token)
.await
.is_none(),
"the competing TS activation must remove the old preserved HLS session even though there is no live socket left to kick"
);
assert_eq!(
app_state.active_users.user_connections(&user.username).await,
0,
"eviction must not leave a real slot before the TS stream commits"
);
assert_eq!(app_state.active_users.active_users_and_connections().await, (0, 0));
app_state.connection_manager.add_connection(&ts_addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user: &user,
session_token: &ts_token,
virtual_id,
provider: "provider_1",
stream_url: ts_channel.url.as_ref(),
addr: &ts_addr,
connection_permission: ts_admission.permission,
connection_kind: ts_admission.kind,
socket_bound: true,
})
.await;
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 2,
username: &user.username,
max_connections: user.max_connections,
soft_connections: user.soft_connections,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &ts_fingerprint,
provider: "provider_1".intern(),
stream_channel: &ts_channel,
user_agent: Cow::Borrowed("libmpv"),
session_token: Some(&ts_token),
})
.await;
assert_eq!(app_state.active_users.user_connections(&user.username).await, 1);
assert_eq!(app_state.active_users.active_users_and_connections().await, (1, 1));
let active_streams = app_state.active_users.active_streams().await;
assert_eq!(active_streams.len(), 1);
assert_eq!(active_streams.first().and_then(|stream| stream.session_token.as_deref()), Some(ts_token.as_str()));
assert!(
app_state
.active_users
.get_and_update_user_session(&user.username, &hls_token)
.await
.is_none(),
"after the competing TS request, the old Xtream HLS session must be gone so later /hls segment fetches cannot revive it"
);
assert!(
app_state.active_users.get_and_update_user_session(&user.username, &ts_token).await.is_some(),
"the winning TS playback should remain tracked under its socket-bound Xtream token"
);
}
#[test]
fn socket_bound_playback_session_matches_only_plain_live_playback() {
assert!(is_socket_bound_playback_session(PlaylistItemType::Live, None));
assert!(!is_socket_bound_playback_session(PlaylistItemType::Live, Some(HLS_EXT)));
assert!(!is_socket_bound_playback_session(PlaylistItemType::Live, Some(DASH_EXT)));
assert!(!is_socket_bound_playback_session(PlaylistItemType::LiveHls, None));
assert!(!is_socket_bound_playback_session(PlaylistItemType::Video, None));
assert!(!is_socket_bound_playback_session(PlaylistItemType::Series, None));
assert!(!is_socket_bound_playback_session(PlaylistItemType::Catchup, None));
}
#[test]
fn session_reacquire_cleanup_addrs_excludes_current_and_deduplicates() {
let primary: SocketAddr = "127.0.0.1:55191".parse().unwrap_or_else(|_| unreachable!());
let overlap: SocketAddr = "127.0.0.1:55192".parse().unwrap_or_else(|_| unreachable!());
let seek: SocketAddr = "127.0.0.1:55193".parse().unwrap_or_else(|_| unreachable!());
let session = UserSession {
token: "tok-vod".to_string(),
transition_version: 1,
virtual_id: 9001,
provider: "provider-a".intern(),
stream_url: "http://localhost/movie.mkv".intern(),
provider_session_headers: HashMap::new(),
addr: seek,
socket_bound: false,
active_addrs: vec![primary, overlap, seek, overlap],
ts: 1,
started_at: 1,
permission: UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
lifecycle: crate::api::model::PlaybackLifecycle::Active,
};
assert_eq!(session_reacquire_cleanup_addrs(&session, &seek), vec![primary, overlap]);
}
#[tokio::test]
async fn intentional_deferred_open_retains_provider_grace_handle() {
let app_state = create_test_provider_app_state();
let provider_name = "provider_1".intern();
let holder_addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!());
let deferred_addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!());
let holder_handle = app_state
.active_provider
.acquire_exact_connection_with_grace(
&provider_name,
&holder_addr,
false,
0,
crate::api::model::ConnectionKind::Normal,
)
.await
.expect("holder occupies the provider slot");
let input = app_state.app_config.get_input_by_name(&provider_name).expect("provider input");
let stream_url = "http://provider-1.example/live/user1/pass1/100.m3u8";
let mut channel = create_test_live_channel(stream_url);
channel.item_type = PlaylistItemType::LiveHls;
let fingerprint = create_test_fingerprint(deferred_addr);
let mut details = create_stream_response_details(
&app_state,
&get_stream_options(&app_state.app_config),
stream_url,
"deferred-user",
&fingerprint,
&HeaderMap::new(),
&input,
&channel,
PlaylistItemType::LiveHls,
crate::api::model::ProviderContentRepresentationMode::PreserveOrigin,
false,
UserConnectionPermission::Allowed,
None,
true,
true,
VirtualId::new(channel.virtual_id),
0,
crate::api::model::ConnectionKind::Normal,
false,
Some("deferred-session"),
None,
false,
Some(true),
None,
)
.await
.expect("provider grace creates deferred stream details");
assert!(details.stream.is_none());
assert!(details.has_deferred_provider_open());
assert!(details.provider_handle.is_some(), "deferred open must retain its provider allocation");
app_state.connection_manager.release_provider_handle(details.provider_handle.take()).await;
app_state.connection_manager.release_provider_handle(Some(holder_handle)).await;
}
#[test]
fn grace_hold_defers_live_and_fresh_video_but_not_catchup_or_affine_reopens() {
assert!(should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::LiveHls, false));
assert!(should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Video, false));
assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Catchup, false));
assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Catchup, true));
assert!(!should_defer_provider_open_for_grace_hold(true, true, PlaylistItemType::Video, true));
assert!(!should_defer_provider_open_for_grace_hold(true, false, PlaylistItemType::Video, true));
}
#[tokio::test]
async fn forced_reopen_cleanup_for_adaptive_streams_does_not_close_client_socket() {
let app_state = create_test_app_state();
let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!());
let mut close_rx = app_state.connection_manager.get_close_connection_channel();
cleanup_forced_reopen_addrs(&app_state, PlaylistItemType::LiveHls, &[addr]).await;
let signal =
tokio::time::timeout(std::time::Duration::from_millis(50), close_rx.recv()).await.ok().and_then(Result::ok);
assert!(signal.is_none(), "adaptive cleanup should not hard-close the previous client socket");
}
#[tokio::test]
async fn forced_reopen_cleanup_for_non_adaptive_streams_closes_client_socket() {
let app_state = create_test_app_state();
let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!());
let mut close_rx = app_state.connection_manager.get_close_connection_channel();
cleanup_forced_reopen_addrs(&app_state, PlaylistItemType::Live, &[addr]).await;
let signal =
tokio::time::timeout(std::time::Duration::from_millis(50), close_rx.recv()).await.ok().and_then(Result::ok);
assert!(matches!(
signal,
Some(crate::api::model::CloseConnectionSignal::WithReason(signal_addr, _)) if signal_addr == addr
));
}
#[tokio::test]
async fn get_query_path_strips_extension_for_live_with_flag() {
use crate::model::ConfigInputFlags;
use shared::model::{InputType, PlaylistItemType, XtreamCluster, XtreamPlaylistItem};
let mut input = ConfigInput {
id: 1,
name: "provider_with_flag".intern(),
input_type: InputType::Xtream,
..ConfigInput::default()
};
let mut options = crate::model::ConfigInputOptions::defaults().clone();
options.flags.set(ConfigInputFlags::XtreamLiveStreamWithoutExtension);
input.options = Some(options);
let sources = SourcesConfig { inputs: vec![Arc::new(input)], ..SourcesConfig::default() };
let mut app_cfg_raw = create_test_app_config();
app_cfg_raw.sources = Arc::new(ArcSwap::from_pointee(sources));
let app_state = create_test_app_state_for_config(Arc::new(app_cfg_raw));
let pli = XtreamPlaylistItem {
virtual_id: VirtualId::new(100),
provider_id: 1,
name: "test".intern(),
logo: "".intern(),
logo_small: "".intern(),
group: "".intern(),
title: "".intern(),
parent_code: "".intern(),
rec: "".intern(),
url: "http://example.com/123".intern(),
epg_channel_id: None,
xtream_cluster: XtreamCluster::Live,
additional_properties: None,
item_type: PlaylistItemType::Live,
category_id: 0,
input_name: "provider_with_flag".intern(),
channel_no: 0,
source_ordinal: 0,
input_stream_id: "1".intern(),
upstream_user_agent: None,
};
let hls_ext = shared::defaults::HLS_EXT.to_string();
let (query_path, extension) =
crate::api::endpoints::xtream_api::get_query_path("", Some(&hls_ext), &pli, &app_state);
assert_eq!(extension, "");
assert_eq!(query_path, "1");
let dash_ext = shared::defaults::DASH_EXT.to_string();
let (query_path, extension) =
crate::api::endpoints::xtream_api::get_query_path("", Some(&dash_ext), &pli, &app_state);
assert_eq!(extension, "");
assert_eq!(query_path, "1");
}
// =========================================================================================
// evaluate_network_access tests
// =========================================================================================
/// Run `evaluate_network_access` for a synthetic user built from
/// `network_access` and assert the decision matches `expected`. Centralizes
/// the boilerplate (`user_with_network_access` + geoip setup + call +
/// assert) shared by every `evaluate_network_access` test below.
fn assert_network_decision(
network_access: Option<NetworkAccess>,
geoip: &Arc<ArcSwapOption<GeoIp>>,
ip: &str,
expected: NetworkAccessDecision,
) {
let user = user_with_network_access(network_access);
assert_eq!(evaluate_network_access(&user, ip, geoip, GeoIpUnavailablePolicy::Deny), expected);
}
/// `Arc<ArcSwapOption<GeoIp>>` with no `GeoIP` database loaded.
fn empty_geoip() -> Arc<ArcSwapOption<GeoIp>> { Arc::new(ArcSwapOption::<GeoIp>::default()) }
/// `Arc<ArcSwapOption<GeoIp>>` with a mock `GeoIP` that always reports the
/// given country for any lookup.
fn mock_geoip(country: &str) -> Arc<ArcSwapOption<GeoIp>> {
Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new(country)))))
}
/// Helper to create a test user with specific network access
fn user_with_network_access(network_access: Option<NetworkAccess>) -> ProxyUserCredentials {
ProxyUserCredentials {
username: "test".to_string(),
password: "test".to_string(),
token: None,
proxy: ProxyType::default(),
server: None,
epg_timeshift: None,
epg_request_timeshift: None,
created_at: None,
exp_date: None,
max_connections: 0,
status: None,
output_clusters: ClusterFlags::all(),
ui_enabled: true,
comment: None,
priority: 0,
soft_connections: 0,
soft_priority: 0,
t_is_api_user: false,
network_access,
plan: None,
filter: None,
raw_output_clusters: None,
raw_max_connections: 0,
raw_soft_connections: 0,
raw_proxy: Some(ProxyType::default()),
t_filter: None,
t_has_unresolved_plan: false,
t_has_invalid_filter: false,
}
}
#[test]
fn no_config_allows_all() {
assert_network_decision(None, &empty_geoip(), "192.168.1.1", NetworkAccessDecision::Allowed);
}
#[test]
fn empty_config_allows_all() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec![] }),
&empty_geoip(),
"192.168.1.1",
NetworkAccessDecision::Allowed,
);
}
#[test]
fn cidr_match_allows() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["192.168.1.0/24".parse().unwrap()] }),
&empty_geoip(),
"192.168.1.42",
NetworkAccessDecision::Allowed,
);
}
#[test]
fn cidr_miss_denies() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["192.168.1.0/24".parse().unwrap()] }),
&empty_geoip(),
"10.0.0.1",
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch),
);
}
#[test]
fn country_match_allows() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }),
&mock_geoip("DE"),
"8.8.8.8",
NetworkAccessDecision::Allowed,
);
}
#[test]
fn country_miss_denies() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }),
&mock_geoip("US"),
"8.8.8.8",
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch),
);
}
#[test]
fn no_geoip_denies_on_country_restriction() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec!["DE".to_string()], allowed_networks: vec![] }),
&empty_geoip(),
"8.8.8.8",
NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable),
);
}
#[test]
fn ipv4_vs_ipv6_denies_gracefully() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["2001:db8::/32".parse().unwrap()] }),
&empty_geoip(),
"192.168.1.1",
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch),
);
}
#[test]
fn ipv6_vs_ipv4_denies_gracefully() {
assert_network_decision(
Some(NetworkAccess { allowed_countries: vec![], allowed_networks: vec!["192.168.1.0/24".parse().unwrap()] }),
&empty_geoip(),
"2001:db8::1",
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch),
);
}
#[test]
fn either_cidr_or_country_match_allows() {
assert_network_decision(
Some(NetworkAccess {
allowed_countries: vec!["US".to_string()],
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
}),
&mock_geoip("DE"),
"192.168.1.42",
NetworkAccessDecision::Allowed,
);
}
#[test]
fn single_ip_cidr() {
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec![],
allowed_networks: vec!["192.168.1.1/32".parse().unwrap()],
}));
let geoip = empty_geoip();
assert_eq!(
evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny),
NetworkAccessDecision::Allowed
);
assert_eq!(
evaluate_network_access(&user, "192.168.1.2", &geoip, GeoIpUnavailablePolicy::Deny),
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)
);
}
// =========================================================================================
// network denied reason tests
// =========================================================================================
// The three `network_denied_reason_*` cases remain because they cover the
// `NetworkAccessDenyReason`-focused API surface directly, while
// `cidr_miss_denies`, `country_miss_denies`, and
// `no_geoip_denies_on_country_restriction` above assert the same deny
// reasons through broader `evaluate_network_access(...)` behavior. The
// overlap is intentional so both the general decision path and the
// reason-reporting-focused path stay pinned by tests.
#[test]
fn network_denied_reason_country_unknown_when_geoip_loaded_but_unknown_ip() {
// GeoIP is loaded (not None), but lookup returns None for this IP (private/unknown).
// We need a GeoIP that only covers a private range, so public IPs get None.
// Use a CIDR-only restriction (no country rules) so we can verify
// that when countries ARE checked, lookup None gives "country_unknown".
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], // miss CIDR first
}));
// Use the real GeoIp::new() which only seeds private ranges.
// For 8.8.8.8 (public), lookup returns None.
let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new()))));
// CIDR miss -> country check -> geoip loaded but lookup returns None
assert_eq!(
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny),
NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown)
);
}
#[test]
fn network_denied_reason_none_when_allowed() {
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec![],
}));
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("DE")))));
assert_eq!(
evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny),
NetworkAccessDecision::Allowed
);
}
#[test]
fn network_denied_reason_none_when_no_config() {
let user = user_with_network_access(None);
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
assert_eq!(
evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny),
NetworkAccessDecision::Allowed
);
}
// =========================================================================================
// GeoIP unavailable policy tests
// =========================================================================================
#[test]
fn geoip_unavailable_default_deny_denies() {
// Country rule exists but GeoIP is unavailable — default policy is Deny
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec![],
}));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny);
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable));
}
#[test]
fn geoip_unavailable_explicit_allow_allows() {
// Country rule exists, GeoIP unavailable, but policy is Allow — allows
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec![],
}));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow);
assert_eq!(decision, NetworkAccessDecision::AllowedGeoIpUnavailable);
}
#[test]
fn geoip_unavailable_cidr_only_still_denies() {
// CIDR only rules, no match — should deny even with Allow policy
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec![],
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
}));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Allow);
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch));
}
#[test]
fn geoip_unavailable_cidr_match_allows() {
// CIDR match always allows, regardless of policy
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec!["10.0.0.0/8".parse().unwrap()],
}));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Deny);
assert_eq!(decision, NetworkAccessDecision::Allowed);
}
#[test]
fn geoip_loaded_country_mismatch_still_denies() {
// Loaded GeoIP but country doesn't match — should deny under Allow policy
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec![],
}));
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("US")))));
let decision = evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Allow);
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch));
}
#[test]
fn geoip_loaded_unknown_country_still_denies() {
// Loaded GeoIP but lookup returns None — should deny under Allow policy
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
}));
let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new()))));
let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow);
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown));
}
#[test]
fn malformed_ip_denies_even_when_geoip_unavailable_policy_is_allow() {
let user = user_with_network_access(Some(NetworkAccess::from(&shared::model::NetworkAccessDto {
allowed_countries: Some(vec!["DE".to_string()]),
allowed_networks: None,
})));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
let decision = evaluate_network_access(&user, "not-an-ip", &geoip, GeoIpUnavailablePolicy::Allow);
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::MalformedClientIp));
}
#[test]
fn evaluate_network_access_respects_allow_policy() {
// verify evaluate_network_access returns AllowedGeoIpUnavailable with Allow policy
let user = user_with_network_access(Some(NetworkAccess {
allowed_countries: vec!["DE".to_string()],
allowed_networks: vec![],
}));
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
// Default deny policy should return Denied
assert_eq!(
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny),
NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable)
);
// Allow policy should return AllowedGeoIpUnavailable
assert_eq!(
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow),
NetworkAccessDecision::AllowedGeoIpUnavailable
);
}