use std::error::Error;
use std::num::{NonZeroU32, NonZeroUsize};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
use haematite::db::DatabaseConfig;
use haematite::store::NodeCacheBudget;
use serde_json::value::RawValue;
use serde_json::{Value, json};
use crate::client::{Client, Reply};
use crate::serve::pass::{AUDIENCE, Gate, Unreachable, now};
use crate::serve::service::Service;
use crate::serve::storage::Storage;
use crate::serve::transport::{Limits, Listener};
type TestResult = Result<(), Box<dyn Error>>;
const PAGE: NonZeroU32 = NonZeroU32::MIN.saturating_add(9);
const STEP: Duration = Duration::from_secs(60);
const ISSUER: &str = "https://lys.example";
const KID: &str = "haematite-author-test";
fn signer() -> SigningKey {
SigningKey::from_bytes(&[13; 32])
}
fn keys() -> String {
let x = URL_SAFE_NO_PAD.encode(signer().verifying_key().as_bytes());
json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x, "alg": "EdDSA", "use": "sig"}]})
.to_string()
}
fn pass(holder: &str, kind: &str, responsible: Option<&str>) -> String {
let issued = now();
let rights: Vec<Value> = [
("haematite.database", "wards"),
("haematite.collection", "wards.patients"),
("haematite.branch", "wards.main"),
]
.into_iter()
.map(|(kind, id)| {
json!({"resource": {"kind": kind, "id": id}, "actions": ["read", "query", "write"],
"mode": "outright", "grant": "author-grant"})
})
.collect();
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": kind, "responsible": responsible},
"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 served(directory: &Path, lys: bool) -> Result<PathBuf, Box<dyn Error>> {
let private = directory.join("store");
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
let storage = Storage::create(
DatabaseConfig {
data_dir: private.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 service = Service::new(storage, PAGE)?;
let service = Arc::new(if lys {
service.with_lys(Gate::new(&keys(), ISSUER, "wards", Arc::new(Unreachable))?)
} else {
service
});
let socket = private.join("s.sock");
let listener = Listener::bind(&socket)?;
let limits = Limits {
max_frame: NonZeroU32::MIN.saturating_add(65_535),
max_clients: NonZeroUsize::MIN.saturating_add(3),
frame_timeout: Duration::from_secs(60),
};
std::thread::spawn(move || listener.run(&service, limits));
Ok(socket)
}
fn login() -> Result<String, Box<dyn Error>> {
let output = std::process::Command::new("/usr/bin/id")
.arg("-un")
.output()?;
assert!(output.status.success(), "id -un failed");
Ok(String::from_utf8(output.stdout)?.trim_end().to_owned())
}
fn raw(value: &Value) -> Result<Box<RawValue>, Box<dyn Error>> {
Ok(serde_json::value::to_raw_value(value)?)
}
fn answered(client: &mut Client, method: &str, params: &Value) -> Result<Value, Box<dyn Error>> {
match client.call(method, Some(&raw(params)?))? {
Reply::Answered(answer) => Ok(serde_json::from_str(answer.get())?),
Reply::Refused(refusal) => {
Err(format!("{method} refused: {}: {}", refusal.code, refusal.message).into())
}
}
}
fn head(client: &mut Client) -> Result<Value, Box<dyn Error>> {
let answer = answered(client, "branches.list", &json!({"limit": 10}))?;
answer["entries"]
.as_array()
.ok_or("branches has no entries")?
.iter()
.find(|row| row["branch"] == "main")
.map(|row| row["version"].clone())
.ok_or_else(|| "main is missing".into())
}
fn apply(client: &mut Client, request_id: &str, change: &Value) -> Result<Value, Box<dyn Error>> {
let expected = head(client)?;
let receipt = answered(
client,
"mutation.apply",
&json!({"request_id": request_id, "branch": "main", "expected": expected, "change": change}),
)?;
Ok(receipt["version"].clone())
}
fn patients(client: &mut Client) -> Result<Value, Box<dyn Error>> {
apply(
client,
"make-patients",
&json!({"kind": "collection.put", "value": {"collection": "patients", "schema": null}}),
)
}
fn put(
client: &mut Client,
request_id: &str,
id: &str,
document: &Value,
) -> Result<Value, Box<dyn Error>> {
apply(
client,
request_id,
&json!({"kind": "document.put", "value": {"collection": "patients", "document_id": id, "document": document}}),
)
}
fn authors(client: &mut Client, version: &Value) -> Result<Value, Box<dyn Error>> {
let history = answered(
client,
"history.list",
&json!({"branch": "main", "after": null, "limit": 10}),
)?;
let entry = history["entries"]
.as_array()
.ok_or("history has no entries")?
.iter()
.find(|entry| &entry["version"] == version)
.ok_or_else(|| format!("history does not hold {version}: {history}"))?;
entry
.get("authors")
.cloned()
.ok_or_else(|| format!("history answers no author beside {version}: {entry}").into())
}
#[test]
fn a_put_through_a_store_with_no_lys_records_the_socket_owner() -> TestResult {
let directory = tempfile::tempdir()?;
let socket = served(directory.path(), false)?;
let mut client = Client::connect(&socket, STEP)?;
patients(&mut client)?;
let written = put(&mut client, "put-p1", "p1", &json!({"name": "Ada"}))?;
assert_eq!(
authors(&mut client, &written)?,
json!([{"request_id": "put-p1", "caller": {"issuer": "local", "subject": login()?}}])
);
Ok(())
}
#[test]
fn a_put_through_lys_records_the_pass_holder_and_an_agents_person() -> TestResult {
let directory = tempfile::tempdir()?;
let socket = served(directory.path(), true)?;
let mut person = Client::connect(&socket, STEP)?;
answered(
&mut person,
"session.pass",
&json!({"pass": pass("clinician", "person", None)}),
)?;
patients(&mut person)?;
let by_person = put(&mut person, "put-person", "p1", &json!({"name": "Ada"}))?;
assert_eq!(
authors(&mut person, &by_person)?,
json!([{"request_id": "put-person", "caller": {"issuer": ISSUER, "subject": "clinician"}}])
);
let mut agent = Client::connect(&socket, STEP)?;
answered(
&mut agent,
"session.pass",
&json!({"pass": pass("agent-7", "agent", Some("clinician"))}),
)?;
let by_agent = put(&mut agent, "put-agent", "p2", &json!({"name": "Grace"}))?;
assert_eq!(
authors(&mut agent, &by_agent)?,
json!([{"request_id": "put-agent", "caller":
{"issuer": ISSUER, "subject": "agent-7", "responsible": "clinician"}}])
);
Ok(())
}
#[test]
fn a_request_naming_an_author_never_becomes_the_author() -> TestResult {
let directory = tempfile::tempdir()?;
let socket = served(directory.path(), false)?;
let mut client = Client::connect(&socket, STEP)?;
patients(&mut client)?;
let named = json!({"name": "Ada", "author": {"issuer": "local", "subject": "mallory"},
"caller": {"issuer": "local", "subject": "mallory"}});
let written = put(&mut client, "put-named", "p1", &named)?;
assert_eq!(
authors(&mut client, &written)?,
json!([{"request_id": "put-named", "caller": {"issuer": "local", "subject": login()?}}])
);
let before = head(&mut client)?;
let claimed = client.call(
"mutation.apply",
Some(&raw(
&json!({"request_id": "put-claimed", "branch": "main", "expected": before,
"caller": {"issuer": "local", "subject": "mallory"},
"change": {"kind": "document.put", "value": {"collection": "patients",
"document_id": "p2", "document": {"name": "Grace"}}}}),
)?),
);
assert!(
!matches!(claimed, Ok(Reply::Answered(_))),
"a change naming its caller was applied: {claimed:?}"
);
let mut after = Client::connect(&socket, STEP)?;
assert_eq!(head(&mut after)?, before, "a refused change moved main");
Ok(())
}
const MOST_PAGE: NonZeroU32 = NonZeroU32::MIN.saturating_add(99);
fn dispatched(service: &Service, method: &str, params: &Value) -> Result<Value, Box<dyn Error>> {
let operation = serde_json::from_value(json!({"method": method, "params": params}))?;
service.dispatch(operation).map_err(|failure| {
serde_json::to_string(&failure)
.unwrap_or_else(|error| error.to_string())
.into()
})
}
fn applied(service: &Service, request_id: &str, change: &Value) -> Result<Value, Box<dyn Error>> {
let branches = dispatched(service, "branches.list", &json!({"limit": 10}))?;
let expected = branches["entries"][0]["version"].clone();
let receipt = dispatched(
service,
"mutation.apply",
&json!({"request_id": request_id, "branch": "main", "expected": expected, "change": change}),
)?;
Ok(receipt["version"].clone())
}
#[test]
fn a_delete_of_the_most_ids_a_service_takes_commits_and_names_its_author() -> TestResult {
let directory = tempfile::tempdir()?;
let storage = Storage::create(
DatabaseConfig {
data_dir: directory.path().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 service = Service::new(storage, MOST_PAGE)?;
let most = usize::try_from(MOST_PAGE.get())? * 64;
let ids: Vec<String> = (0..most)
.map(|index| format!("record-{index:05}"))
.collect();
applied(
&service,
"make-patients",
&json!({"kind": "collection.put", "value": {"collection": "patients", "schema": null}}),
)?;
let documents: Vec<Value> = ids
.iter()
.map(|id| json!({"document_id": id, "document": {"name": id}}))
.collect();
applied(
&service,
"import",
&json!({"kind": "documents.put_many", "value": {"collection": "patients", "documents": documents}}),
)?;
let deleted = applied(
&service,
"delete-most",
&json!({"kind": "documents.delete_many", "value": {"collection": "patients", "document_ids": ids}}),
)?;
let history = dispatched(
&service,
"history.list",
&json!({"branch": "main", "after": null, "limit": 10}),
)?;
let entry = history["entries"]
.as_array()
.ok_or("history has no entries")?
.iter()
.find(|entry| entry["version"] == deleted)
.ok_or_else(|| format!("history does not hold {deleted}: {history}"))?;
assert_eq!(
entry["authors"],
json!([{"request_id": "delete-most", "caller": {"issuer": "local", "subject": login()?}}])
);
Ok(())
}