AkurAI Build
Menu

BifrOSt-Apps

public

Latest 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
        );
    }
}