haematite-cli 0.32.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use serde_json::{Value, json};

use super::Config;

fn sample() -> Value {
    json!({
        "listen": "127.0.0.1:8740",
        "origin": "https://studio.example",
        "files": "/opt/haematite/studio",
        "stores": {"wards": "/run/haematite/wards.sock"},
        "lys": {
            "issuer": "https://lys.example/",
            "client_id": "studio",
            "secret_file": "/etc/haematite/studio.secret",
            "redirect": "https://studio.example/callback"
        }
    })
}

fn parsed(value: &Value) -> Result<Config, String> {
    Config::parse(value.to_string().as_bytes())
}

#[test]
fn a_whole_config_is_taken_with_its_defaults() -> Result<(), String> {
    let config = parsed(&sample())?;
    let site = config.site(&crate::client::network::Trust::system()?)?;
    assert_eq!(site.origin, "https://studio.example");
    assert_eq!(site.stores.len(), 1);
    assert_eq!(config.limits().max_connections, 64);
    Ok(())
}

/// Each change is refused alone, with the field it names.
#[test]
fn each_unsafe_field_is_refused_by_name() -> Result<(), String> {
    let cases: [(&str, Value, &str); 9] = [
        ("/listen", json!("0.0.0.0:8740"), "listen"),
        ("/origin", json!("http://studio.example"), "origin"),
        ("/origin", json!("https://studio.example/app"), "origin"),
        ("/files", json!("studio"), "files"),
        ("/stores", json!({}), "stores"),
        (
            "/stores",
            json!({"a/b": "/run/haematite/a.sock"}),
            "store name",
        ),
        ("/stores", json!({"wards": "wards.sock"}), "store socket"),
        (
            "/lys/redirect",
            json!("https://elsewhere.example/callback"),
            "redirect",
        ),
        ("/lys/secret_file", json!("studio.secret"), "secret_file"),
    ];
    for (pointer, value, named) in cases {
        let mut config = sample();
        let slot = config
            .pointer_mut(pointer)
            .ok_or_else(|| format!("the sample has no {pointer}"))?;
        *slot = value.clone();
        let message = parsed(&config)
            .err()
            .ok_or_else(|| format!("{pointer} = {value} was taken"))?;
        assert!(message.contains(named), "{pointer} = {value}: {message}");
    }
    Ok(())
}

#[test]
fn an_unknown_field_is_refused() -> Result<(), String> {
    let mut config = sample();
    config
        .as_object_mut()
        .ok_or("the sample is not an object")?
        .insert("secret".to_owned(), json!("x"));
    assert!(parsed(&config).is_err());
    Ok(())
}

/// ACCESS-003 R1: a door on a laptop, with no lys block.
fn local() -> Value {
    json!({
        "listen": "127.0.0.1:7443",
        "origin": "http://127.0.0.1:7443",
        "files": "/opt/haematite/studio",
        "stores": {"wards": "/run/haematite/wards.sock"}
    })
}

/// Fails while a door with no lys block is refused for the missing block, or
/// its --check asks for a secret file or a Lys: the configuration parses with
/// its loopback http origin, and --check passes with no secret file anywhere.
#[test]
fn a_door_with_no_lys_block_takes_a_loopback_origin_and_checks_with_no_secret()
-> Result<(), Box<dyn std::error::Error>> {
    let config = parsed(&local())?;
    let site = config.site(&crate::client::network::Trust::system()?)?;
    assert_eq!(site.origin, "http://127.0.0.1:7443");
    let folder = tempfile::tempdir()?;
    let path = folder.path().join("door.json");
    std::fs::write(&path, local().to_string())?;
    let args = [
        "--check",
        "--config",
        path.to_str().ok_or("path is not text")?,
    ];
    crate::door::cli::run(args.into_iter().map(str::to_owned))?;
    Ok(())
}

/// Fails while a door with no lys block takes an origin that is not loopback,
/// or has a path, or refuses one without naming it.
#[test]
fn a_door_with_no_lys_block_refuses_any_other_origin_by_name() -> Result<(), String> {
    for origin in [
        "http://10.0.0.5:7443",
        "https://studio.example",
        "http://127.0.0.1:7443/x",
    ] {
        let mut config = local();
        config["origin"] = json!(origin);
        let message = parsed(&config)
            .err()
            .ok_or_else(|| format!("{origin} was taken"))?;
        assert!(
            message.starts_with("door_local_origin_not_loopback: ") && message.contains(origin),
            "{origin}: {message}"
        );
    }
    Ok(())
}