netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! Integration tests for network functions.

use super::*;

use anyhow::{Context, Result, anyhow, ensure};
use std::sync::{
    Arc,
    atomic::{AtomicBool, Ordering},
};

use crate::localization::{self, keys};
use crate::stdlib::DEFAULT_FETCH_CACHE_DIR;
use camino::Utf8Path;
use minijinja::{
    ErrorKind,
    value::{Kwargs, Value},
};
use rstest::rstest;
use test_support::{fs, http};
use url::Url;

#[path = "tests_support.rs"]
mod support;
use support::{
    CacheWorkspace, assert_cache_entry_exists, assert_fetch_policy_rejection,
    assert_open_cache_dir_rejects, cache_relative_error, cache_workspace, limit_with_offset,
    make_context, make_context_with,
};

#[rstest]
fn cache_key_hashes_url() {
    let digest = cache_key("https://example.com");
    assert_eq!(digest.len(), 64);
}

#[rstest]
fn to_value_returns_bytes_for_invalid_utf8() {
    let value = value_from_bytes(vec![0xff, 0xfe, 0xfd]);
    assert_eq!(value.as_bytes(), Some(&[0xff, 0xfe, 0xfd][..]));
}

#[rstest]
#[case::empty("", keys::STDLIB_FETCH_CACHE_EMPTY, None)]
#[case::absolute(
    "/etc/netsuke-cache",
    keys::STDLIB_FETCH_CACHE_NOT_RELATIVE,
    Some("/etc/netsuke-cache")
)]
#[case::escape("../escape", keys::STDLIB_FETCH_CACHE_ESCAPES, Some("../escape"))]
fn open_cache_dir_rejects_invalid_paths(
    cache_workspace: Result<CacheWorkspace>,
    #[case] path: &str,
    #[case] key: &'static str,
    #[case] path_arg: Option<&str>,
) -> Result<()> {
    let (_temp, root, _path) = cache_workspace?;
    let expected = cache_relative_error(key, path_arg);
    assert_open_cache_dir_rejects(root.as_ref(), Utf8Path::new(path), &expected)
}

#[rstest]
fn open_cache_dir_errors_for_file_path(cache_workspace: Result<CacheWorkspace>) -> Result<()> {
    let (_temp, root, path) = cache_workspace?;
    let file_path = path.join("file");
    fs::write(file_path.as_std_path(), b"data").context("write file placeholder")?;
    let expected = cache_relative_error(
        keys::STDLIB_FETCH_CACHE_NOT_RELATIVE,
        Some(file_path.as_str()),
    );
    assert_open_cache_dir_rejects(root.as_ref(), file_path.as_path(), &expected)
}

#[rstest]
fn open_cache_dir_creates_relative_directory(
    cache_workspace: Result<CacheWorkspace>,
) -> Result<()> {
    let (_temp, root, path) = cache_workspace?;
    let dir = open_cache_dir(&root, Utf8Path::new("cache"))?;
    assert_cache_entry_exists(dir, Utf8Path::new("cache"), path.as_path(), "entry")
}

#[rstest]
fn fetch_rejects_template_cache_dir_argument(
    cache_workspace: Result<CacheWorkspace>,
) -> Result<()> {
    let (_temp, root, _path) = cache_workspace?;
    let context = make_context(root);
    let kwargs = [(String::from("cache_dir"), Value::from(".netsuke/cache"))]
        .into_iter()
        .collect::<Kwargs>();
    let impure = Arc::new(AtomicBool::new(false));
    let Err(err) = fetch("http://127.0.0.1:9", &kwargs, &impure, &context) else {
        return Err(anyhow!(
            "expected cache_dir keyword to fail but request succeeded"
        ));
    };
    ensure!(
        err.kind() == ErrorKind::TooManyArguments,
        "unexpected error kind {kind:?}",
        kind = err.kind()
    );
    ensure!(
        err.to_string().contains("cache_dir"),
        "error should mention unexpected cache_dir argument: {err}",
    );
    ensure!(
        !impure.load(Ordering::Relaxed),
        "rejecting cache_dir must not mark the template impure",
    );
    Ok(())
}

#[rstest]
fn fetch_cache_opens_default_directory(cache_workspace: Result<CacheWorkspace>) -> Result<()> {
    let (_temp, root, path) = cache_workspace?;
    let context = make_context(root);
    let dir = context.open_cache_dir()?;
    assert_cache_entry_exists(
        dir,
        Utf8Path::new(DEFAULT_FETCH_CACHE_DIR),
        path.as_path(),
        "entry",
    )
}

#[rstest]
fn fetch_rejects_responses_over_the_limit(cache_workspace: Result<CacheWorkspace>) -> Result<()> {
    let (_temp, root, _path) = cache_workspace?;
    let limit = 16_u64;
    let body = "x".repeat(limit_with_offset(limit, 1)?);
    let (url, _server) =
        http::spawn_http_server(body).context("spawn HTTP server for oversized response test")?;
    let policy = NetworkPolicy::default()
        .allow_scheme("http")
        .context("allow http scheme for oversized response test")?;
    let context = make_context_with(root, policy, limit);
    let kwargs = std::iter::empty::<(String, Value)>().collect::<Kwargs>();
    let impure = Arc::new(AtomicBool::new(false));
    let Err(err) = fetch(&url, &kwargs, &impure, &context) else {
        return Err(anyhow!("expected fetch to reject response exceeding limit"));
    };
    let parsed = Url::parse(&url).context("parse URL for response limit error")?;
    let expected = localization::message(keys::STDLIB_FETCH_RESPONSE_LIMIT_EXCEEDED)
        .with_arg("url", parsed.as_str())
        .with_arg("limit", limit)
        .to_string();
    ensure!(
        err.to_string().contains(&expected),
        "error should describe limit: {err}",
    );
    ensure!(
        impure.load(Ordering::Relaxed),
        "oversized network response should mark the template impure",
    );
    Ok(())
}

#[rstest]
fn fetch_streams_responses_into_cache(cache_workspace: Result<CacheWorkspace>) -> Result<()> {
    let (_temp, root, workspace) = cache_workspace?;
    let policy = NetworkPolicy::default()
        .allow_scheme("http")
        .context("allow http scheme for cache streaming test")?;
    let limit = 1024_u64;
    let body = "cached response";
    let (url, _server) =
        http::spawn_http_server(body).context("spawn HTTP server for cache streaming test")?;
    let context = make_context_with(Arc::clone(&root), policy, limit);
    let kwargs = [(String::from("cache"), Value::from(true))]
        .into_iter()
        .collect::<Kwargs>();
    let impure = Arc::new(AtomicBool::new(false));
    let value = fetch(&url, &kwargs, &impure, &context)?;
    ensure!(
        value.as_bytes() == Some(body.as_bytes()),
        "fetch should return response body",
    );
    ensure!(
        impure.load(Ordering::Relaxed),
        "successful fetch should mark template impure",
    );
    let cache_dir = context.open_cache_dir()?;
    let parsed = Url::parse(&url).context("parse cache URL")?;
    let key = cache_key(parsed.as_str());
    let cached =
        read_cached(&cache_dir, &key, limit)?.context("cached response should be present")?;
    ensure!(
        cached == body.as_bytes(),
        "cache contents should match response"
    );
    let cache_path = workspace
        .join(DEFAULT_FETCH_CACHE_DIR)
        .join(Utf8Path::new(&key));
    ensure!(
        fs::exists(cache_path.as_std_path()),
        "cache entry should be written to disk",
    );
    Ok(())
}

#[rstest]
fn fetch_clears_partial_cache_on_limit_error(
    cache_workspace: Result<CacheWorkspace>,
) -> Result<()> {
    let (_temp, root, workspace) = cache_workspace?;
    let policy = NetworkPolicy::default()
        .allow_scheme("http")
        .context("allow http scheme for cache failure test")?;
    let limit = 32_u64;
    let body = "y".repeat(limit_with_offset(limit, 8)?);
    let (url, _server) =
        http::spawn_http_server(body).context("spawn HTTP server for cache failure test")?;
    let context = make_context_with(Arc::clone(&root), policy, limit);
    let kwargs = [(String::from("cache"), Value::from(true))]
        .into_iter()
        .collect::<Kwargs>();
    let impure = Arc::new(AtomicBool::new(false));
    let parsed = Url::parse(&url).context("parse cache URL for failure test")?;
    let key = cache_key(parsed.as_str());
    let Err(err) = fetch(&url, &kwargs, &impure, &context) else {
        return Err(anyhow!(
            "expected fetch to reject oversized cached response"
        ));
    };
    let expected = localization::message(keys::STDLIB_FETCH_RESPONSE_LIMIT_EXCEEDED)
        .with_arg("url", parsed.as_str())
        .with_arg("limit", limit)
        .to_string();
    ensure!(
        err.to_string().contains(&expected),
        "limit error should mention configured limit: {err}",
    );
    ensure!(
        impure.load(Ordering::Relaxed),
        "failed fetch should mark template impure",
    );
    let cache_dir = context.open_cache_dir()?;
    ensure!(
        cache_dir
            .open(Utf8Path::new(&key))
            .expect_err("oversized fetch should not leave cache entry")
            .kind()
            == io::ErrorKind::NotFound,
        "partial cache file should be removed",
    );
    let cache_path = workspace
        .join(DEFAULT_FETCH_CACHE_DIR)
        .join(Utf8Path::new(&key));
    ensure!(
        !fs::exists(cache_path.as_std_path()),
        "no cache file should remain on disk",
    );
    Ok(())
}

#[rstest]
fn fetch_rejects_cached_entries_exceeding_limit(
    cache_workspace: Result<CacheWorkspace>,
) -> Result<()> {
    let (_temp, root, workspace) = cache_workspace?;
    let policy = NetworkPolicy::default()
        .allow_scheme("http")
        .context("allow http scheme for cached entry limit test")?;
    let limit = 24_u64;
    let (url, _server) = http::spawn_http_server("cached")
        .context("spawn HTTP server for cached entry limit test")?;
    let context = make_context_with(Arc::clone(&root), policy, limit);
    let cache_dir = context.open_cache_dir()?;
    let parsed = Url::parse(&url).context("parse cache URL for oversized entry test")?;
    let key = cache_key(parsed.as_str());
    let oversized = "z".repeat(limit_with_offset(limit, 1)?);
    cache_dir
        .write(Utf8Path::new(&key), oversized.as_bytes())
        .context("seed oversized cache entry")?;
    let kwargs = [(String::from("cache"), Value::from(true))]
        .into_iter()
        .collect::<Kwargs>();
    let impure = Arc::new(AtomicBool::new(false));
    let Err(err) = fetch(&url, &kwargs, &impure, &context) else {
        return Err(anyhow!("expected fetch to reject oversized cache entry"));
    };
    let expected = localization::message(keys::STDLIB_FETCH_CACHE_LIMIT_EXCEEDED)
        .with_arg("name", key.as_str())
        .with_arg("limit", limit)
        .to_string();
    ensure!(
        err.to_string().contains(&expected),
        "error should mention cached entry limit: {err}",
    );
    ensure!(
        !impure.load(Ordering::Relaxed),
        "cache limit failure should not mark the template impure",
    );
    let cache_path = workspace
        .join(DEFAULT_FETCH_CACHE_DIR)
        .join(Utf8Path::new(&key));
    ensure!(
        fs::exists(cache_path.as_std_path()),
        "existing cache entry should remain for investigation",
    );
    Ok(())
}

/// A disallowed-fetch scenario: the request URL, the localization key and
/// argument describing why the network policy rejects it, and the policy to
/// enforce.
struct DisallowedRequestCase {
    url: &'static str,
    details_key: &'static str,
    details_arg_name: &'static str,
    details_arg_value: &'static str,
    policy: NetworkPolicy,
}

#[rstest]
#[case(DisallowedRequestCase {
    url: "http://example.com",
    details_key: keys::NETWORK_POLICY_SCHEME_NOT_ALLOWED,
    details_arg_name: "scheme",
    details_arg_value: "http",
    policy: NetworkPolicy::default(),
})]
#[case(DisallowedRequestCase {
    url: "https://example.com",
    details_key: keys::NETWORK_POLICY_HOST_NOT_ALLOWLISTED,
    details_arg_name: "host",
    details_arg_value: "example.com",
    policy: NetworkPolicy::default().deny_all_hosts(),
})]
fn fetch_rejects_disallowed_request(
    cache_workspace: Result<CacheWorkspace>,
    #[case] case: DisallowedRequestCase,
) -> Result<()> {
    let DisallowedRequestCase {
        url,
        details_key,
        details_arg_name,
        details_arg_value,
        policy,
    } = case;
    let (_temp, root, _path) = cache_workspace?;
    let details = localization::message(details_key)
        .with_arg(details_arg_name, details_arg_value)
        .to_string();
    let expected = localization::message(keys::STDLIB_FETCH_DISALLOWED)
        .with_arg("url", url)
        .with_arg("details", details)
        .to_string();
    assert_fetch_policy_rejection(root, policy, url, &expected)
}