use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use serde_json::{Value, json};
use super::{SCHEMA, operation_id, parse, read_credential, run};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const CREDENTIAL: &str = "lys-app.haematite.never-shown";
fn args(words: &[&str]) -> impl Iterator<Item = String> {
words
.iter()
.map(|word| (*word).to_owned())
.collect::<Vec<_>>()
.into_iter()
}
fn double(held: Value) -> Result<(String, mpsc::Receiver<(String, String)>), std::io::Error> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let origin = format!("http://{}/", listener.local_addr()?);
let (sent, seen) = mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { return };
let mut bytes = Vec::new();
let (method, body) = loop {
let mut buffer = [0; 4096];
let Ok(count) = stream.read(&mut buffer) else {
return;
};
if count == 0 {
return;
}
bytes.extend_from_slice(buffer.get(..count).unwrap_or_default());
let text = String::from_utf8_lossy(&bytes).into_owned();
let Some((request_head, body)) = text.split_once("\r\n\r\n") else {
continue;
};
let length = request_head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())?
})
.unwrap_or(0);
if body.len() >= length {
let method = request_head
.split(' ')
.next()
.unwrap_or_default()
.to_owned();
break (method, body.to_owned());
}
};
let answer = if method == "PUT" {
json!({"applied": true})
} else {
held.clone()
};
if sent.send((method, body)).is_err() {
return;
}
let answer = answer.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}",
answer.len()
);
if stream.write_all(response.as_bytes()).is_err() {
return;
}
}
});
Ok((origin, seen))
}
fn credential(directory: &std::path::Path) -> Result<String, std::io::Error> {
let path = directory.join("credential");
std::fs::write(&path, format!("{CREDENTIAL}\n"))?;
Ok(path.display().to_string())
}
#[test]
fn a_registered_schema_equal_to_the_file_passes_the_check() -> TestResult {
let directory = tempfile::tempdir()?;
let schema: Value = serde_json::from_str(SCHEMA)?;
let (issuer, seen) = double(json!({"app": "haematite", "version": 4, "schema": schema}))?;
let path = credential(directory.path())?;
run(args(&[
"--issuer",
&issuer,
"--credential-file",
&path,
"--check",
]))?;
let (method, _) = seen.recv()?;
assert_eq!(method, "GET");
assert!(seen.try_recv().is_err(), "a check wrote to Lys");
Ok(())
}
#[test]
fn the_schema_as_lys_stores_it_passes_the_check() -> TestResult {
let directory = tempfile::tempdir()?;
let mut schema: Value = serde_json::from_str(SCHEMA)?;
let kinds = schema["kinds"]
.as_object_mut()
.ok_or("schema.json has no kinds")?;
for kind in kinds.values_mut() {
if let Some(actions) = kind["actions"].as_array_mut() {
actions.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
actions.reverse();
}
if kind.get("relations").is_none() {
kind["relations"] = json!({});
}
kind["parents"] = kind.get("parents").cloned().unwrap_or_else(|| json!([]));
}
let (issuer, seen) = double(json!({"app": "haematite", "version": 2, "schema": schema}))?;
let path = credential(directory.path())?;
run(args(&[
"--issuer",
&issuer,
"--credential-file",
&path,
"--check",
]))?;
let (method, _) = seen.recv()?;
assert_eq!(method, "GET");
assert!(seen.try_recv().is_err(), "a check wrote to Lys");
Ok(())
}
#[test]
fn an_action_missing_or_added_still_drifts() -> TestResult {
let schema: Value = serde_json::from_str(SCHEMA)?;
let mut missing = schema.clone();
missing["kinds"]["haematite.database"]["actions"]
.as_array_mut()
.ok_or("the database kind has no actions")?
.pop();
let mut added = schema;
added["kinds"]["haematite.database"]["actions"]
.as_array_mut()
.ok_or("the database kind has no actions")?
.push(json!("drop"));
for registered in [missing, added] {
let directory = tempfile::tempdir()?;
let (issuer, seen) =
double(json!({"app": "haematite", "version": 2, "schema": registered}))?;
let path = credential(directory.path())?;
let refused = run(args(&[
"--issuer",
&issuer,
"--credential-file",
&path,
"--check",
]));
let Err(words) = refused else {
return Err("a schema with a different action passed the check".into());
};
assert!(words.starts_with("schema_drift:"), "{words}");
assert_eq!(seen.recv()?.0, "GET");
assert!(seen.try_recv().is_err(), "a check wrote to Lys");
}
Ok(())
}
#[test]
fn a_drifted_schema_fails_the_check_by_name_and_writes_nothing() -> TestResult {
let directory = tempfile::tempdir()?;
let (issuer, seen) = double(json!({"app": "haematite", "version": 4, "schema": {}}))?;
let path = credential(directory.path())?;
let refused = run(args(&[
"--issuer",
&issuer,
"--credential-file",
&path,
"--check",
]))
.err()
.ok_or("a drifted schema passed the check")?;
assert!(refused.starts_with("schema_drift"), "{refused}");
assert!(
!refused.contains(CREDENTIAL),
"the credential was shown: {refused}"
);
let (method, _) = seen.recv()?;
assert_eq!(method, "GET");
assert!(seen.try_recv().is_err(), "a check wrote to Lys");
Ok(())
}
#[test]
fn a_registration_replaces_the_held_version_with_the_file() -> TestResult {
let directory = tempfile::tempdir()?;
let (issuer, seen) = double(json!({"app": "haematite", "version": 4, "schema": {}}))?;
let path = credential(directory.path())?;
run(args(&[
"--issuer",
&issuer,
"--credential-file",
&path,
"--register",
]))?;
let (first, _) = seen.recv()?;
assert_eq!(first, "GET");
let (method, body) = seen.recv()?;
assert_eq!(method, "PUT");
let body: Value = serde_json::from_str(&body)?;
assert_eq!(body["replaces"], 4);
assert_eq!(body["schema"], serde_json::from_str::<Value>(SCHEMA)?);
assert_eq!(body["operation"], operation_id());
Ok(())
}
#[test]
fn the_operation_id_is_lys_shaped_and_the_same_for_the_same_file() {
let id = operation_id();
assert!(id.starts_with("op-"));
assert_eq!(id.len(), 35);
assert!(id.bytes().skip(3).all(|byte| byte.is_ascii_hexdigit()));
assert_eq!(id, operation_id());
}
#[test]
fn the_arguments_are_each_given_once_and_the_credential_path_is_absolute() {
for refused in [
&["--issuer", "http://lys", "--credential-file", "/c"][..],
&[
"--issuer",
"http://lys",
"--credential-file",
"c",
"--check",
],
&[
"--issuer",
"http://lys",
"--credential-file",
"/c",
"--check",
"--register",
],
&["--credential-file", "/c", "--check"],
] {
assert!(parse(args(refused)).is_err(), "{refused:?} was taken");
}
}
#[test]
fn a_credential_file_holds_haematites_app_credential_on_one_line() -> TestResult {
let directory = tempfile::tempdir()?;
let path = directory.path().join("credential");
std::fs::write(&path, format!("{CREDENTIAL}\n"))?;
assert_eq!(read_credential(&path)?, CREDENTIAL);
for wrong in ["lys-app.cambium.x\n", "lys-app.haematite.x\nsecond\n", ""] {
std::fs::write(&path, wrong)?;
let refused = read_credential(&path)
.err()
.ok_or("a wrong credential was taken")?;
assert!(
!refused.contains("lys-app"),
"the credential was shown: {refused}"
);
}
Ok(())
}
#[test]
fn a_recorded_schema_equal_to_the_file_passes_offline() -> TestResult {
let directory = tempfile::tempdir()?;
let path = directory.path().join("recorded.json");
let schema: Value = serde_json::from_str(SCHEMA)?;
std::fs::write(&path, json!({ "version": 7, "schema": schema }).to_string())?;
let path = path.to_str().ok_or("path is not text")?;
run(args(&["--recorded", path]))?;
Ok(())
}
#[test]
fn a_recorded_schema_that_differs_is_drift() -> TestResult {
let directory = tempfile::tempdir()?;
let path = directory.path().join("recorded.json");
std::fs::write(&path, json!({ "version": 7, "schema": {} }).to_string())?;
let path = path.to_str().ok_or("path is not text")?;
let refused = run(args(&["--recorded", path]))
.err()
.ok_or("drift passed")?;
assert!(
refused.starts_with("schema_drift: the recorded haematite schema version 7"),
"{refused}"
);
let missing = run(args(&["--recorded", "/nonexistent/recorded.json"]))
.err()
.ok_or("a missing file passed")?;
assert!(missing.contains("could not be read"), "{missing}");
Ok(())
}