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::collections::BTreeMap;
use std::io::Cursor;
use std::time::Duration;

use lys_pass::Error;
use serde_json::{Value, json};

use super::{Site, answer};
use crate::door::http::{Request, Response, read};
use crate::door::packed::Packed;
use crate::door::signin::{Door, Issuer, SignedIn, Started};

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

const NOW: u64 = 1_000;
const ORIGIN: &str = "https://studio.example";

struct Lys;

impl Issuer for Lys {
    fn start(&self) -> Result<Started, Error> {
        Ok(Started {
            url: "https://lys.example/oauth/authorize?state=s1".to_owned(),
            state: "s1".to_owned(),
            verifier: "v1".to_owned(),
            browser: "b1".to_owned(),
        })
    }

    fn finish(
        &self,
        code: &str,
        _: &str,
        browser: &str,
        started: &Started,
    ) -> Result<SignedIn, Error> {
        if browser != started.browser {
            return Err(Error::Invalid("another browser"));
        }
        Ok(SignedIn {
            token: format!("pass-for-{code}"),
            holder: "clinician".to_owned(),
            expires_at: NOW + 600,
            refresh: None,
        })
    }

    fn refresh(&self, _: &str) -> Result<SignedIn, Error> {
        Err(Error::CannotAsk("no refresh in this double"))
    }
}

fn site(files: &std::path::Path) -> Site {
    Site {
        origin: ORIGIN.to_owned(),
        files: files.to_owned(),
        stores: BTreeMap::from([("wards".to_owned(), files.join("absent.sock"))]),
        timeout: Duration::from_secs(5),
        packed: Packed::default(),
        store_children: BTreeMap::new(),
    }
}

fn request(text: &str) -> Result<Request, Box<dyn std::error::Error>> {
    Ok(read(
        &mut Cursor::new(text.as_bytes().to_vec()),
        16_384,
        1 << 20,
    )?)
}

fn written(response: Response) -> Result<String, Box<dyn std::error::Error>> {
    let mut out = Vec::new();
    response.write(&mut out)?;
    Ok(String::from_utf8(out)?)
}

fn cookie<'a>(text: &'a str, name: &str) -> Option<&'a str> {
    text.lines()
        .filter_map(|line| line.strip_prefix("Set-Cookie: "))
        .find(|cookie| cookie.starts_with(&format!("{name}=")))
}

/// R2: sign-in goes to Lys with a Lax state cookie, and the callback answers
/// a Strict session cookie holding an opaque id, never the pass.
#[test]
fn a_sign_in_round_trip_sets_an_opaque_strict_session_cookie() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    let begun = written(answer(
        &door,
        &site,
        &request("GET /signin?return=%23%2Fdata HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    assert!(
        begun.contains("Location: https://lys.example/oauth/authorize?state=s1\r\n"),
        "{begun}"
    );
    let state = cookie(&begun, "haem_state").ok_or("no state cookie")?;
    assert!(
        state.contains("SameSite=Lax") && state.contains("HttpOnly") && state.contains("Secure"),
        "{state}"
    );
    let back = written(answer(
        &door,
        &site,
        &request(
            "GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=b1; haem_return=%23%2Fdata\r\n\r\n",
        )?,
        NOW,
    ))?;
    let session = cookie(&back, "haem_session").ok_or("no session cookie")?;
    assert!(
        session.contains("SameSite=Strict")
            && session.contains("HttpOnly")
            && session.contains("Secure"),
        "{session}"
    );
    assert!(
        !back.contains("pass-for-c1"),
        "the pass reached the browser: {back}"
    );
    assert!(back.contains("Location: /#/data\r\n"), "{back}");
    let id = session
        .trim_start_matches("haem_session=")
        .split(';')
        .next()
        .ok_or("no id")?;
    let line = written(answer(
        &door,
        &site,
        &request(&format!(
            "GET /api/session HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
        ))?,
        NOW,
    ))?;
    let body: Value = serde_json::from_str(line.split("\r\n\r\n").nth(1).ok_or("no body")?)?;
    assert_eq!(body["holder"], "clinician");
    Ok(())
}

/// The stores answer is the object Studio reads (`answers.ts` `reachable`):
/// fails while the door answers a bare list, which Studio refuses as
/// `answer_unreadable` after a sign-in that passed.
#[test]
fn the_stores_answer_is_the_object_studio_reads() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    written(answer(
        &door,
        &site,
        &request("GET /signin HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    let back = written(answer(
        &door,
        &site,
        &request("GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=b1\r\n\r\n")?,
        NOW,
    ))?;
    let session = cookie(&back, "haem_session").ok_or("no session cookie")?;
    let id = session
        .trim_start_matches("haem_session=")
        .split(';')
        .next()
        .ok_or("no id")?;
    let listed = written(answer(
        &door,
        &site,
        &request(&format!(
            "GET /api/haem HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
        ))?,
        NOW,
    ))?;
    assert!(listed.starts_with("HTTP/1.1 200"), "{listed}");
    let body: Value = serde_json::from_str(listed.split("\r\n\r\n").nth(1).ok_or("no body")?)?;
    assert_eq!(body, serde_json::json!({"stores": [{"name": "wards"}]}));
    Ok(())
}

/// Without a session every /api answer is `session_unknown`, which Studio
/// answers by signing in; nothing is carried.
#[test]
fn an_api_call_without_a_session_is_refused_session_unknown() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    for text in [
        "GET /api/haem HTTP/1.1\r\n\r\n".to_owned(),
        format!(
            "POST /api/haem/wards HTTP/1.1\r\nOrigin: {ORIGIN}\r\nContent-Length: 20\r\n\r\n{{\"method\":\"hello\"}}  "
        ),
    ] {
        let refused = written(answer(&door, &site, &request(&text)?, NOW))?;
        assert!(refused.starts_with("HTTP/1.1 401"), "{refused}");
        assert!(refused.contains("\"session_unknown\""), "{refused}");
    }
    Ok(())
}

/// A verb from another origin is refused before its session is read.
#[test]
fn a_verb_from_another_origin_is_refused() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    let refused = written(answer(
        &door,
        &site,
        &request(
            "POST /api/haem/wards HTTP/1.1\r\nOrigin: https://evil.example\r\nContent-Length: 2\r\n\r\n{}",
        )?,
        NOW,
    ))?;
    assert!(refused.starts_with("HTTP/1.1 403"), "{refused}");
    assert!(refused.contains("\"origin_refused\""), "{refused}");
    Ok(())
}

/// A sign-out from Studio's origin forgets the session and clears its
/// cookie with a 204; the session's id answers nothing afterwards. Fails
/// while the door has no /signout (the POST is refused `method_refused`).
#[test]
fn a_sign_out_forgets_the_session_and_clears_its_cookie() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    let id = signed_in(&door, &site)?;
    let out = written(answer(
        &door,
        &site,
        &request(&format!(
            "POST /signout HTTP/1.1\r\nOrigin: {ORIGIN}\r\nCookie: haem_session={id}\r\n\r\n"
        ))?,
        NOW,
    ))?;
    assert!(out.starts_with("HTTP/1.1 204"), "{out}");
    let cleared = cookie(&out, "haem_session").ok_or("the cookie was not cleared")?;
    assert!(
        cleared.starts_with("haem_session=;")
            && cleared.contains("Max-Age=0")
            && cleared.contains("Path=/"),
        "{cleared}"
    );
    let after = written(answer(
        &door,
        &site,
        &request(&format!(
            "GET /api/session HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
        ))?,
        NOW,
    ))?;
    assert!(after.starts_with("HTTP/1.1 401"), "{after}");
    assert!(after.contains("\"session_unknown\""), "{after}");
    let again = written(answer(
        &door,
        &site,
        &request(&format!(
            "POST /signout HTTP/1.1\r\nOrigin: {ORIGIN}\r\nCookie: haem_session={id}\r\n\r\n"
        ))?,
        NOW,
    ))?;
    assert!(again.starts_with("HTTP/1.1 204"), "{again}");
    Ok(())
}

/// A sign-out from another origin, or with no Origin, is refused and the
/// session still holds.
#[test]
fn a_sign_out_from_another_origin_is_refused() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    let id = signed_in(&door, &site)?;
    for origin in ["Origin: https://evil.example\r\n", ""] {
        let refused = written(answer(
            &door,
            &site,
            &request(&format!(
                "POST /signout HTTP/1.1\r\n{origin}Cookie: haem_session={id}\r\n\r\n"
            ))?,
            NOW,
        ))?;
        assert!(refused.starts_with("HTTP/1.1 403"), "{origin}: {refused}");
        assert!(refused.contains("\"origin_refused\""), "{refused}");
        assert!(cookie(&refused, "haem_session").is_none(), "{refused}");
    }
    let line = written(answer(
        &door,
        &site,
        &request(&format!(
            "GET /api/session HTTP/1.1\r\nCookie: haem_session={id}\r\n\r\n"
        ))?,
        NOW,
    ))?;
    assert!(line.starts_with("HTTP/1.1 200"), "{line}");
    Ok(())
}

/// Studio's files are served from its folder and nothing outside it; any
/// other address is the app, which routes by its #.
#[test]
fn studios_files_are_served_and_nothing_outside_them() -> TestResult {
    let files = tempfile::tempdir()?;
    std::fs::write(files.path().join("index.html"), "<html>studio</html>")?;
    std::fs::write(files.path().join("app.js"), "let a = 1;")?;
    let site = site(files.path());
    let door = Door::new(Lys);
    let script = written(answer(
        &door,
        &site,
        &request("GET /app.js HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    assert!(script.contains("Content-Type: text/javascript"), "{script}");
    assert!(script.ends_with("let a = 1;"));
    let index = written(answer(
        &door,
        &site,
        &request("GET / HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    assert!(index.ends_with("<html>studio</html>"));
    for escape in [
        "GET /../secret HTTP/1.1\r\n\r\n",
        "GET /a/..%2F..%2Fsecret HTTP/1.1\r\n\r\n",
    ] {
        let refused = written(answer(&door, &site, &request(escape)?, NOW))?;
        assert!(refused.starts_with("HTTP/1.1 404"), "{escape}: {refused}");
    }
    Ok(())
}

/// A refused sign-in writes one log line (10 Oct 2026 row: the door logged
/// nothing when Lys refused the exchange). Fails while the door is silent.
#[test]
fn a_refused_sign_in_is_logged() -> TestResult {
    let files = tempfile::tempdir()?;
    let site = site(files.path());
    let door = Door::new(Lys);
    written(answer(
        &door,
        &site,
        &request("GET /signin HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    let before = super::callback_refusals();
    let refused = written(answer(
        &door,
        &site,
        &request("GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=other\r\n\r\n")?,
        NOW,
    ))?;
    assert!(!refused.starts_with("HTTP/1.1 3"), "{refused}");
    assert_eq!(super::callback_refusals(), before + 1);
    let missing = written(answer(
        &door,
        &site,
        &request("GET /callback HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    assert!(missing.contains("\"sign_in_refused\""), "{missing}");
    assert_eq!(super::callback_refusals(), before + 2);
    Ok(())
}

#[path = "route_asset_tests.rs"]
mod assets;
#[path = "route_follow_tests.rs"]
mod follows;

/// Sign in through the Lys double, answering the session's id.
fn signed_in(door: &Door<Lys>, site: &Site) -> Result<String, Box<dyn std::error::Error>> {
    written(answer(
        door,
        site,
        &request("GET /signin HTTP/1.1\r\n\r\n")?,
        NOW,
    ))?;
    let back = written(answer(
        door,
        site,
        &request("GET /callback?code=c1&state=s1 HTTP/1.1\r\nCookie: haem_state=b1\r\n\r\n")?,
        NOW,
    ))?;
    let session = cookie(&back, "haem_session").ok_or("no session cookie")?;
    let id = session
        .trim_start_matches("haem_session=")
        .split(';')
        .next()
        .ok_or("no id")?;
    Ok(id.to_owned())
}