mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-29 02:02:23 +01:00
1290 lines
50 KiB
Rust
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(())
|
|
}
|
|
}
|