use std::error::Error as StdError;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::Path;
use serde_json::{Value, json};
use tempfile::TempDir;
use super::install;
#[path = "plugin_tests.rs"]
mod plugin;
pub(super) type TestResult = Result<(), Box<dyn StdError>>;
pub(super) struct Fixture {
pub(super) directory: TempDir,
pub(super) binary: std::path::PathBuf,
pub(super) config: std::path::PathBuf,
pub(super) bundle: std::path::PathBuf,
}
impl Fixture {
pub(super) fn new() -> Result<Self, Box<dyn StdError>> {
let directory = TempDir::new()?;
let root = directory.path().canonicalize()?;
let binary = root.join("haematite-source");
let worker = root.join("query-worker");
executable(&binary, b"operator")?;
executable(&worker, b"query")?;
let config = root.join("serve.json");
let value = json!({
"data_dir": directory.path().join("data"),
"socket": directory.path().join("service.sock"),
"branch_cache_bytes_per_shard": 1_048_576,
"max_frame": 1_048_576, "max_clients": 4, "max_page": 32, "timeout_ms": 30000,
"query_time_ms": 100, "query_memory_bytes": 1_048_576,
"query_outputs_per_record": 16, "query_scan_records": 32,
"query_max_running": 2, "query_worker": worker,
});
fs::write(&config, serde_json::to_vec(&value)?)?;
let bundle = root.join("bundle");
Ok(Self {
directory,
binary,
config,
bundle,
})
}
fn value(&self) -> Result<Value, Box<dyn StdError>> {
Ok(serde_json::from_slice(&fs::read(&self.config)?)?)
}
fn set(&self, value: &Value) -> TestResult {
fs::write(&self.config, serde_json::to_vec(value)?)?;
Ok(())
}
}
fn executable(path: &Path, bytes: &[u8]) -> TestResult {
fs::write(path, bytes)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[test]
fn bundle_contains_real_workers_and_local_plugin_with_sha256s() -> TestResult {
let fixture = Fixture::new()?;
let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
assert!(!report.already_installed);
assert_eq!(report.files, 10);
assert_eq!(fs::read(fixture.bundle.join("bin/haematite"))?, b"operator");
assert_eq!(fs::read(fixture.bundle.join("bin/haematite-jq"))?, b"query");
let config: Value = serde_json::from_slice(&fs::read(fixture.bundle.join("serve.json"))?)?;
assert_eq!(
config["query_worker"],
fixture
.bundle
.join("bin/haematite-jq")
.to_str()
.ok_or("path")?
);
let plugin: Value = serde_json::from_slice(&fs::read(
fixture.bundle.join(".claude-plugin/plugin.json"),
)?)?;
assert_eq!(
plugin["mcpServers"]["haematite"]["command"],
"${CLAUDE_PLUGIN_ROOT}/scripts/mcp.sh"
);
assert_eq!(
plugin["mcpServers"]["haematite"]["env"]["HAEMATITE_URL"],
"https://haematite.ablative.com.au/mcp"
);
assert!(plugin.get("userConfig").is_none());
let manifest: Value = serde_json::from_slice(&fs::read(fixture.bundle.join("bundle.json"))?)?;
assert_eq!(
manifest["files"]["bin/haematite"]["sha256"],
"06e55b633481f7bb072957eabcf110c972e86691c3cfedabe088024bffe42f23"
);
assert_eq!(
fs::metadata(fixture.bundle.join("serve.json"))?
.permissions()
.mode()
& 0o777,
0o600
);
assert!(!fixture.bundle.join("bin/haematite-embed-stand-in").exists());
Ok(())
}
#[test]
fn identical_install_is_verified_and_does_not_rewrite_files() -> TestResult {
use std::os::unix::fs::MetadataExt;
let fixture = Fixture::new()?;
install(&fixture.binary, &fixture.config, &fixture.bundle)?;
let path = fixture.bundle.join("bundle.json");
let before = fs::metadata(&path)?.ino();
let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
assert!(report.already_installed);
assert_eq!(fs::metadata(path)?.ino(), before);
Ok(())
}
#[test]
fn incomplete_destination_is_refused_without_repair() -> TestResult {
let fixture = Fixture::new()?;
fs::create_dir(&fixture.bundle)?;
fs::write(fixture.bundle.join("sentinel"), b"keep")?;
let error = install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted partial")?;
assert_eq!(error.code, "bundle_manifest_invalid");
assert_eq!(fs::read(fixture.bundle.join("sentinel"))?, b"keep");
assert_eq!(fs::read_dir(&fixture.bundle)?.count(), 1);
Ok(())
}
#[test]
fn removed_or_corrupt_payload_refuses_the_repeat() -> TestResult {
for remove in [false, true] {
let fixture = Fixture::new()?;
install(&fixture.binary, &fixture.config, &fixture.bundle)?;
let worker = fixture.bundle.join("bin/haematite-jq");
if remove {
fs::remove_file(&worker)?;
} else {
fs::write(&worker, b"wrong")?;
}
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted corrupt")?
.code,
"bundle_payload_invalid"
);
}
Ok(())
}
#[test]
fn changed_source_is_a_conflict_and_keeps_installed_bytes() -> TestResult {
let fixture = Fixture::new()?;
install(&fixture.binary, &fixture.config, &fixture.bundle)?;
executable(&fixture.binary, b"new operator")?;
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted different")?
.code,
"bundle_conflict"
);
assert_eq!(fs::read(fixture.bundle.join("bin/haematite"))?, b"operator");
Ok(())
}
#[test]
fn missing_worker_fails_before_destination_is_created() -> TestResult {
let fixture = Fixture::new()?;
let worker = fixture.value()?["query_worker"]
.as_str()
.ok_or("worker path")?
.to_owned();
fs::remove_file(worker)?;
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted missing")?
.code,
"bundle_source_invalid"
);
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn configured_embedding_worker_is_copied_without_copying_the_model() -> TestResult {
let fixture = Fixture::new()?;
let worker = fixture.directory.path().join("embedding-source");
executable(&worker, b"embed")?;
let mut value = fixture.value()?;
let fields = json!({
"embed_worker": worker, "embed_model_dir": fixture.directory.path().join("model"),
"embed_model_sha256": "a".repeat(64), "embed_memory_bytes": 1_048_576,
"embed_threads": 1, "embed_batch_items": 4, "embed_input_bytes": 4096,
"embed_tokens": 512, "embed_frame_bytes": 65536, "embed_ready_ms": 100,
"embed_call_ms": 100, "embed_waiting": 4, "embed_request_items": 4,
"embed_page_records": 32
});
for (key, field) in fields.as_object().ok_or("fields")? {
value[key] = field.clone();
}
fixture.set(&value)?;
let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
assert_eq!(report.files, 11);
assert_eq!(
fs::read(fixture.bundle.join("bin/haematite-embed"))?,
b"embed"
);
assert!(!fixture.bundle.join("model").exists());
Ok(())
}
#[test]
fn symlink_or_unexpected_entry_or_changed_mode_is_not_a_verified_bundle() -> TestResult {
for kind in ["symlink", "extra", "mode"] {
let fixture = Fixture::new()?;
install(&fixture.binary, &fixture.config, &fixture.bundle)?;
let worker = fixture.bundle.join("bin/haematite-jq");
match kind {
"symlink" => {
fs::remove_file(&worker)?;
symlink(&fixture.binary, &worker)?;
}
"extra" => {
fs::write(fixture.bundle.join("extra"), b"unknown")?;
}
"mode" => fs::set_permissions(worker, fs::Permissions::from_mode(0o600))?,
_ => return Err("unknown fixture".into()),
}
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted drift")?
.code,
if kind == "extra" {
"bundle_inventory_invalid"
} else {
"bundle_payload_invalid"
}
);
}
Ok(())
}
#[test]
fn symlink_destination_and_relative_data_paths_are_refused() -> TestResult {
let fixture = Fixture::new()?;
let other = fixture.directory.path().join("other");
fs::create_dir(&other)?;
symlink(&other, &fixture.bundle)?;
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted link")?
.code,
"bundle_destination_invalid"
);
fs::remove_file(&fixture.bundle)?;
let mut value = fixture.value()?;
value["data_dir"] = json!("relative");
fixture.set(&value)?;
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted relative")?
.code,
"bundle_config_invalid"
);
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn disabled_workers_are_omitted_and_an_incomplete_group_is_refused() -> TestResult {
let fixture = Fixture::new()?;
let mut value = fixture.value()?;
let object = value.as_object_mut().ok_or("configuration object")?;
for key in crate::serve::query::settings::NAMES {
object.remove(key);
}
fixture.set(&value)?;
let report = install(&fixture.binary, &fixture.config, &fixture.bundle)?;
assert_eq!(report.files, 9);
assert!(!fixture.bundle.join("bin/haematite-jq").exists());
let fixture = Fixture::new()?;
let mut value = fixture.value()?;
value
.as_object_mut()
.ok_or("configuration object")?
.remove("query_worker");
fixture.set(&value)?;
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted partial settings")?
.code,
"bundle_config_invalid"
);
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn test_only_worker_is_refused_before_any_bundle_write() -> TestResult {
let fixture = Fixture::new()?;
let worker = fixture.directory.path().join("haematite-embed-stand-in");
executable(&worker, b"embed-stand-in")?;
let mut value = fixture.value()?;
value["query_worker"] = json!(worker);
fixture.set(&value)?;
assert_eq!(
install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("accepted test-only worker")?
.code,
"bundle_test_worker_refused"
);
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn a_renamed_stand_in_is_refused_by_content_before_any_write() -> TestResult {
let fixture = Fixture::new()?;
let worker = fixture.directory.path().join("renamed-worker");
executable(&worker, b"binary-prefix embed-stand-in binary-suffix")?;
let mut value = fixture.value()?;
value["query_worker"] = json!(worker);
fixture.set(&value)?;
let error = install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("renamed stand-in accepted")?;
assert_eq!(error.code, "bundle_test_worker_refused");
assert!(error.partial.is_none());
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn changing_the_config_snapshot_requires_a_new_bundle_directory() -> TestResult {
let fixture = Fixture::new()?;
install(&fixture.binary, &fixture.config, &fixture.bundle)?;
let old_config = fs::read(fixture.bundle.join("serve.json"))?;
let mut value = fixture.value()?;
value["socket"] = json!(fixture.directory.path().join("replacement.sock"));
value["data_dir"] = json!(fixture.directory.path().join("replacement-data"));
fixture.set(&value)?;
let error = install(&fixture.binary, &fixture.config, &fixture.bundle)
.err()
.ok_or("new config overwrote bundle")?;
assert_eq!(error.code, "bundle_conflict");
assert_eq!(fs::read(fixture.bundle.join("serve.json"))?, old_config);
let fresh = fixture.directory.path().join("new-bundle");
install(&fixture.binary, &fixture.config, &fresh)?;
let installed: Value = serde_json::from_slice(&fs::read(fresh.join("serve.json"))?)?;
assert_eq!(installed["socket"], value["socket"]);
assert_eq!(installed["data_dir"], value["data_dir"]);
Ok(())
}
#[test]
fn an_empty_stage_inventory_is_absent_from_error_display() {
let error = super::Error::new("bundle_arguments_invalid", "argument missing");
assert_eq!(
error.to_string(),
"bundle_arguments_invalid: argument missing"
);
let partial = super::Error::new("bundle_write_failed", "payload")
.with_partial(&["bin/haematite".to_owned()], false);
assert_eq!(
partial.to_string(),
"bundle_write_failed: payload; bundle_partial: completed=[\"bin/haematite\"]; published=false"
);
}
#[test]
fn a_nonempty_stage_inventory_remains_in_error_display() {
let error = super::Error::new("bundle_arguments_invalid", "argument missing")
.with_stale_stages(vec![".haem-stage-existing".to_owned()]);
assert_eq!(
error.to_string(),
"bundle_arguments_invalid: argument missing; stale_stages=[\".haem-stage-existing\"]"
);
}