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
//! ACCESS-003 R2: the person at the keyboard signs in by the key the door
//! printed, once, and nothing else opens a session.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde_json::{Value, json};

use super::{Local, NoLys, link};
use crate::door::http::Request;
use crate::door::packed::Packed;
use crate::door::route::{Site, answer};
use crate::door::signin::Door;
use crate::serve::contracts::Caller;

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

fn site(origin: &str) -> Site {
    Site {
        origin: origin.into(),
        files: PathBuf::from("/unused"),
        stores: BTreeMap::new(),
        timeout: std::time::Duration::from_secs(1),
        packed: Packed::default(),
        store_children: BTreeMap::new(),
    }
}

fn get(path: &str, query: &str, cookie: Option<&str>) -> Request {
    Request {
        method: "GET".into(),
        path: path.into(),
        query: query.into(),
        headers: cookie
            .map(|cookie| vec![("cookie".into(), format!("haem_session={cookie}"))])
            .unwrap_or_default(),
        body: Vec::new(),
    }
}

/// The door's whole answer to `request`, as the browser reads it.
fn asked(
    door: &Door<NoLys>,
    site: &Site,
    request: &Request,
) -> Result<String, Box<dyn std::error::Error>> {
    let mut bytes = Vec::new();
    answer(door, site, request, 1_000).write(&mut bytes)?;
    Ok(String::from_utf8(bytes)?)
}

fn body(response: &str) -> Result<Value, Box<dyn std::error::Error>> {
    let (_, body) = response.split_once("\r\n\r\n").ok_or("no body")?;
    Ok(serde_json::from_str(body)?)
}

fn door() -> Result<(Door<NoLys>, String), Box<dyn std::error::Error>> {
    let (local, key) = Local::new(Caller::local("tom")?)?;
    Ok((Door::at_keyboard(NoLys, local), key))
}

/// Fails while the printed key opens no session, or the session names
/// anyone but the door's local owner, or a cookie flag is missing.
#[test]
fn the_printed_key_opens_one_session_for_the_local_owner() -> TestResult {
    let (door, key) = door()?;
    assert_eq!(key.len(), 64);
    assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit()));
    assert_eq!(
        link("http://127.0.0.1:7443", &key),
        format!("http://127.0.0.1:7443/local?key={key}")
    );
    let site = site("http://127.0.0.1:7443");
    let opened = asked(&door, &site, &get("/local", &format!("key={key}"), None))?;
    assert!(opened.starts_with("HTTP/1.1 303 "), "{opened}");
    assert!(opened.contains("\r\nLocation: /\r\n"), "{opened}");
    let cookie = opened
        .lines()
        .find_map(|line| line.strip_prefix("Set-Cookie: haem_session="))
        .ok_or("no session cookie")?;
    let (id, flags) = cookie.split_once(';').ok_or("no cookie flags")?;
    assert_eq!(flags, " HttpOnly; SameSite=Strict; Path=/");
    let session = asked(&door, &site, &get("/api/session", "", Some(id)))?;
    assert_eq!(body(&session)?, json!({"holder": "tom", "local": true}));
    Ok(())
}

/// Fails while an https origin's cookie lacks Secure.
#[test]
fn an_https_origin_marks_the_cookie_secure() -> TestResult {
    let (door, key) = door()?;
    let opened = asked(
        &door,
        &site("https://localhost:7443"),
        &get("/local", &format!("key={key}"), None),
    )?;
    assert!(
        opened.contains("Set-Cookie: haem_session=")
            && opened.contains("; HttpOnly; Secure; SameSite=Strict; Path=/\r\n"),
        "{opened}"
    );
    Ok(())
}

/// Fails while a spent, a wrong or a missing key opens a session, or while
/// their refusals differ in any word.
#[test]
fn a_spent_a_wrong_and_no_key_are_refused_in_the_same_words() -> TestResult {
    let (door, key) = door()?;
    let site = site("http://127.0.0.1:7443");
    let wrong = asked(
        &door,
        &site,
        &get("/local", &format!("key={}", "0".repeat(64)), None),
    )?;
    let none = asked(&door, &site, &get("/local", "", None))?;
    asked(&door, &site, &get("/local", &format!("key={key}"), None))?;
    let spent = asked(&door, &site, &get("/local", &format!("key={key}"), None))?;
    for refused in [&wrong, &none, &spent] {
        assert!(refused.starts_with("HTTP/1.1 401 "), "{refused}");
        assert!(!refused.contains("Set-Cookie"), "{refused}");
        assert_eq!(body(refused)?["code"], "door_local_key_refused");
    }
    assert_eq!(body(&wrong)?, body(&spent)?);
    assert_eq!(body(&none)?, body(&spent)?);
    Ok(())
}

/// Fails while a request with no session, or with a session id the door
/// never made, reaches the stores: a loopback address alone opens nothing.
#[test]
fn no_session_opens_nothing() -> TestResult {
    let (door, _key) = door()?;
    let site = site("http://127.0.0.1:7443");
    let never_made = "0".repeat(64);
    for cookie in [None, Some(never_made.as_str())] {
        let stores = asked(&door, &site, &get("/api/haem", "", cookie))?;
        assert_eq!(body(&stores)?["code"], "session_unknown", "{stores}");
        let mut verb = get("/api/haem/wards", "", cookie);
        verb.method = "POST".into();
        verb.headers.push(("origin".into(), site.origin.clone()));
        verb.body = br#"{"method":"branches.list"}"#.to_vec();
        let refused = asked(&door, &site, &verb)?;
        assert_eq!(body(&refused)?["code"], "session_unknown", "{refused}");
    }
    Ok(())
}

/// Fails while a door with no lys block starts a sign-in through Lys, or
/// starts its assistant, or a sign-out leaves the session open.
#[test]
fn a_door_with_no_lys_block_signs_nobody_in_through_lys_and_starts_no_assistant() -> TestResult {
    let (door, key) = door()?;
    let site = site("http://127.0.0.1:7443");
    for path in ["/signin", "/callback"] {
        let refused = asked(&door, &site, &get(path, "code=c&state=s", None))?;
        assert_eq!(
            body(&refused)?["code"],
            "door_local_key_refused",
            "{refused}"
        );
    }
    let opened = asked(&door, &site, &get("/local", &format!("key={key}"), None))?;
    let id = opened
        .lines()
        .find_map(|line| line.strip_prefix("Set-Cookie: haem_session="))
        .and_then(|cookie| cookie.split(';').next())
        .ok_or("no session cookie")?
        .to_owned();
    let mut start = get("/api/assistant/wards/start", "", Some(&id));
    start.method = "POST".into();
    start.headers.push(("origin".into(), site.origin.clone()));
    let refused = asked(&door, &site, &start)?;
    assert_eq!(body(&refused)?["code"], "assistant_needs_lys", "{refused}");
    let events = asked(
        &door,
        &site,
        &get("/api/assistant/wards/events", "", Some(&id)),
    )?;
    assert!(events.contains("text/event-stream"), "{events}");
    assert!(events.contains("event: refused\ndata: "), "{events}");
    assert!(events.contains("assistant_needs_lys"), "{events}");
    let mut out = get("/signout", "", Some(&id));
    out.method = "POST".into();
    out.headers.push(("origin".into(), site.origin.clone()));
    let signed_out = asked(&door, &site, &out)?;
    assert!(signed_out.starts_with("HTTP/1.1 204 "), "{signed_out}");
    assert!(
        signed_out.contains(
            "Set-Cookie: haem_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0\r\n"
        ),
        "{signed_out}"
    );
    let after = asked(&door, &site, &get("/api/session", "", Some(&id)))?;
    assert_eq!(body(&after)?["code"], "session_unknown", "{after}");
    Ok(())
}