use std::error::Error;
use std::num::{NonZeroU32, NonZeroUsize};
use std::os::unix::net::UnixStream;
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, json};
use super::{Limits, connection};
use crate::serve::pass::{AUDIENCE, Gate, Unreachable, now};
use crate::serve::protocol::{BranchPage, Operation, Version};
use crate::serve::{frame, service::Service, storage::Storage};
type TestResult = Result<(), Box<dyn Error>>;
const PAGE: NonZeroU32 = NonZeroU32::MIN.saturating_add(9);
const MAX_FRAME: NonZeroU32 = NonZeroU32::MIN.saturating_add(65_535);
const WAIT: Duration = Duration::from_secs(30);
const ISSUER: &str = "https://lys.example";
const KID: &str = "haematite-follow-test";
fn signer() -> SigningKey {
SigningKey::from_bytes(&[11; 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, branches: &[&str]) -> String {
let issued = now();
let rights: Vec<Value> = branches
.iter()
.map(|branch| {
json!({"resource": {"kind": "haematite.branch", "id": format!("wards.{branch}")},
"actions": ["read"], "mode": "outright", "grant": "follow-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": "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 service(directory: &std::path::Path, lys: bool) -> 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 service = Service::new(storage, PAGE)?;
Ok(if lys {
service.with_lys(Gate::new(&keys(), ISSUER, "wards", Arc::new(Unreachable))?)
} else {
service
})
}
fn head(service: &Service, branch: &str) -> 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"] == branch)
.ok_or_else(|| format!("branch {branch} missing"))?;
Ok(serde_json::from_value(row["version"].clone())?)
}
fn mutate(service: &Service, branch: &str, request_id: &str, change: &Value) -> TestResult {
let expected = head(service, branch)?;
let operation: Operation = serde_json::from_value(json!({
"method": "mutation.apply",
"params": {"request_id": request_id, "branch": branch, "expected": expected, "change": change},
}))?;
service.dispatch(operation)?;
Ok(())
}
fn fork_trial(service: &Service) -> TestResult {
let from = head(service, "main")?;
mutate(
service,
"main",
"fork-trial",
&json!({"kind": "branch.fork", "value": {"name": "trial", "from": from}}),
)
}
fn discard_trial(service: &Service) -> TestResult {
mutate(
service,
"trial",
"discard-trial",
&json!({"kind": "branch.discard", "value": {"name": "trial"}}),
)
}
fn send(client: &mut UnixStream, request: &Value) -> TestResult {
frame::write(client, &serde_json::to_vec(request)?, MAX_FRAME)?;
Ok(())
}
fn receive(client: &mut UnixStream) -> Result<Value, Box<dyn Error>> {
let bytes = frame::read(client, MAX_FRAME)?.ok_or("the connection closed")?;
Ok(serde_json::from_slice(&bytes)?)
}
fn ask(client: &mut UnixStream, request: &Value) -> Result<Value, Box<dyn Error>> {
send(client, request)?;
receive(client)
}
fn hello(client: &mut UnixStream) -> TestResult {
let answer = ask(
client,
&json!({"id": "hello", "operation": {"method": "hello", "params": {
"protocol": crate::serve::protocol::PROTOCOL,
"capabilities": [crate::serve::protocol::RECEIPT_CAPABILITY],
"max_frame": MAX_FRAME,
}}}),
)?;
assert!(answer.get("error").is_none(), "hello refused: {answer}");
Ok(())
}
fn follow(id: &str, branch: &str) -> Value {
json!({"id": id, "operation": {"method": "head.follow", "params": {"branch": branch}}})
}
fn refused(answer: &Value, id: &str, code: &str) {
assert_eq!(answer["id"], id, "{answer}");
assert!(answer.get("result").is_none(), "{answer}");
assert_eq!(answer["error"]["code"], code, "{answer}");
}
fn headed(answer: &Value, id: &str, branch: &str, version: Version) -> TestResult {
assert_eq!(answer["id"], id, "{answer}");
assert_eq!(
answer["result"],
json!({"branch": branch, "version": serde_json::to_value(version)?}),
"{answer}"
);
Ok(())
}
fn with_connection(
service: &Service,
test: impl FnOnce(&mut UnixStream) -> TestResult,
) -> TestResult {
let limits = Limits {
max_frame: MAX_FRAME,
max_clients: NonZeroUsize::MIN,
frame_timeout: Duration::from_secs(60),
};
let (mut client, server) = UnixStream::pair()?;
client.set_read_timeout(Some(WAIT))?;
std::thread::scope(|scope| {
let worker = scope.spawn(move || connection(server, service, limits));
let outcome = test(&mut client);
drop(client);
let released = if published(service, "trial")? {
discard_trial(service)
} else {
Ok(())
};
let ended = worker.join().map_err(|_| "the connection panicked")?;
outcome?;
released?;
ended?;
Ok(())
})
}
fn published(service: &Service, branch: &str) -> Result<bool, Box<dyn Error>> {
let answer = service.dispatch(Operation::Branches(BranchPage {
after: None,
limit: PAGE,
}))?;
Ok(answer["entries"]
.as_array()
.ok_or("branches has no entries")?
.iter()
.any(|row| row["branch"] == branch))
}
#[test]
fn a_follow_streams_heads_under_its_id_then_ends_and_the_connection_answers() -> TestResult {
let directory = tempfile::tempdir()?;
let service = service(directory.path(), false)?;
fork_trial(&service)?;
with_connection(&service, |client| {
hello(client)?;
send(client, &follow("f", "trial"))?;
headed(&receive(client)?, "f", "trial", head(&service, "trial")?)?;
mutate(
&service,
"trial",
"one",
&json!({"kind": "collection.put", "value": {"collection": "one", "schema": null}}),
)?;
headed(&receive(client)?, "f", "trial", head(&service, "trial")?)?;
discard_trial(&service)?;
refused(&receive(client)?, "f", "branch_unknown");
let listed = ask(
client,
&json!({"id": "after", "operation": {"method": "branches.list", "params": {"limit": 1}}}),
)?;
assert_eq!(listed["id"], "after");
assert!(listed.get("result").is_some(), "{listed}");
Ok(())
})
}
#[test]
fn a_follow_is_decided_through_lys_and_a_refused_one_gets_no_head() -> TestResult {
let directory = tempfile::tempdir()?;
let service = service(directory.path(), true)?;
fork_trial(&service)?;
with_connection(&service, |client| {
hello(client)?;
let bare = ask(client, &follow("bare", "trial"))?;
refused(&bare, "bare", "pass_missing");
let present = |token: String| json!({"id": "pass", "operation": {"method": "session.pass", "params": {"pass": token}}});
let other = ask(client, &present(pass("clinician", &["main"])))?;
assert!(other.get("result").is_some(), "{other}");
refused(
&ask(client, &follow("denied", "trial"))?,
"denied",
"permission_refused",
);
let reader = ask(client, &present(pass("clinician", &["trial"])))?;
assert!(reader.get("result").is_some(), "{reader}");
send(client, &follow("allowed", "trial"))?;
headed(
&receive(client)?,
"allowed",
"trial",
head(&service, "trial")?,
)?;
discard_trial(&service)?;
refused(&receive(client)?, "allowed", "branch_unknown");
Ok(())
})
}