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::assistant_test_support::*;
use serde_json::json;

#[path = "assistant_decision_revision_tests.rs"]
mod revision;

#[path = "assistant_reason_tests.rs"]
mod reason;

#[test]
fn accepted_decision_only_then_reaches_seat() -> Result {
    let f = Fixture::new(json!({}))?;
    let mut seat = f.seat("agent-pass", "wards")?;
    let door = std::sync::Arc::clone(&f.door);
    let (send, read) = std::sync::mpsc::channel();
    let worker = std::thread::spawn(move || -> Result {
        let op = seat.read()?;
        assert_eq!(
            door.issuer()
                .decisions
                .load(std::sync::atomic::Ordering::SeqCst),
            1
        );
        seat.send(&json!({"ack":{"id":op["op"]["id"]}}))?;
        send.send(op)?;
        Ok(())
    });
    check(
        f.answer(
            "POST",
            "/decide",
            &json!({"draft":"draft-accepted","decision":"approve"}),
        ),
        200,
        "seat_told",
    )?;
    let op = read.recv_timeout(std::time::Duration::from_secs(5))?;
    assert_eq!(
        op["op"]["decided"],
        json!({"draft":"draft-accepted","decision":"approve"})
    );
    worker.join().map_err(|_| anyhow::anyhow!("seat panic"))??;
    Ok(())
}
#[test]
fn lys_refusal_has_draft_url_and_sends_nothing_to_seat() -> Result {
    let f = Fixture::new(json!({}))?;
    let mut seat = f.seat("agent-pass", "wards")?;
    let out = written(f.answer(
        "POST",
        "/decide",
        &json!({"draft":"draft-refused","decision":"approve"}),
    ))?;
    assert!(
        out.contains("draft_not_pending")
            && out.contains("https://lys.example/#/access/drafts?draft=draft-refused"),
        "{out}"
    );
    let read = std::thread::spawn(move || -> Result {
        let op = seat.read()?;
        assert_eq!(op["op"]["text"], "after refusal");
        seat.send(&json!({"ack":{"id":op["op"]["id"]}}))
    });
    check(
        f.answer("POST", "", &json!({"text":"after refusal"})),
        202,
        "id",
    )?;
    read.join().map_err(|_| anyhow::anyhow!("seat panic"))??;
    Ok(())
}
#[test]
fn start_names_lys_when_person_app_pass_is_not_admitted() -> Result {
    let f = Fixture::new(json!({}))?;
    let store = super::assistant_start_tests::Store::start(
        &f,
        vec![vec!["_assistant"]],
        super::assistant_start_tests::Put::Refused,
    )?;
    let text = written(f.answer("POST", "/start", &json!({})))?;
    assert!(
        text.starts_with("HTTP/1.1 501 ")
            && text.contains("assistant_start_in_lys")
            && text.contains("https://lys.example"),
        "{text}"
    );
    assert!(store.mutations()?.is_empty());
    Ok(())
}

#[tokio::test(flavor = "current_thread")]
async fn native_draft_client_carries_person_pass_digest_and_lys_operation_id() -> Result {
    let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
    let issuer = format!("http://{}", listener.local_addr()?);
    let server = std::thread::spawn(move || -> Result {
        for step in 0..2 {
            let (socket, _) = listener.accept()?;
            socket.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
            let request =
                super::http::read(&mut std::io::BufReader::new(&socket), 16_384, 1_048_576)?;
            assert_eq!(request.header("authorization"), Some("Bearer person-pass"));
            let response = if step == 0 {
                assert_eq!(request.path, "/api/product-drafts");
                super::http::Response::json(
                    200,
                    &json!({"drafts":[{"id":"draft-native","app":"haematite","holder":"agent-assistant","responsible":PERSON,"target":{"kind":"haematite.database","id":"wards","action":"agent_permission"},"request_digest":"a".repeat(64)}],"next":null}),
                )
            } else {
                assert_eq!(request.path, "/api/product-drafts/draft-native/approve");
                let body: serde_json::Value = serde_json::from_slice(&request.body)?;
                assert_eq!(body["request_digest"], "a".repeat(64));
                let valid = body["operation"]
                    .as_str()
                    .and_then(|id| id.strip_prefix("op-"))
                    .is_some_and(|id| {
                        id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())
                    });
                if valid {
                    super::http::Response::json(
                        200,
                        &json!({"draft":"draft-native","state":"approved"}),
                    )
                } else {
                    super::http::Response::json(400, &json!({"refusal":"IdentifierMalformed"}))
                }
            };
            response.write_to(&socket)?;
        }
        Ok(())
    });
    let client = super::assistant_lys::Client {
        issuer,
        http: reqwest::Client::builder()
            .no_proxy()
            .redirect(reqwest::redirect::Policy::none())
            .timeout(std::time::Duration::from_secs(5))
            .build()?,
    };
    let answer = client
        .decide("person-pass", "draft-native", "approve", None)
        .await;
    server
        .join()
        .map_err(|_| anyhow::anyhow!("Lys fixture panic"))??;
    let answer = answer?;
    assert_eq!(answer["state"], "approved");
    Ok(())
}