haematite-cli 0.21.3

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! A change into a collection its branch does not hold is decided by the
//! database's right, so the store answers `collection_missing`; a collection
//! the branch does hold keeps its own refusal.

use std::error::Error;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::Signer;
use haematite::db::DatabaseConfig;
use haematite::store::NodeCacheBudget;
use serde_json::{Value, json};

use super::tests::{ISSUER, KID, role, signer};
use super::{AUDIENCE, Gate, Unreachable, now};
use crate::serve::protocol::{BranchPage, Operation, Version};
use crate::serve::service::Service;
use crate::serve::storage::{Failure, Storage};

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

const PAGE: NonZeroU32 = NonZeroU32::MIN.saturating_add(9);

/// A pass for `holder` carrying `rights`, issued now, as the service's own
/// clock decides it.
pub(super) fn pass(holder: &str, rights: &Value) -> String {
    let issued = now();
    let header =
        URL_SAFE_NO_PAD.encode(json!({"alg": "EdDSA", "typ": "JWT", "kid": KID}).to_string());
    let claims = URL_SAFE_NO_PAD.encode(
        json!({
            "iss": ISSUER, "sub": holder, "aud": AUDIENCE, "iat": issued - 10, "exp": issued + 600,
            "holder": {"id": holder, "kind": "person", "responsible": null},
            "rights": rights,
        })
        .to_string(),
    );
    let signature = URL_SAFE_NO_PAD.encode(
        signer()
            .sign(format!("{header}.{claims}").as_bytes())
            .to_bytes(),
    );
    format!("{header}.{claims}.{signature}")
}

fn head(service: &Service) -> Result<Version, Box<dyn Error>> {
    let answer = service.dispatch(Operation::Branches(BranchPage {
        after: None,
        limit: PAGE,
    }))?;
    let row = answer["entries"]
        .as_array()
        .ok_or("branches has no entries")?
        .iter()
        .find(|row| row["branch"] == "main")
        .ok_or("main missing")?;
    Ok(serde_json::from_value(row["version"].clone())?)
}

fn change(service: &Service, kind: &str, value: &Value) -> Result<Operation, Box<dyn Error>> {
    Ok(serde_json::from_value(json!({
        "method": "mutation.apply",
        "params": {"request_id": format!("{kind} {value}"), "branch": "main",
            "expected": head(service)?, "change": {"kind": kind, "value": value}},
    }))?)
}

fn put(service: &Service, collection: &str) -> Result<Operation, Box<dyn Error>> {
    change(
        service,
        "document.put",
        &json!({"collection": collection, "document_id": "a", "document": {}}),
    )
}

/// A gated service on database `wards` whose main holds `patients`, which
/// the passes here carry rights on, and `unplaced`, which they do not.
fn service(directory: &std::path::Path) -> Result<Service, Box<dyn Error>> {
    let storage = Storage::create(
        DatabaseConfig {
            data_dir: directory.join("database"),
            shard_count: 1,
            distributed: None,
            executor_threads: Some(1),
            node_cache_budget: Some(NodeCacheBudget::Unlimited),
            retention: haematite::Retention::KeepAll,
        },
        NodeCacheBudget::Unlimited,
        Duration::from_secs(1),
    )?;
    let x = URL_SAFE_NO_PAD.encode(signer().verifying_key().as_bytes());
    let keys = json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x, "alg": "EdDSA", "use": "sig"}]});
    let service = Service::new(storage, PAGE)?.with_lys(Gate::new(
        &keys.to_string(),
        ISSUER,
        "wards",
        Arc::new(Unreachable),
    )?);
    for collection in ["patients", "unplaced"] {
        let define = change(
            &service,
            "collection.put",
            &json!({"collection": collection, "schema": null}),
        )?;
        service.dispatch(define)?;
    }
    Ok(service)
}

fn refused_on(answer: Result<(), Failure>, id: &str) -> TestResult {
    let refused = answer.err().ok_or_else(|| format!("{id} was allowed"))?;
    assert_eq!(refused.code, "permission_refused", "{id}");
    let shape: Value = serde_json::from_str(&refused.message)?;
    assert_eq!(shape["refused"]["needed"]["id"], id);
    Ok(())
}

#[test]
fn a_writer_putting_into_an_absent_collection_is_told_it_is_missing() -> TestResult {
    let directory = tempfile::tempdir()?;
    let service = service(directory.path())?;
    let writer = pass("writer", &role(&["read", "query", "write"], "writer-grant"));
    let absent = put(&service, "nothere")?;
    service.authorize(Some(&writer), &absent, None)?;
    let missing = service
        .dispatch(absent)
        .err()
        .ok_or("a put into an absent collection was applied")?;
    assert_eq!(missing.code, "collection_missing");
    let observer = pass("observer", &role(&["read", "query"], "observer-grant"));
    refused_on(
        service.authorize(Some(&observer), &put(&service, "nothere")?, None),
        "wards.nothere",
    )?;
    // A collection the branch holds is decided by its own right alone.
    refused_on(
        service.authorize(Some(&writer), &put(&service, "unplaced")?, None),
        "wards.unplaced",
    )?;
    service.authorize(Some(&writer), &put(&service, "patients")?, None)?;
    // A branch that does not exist is decided by the database's right, and
    // the store names it.
    let nowhere: Operation = serde_json::from_value(json!({
        "method": "mutation.apply",
        "params": {"request_id": "nowhere", "branch": "nowhere", "expected": head(&service)?,
            "change": {"kind": "document.put",
                "value": {"collection": "patients", "document_id": "a", "document": {}}}},
    }))?;
    service.authorize(Some(&writer), &nowhere, None)?;
    let missing = service
        .dispatch(nowhere)
        .err()
        .ok_or("a put on an absent branch was applied")?;
    assert_eq!(missing.code, "branch_missing");
    Ok(())
}