Files
tuliprox/tools/tuliprox-testkit/src/config.rs
T
2026-10-19 18:34:19 +02:00

1290 lines
50 KiB
Rust

use crate::{
oracle::{AdmissionStrategy, GraceMode},
TestkitError,
};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
time::Duration,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scenario {
pub schema_version: u16,
pub name: String,
pub tuliprox: TuliproxConfig,
#[serde(default)]
pub origin: Option<OriginConfig>,
#[serde(default)]
pub actors: Vec<Actor>,
#[serde(default)]
pub channels: HashMap<String, Channel>,
#[serde(default)]
pub agents: AgentsConfig,
#[serde(default)]
pub policy_contract: Option<PolicyContract>,
#[serde(default)]
pub steps: Vec<Step>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentsConfig {
#[serde(default)]
pub required: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyContract {
pub user_access_control: bool,
#[serde(default)]
pub users: HashMap<String, UserPolicy>,
/// `None` preserves an omitted field, which has distinct Tuliprox semantics
/// from an explicit empty admission-strategy list.
#[serde(default)]
pub admission_strategies: Option<Vec<AdmissionStrategy>>,
/// Overrides the reentry cooldown applied after an eviction. When set, a retry of
/// the evicted playback inside this window is suppressed instead of evicting its
/// replacement. Used by the reentry-suppression scenario.
#[serde(default)]
pub recent_eviction_reentry_ttl_ms: Option<u64>,
/// Overrides `reverse_proxy.stream.hls_session_ttl_secs`, the HLS reconnect window.
#[serde(default)]
pub hls_session_ttl_secs: Option<u64>,
/// Overrides `reverse_proxy.stream.provider_affinity_ttl_secs`.
#[serde(default)]
pub provider_affinity_ttl_secs: Option<u64>,
/// Overrides `reverse_proxy.stream.hls_wrap_media_playlist`.
#[serde(default)]
pub hls_wrap_media_playlist: Option<bool>,
#[serde(default)]
pub grace: Option<GraceContract>,
#[serde(default)]
pub provider_max_connections: Option<u16>,
#[serde(default)]
pub provider_pool: Vec<ProviderPoolAccount>,
#[serde(default)]
pub expected_provider_slots: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderPoolAccount {
pub name: String,
pub max_connections: u16,
#[serde(default)]
pub priority: Option<i16>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UserPolicy {
pub max_connections: usize,
#[serde(default)]
pub soft_connections: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GraceContract {
pub mode: GraceMode,
pub timeout_millis: u64,
}
impl PolicyContract {
#[must_use]
pub fn effective_admission_strategies(&self) -> Vec<AdmissionStrategy> {
self.admission_strategies.clone().unwrap_or_else(|| {
self.grace.as_ref().map_or_else(Vec::new, |grace| match grace.mode {
GraceMode::Instant => vec![AdmissionStrategy::GraceInstantStream],
GraceMode::HoldStream => vec![AdmissionStrategy::GraceHoldStream],
})
})
}
#[must_use]
pub fn provider_capacity(&self) -> Option<usize> {
if self.provider_pool.is_empty() {
self.provider_max_connections.map(usize::from)
} else {
Some(self.provider_pool.iter().map(|account| usize::from(account.max_connections)).sum())
}
}
}
/// Which provider protocol the isolated fixture imports from.
///
/// `m3u` (default) keeps the generated playlist input. `stalker` points the input at the
/// Ministra portal the fixture origin emulates, so the import, the runtime `create_link`
/// resolution and the destination guard can be exercised against a scripted portal.
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FixtureInputType {
#[default]
M3u,
Stalker,
}
/// The emulated portal's identity and its scripted refusals.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FixtureStalker {
/// MAC the fixture input authenticates with. The emulated portal accepts any MAC.
pub mac_address: String,
/// MAG preset written into the fixture input. Must be one of the four presets.
pub mag_preset: String,
/// Refuse the first `create_link` with a stale-session body (`code` 44) and answer the
/// retry. A portal that invalidates the session out of band behaves this way; the
/// playback path has to re-handshake instead of reporting the item as unresolvable.
pub refuse_create_link_once: bool,
/// Channels whose `create_link` is always refused with the same stale-session body.
pub refuse_create_link_markers: Vec<u32>,
/// Serve a distinct `cmd_1` descriptor command alongside the raw `cmd`.
pub separate_descriptor_command: bool,
}
impl FixtureStalker {
pub const PRESETS: [&'static str; 4] = ["generic_safe", "mag250_legacy", "mag254_strict", "ministra_modern"];
}
impl Default for FixtureStalker {
fn default() -> Self {
Self {
mac_address: "00:1A:79:00:00:01".to_owned(),
mag_preset: "generic_safe".to_owned(),
refuse_create_link_once: false,
refuse_create_link_markers: Vec::new(),
separate_descriptor_command: false,
}
}
}
/// Replaces the single generated provider input with one m3u input per name, each served as
/// its own origin account, and turns failover groups on for the generated target. All inputs
/// carry the same channels, so the target groups them automatically.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FixtureFailoverGroups {
pub inputs: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TuliproxConfig {
pub base_url: String,
#[serde(default)]
pub api_base_url: Option<String>,
#[serde(default)]
pub input_type: FixtureInputType,
#[serde(default)]
pub stalker: FixtureStalker,
#[serde(default)]
pub failover_groups: Option<FixtureFailoverGroups>,
#[serde(default)]
pub admin_username: Option<String>,
#[serde(default)]
pub admin_password_env: Option<String>,
#[serde(default)]
pub playlist_url: Option<String>,
#[serde(default)]
pub bootstrap: Option<BootstrapConfig>,
#[serde(default)]
pub execution_mode: ExecutionMode,
#[serde(default)]
pub playback_endpoint: PlaybackEndpoint,
#[serde(default)]
pub fixture_stream: FixtureStreamOptions,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OriginConfig {
pub run_id: String,
#[serde(default)]
pub account_limit: Option<usize>,
#[serde(default)]
pub limit_mode: Option<crate::origin_transport::OriginLimitMode>,
/// See `OriginPolicy::connect_latency_ms`.
#[serde(default)]
pub connect_latency_ms: Option<u64>,
#[serde(default)]
pub hls_profile: Option<crate::hls_probe::HlsOriginProfile>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BootstrapConfig {
pub command: String,
#[serde(default)]
pub arguments: Vec<String>,
pub readiness_url: String,
#[serde(default = "default_readiness_timeout_millis")]
pub readiness_timeout_millis: u64,
/// Files copied into the isolated config directory before the SUT starts, for example a
/// `failover.d/` override file.
#[serde(default)]
pub extra_config_files: Vec<ExtraConfigFile>,
}
/// One fixture file placed next to the generated `config.yml`.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExtraConfigFile {
/// Path relative to the scenario file's directory.
pub source: String,
/// Path relative to the isolated config directory. Must stay below it.
pub destination: String,
}
fn default_readiness_timeout_millis() -> u64 { 30_000 }
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionMode {
#[default]
IsolatedFixture,
ExistingInstance,
}
/// Selects which Tuliprox playback endpoint the controller uses to build stream URLs.
/// `m3u` (default) discovers URLs from the M3U playlist; `xtream` builds an Xtream live URL
/// from the discovered virtual ID. Currently only `xtream_ts` channels support `xtream`.
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PlaybackEndpoint {
#[default]
M3u,
Xtream,
}
/// Controls per-fixture live-stream sharing. Defaults mirror the current global behaviour
/// (both HLS and MPEG-TS sharing enabled) so existing scenarios are unaffected.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FixtureStreamOptions {
pub share_live_hls: bool,
pub share_live_mpeg_ts: bool,
#[serde(default)]
pub hls_startup_mode: Option<crate::hls_probe::StartupMode>,
}
impl Default for FixtureStreamOptions {
fn default() -> Self { Self { share_live_hls: true, share_live_mpeg_ts: true, hls_startup_mode: None } }
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Actor {
pub id: String,
pub agent: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password_env: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub client_ip: Option<ClientIp>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientIp {
pub mode: String,
#[serde(default)]
pub value: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Channel {
pub origin_marker: u32,
pub protocol: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub command_id: String,
/// Idles the scenario for this many milliseconds, for example to let a
/// reconnect window lapse between two requests of one playback.
#[serde(default)]
pub pause_millis: Option<u64>,
#[serde(default)]
pub expect: ExpectedPlayback,
pub start: Option<Start>,
#[serde(default)]
pub stop: Option<Stop>,
#[serde(default)]
pub repeat: Option<Repeat>,
#[serde(default)]
pub await_frames: Option<u64>,
#[serde(default, rename = "await")]
pub await_condition: Option<AwaitCondition>,
#[serde(default)]
pub assert_origin: Option<AssertOrigin>,
#[serde(default)]
pub assert_runtime: Option<RuntimeAssertions>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ExpectedRejection {
#[serde(default)]
pub custom_video: Option<crate::custom_video::CustomVideoKind>,
#[serde(default)]
pub http_status: Option<u16>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedPlayback {
#[default]
Streaming,
Rejected,
RejectedWith(ExpectedRejection),
/// A recently evicted playback retried and the SUT terminated it quietly
/// (HTTP 204, no custom error video, no connection-denied event).
Suppressed,
/// The SUT answered with a plain HTTP failure instead of a stream — the SUT's own
/// rejection statuses are covered by [`Self::Rejected`]. Used where the stream cannot
/// be served for a reason the SUT reports as an error, such as a Stalker `create_link`
/// target the destination guard refuses.
HttpError(u16),
}
impl ExpectedPlayback {
#[must_use]
pub fn is_streaming(&self) -> bool { matches!(self, Self::Streaming) }
#[must_use]
pub fn is_rejected(&self) -> bool {
matches!(self, Self::Rejected | Self::RejectedWith(_) | Self::Suppressed | Self::HttpError(_))
}
#[must_use]
pub fn matches_outcome(&self, outcome: &crate::protocol::PlaybackOutcome) -> bool {
use crate::protocol::{PlaybackOutcome, RejectionReason};
match (self, outcome) {
(Self::Streaming, PlaybackOutcome::Streaming { .. })
| (Self::Rejected, PlaybackOutcome::AdmissionRejected { .. })
// A quiet suppression is exactly the 204 the SUT returns for a
// reentry-protected retry. Any other rejection reason must fail.
| (Self::Suppressed, PlaybackOutcome::AdmissionRejected { reason: RejectionReason::HttpStatus(204) }) => {
true
}
(Self::HttpError(expected), PlaybackOutcome::HttpError { status }) => expected == status,
(Self::RejectedWith(expected), PlaybackOutcome::AdmissionRejected { reason }) => match reason {
RejectionReason::CustomVideo(kind) => {
expected.custom_video.is_none_or(|expected_kind| expected_kind == *kind)
}
RejectionReason::HttpStatus(status) => {
expected.http_status.is_none_or(|expected_status| expected_status == *status)
}
_ => expected.custom_video.is_none() && expected.http_status.is_none(),
},
_ => false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AwaitCondition {
#[serde(default)]
pub valid_frames: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AssertOrigin {
#[serde(default)]
pub max_active_tcp: Option<usize>,
#[serde(default)]
pub max_active_body: Option<usize>,
#[serde(default)]
pub active_body: Option<usize>,
#[serde(default)]
pub no_evictions: Option<bool>,
#[serde(default)]
pub no_limit_rejections: Option<bool>,
#[serde(default)]
pub latest_request_account: Option<String>,
/// Portal handshakes the emulated Stalker portal answered at least.
#[serde(default)]
pub stalker_handshakes_at_least: Option<u64>,
/// `create_link` requests the portal received at least, refusals included.
#[serde(default)]
pub stalker_create_links_at_least: Option<u64>,
/// `create_link` requests the portal refused with a stale-session body, at least.
#[serde(default)]
pub stalker_token_refusals_at_least: Option<u64>,
/// Markers the portal answered with a stream URL, compared as a set. Proves that a
/// playback resolved the requested channel's own stored command.
#[serde(default)]
pub stalker_create_link_markers: Option<Vec<u32>>,
/// Markers resolved through the portal's `cmd_1` descriptor command.
#[serde(default)]
pub stalker_descriptor_markers: Option<Vec<u32>>,
/// Markers resolved through the raw `cmd` fallback command.
#[serde(default)]
pub stalker_raw_command_markers: Option<Vec<u32>>,
}
/// Per-step assertions against the SUT runtime status (`/api/v1/status`).
///
/// These verify the user-side accounting the operator sees in the Web UI, so a
/// leaked or double-counted connection fails the scenario even when the origin
/// and provider counters look correct.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct RuntimeAssertions {
#[serde(default)]
pub active_users: Option<usize>,
#[serde(default)]
pub active_user_connections: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PostReadAction {
#[default]
KeepOpen,
Pause,
Close,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Start {
pub actor: String,
pub playback_id: String,
pub session_group: String,
#[serde(default)]
pub expect_evicted: bool,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub range: Option<String>,
#[serde(default)]
pub expected_status: Option<u16>,
#[serde(default)]
pub expected_content_range: Option<String>,
#[serde(default)]
pub read_limit_bytes: Option<u64>,
#[serde(default)]
pub post_read_action: Option<PostReadAction>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub vod_object: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stop {
pub playback_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Repeat {
pub count: usize,
pub steps: Vec<Step>,
}
impl Scenario {
pub fn from_path(path: &Path) -> Result<Self, TestkitError> {
let content = fs::read_to_string(path)?;
let scenario: Self =
serde_saphyr::from_str(&content).map_err(|error| TestkitError::Configuration(error.to_string()))?;
scenario.validate()?;
Ok(scenario)
}
#[allow(clippy::too_many_lines)]
pub fn validate(&self) -> Result<(), TestkitError> {
if self.schema_version != 1 {
return Err(TestkitError::Configuration("unsupported scenario schema version".to_owned()));
}
if self.name.trim().is_empty() || self.tuliprox.base_url.trim().is_empty() {
return Err(TestkitError::Configuration("scenario name and Tuliprox URL are required".to_owned()));
}
if self.tuliprox.execution_mode == ExecutionMode::IsolatedFixture && self.tuliprox.bootstrap.is_none() {
return Err(TestkitError::Configuration("isolated_fixture requires a bootstrap definition".to_owned()));
}
if self.origin.as_ref().is_some_and(|origin| origin.run_id.trim().is_empty()) {
return Err(TestkitError::Configuration("origin run_id cannot be empty".to_owned()));
}
if self.policy_contract.as_ref().and_then(|contract| contract.provider_max_connections) == Some(0) {
return Err(TestkitError::Configuration(
"provider_max_connections must be positive when specified".to_owned(),
));
}
if let Some(contract) = &self.policy_contract {
validate_provider_pool_contract(contract)?;
}
if let Some(profile) = self.origin.as_ref().and_then(|origin| origin.hls_profile.as_ref()) {
profile.validate()?;
}
let actor_ids = self.actors.iter().map(|actor| actor.id.as_str()).collect::<HashSet<_>>();
if actor_ids.len() != self.actors.len() || actor_ids.iter().any(|id| id.is_empty()) {
return Err(TestkitError::Configuration("actor IDs must be unique and non-empty".to_owned()));
}
let steps = self.expanded_steps()?;
let mut command_ids = HashSet::new();
let mut playback_ids = HashSet::new();
let mut seen_playback_ids = HashSet::new();
let mut expected_evictions = HashSet::new();
for step in &steps {
if !command_ids.insert(step.command_id.as_str()) || step.command_id.is_empty() {
return Err(TestkitError::Configuration("command IDs must be unique and non-empty".to_owned()));
}
if step.pause_millis.is_some() {
if !step.expect.is_streaming()
|| step.await_frames.is_some()
|| step.await_condition.is_some()
|| step.assert_origin.is_some()
|| step.assert_runtime.is_some()
{
return Err(TestkitError::Configuration(
"pause_millis cannot be combined with playback expectations or checks".to_owned(),
));
}
continue;
}
if step.start.is_some() == step.stop.is_some() {
return Err(TestkitError::Configuration(
"each step must contain exactly one of start or stop".to_owned(),
));
}
let Some(start) = &step.start else {
let stop = step.stop.as_ref().ok_or_else(|| TestkitError::Configuration("missing stop".to_owned()))?;
if stop.playback_id.is_empty() || !playback_ids.contains(stop.playback_id.as_str()) {
return Err(TestkitError::Configuration(format!(
"stop references inactive playback {}",
stop.playback_id
)));
}
if expected_evictions.contains(stop.playback_id.as_str()) {
return Err(TestkitError::Configuration(format!(
"playback {} expects eviction and cannot be explicitly stopped",
stop.playback_id
)));
}
playback_ids.remove(stop.playback_id.as_str());
continue;
};
if !actor_ids.contains(start.actor.as_str()) {
return Err(TestkitError::Configuration(format!("unknown actor {}", start.actor)));
}
if start.expect_evicted {
if step.expect.is_rejected()
|| self.actors.iter().any(|actor| actor.id == start.actor && actor.agent != "local")
|| ((start.vod_object.is_some()
|| start.range.is_some()
|| start.read_limit_bytes.is_some()
|| start.method.is_some())
&& (start.post_read_action.is_some_and(|action| action != PostReadAction::KeepOpen)
|| start.method.as_deref() == Some("HEAD")))
{
return Err(TestkitError::Configuration(
"expect_evicted requires a held streaming playback on a local actor".to_owned(),
));
}
expected_evictions.insert(start.playback_id.as_str());
}
if start.playback_id.is_empty() || start.session_group.is_empty() {
return Err(TestkitError::Configuration("start requires playback_id and session_group".to_owned()));
}
let target_count = [start.url.is_some(), start.channel.is_some(), start.vod_object.is_some()]
.into_iter()
.filter(|&v| v)
.count();
if target_count != 1 {
return Err(TestkitError::Configuration(
"start requires exactly one of url, channel, or vod_object".to_owned(),
));
}
if start.url.as_deref().is_some_and(str::is_empty)
|| start.channel.as_deref().is_some_and(str::is_empty)
|| start.vod_object.as_deref().is_some_and(str::is_empty)
{
return Err(TestkitError::Configuration(
"start URL, channel, or vod_object cannot be empty".to_owned(),
));
}
if let Some(channel) = &start.channel {
if !self.channels.contains_key(channel) {
return Err(TestkitError::Configuration(format!("unknown channel {channel}")));
}
}
if !seen_playback_ids.insert(start.playback_id.as_str()) {
return Err(TestkitError::Configuration(format!("duplicate playback ID {}", start.playback_id)));
}
playback_ids.insert(start.playback_id.as_str());
if step.expect.is_rejected()
&& step
.await_frames
.or_else(|| step.await_condition.as_ref().and_then(|condition| condition.valid_frames))
.is_some()
{
return Err(TestkitError::Configuration("rejected playback must not await valid frames".to_owned()));
}
}
self.validate_policy_contract()?;
self.validate_playback_endpoint()?;
self.validate_stalker_fixture()?;
self.validate_failover_groups_fixture()?;
self.validate_extra_config_files()?;
Ok(())
}
fn validate_policy_contract(&self) -> Result<(), TestkitError> {
if let Some(contract) = &self.policy_contract {
if !contract.user_access_control && !contract.users.is_empty() {
return Err(TestkitError::Configuration("policy users require user_access_control".to_owned()));
}
if contract.grace.as_ref().is_some_and(|grace| grace.timeout_millis == 0) {
return Err(TestkitError::Configuration("grace timeout must be non-zero".to_owned()));
}
}
Ok(())
}
fn validate_playback_endpoint(&self) -> Result<(), TestkitError> {
if self.tuliprox.playback_endpoint == PlaybackEndpoint::Xtream {
for (name, channel) in &self.channels {
if channel.protocol != "xtream_ts" {
return Err(TestkitError::Configuration(format!(
"channel {name} uses protocol '{}' which is not supported with playback_endpoint: xtream (only xtream_ts is supported)",
channel.protocol
)));
}
}
}
Ok(())
}
/// The emulated Stalker portal only serves what the fixture can answer for: the
/// channels the scenario declares, the presets the SUT knows, and no multi-account
/// pool (Stalker aliases carry their own portal identities, which the fixture does
/// not model).
fn validate_stalker_fixture(&self) -> Result<(), TestkitError> {
if self.tuliprox.input_type != FixtureInputType::Stalker {
if self.tuliprox.stalker.refuse_create_link_once
|| !self.tuliprox.stalker.refuse_create_link_markers.is_empty()
{
return Err(TestkitError::Configuration(
"stalker portal refusals require input_type: stalker".to_owned(),
));
}
return Ok(());
}
if !FixtureStalker::PRESETS.contains(&self.tuliprox.stalker.mag_preset.as_str()) {
return Err(TestkitError::Configuration(format!(
"unknown mag_preset '{}'; expected one of {:?}",
self.tuliprox.stalker.mag_preset,
FixtureStalker::PRESETS
)));
}
if self.tuliprox.stalker.mac_address.trim().is_empty() {
return Err(TestkitError::Configuration("stalker.mac_address cannot be empty".to_owned()));
}
if self.policy_contract.as_ref().is_some_and(|contract| !contract.provider_pool.is_empty()) {
return Err(TestkitError::Configuration(
"a provider_pool is not supported for input_type: stalker (the fixture portal has one identity)"
.to_owned(),
));
}
let markers =
self.channels.values().map(|channel| channel.origin_marker).collect::<std::collections::HashSet<_>>();
for marker in &self.tuliprox.stalker.refuse_create_link_markers {
if !markers.contains(marker) {
return Err(TestkitError::Configuration(format!(
"stalker.refuse_create_link_markers names marker {marker}, which no channel of this scenario uses"
)));
}
}
Ok(())
}
/// Failover-group inputs are plain m3u inputs of the fixture origin; they cannot be
/// combined with a provider pool or the Stalker portal, which define the inputs themselves.
fn validate_failover_groups_fixture(&self) -> Result<(), TestkitError> {
let Some(failover) = &self.tuliprox.failover_groups else {
return Ok(());
};
let mut names = HashSet::new();
let valid_names = failover.inputs.iter().all(|name| {
!name.is_empty()
&& name.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
&& names.insert(name.as_str())
});
if failover.inputs.len() < 2 || !valid_names {
return Err(TestkitError::Configuration(
"failover_groups requires at least two unique URL-safe input names".to_owned(),
));
}
let uses_pool = self
.policy_contract
.as_ref()
.is_some_and(|contract| !contract.provider_pool.is_empty() || contract.provider_max_connections.is_some());
if uses_pool || self.tuliprox.input_type != FixtureInputType::M3u {
return Err(TestkitError::Configuration(
"failover_groups cannot be combined with provider_pool, provider_max_connections or input_type: stalker"
.to_owned(),
));
}
Ok(())
}
fn validate_extra_config_files(&self) -> Result<(), TestkitError> {
for file in self.tuliprox.bootstrap.iter().flat_map(|bootstrap| &bootstrap.extra_config_files) {
if !is_confined_relative_path(&file.destination) {
return Err(TestkitError::Configuration(format!(
"extra_config_files destination '{}' must be a relative path inside the config directory",
file.destination
)));
}
}
Ok(())
}
pub fn expanded_steps(&self) -> Result<Vec<Step>, TestkitError> {
let mut expanded = Vec::new();
expand_steps(&self.steps, "", &mut expanded)?;
Ok(expanded)
}
}
/// True for a non-empty relative path made of normal components only, so joining it to a
/// directory can never leave that directory.
#[must_use]
pub fn is_confined_relative_path(path: &str) -> bool {
!path.is_empty()
&& Path::new(path).components().all(|component| matches!(component, std::path::Component::Normal(_)))
}
fn validate_provider_pool_contract(contract: &PolicyContract) -> Result<(), TestkitError> {
if contract.provider_max_connections.is_some() && !contract.provider_pool.is_empty() {
return Err(TestkitError::Configuration(
"provider_max_connections and provider_pool are mutually exclusive".to_owned(),
));
}
let mut account_names = HashSet::new();
for account in &contract.provider_pool {
let valid_name = !account.name.is_empty()
&& account.name.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
if !valid_name || account.max_connections == 0 || !account_names.insert(account.name.as_str()) {
return Err(TestkitError::Configuration(
"provider_pool accounts require unique URL-safe names and positive max_connections".to_owned(),
));
}
}
Ok(())
}
fn expand_steps(steps: &[Step], prefix: &str, expanded: &mut Vec<Step>) -> Result<(), TestkitError> {
const MAX_REPEAT_COUNT: usize = 1_000;
const MAX_PAUSE_MILLIS: u64 = 300_000;
for step in steps {
if step.command_id.is_empty() {
return Err(TestkitError::Configuration("command IDs must be unique and non-empty".to_owned()));
}
if let Some(pause_millis) = step.pause_millis {
if step.start.is_some() || step.stop.is_some() || step.repeat.is_some() {
return Err(TestkitError::Configuration(
"pause_millis cannot be combined with start, stop or repeat".to_owned(),
));
}
if pause_millis == 0 || pause_millis > MAX_PAUSE_MILLIS {
return Err(TestkitError::Configuration(format!(
"pause_millis must be between 1 and {MAX_PAUSE_MILLIS}"
)));
}
let mut child = step.clone();
child.command_id = format!("{prefix}{}", step.command_id);
expanded.push(child);
continue;
}
if let Some(repeat) = &step.repeat {
if step.start.is_some() || step.stop.is_some() {
return Err(TestkitError::Configuration("repeat cannot be combined with start or stop".to_owned()));
}
if repeat.count == 0 || repeat.count > MAX_REPEAT_COUNT || repeat.steps.is_empty() {
return Err(TestkitError::Configuration("repeat requires 1 through 1000 child steps".to_owned()));
}
for iteration in 0..repeat.count {
expand_steps(&repeat.steps, &format!("{prefix}{}:{iteration}:", step.command_id), expanded)?;
}
continue;
}
if step.start.is_some() == step.stop.is_some() {
return Err(TestkitError::Configuration("each step must contain exactly one of start or stop".to_owned()));
}
let mut child = step.clone();
child.command_id = format!("{prefix}{}", step.command_id);
if let Some(start) = child.start.as_mut() {
start.playback_id = format!("{prefix}{}", start.playback_id);
start.session_group = format!("{prefix}{}", start.session_group);
}
if let Some(stop) = child.stop.as_mut() {
stop.playback_id = format!("{prefix}{}", stop.playback_id);
}
expanded.push(child);
}
Ok(())
}
#[must_use]
pub fn lease_duration(millis: u64) -> Duration { Duration::from_millis(millis.max(1)) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expected_eviction_requires_held_vod_playback() -> Result<(), TestkitError> {
let mut scenario: Scenario = serde_saphyr::from_str(
r"
schema_version: 1
name: vod-eviction-validation
tuliprox: { base_url: http://example.invalid, execution_mode: existing_instance }
actors: [{ id: a, agent: local }]
steps:
- command_id: start
start: { actor: a, playback_id: vod, session_group: vod, vod_object: movie.mkv, expect_evicted: true, post_read_action: close }
",
)
.map_err(|error| TestkitError::Configuration(error.to_string()))?;
assert!(scenario.validate().is_err());
scenario.steps[0]
.start
.as_mut()
.ok_or_else(|| TestkitError::Configuration("missing start".to_owned()))?
.post_read_action = Some(PostReadAction::Pause);
assert!(scenario.validate().is_err());
scenario.steps[0]
.start
.as_mut()
.ok_or_else(|| TestkitError::Configuration("missing start".to_owned()))?
.post_read_action = Some(PostReadAction::KeepOpen);
assert!(scenario.validate().is_ok());
scenario.steps[0]
.start
.as_mut()
.ok_or_else(|| TestkitError::Configuration("missing start".to_owned()))?
.method = Some("HEAD".to_owned());
assert!(scenario.validate().is_err());
Ok(())
}
#[test]
fn duplicate_playbacks_are_rejected() {
let scenario = Scenario {
schema_version: 1,
name: "test".to_owned(),
tuliprox: TuliproxConfig {
base_url: "http://example.invalid".to_owned(),
api_base_url: None,
admin_username: None,
admin_password_env: None,
playlist_url: None,
bootstrap: None,
execution_mode: ExecutionMode::IsolatedFixture,
playback_endpoint: PlaybackEndpoint::default(),
fixture_stream: FixtureStreamOptions::default(),
input_type: FixtureInputType::default(),
stalker: FixtureStalker::default(),
failover_groups: None,
},
origin: None,
actors: vec![Actor {
id: "a".to_owned(),
agent: "local".to_owned(),
username: None,
password_env: None,
user_agent: None,
client_ip: None,
}],
channels: HashMap::new(),
agents: AgentsConfig::default(),
policy_contract: None,
steps: (0..2)
.map(|number| Step {
command_id: number.to_string(),
pause_millis: None,
expect: ExpectedPlayback::Streaming,
start: Some(Start {
actor: "a".to_owned(),
playback_id: "same".to_owned(),
session_group: "s".to_owned(),
url: Some("http://example.invalid".to_owned()),
channel: None,
..Default::default()
}),
stop: None,
repeat: None,
await_frames: None,
await_condition: None,
assert_origin: None,
assert_runtime: None,
})
.collect(),
};
assert!(scenario.validate().is_err());
}
#[test]
fn checked_in_admission_scenarios_match_the_versioned_schema() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/fixtures/testkit/scenarios");
for filename in [
"hard-limit.yml",
"evict-same-ip-latest.yml",
"evict-same-ip-oldest.yml",
"evict-user-oldest.yml",
"evict-user-latest.yml",
"provider-hard-limit.yml",
"m3u-hls-retries-stable-provider-lease.yml",
"m3u-hls-provider-affinity-after-lease-expiry.yml",
"shared-stream-single-provider-slot.yml",
"vod-range-reopen-preserves-three-live.yml",
"vod-range-reopen-strict-cap.yml",
"vod-reopen-backpressured-body.yml",
"live-ts-same-channel-retry-latest-wins.yml",
"live-ts-channel-switch-single-user-two-provider-slots.yml",
"reentry-suppresses-evicted-retry.yml",
"soft-slot-precedes-eviction.yml",
"soft-slot-exhausts-without-upstream-leak.yml",
] {
assert!(Scenario::from_path(&root.join(filename)).is_ok(), "{filename}");
}
}
#[test]
fn pause_step_expands_alone_and_rejects_combinations() {
let scenario: Scenario = serde_saphyr::from_str(
r"
schema_version: 1
name: pause-validation
tuliprox: { base_url: http://example.invalid, execution_mode: existing_instance }
actors: [{ id: a, agent: local }]
steps:
- command_id: wait
pause_millis: 1500
",
)
.unwrap();
assert!(scenario.validate().is_ok());
assert_eq!(scenario.expanded_steps().unwrap()[0].pause_millis, Some(1_500));
for invalid_step in [
"{ command_id: wait, pause_millis: 0 }",
"{ command_id: wait, pause_millis: 300001 }",
"{ command_id: wait, pause_millis: 10, stop: { playback_id: missing } }",
"{ command_id: wait, pause_millis: 10, expect: rejected }",
"{ command_id: wait, pause_millis: 10, expect: suppressed }",
"{ command_id: wait, pause_millis: 10, await_frames: 0 }",
"{ command_id: wait, pause_millis: 10, await: { valid_frames: 1 } }",
"{ command_id: wait, pause_millis: 10, await: {} }",
"{ command_id: wait, pause_millis: 10, assert_origin: {} }",
"{ command_id: wait, pause_millis: 10, assert_runtime: {} }",
] {
let yaml = format!(
"schema_version: 1\nname: pause-invalid\ntuliprox: {{ base_url: http://example.invalid, execution_mode: existing_instance }}\nactors: [{{ id: a, agent: local }}]\nsteps: [{invalid_step}]\n"
);
let scenario: Scenario = serde_saphyr::from_str(&yaml).unwrap();
assert!(scenario.validate().is_err(), "{invalid_step}");
}
}
#[test]
fn stop_must_reference_an_active_playback() {
let scenario: Scenario = serde_saphyr::from_str(
r"
schema_version: 1
name: stop-validation
tuliprox: { base_url: http://example.invalid, execution_mode: existing_instance }
actors: [{ id: a, agent: local }]
steps:
- command_id: stop-missing
stop: { playback_id: missing }
",
)
.unwrap();
assert!(scenario.validate().is_err());
}
#[test]
fn repeat_derives_distinct_playback_and_command_ids_per_iteration() {
let scenario: Scenario = serde_saphyr::from_str(
r"
schema_version: 1
name: repeat-validation
tuliprox: { base_url: http://example.invalid, execution_mode: existing_instance }
actors: [{ id: a, agent: local }]
steps:
- command_id: repeat
repeat:
count: 2
steps:
- command_id: begin
start: { actor: a, playback_id: playback, session_group: session, url: http://example.invalid/17.ts }
- command_id: end
stop: { playback_id: playback }
",
)
.unwrap();
assert!(scenario.validate().is_ok());
let expanded = scenario.expanded_steps().unwrap();
assert_eq!(expanded.len(), 4);
assert_eq!(expanded[0].command_id, "repeat:0:begin");
assert_eq!(expanded[0].start.as_ref().map(|start| start.playback_id.as_str()), Some("repeat:0:playback"));
assert_eq!(expanded[2].command_id, "repeat:1:begin");
assert_eq!(expanded[3].stop.as_ref().map(|stop| stop.playback_id.as_str()), Some("repeat:1:playback"));
}
#[test]
fn http_error_expectation_matches_only_the_declared_status() {
use crate::protocol::PlaybackOutcome;
let expected = ExpectedPlayback::HttpError(502);
assert!(expected.matches_outcome(&PlaybackOutcome::HttpError { status: 502 }));
assert!(!expected.matches_outcome(&PlaybackOutcome::HttpError { status: 400 }));
assert!(!expected.matches_outcome(&PlaybackOutcome::Streaming { frames: 1, bytes: 1 }));
// A status the SUT itself reports as a rejection is a different outcome class.
assert!(!expected.matches_outcome(&PlaybackOutcome::AdmissionRejected {
reason: crate::protocol::RejectionReason::HttpStatus(502)
}));
assert!(expected.is_rejected(), "an error step must not await frames");
}
#[test]
fn stalker_fixture_validation_rejects_unknown_presets_and_markers() {
let mut scenario = Scenario {
schema_version: 1,
name: "stalker".to_owned(),
tuliprox: TuliproxConfig {
base_url: "http://example.invalid".to_owned(),
api_base_url: None,
admin_username: None,
admin_password_env: None,
playlist_url: None,
bootstrap: Some(BootstrapConfig {
command: "true".to_owned(),
arguments: Vec::new(),
readiness_url: "http://example.invalid/healthcheck".to_owned(),
readiness_timeout_millis: 1000,
extra_config_files: Vec::new(),
}),
execution_mode: ExecutionMode::IsolatedFixture,
playback_endpoint: PlaybackEndpoint::M3u,
fixture_stream: FixtureStreamOptions::default(),
input_type: FixtureInputType::Stalker,
stalker: FixtureStalker::default(),
failover_groups: None,
},
origin: None,
actors: Vec::new(),
channels: HashMap::from([(
"live-17".to_owned(),
Channel { origin_marker: 17, protocol: "xtream_ts".to_owned() },
)]),
agents: AgentsConfig::default(),
policy_contract: None,
steps: Vec::new(),
};
assert!(scenario.validate().is_ok());
scenario.tuliprox.stalker.mag_preset = "mag999".to_owned();
assert!(scenario.validate().is_err(), "an unknown MAG preset must be rejected");
scenario.tuliprox.stalker.mag_preset = "generic_safe".to_owned();
scenario.tuliprox.stalker.refuse_create_link_markers = vec![42];
assert!(scenario.validate().is_err(), "a marker no channel uses must be rejected");
scenario.tuliprox.input_type = FixtureInputType::M3u;
scenario.tuliprox.stalker.refuse_create_link_markers.clear();
scenario.tuliprox.stalker.refuse_create_link_once = true;
assert!(scenario.validate().is_err(), "portal refusals require a stalker input");
}
#[test]
fn rejected_playback_does_not_match_500_corrupt_data_or_early_eof() {
use crate::{
custom_video::CustomVideoKind,
protocol::{PlaybackOutcome, RejectionReason},
};
let expected_rejected = ExpectedPlayback::Rejected;
// Proved admission rejection matches
let valid_cv = PlaybackOutcome::AdmissionRejected {
reason: RejectionReason::CustomVideo(CustomVideoKind::UserConnectionsExhausted),
};
assert!(expected_rejected.matches_outcome(&valid_cv));
let valid_http = PlaybackOutcome::AdmissionRejected { reason: RejectionReason::HttpStatus(429) };
assert!(expected_rejected.matches_outcome(&valid_http));
// HTTP 500 MUST NOT pass as expected rejection
let http_500 = PlaybackOutcome::HttpError { status: 500 };
assert!(!expected_rejected.matches_outcome(&http_500));
// Invalid marker/corrupt frame MUST NOT pass
let invalid_marker = PlaybackOutcome::InvalidData { message: "wrong marker".to_owned() };
assert!(!expected_rejected.matches_outcome(&invalid_marker));
// Early EOF MUST NOT pass
let early_eof = PlaybackOutcome::UnexpectedEof { frames: 0, bytes: 0 };
assert!(!expected_rejected.matches_outcome(&early_eof));
// Transport error MUST NOT pass
let transport_err = PlaybackOutcome::TransportError { message: "connection reset".to_owned() };
assert!(!expected_rejected.matches_outcome(&transport_err));
// Idle timeout MUST NOT pass
assert!(!expected_rejected.matches_outcome(&PlaybackOutcome::IdleTimeout));
}
#[test]
fn suppressed_matches_only_204_quiet_termination() {
use crate::{
custom_video::CustomVideoKind,
protocol::{PlaybackOutcome, RejectionReason},
};
let suppressed = ExpectedPlayback::Suppressed;
assert!(suppressed
.matches_outcome(&PlaybackOutcome::AdmissionRejected { reason: RejectionReason::HttpStatus(204) }));
// A custom error video (the regression this scenario guards against) must fail.
assert!(!suppressed.matches_outcome(&PlaybackOutcome::AdmissionRejected {
reason: RejectionReason::CustomVideo(CustomVideoKind::UserConnectionsExhausted),
}));
assert!(!suppressed
.matches_outcome(&PlaybackOutcome::AdmissionRejected { reason: RejectionReason::HttpStatus(503) }));
// An empty body without the 204 classification (bare EOF) must fail too.
assert!(!suppressed.matches_outcome(&PlaybackOutcome::UnexpectedEof { frames: 0, bytes: 0 }));
}
#[test]
fn playback_endpoint_defaults_to_m3u() {
let cfg: TuliproxConfig = serde_saphyr::from_str("base_url: 'http://example.invalid'").unwrap();
assert_eq!(cfg.playback_endpoint, PlaybackEndpoint::M3u);
}
#[test]
fn playback_endpoint_xtream_deserializes() {
let cfg: TuliproxConfig =
serde_saphyr::from_str("base_url: 'http://example.invalid'\nplayback_endpoint: xtream").unwrap();
assert_eq!(cfg.playback_endpoint, PlaybackEndpoint::Xtream);
}
#[test]
fn fixture_stream_defaults_enable_sharing() {
let opts = FixtureStreamOptions::default();
assert!(opts.share_live_hls);
assert!(opts.share_live_mpeg_ts);
}
#[test]
fn playback_endpoint_xtream_rejects_non_xtream_ts_channel() {
let scenario: Scenario = serde_saphyr::from_str(
r"
schema_version: 1
name: xtream-validation
tuliprox:
base_url: 'http://example.invalid'
execution_mode: existing_instance
playback_endpoint: xtream
actors: [{ id: a, agent: local }]
channels:
ch: { origin_marker: 17, protocol: hls }
steps:
- command_id: s
start: { actor: a, playback_id: p, session_group: s, channel: ch }
",
)
.unwrap();
assert!(scenario.validate().is_err());
}
fn failover_scenario(extra: &str) -> Result<Scenario, TestkitError> {
let yaml = format!(
r"
schema_version: 1
name: failover-validation
tuliprox:
base_url: 'http://example.invalid'
bootstrap:
command: 'true'
readiness_url: 'http://example.invalid/healthcheck'
{extra}
"
);
serde_saphyr::from_str(&yaml).map_err(|error| TestkitError::Configuration(error.to_string()))
}
#[test]
fn failover_groups_fixture_accepts_two_unique_inputs() -> Result<(), TestkitError> {
failover_scenario(" failover_groups: { inputs: [input-a, input-b] }")?.validate()
}
#[test]
fn failover_groups_fixture_rejects_bad_input_lists() -> Result<(), TestkitError> {
for inputs in ["[input-a]", "[input-a, input-a]", "[input-a, 'bad name']", "[input-a, '']"] {
let scenario = failover_scenario(&format!(" failover_groups: {{ inputs: {inputs} }}"))?;
assert!(scenario.validate().is_err(), "{inputs} must be rejected");
}
Ok(())
}
#[test]
fn failover_groups_fixture_rejects_provider_pool() -> Result<(), TestkitError> {
let mut scenario = failover_scenario(" failover_groups: { inputs: [input-a, input-b] }")?;
scenario.policy_contract = Some(PolicyContract {
user_access_control: false,
users: HashMap::new(),
admission_strategies: None,
recent_eviction_reentry_ttl_ms: None,
hls_session_ttl_secs: None,
provider_affinity_ttl_secs: None,
hls_wrap_media_playlist: None,
grace: None,
provider_max_connections: None,
provider_pool: vec![ProviderPoolAccount {
name: "account-a".to_owned(),
max_connections: 1,
priority: None,
}],
expected_provider_slots: None,
});
assert!(scenario.validate().is_err());
Ok(())
}
#[test]
fn extra_config_file_destinations_must_be_confined() -> Result<(), TestkitError> {
let valid =
failover_scenario(" extra_config_files: [{ source: ../x.yml, destination: failover.d/testkit.yml }]")?;
valid.validate()?;
let escaping = failover_scenario(" extra_config_files: [{ source: x.yml, destination: ../x.yml }]")?;
assert!(escaping.validate().is_err());
assert!(is_confined_relative_path("failover.d/testkit.yml"));
assert!(!is_confined_relative_path("/etc/passwd"));
assert!(!is_confined_relative_path("a/../../b"));
assert!(!is_confined_relative_path(""));
Ok(())
}
}