Menu
BifrOSt-Apps
publicLatest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson
use ruv::api::RuvClient;
use ruv::model::{AppError, Channel};
#[test]
fn public_contract_is_constructible_and_channels_are_stable() {
let client = RuvClient::new().expect("the finite-timeout HTTPS client should build");
let cloned = client.clone();
drop(cloned);
assert_eq!(Channel::Ruv.slug(), "ruv");
assert_eq!(Channel::Ruv.title(), "RÚV");
assert_eq!(Channel::Ruv2.slug(), "ruv2");
assert_eq!(Channel::Ruv2.title(), "RÚV 2");
assert_eq!(
AppError::UntrustedUrl.to_string(),
"Ótraust veffang var stöðvað."
);
}
#[allow(dead_code)]
#[path = "../src/model.rs"]
mod model;
mod fixture_contract {
#![allow(dead_code)]
include!("../src/api.rs");
const NEWS: &[u8] = include_bytes!("fixtures/news.xml");
const VOD: &[u8] = include_bytes!("fixtures/vod.json");
const PROGRAM: &[u8] = include_bytes!("fixtures/program.json");
const LIVE: &[u8] = include_bytes!("fixtures/live.json");
const EPG: &[u8] = include_bytes!("fixtures/epg.json");
const SEARCH_ARTICLES: &[u8] = include_bytes!("fixtures/search_articles.json");
const SEARCH_VOD: &[u8] = include_bytes!("fixtures/search_vod.json");
#[test]
fn rss_maps_html_body_author_dates_and_discards_untrusted_items() {
let articles = parse_rss_body(NEWS).expect("RSS fixture should parse");
assert_eq!(articles.len(), 1);
let article = &articles[0];
assert_eq!(article.title, "Prófunarfrétt");
assert_eq!(article.author.as_deref(), Some("Fréttastofa RÚV"));
assert!(article.body.contains("Fyrsta"));
assert!(article.body.contains("Önnur málsgrein."));
assert!(!article.body.contains('<'));
assert_eq!(article.published.expect("date").timestamp(), 1_786_019_696);
assert_eq!(
article.image_url.as_deref(),
Some("https://images.nyr.ruv.is/example.jpg")
);
}
#[test]
fn frontpage_panels_map_to_shelves_with_tolerant_optionals() {
let shelves = parse_frontpage_body(VOD).expect("VOD fixture should parse");
assert_eq!(shelves.len(), 2);
assert_eq!(shelves[0].slug, "hladbord");
assert_eq!(shelves[0].programs[0].program_id, 30_762);
assert_eq!(shelves[0].programs[0].episode_id.as_deref(), Some("bfblpb"));
assert_eq!(shelves[0].programs[0].rating, Some(6));
assert_eq!(shelves[1].programs[0].program_id, 41_000);
assert_eq!(shelves[1].programs[0].foreign_title, None);
assert_eq!(shelves[1].programs[0].image_url, None);
}
#[test]
fn program_maps_playback_fields_and_rejects_bad_media_origins() {
let program = parse_program_body(PROGRAM, 1).expect("program fixture should parse");
assert_eq!(program.program_id, 30_762);
assert_eq!(program.episodes.len(), 2);
let episode = &program.episodes[0];
assert_eq!(episode.duration_seconds, Some(1_800));
assert_eq!(
episode
.first_run
.expect("first run")
.offset()
.local_minus_utc(),
0
);
assert_eq!(episode.rating, Some(6));
assert_eq!(episode.subtitles.len(), 1);
assert!(
episode
.stream_url
.as_deref()
.is_some_and(|url| url.ends_with("token=opaque"))
);
assert_eq!(program.episodes[1].title, "");
assert_eq!(program.episodes[1].stream_url, None);
}
#[test]
fn epg_uses_utc_for_offsetless_reykjavik_wall_times_and_skips_bad_dates() {
let events = parse_schedule_body(EPG).expect("EPG fixture should parse");
assert_eq!(events.len(), 2);
assert_eq!(events[0].start.offset().local_minus_utc(), 0);
assert_eq!(events[0].start.timestamp_subsec_millis(), 125);
assert_eq!(events[0].program_id, Some(100));
assert!(events[0].is_live);
assert_eq!(events[1].subtitle, None);
assert_eq!(events[1].end, None);
}
#[test]
fn live_resolver_only_yields_allowlisted_hls() {
let live = parse_live_body(LIVE).expect("live fixture should parse");
assert_eq!(
live.url.as_deref().and_then(stream_url).as_deref(),
Some("https://ruvlive.akamaized.net/out/v1/example/index.m3u8")
);
assert_eq!(
stream_url("https://ed4.cdn.ruv.is/token/live/d0/ruv/example/index.m3u8").as_deref(),
Some("https://ed4.cdn.ruv.is/token/live/d0/ruv/example/index.m3u8")
);
assert!(stream_url("https://attacker.example/live.m3u8").is_none());
assert!(stream_url("http://ruvlive.akamaized.net/live.m3u8").is_none());
assert!(stream_url("https://ruvlive.akamaized.net/not-hls.mp4").is_none());
}
#[test]
fn unified_search_fixtures_map_articles_and_programs() {
let articles = parse_article_search_body(SEARCH_ARTICLES).expect("article search");
let programs = parse_vod_search_body(SEARCH_VOD).expect("VOD search");
assert_eq!(articles.len(), 1);
assert_eq!(articles[0].title, "Leitarniðurstaða");
assert_eq!(
articles[0]
.published
.expect("date")
.offset()
.local_minus_utc(),
0
);
assert!(articles[0].body.contains("Leitartexti"));
assert_eq!(programs.len(), 1);
assert_eq!(programs[0].program_id, 30_762);
assert_eq!(programs[0].episode_id.as_deref(), Some("bfblpb"));
}
#[test]
fn search_url_percent_encodes_data_instead_of_accepting_query_injection() {
let url = build_search_url("eldgos &page=99/ís").expect("fixed search URL");
assert_eq!(url.scheme(), "https");
assert_eq!(url.host_str(), Some("gql.nyr.ruv.is"));
let pairs: Vec<_> = url.query_pairs().collect();
assert_eq!(pairs[0].0, "search");
assert_eq!(pairs[0].1, "eldgos &page=99/ís");
assert_eq!(pairs[1], ("page".into(), "1".into()));
assert_eq!(pairs[2], ("limit".into(), "20".into()));
}
#[test]
fn malformed_graphql_and_oversized_chunks_return_user_safe_errors() {
let graph_error = br#"{"data":null,"errors":[{"message":"database secret"}]}"#;
assert_eq!(
parse_frontpage_body(graph_error),
Err(AppError::InvalidResponse)
);
assert_eq!(
parse_frontpage_body(b"not json"),
Err(AppError::InvalidResponse)
);
let mut body = vec![0_u8; 4];
assert_eq!(
bounded_extend(&mut body, &[1, 2], 5),
Err(AppError::ResponseTooLarge)
);
assert_eq!(body, vec![0_u8; 4]);
assert!(!AppError::InvalidResponse.to_string().contains("database"));
}
#[test]
fn timestamp_parser_accepts_rfc2822_iso_offsets_and_fractional_wall_time() {
assert_eq!(
parse_timestamp("Thu, 06 Aug 2026 12:34:56 +0000")
.expect("RFC 2822")
.timestamp(),
1_786_019_696
);
assert_eq!(
parse_timestamp("2026-08-06T14:34:56+02:00")
.expect("RFC 3339")
.timestamp(),
1_786_019_696
);
assert_eq!(
parse_timestamp("2026-08-06T12:34:56.250")
.expect("RÚV wall time")
.timestamp_subsec_millis(),
250
);
assert!(parse_timestamp("2026-99-99").is_none());
}
#[test]
fn suffix_matched_url_policies_reject_deceptive_hosts() {
for kind in [UrlKind::Image, UrlKind::Subtitle] {
assert!(validate_url("https://ruv.is/mynd.jpg", kind).is_ok());
assert!(validate_url("https://images.nyr.ruv.is/mynd.jpg", kind).is_ok());
for value in [
"https://evil-cdn.ruv.is.attacker.com/mynd.jpg",
"https://cdn.ruv.is.evil.com/mynd.jpg",
"https://ruv.is.attacker.com/mynd.jpg",
"https://notruv.is/mynd.jpg",
] {
assert_eq!(
validate_url(value, kind).err(),
Some(AppError::UntrustedUrl),
"{value}"
);
}
}
assert!(validate_url("https://ed4.cdn.ruv.is/live/index.m3u8", UrlKind::Stream).is_ok());
for value in [
"https://evil-cdn.ruv.is.attacker.com/live/index.m3u8",
"https://cdn.ruv.is.evil.com/live/index.m3u8",
"https://notcdn.ruv.is/live/index.m3u8",
"https://cdn.ruv.is/live/index.m3u8",
"https://ruv.is/live/index.m3u8",
"https://evil.akamaized.net/live/index.m3u8",
"https://ruvlive.akamaized.net.evil.com/live/index.m3u8",
] {
assert_eq!(
validate_url(value, UrlKind::Stream).err(),
Some(AppError::UntrustedUrl),
"{value}"
);
}
assert!(validate_url("https://www.ruv.is/rss/frettir", UrlKind::Api).is_ok());
for value in [
"https://ruv.is/rss/frettir",
"https://www.ruv.is.evil.com/rss/frettir",
"https://spilari.nyr.ruv.is.attacker.com/gql/",
] {
assert_eq!(
validate_url(value, UrlKind::Api).err(),
Some(AppError::UntrustedUrl),
"{value}"
);
}
assert!(validate_url("https://switcher.cdn.ruv.is/live", UrlKind::Resolver).is_ok());
for value in [
"https://switcher.cdn.ruv.is.evil.com/live",
"https://evil.switcher.cdn.ruv.is/live",
"https://cdn.ruv.is/live",
] {
assert_eq!(
validate_url(value, UrlKind::Resolver).err(),
Some(AppError::UntrustedUrl),
"{value}"
);
}
}
#[test]
fn url_policy_rejects_userinfo_ports_and_ip_literals_and_normalizes_case() {
assert!(validate_url("https://WWW.RUV.IS/rss/frettir", UrlKind::Api).is_ok());
assert!(validate_url("https://www.ruv.is:443/rss/frettir", UrlKind::Api).is_ok());
assert_eq!(
validate_url(
"https://IMAGES.NYR.RUV.IS.EVIL.COM/mynd.jpg",
UrlKind::Image
)
.err(),
Some(AppError::UntrustedUrl)
);
for value in [
"https://user@www.ruv.is/rss/frettir",
"https://user:leyndarmal@www.ruv.is/rss/frettir",
"https://www.ruv.is:8443/rss/frettir",
"http://www.ruv.is/rss/frettir",
] {
assert_eq!(
validate_url(value, UrlKind::Api).err(),
Some(AppError::UntrustedUrl),
"{value}"
);
}
for value in [
"https://151.101.1.1/mynd.jpg",
"https://[2606:2e00::1]/mynd.jpg",
] {
assert_eq!(
validate_url(value, UrlKind::Image).err(),
Some(AppError::UntrustedUrl),
"{value}"
);
}
}
#[test]
fn redirect_allowlist_rejects_deceptive_suffixes_schemes_ports_and_ips() {
for value in [
"https://ruv.is/a",
"https://static.ruv.is/a",
"https://ruv-vod.akamaized.net/a",
"https://ruvlive.akamaized.net/a",
] {
assert!(
is_redirect_origin_allowed(&Url::parse(value).expect("test url")),
"{value}"
);
}
for value in [
"https://evil-cdn.ruv.is.attacker.com/a",
"https://ruv.is.evil.com/a",
"https://notruv.is/a",
"https://evil.akamaized.net/a",
"https://ruvlive.akamaized.net.evil.com/a",
"http://ruv.is/a",
"https://ruv.is:8443/a",
"https://151.101.1.1/a",
"https://[2606:2e00::1]/a",
] {
assert!(
!is_redirect_origin_allowed(&Url::parse(value).expect("test url")),
"{value}"
);
}
}
#[test]
fn truncated_or_garbled_rss_returns_an_error_instead_of_panicking() {
for bytes in [
&NEWS[..NEWS.len() / 2],
&b"<rss><channel><item><title>Hei"[..],
&b"{\"ekki\":\"xml\"}"[..],
&b""[..],
&[0xFF, 0xFE, 0x00, 0x00][..],
] {
assert_eq!(parse_rss_body(bytes), Err(AppError::InvalidResponse));
}
}
#[test]
fn overlong_episode_durations_are_discarded_as_malformed() {
let map_duration = |duration: u64| {
let json = format!(r#"{{"id":"bfblpb","title":"Titill","duration":{duration}}}"#);
let dto: EpisodeDto = serde_json::from_str(&json).expect("episode DTO");
episode_from_dto(dto).expect("episode with an id should map")
};
assert_eq!(
map_duration(MAX_EPISODE_DURATION_SECONDS).duration_seconds,
Some(MAX_EPISODE_DURATION_SECONDS)
);
assert_eq!(
map_duration(MAX_EPISODE_DURATION_SECONDS + 1).duration_seconds,
None
);
}
}