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 std::net::TcpListener;

use lys_pass::Target;

use super::LysLive;
use crate::serve::pass::LiveCheck;

type TestResult = Result<(), Box<dyn std::error::Error>>;

/// A loopback origin nothing listens on: the port is taken, then released.
fn closed_origin() -> Result<String, std::io::Error> {
    let listener = TcpListener::bind("127.0.0.1:0")?;
    let port = listener.local_addr()?.port();
    drop(listener);
    Ok(format!("http://127.0.0.1:{port}/"))
}

/// D10: with Lys unreachable the live check fails by name, so a deliberate
/// route is refused and never decided from the pass.
#[test]
fn an_unreachable_lys_is_named_lys_could_not_be_asked() -> TestResult {
    let live = LysLive::new(&closed_origin()?)?;
    let target = Target::new("haematite.database", "wards", "branch")?;
    let error = live
        .ask("a-pass", "holder", &target)
        .err()
        .ok_or("an unreachable Lys answered")?;
    assert_eq!(error.name(), "lys_could_not_be_asked");
    Ok(())
}

#[test]
fn an_issuer_that_is_not_an_http_origin_is_refused() {
    for issuer in [
        "not a url",
        "ftp://lys.example/",
        "https://user@lys.example/",
    ] {
        assert!(
            LysLive::new(issuer).is_err(),
            "{issuer} was taken as the issuer"
        );
    }
}

/// Lys's routes are under `/api` on the issuer, and its batch check answers
/// only an app or an administrator: with haematite's app credential set the
/// check goes to `/api/grants/check/batch` with the app as the caller, about
/// the connection's holder, never with the seat's pass.
#[test]
fn the_check_is_asked_under_api_as_the_app() -> TestResult {
    use std::io::{Read, Write};
    let listener = TcpListener::bind("127.0.0.1:0")?;
    let origin = format!("http://{}", listener.local_addr()?);
    let seen = std::thread::spawn(move || -> Result<String, std::io::Error> {
        let (mut stream, _) = listener.accept()?;
        stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
        let mut request = Vec::new();
        let mut chunk = [0_u8; 4096];
        loop {
            let read = stream.read(&mut chunk)?;
            if read == 0 {
                break;
            }
            request.extend_from_slice(&chunk[..read]);
            let text = String::from_utf8_lossy(&request).to_lowercase();
            if let Some(end) = text.find("\r\n\r\n") {
                let length = text
                    .lines()
                    .find_map(|line| line.strip_prefix("content-length: "))
                    .and_then(|n| n.trim().parse::<usize>().ok())
                    .unwrap_or(0);
                if request.len() >= end + 4 + length {
                    break;
                }
            }
        }
        let body =
            r#"{"revision":1,"results":[{"allowed":true,"mode":"outright","grant":"grant-1"}]}"#;
        write!(
            stream,
            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
            body.len()
        )?;
        Ok(String::from_utf8_lossy(&request).to_lowercase())
    });
    let live = LysLive::new(&origin)?.asking_as("lys-app.haematite.test".to_owned());
    let target = Target::new("haematite.database", "wards", "branch")?;
    live.ask("the-seats-pass", "agent-1", &target)?;
    let request = seen.join().map_err(|_| "the fake Lys panicked")??;
    assert!(
        request.starts_with("post /api/grants/check/batch "),
        "{request}"
    );
    assert!(
        request.contains("authorization: bearer lys-app.haematite.test"),
        "{request}"
    );
    assert!(!request.contains("the-seats-pass"), "{request}");
    Ok(())
}