haematite-cli 0.33.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", "studio": "/run/haematite/studio.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(), 2);
    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", "studio": "/run/haematite/studio.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(())
}

/// ACCESS-004 R1: the door Tom started on 25 October, one store and no
/// studio, with its socket in the folder the line to add is shaped after.
fn without_studio() -> Value {
    json!({
        "listen": "127.0.0.1:7443",
        "origin": "http://127.0.0.1:7443",
        "files": "/opt/haematite/studio",
        "stores": {"haematite": "/run/haematite/haematite.sock"}
    })
}

/// Fails while a door whose stores do not name studio parses: Studio keeps
/// arrangements and saved views there, so every screen would show the
/// keeper's refusal. The refusal names the store, says what it keeps, and
/// gives the line to add shaped as the configuration's own socket entries.
#[test]
fn a_door_whose_stores_do_not_name_studio_is_refused_with_the_line_to_add() -> Result<(), String> {
    let message = parsed(&without_studio())
        .err()
        .ok_or("a door with no studio store was taken")?;
    assert_eq!(
        message,
        "Studio keeps its arrangements and saved views in a store named studio, and this door's \
         stores name none: serve a store for Studio and add it to stores, as \
         \"studio\": \"/run/haematite/studio.sock\"."
    );
    Ok(())
}

/// Fails while a door whose stores are all on other machines is refused with
/// a socket line: the line to add is shaped as an endpoint entry there.
#[test]
fn a_door_of_network_stores_is_given_an_endpoint_line_to_add() -> Result<(), String> {
    let mut config = without_studio();
    config["stores"] =
        json!({"wards": {"endpoint": "wards.example:7444", "server_name": "wards.example"}});
    let message = parsed(&config)
        .err()
        .ok_or("a door with no studio store was taken")?;
    assert!(
        message.contains(
            "\"studio\": {\"endpoint\": \"<host>:<port>\", \"server_name\": \"<TLS name>\"}"
        ),
        "{message}"
    );
    Ok(())
}

/// Fails while `door --check` answers `door_check_ok` for a door with no
/// studio store, or refuses one once studio is added. The success line's
/// words are read end to end, in `tests/door_local_e2e.rs`.
#[test]
fn door_check_refuses_a_door_with_no_studio_store_and_names_the_stores_it_serves()
-> Result<(), Box<dyn std::error::Error>> {
    let folder = tempfile::tempdir()?;
    let path = folder.path().join("door.json");
    let args = || {
        [
            "--check".to_owned(),
            "--config".to_owned(),
            path.to_string_lossy().into_owned(),
        ]
        .into_iter()
    };
    std::fs::write(&path, without_studio().to_string())?;
    let refused = crate::door::cli::run(args())
        .err()
        .ok_or("door --check passed a door with no studio store")?;
    assert!(
        refused.contains("store named studio") && refused.contains("\"studio\": "),
        "{refused}"
    );
    let mut config = without_studio();
    config["stores"]["studio"] = json!("/run/haematite/studio.sock");
    std::fs::write(&path, config.to_string())?;
    crate::door::cli::run(args())?;
    Ok(())
}