haematite-cli 0.21.7

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use super::children::Children;
use super::config::Config;
use super::http;
use super::route;
use super::signin::{Door, Issuer, SignedIn, Started};
use lys_pass::Error;
use serde_json::json;
use std::io::Cursor;

type TestResult = Result<(), Box<dyn std::error::Error>>;
struct FixtureIssuer;
impl Issuer for FixtureIssuer {
    fn start(&self) -> Result<Started, Error> {
        Ok(Started {
            url: "https://lys.example/authorize".into(),
            state: "state".into(),
            verifier: "verifier".into(),
            browser: "browser".into(),
        })
    }
    fn finish(&self, _: &str, _: &str, _: &str, _: &Started) -> Result<SignedIn, Error> {
        Ok(SignedIn {
            token: "fixture-only".into(),
            holder: "fixture".into(),
            expires_at: 1000,
            refresh: None,
        })
    }
    fn refresh(&self, _: &str) -> Result<SignedIn, Error> {
        Err(Error::CannotAsk("fixture has no refresh"))
    }
}

#[test]
fn r3_given_up_store_answers_503_while_other_routes_are_served() -> TestResult {
    let directory = tempfile::Builder::new()
        .prefix("door-route-")
        .tempdir_in("/tmp")?;
    let records = directory.path().join("starts");
    let config = json!({"listen":"127.0.0.1:0", "origin":"https://studio.example", "files":directory.path(),
        "stores":{"fixture":directory.path().join("absent.sock")},
        "lys":{"issuer":"https://lys.example", "client_id":"fixture", "secret_file":directory.path().join("unused"), "redirect":"https://studio.example/callback"},
        "children":[{"name":"broken-store", "program":"/usr/bin/python3", "args":["-u","-c",
            "import os, sys\np=sys.argv[1]\nn=len(open(p).readlines())+1 if os.path.exists(p) else 1\nwith open(p, 'a') as f: f.write('started\\n')\nif n > 3: print('fixture ready unexpected extra start', file=sys.stderr, flush=True); sys.stdin.read()\nsys.exit(9)", records],
            "ready":"fixture ready", "log":directory.path().join("child.log"), "store":"fixture", "give_up_after":3}]
    });
    let config = Config::parse(&serde_json::to_vec(&config)?)?;
    let site = config.site();
    let children = Children::start(&config.children)?;
    let door = Door::new(FixtureIssuer);
    let begun = door.begin(100)?;
    let session = door.callback("code", "state", &begun.browser, 100)?;
    let body = r#"{"method":"catalogue.list","params":{}}"#;
    let request = format!(
        "POST /api/haem/fixture HTTP/1.1\r\nOrigin: https://studio.example\r\nCookie: haem_session={session}\r\nContent-Length: {}\r\n\r\n{body}",
        body.len()
    );
    let request = http::read(&mut Cursor::new(request), 16384, 65536)?;
    let mut bytes = Vec::new();
    route::answer(&door, &site, &request, 100).write(&mut bytes)?;
    let response = String::from_utf8(bytes)?;
    assert!(response.starts_with("HTTP/1.1 503 "), "{response}");
    assert!(response.contains("store_child_given_up"), "{response}");
    assert!(response.contains("broken-store"), "{response}");
    let request = format!("GET /api/haem HTTP/1.1\r\nCookie: haem_session={session}\r\n\r\n");
    let request = http::read(&mut Cursor::new(request), 16384, 65536)?;
    let mut bytes = Vec::new();
    route::answer(&door, &site, &request, 100).write(&mut bytes)?;
    assert!(String::from_utf8(bytes)?.starts_with("HTTP/1.1 200 "));
    drop(children);
    Ok(())
}

fn failed_site(root: &std::path::Path) -> route::Site {
    use std::collections::BTreeMap;
    use std::sync::{Arc, atomic::AtomicBool};
    route::Site {
        origin: "https://studio.example".into(),
        files: root.into(),
        stores: BTreeMap::from([("fixture".into(), root.join("absent.sock"))]),
        store_children: BTreeMap::from([(
            "fixture".into(),
            ("broken-store".into(), Arc::new(AtomicBool::new(true))),
        )]),
        timeout: std::time::Duration::from_secs(1),
        packed: super::packed::Packed::default(),
    }
}

#[test]
fn review_3_assistant_store_given_up_is_503_after_session_check() -> TestResult {
    let directory = tempfile::tempdir_in("/tmp")?;
    let site = failed_site(directory.path());
    let door = Door::new(FixtureIssuer);
    let begun = door.begin(100)?;
    let session = door.callback("code", "state", &begun.browser, 100)?;
    for (cookie, expected) in [
        (
            format!("Cookie: haem_session={session}\r\n"),
            "HTTP/1.1 503 ",
        ),
        (String::new(), "HTTP/1.1 401 "),
    ] {
        let text = format!(
            "POST /api/assistant/fixture/start HTTP/1.1\r\nOrigin: https://studio.example\r\n{cookie}\r\n"
        );
        let request = http::read(&mut Cursor::new(text), 16_384, 65_536)?;
        let mut response = Vec::new();
        route::answer(&door, &site, &request, 100).write(&mut response)?;
        let response = String::from_utf8(response)?;
        assert!(response.starts_with(expected), "{response}");
        if expected.contains("503") {
            assert!(
                response.contains("store_child_given_up") && response.contains("broken-store"),
                "{response}"
            );
        }
    }
    Ok(())
}

#[test]
fn review_3_store_list_names_the_given_up_child() -> TestResult {
    let directory = tempfile::tempdir_in("/tmp")?;
    let site = failed_site(directory.path());
    let door = Door::new(FixtureIssuer);
    let begun = door.begin(100)?;
    let session = door.callback("code", "state", &begun.browser, 100)?;
    let request = http::read(
        &mut Cursor::new(format!(
            "GET /api/haem HTTP/1.1\r\nCookie: haem_session={session}\r\n\r\n"
        )),
        16_384,
        65_536,
    )?;
    let mut bytes = Vec::new();
    route::answer(&door, &site, &request, 100).write(&mut bytes)?;
    let text = String::from_utf8(bytes)?;
    let body: serde_json::Value =
        serde_json::from_str(text.split_once("\r\n\r\n").ok_or("body missing")?.1)?;
    assert_eq!(
        body["stores"][0],
        json!({"name":"fixture", "given_up":true, "child":"broken-store"})
    );
    Ok(())
}