use std::io::{self, Write};
use std::os::unix::fs::symlink;
use super::{USAGE, canonical_binary, write_outcome, write_report};
use crate::install::tests::{Fixture, TestResult};
struct FailedWriter;
impl Write for FailedWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::other("fixture output refused"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn the_no_agent_notice_names_both_hosts_and_the_launch_refusal() {
let notice = crate::install::agent::NO_AGENT;
for words in [
"No agent is set in the bundle",
"Claude Code asks for the agent id and key file when the plugin is enabled",
"Codex needs HAEMATITE_LYS_AGENT and a proof in its environment",
"haematite_mcp_agent_missing",
] {
assert!(notice.contains(words), "missing {words}: {notice}");
}
assert!(!notice.contains("pass_missing"));
}
#[test]
fn a_symlinked_operator_is_canonicalised_before_regular_file_validation() -> TestResult {
let fixture = Fixture::new()?;
let link = fixture.directory.path().join("operator-link");
symlink(&fixture.binary, &link)?;
let source = canonical_binary(&link)?;
assert_eq!(source, fixture.binary);
crate::install::install(&source, &fixture.config, &fixture.bundle)?;
assert_eq!(
std::fs::read(fixture.bundle.join("bin/haematite"))?,
b"operator"
);
Ok(())
}
#[test]
fn a_missing_operator_target_is_named_before_installing() -> TestResult {
let fixture = Fixture::new()?;
let error = canonical_binary(&fixture.directory.path().join("missing"))
.err()
.ok_or("missing binary accepted")?;
assert_eq!(error.code, "bundle_source_invalid");
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn a_failed_report_write_is_named_after_installing() -> TestResult {
let fixture = Fixture::new()?;
let report = crate::install::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
let error = write_report(&mut FailedWriter, &report)
.err()
.ok_or("output failure accepted")?;
assert_eq!(error.code, "bundle_report_write_failed");
assert!(fixture.bundle.join("bundle.json").is_file());
Ok(())
}
#[test]
fn usage_explains_the_config_snapshot_and_conflicting_repeat() {
assert!(USAGE.contains("snapshot"));
assert!(USAGE.contains("socket or data_dir"));
assert!(USAGE.contains("new bundle directory"));
assert!(USAGE.contains("bundle_conflict"));
}
#[test]
fn every_outcome_names_its_stage_list_on_stderr_and_only_success_on_stdout() -> TestResult {
for success in [false, true] {
let fixture = Fixture::new()?;
let path = fixture.directory.path().join(".haem-stage-prior");
std::fs::create_dir(&path)?;
let path = std::fs::canonicalize(&path)?;
let stages = vec![path.to_str().ok_or("stage path")?.to_owned()];
if !success {
std::fs::remove_file(&fixture.binary)?;
}
let outcome = crate::install::install(&fixture.binary, &fixture.config, &fixture.bundle);
let mut output = Vec::new();
let mut diagnostics = Vec::new();
let result = write_outcome(&mut output, &mut diagnostics, outcome);
let text = std::str::from_utf8(&diagnostics)?;
assert_eq!(text.lines().count(), 1);
let list: Vec<String> = serde_json::from_str(
text.strip_prefix("bundle_stale_stages: ")
.ok_or("stage diagnostic missing")?,
)?;
assert_eq!(list, stages);
if success {
result?;
let report: serde_json::Value = serde_json::from_slice(&output)?;
assert_eq!(report["stale_stages"], serde_json::json!(stages));
} else {
let error = result.err().ok_or("failed install succeeded")?;
assert_eq!(error.code, "bundle_source_invalid");
assert_eq!(error.stale_stages, stages);
assert!(output.is_empty());
}
assert!(path.is_dir());
}
let mut output = Vec::new();
let mut diagnostics = Vec::new();
let result = write_outcome(
&mut output,
&mut diagnostics,
Err(crate::install::Error::new(
"bundle_arguments_invalid",
"invalid arguments",
)),
);
assert_eq!(
result.err().ok_or("invalid arguments accepted")?.code,
"bundle_arguments_invalid"
);
assert_eq!(diagnostics, b"bundle_stale_stages: []\n");
assert!(output.is_empty());
Ok(())
}
#[test]
fn stderr_failure_preserves_the_primary_refusal_and_stage_metadata() -> TestResult {
let stages = vec![".haem-stage-prior".to_owned()];
let original = crate::install::Error::new("bundle_source_invalid", "source missing")
.with_stale_stages(stages.clone());
let mut output = Vec::new();
let error = write_outcome(&mut output, &mut FailedWriter, Err(original))
.err()
.ok_or("stderr failure accepted")?;
assert_eq!(error.code, "bundle_source_invalid");
assert_eq!(error.stale_stages, stages);
assert!(error.to_string().contains("bundle_report_write_failed"));
assert!(output.is_empty());
Ok(())
}
#[test]
fn stdout_failure_retains_the_reported_stages() -> TestResult {
let fixture = Fixture::new()?;
let path = fixture.directory.path().join(".haem-stage-prior");
std::fs::create_dir(&path)?;
let path = std::fs::canonicalize(&path)?;
let report = crate::install::install(&fixture.binary, &fixture.config, &fixture.bundle)?;
let mut diagnostics = Vec::new();
let error = write_outcome(&mut FailedWriter, &mut diagnostics, Ok(report))
.err()
.ok_or("stdout failure accepted")?;
assert_eq!(error.code, "bundle_report_write_failed");
assert_eq!(error.stale_stages, [path.to_str().ok_or("stage path")?]);
assert!(!diagnostics.is_empty());
assert!(path.is_dir());
Ok(())
}
#[test]
fn current_executable_resolution_failure_carries_the_existing_stages() -> TestResult {
let fixture = Fixture::new()?;
let path = fixture.directory.path().join(".haem-stage-prior");
std::fs::create_dir(&path)?;
let path = std::fs::canonicalize(&path)?;
let error =
crate::install::bundle::install_current(&fixture.config, &fixture.bundle, None, || {
Err(crate::install::Error::new(
"bundle_source_invalid",
"current executable missing",
))
})
.err()
.ok_or("source failure accepted")?;
assert_eq!(error.code, "bundle_source_invalid");
assert_eq!(error.stale_stages, [path.to_str().ok_or("stage path")?]);
let mut diagnostics = Vec::new();
let result = write_outcome(&mut Vec::new(), &mut diagnostics, Err(error));
assert!(result.is_err());
assert!(std::str::from_utf8(&diagnostics)?.contains(".haem-stage-prior"));
assert!(path.is_dir());
assert!(!fixture.bundle.exists());
Ok(())
}
#[test]
fn install_accepts_the_complete_agent_proof() -> TestResult {
let key = tempfile::NamedTempFile::new()?;
super::parse(
[
"--bundle",
"bundle",
"--config",
"service.json",
"--lys-issuer",
"https://lys.example",
"--lys-agent",
"agent-1",
"--key-file",
key.path().to_str().ok_or("key path")?,
]
.into_iter()
.map(str::to_owned),
)?;
Ok(())
}
#[test]
fn install_refuses_every_partial_agent_group_by_name() -> TestResult {
let options = [
["--lys-issuer", "https://lys.example"],
["--lys-agent", "agent-1"],
["--key-file", "/key.json"],
];
for mask in [1, 2, 3, 4, 5] {
let mut args = vec!["--bundle", "bundle", "--config", "service.json"];
for (index, option) in options.iter().enumerate() {
if mask & (1 << index) != 0 {
args.extend(option);
}
}
let error = super::parse(args.into_iter().map(str::to_owned))
.err()
.ok_or("partial proof accepted")?;
assert_eq!(error.code, "bundle_lys_agent_incomplete");
for flag in ["--lys-issuer", "--lys-agent", "--key-file"] {
assert!(error.message.contains(flag));
}
}
Ok(())
}