use crate::support::{Fixture, TestResult};
use serde_json::json;
use std::fs;
use std::net::TcpListener;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::process::{Command, Output};
const RECORD_FIRST: &str = "import sys\nwith open(sys.argv[1], 'a') as f: f.write('started\\n')\nprint('fixture ready', file=sys.stderr, flush=True)\nsys.stdin.read()";
fn check(fixture: &Fixture) -> Result<Output, Box<dyn std::error::Error>> {
let path = fixture.root().join("door.json");
fs::write(&path, serde_json::to_vec_pretty(&fixture.config)?)?;
invoke(&path)
}
fn invoke(path: &Path) -> Result<Output, Box<dyn std::error::Error>> {
Ok(Command::new(env!("CARGO_BIN_EXE_haematite"))
.args(["door", "--check", "--config"])
.arg(path)
.current_dir(path.parent().ok_or("parent missing")?)
.env_remove("OPENAI_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.env_remove("CLAUDE_CODE_OAUTH_TOKEN")
.output()?)
}
fn text(output: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
}
fn listing(path: &Path) -> std::io::Result<Vec<std::ffi::OsString>> {
let mut entries = fs::read_dir(path)?
.map(|entry| entry.map(|entry| entry.file_name()))
.collect::<Result<Vec<_>, _>>()?;
entries.sort();
Ok(entries)
}
#[test]
fn r1_check_plans_upgrade_without_changing_bytes_mode_or_listing() -> TestResult {
let mut fixture = Fixture::new()?;
let mut first = fixture.child("first", RECORD_FIRST);
let mut second = fixture.child("second", RECORD_FIRST);
first
.as_object_mut()
.ok_or("child missing")?
.remove("give_up_after");
second
.as_object_mut()
.ok_or("child missing")?
.remove("give_up_after");
fixture.config["children"] = json!([first, second]);
let path = fixture.root().join("door.json");
let bytes = serde_json::to_vec_pretty(&fixture.config)?;
fs::write(&path, &bytes)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o640))?;
let before = listing(fixture.root())?;
let output = invoke(&path)?;
assert_eq!(fs::read(&path)?, bytes);
assert_eq!(fs::metadata(&path)?.permissions().mode() & 0o7777, 0o640);
assert_eq!(listing(fixture.root())?, before);
assert_eq!(output.status.code(), Some(0), "{}", text(&output));
assert!(
text(&output).contains("door_config_upgrade_needed: give_up_after=3 for first, second")
);
assert!(text(&output).contains("door_check_ok: 2 children, 1 stores"));
Ok(())
}
#[test]
fn r1_check_asks_nothing_while_listener_and_store_are_held() -> TestResult {
let mut fixture = Fixture::new()?;
let listener = TcpListener::bind("127.0.0.1:0")?;
let issuer = TcpListener::bind("127.0.0.1:0")?;
let store = UnixListener::bind(fixture.root().join("store.sock"))?;
listener.set_nonblocking(true)?;
issuer.set_nonblocking(true)?;
store.set_nonblocking(true)?;
fixture.config["listen"] = json!(listener.local_addr()?.to_string());
fixture.config["lys"]["issuer"] = json!(format!("http://{}/", issuer.local_addr()?));
let assistant = fixture.root().join("assistant.sock");
fixture.config["assistant"] = json!({"socket":assistant});
fixture.config["children"] = json!([fixture.child("first", RECORD_FIRST)]);
let output = check(&fixture)?;
assert_eq!(output.status.code(), Some(0), "{}", text(&output));
assert_eq!(
issuer.accept().err().ok_or("issuer was asked")?.kind(),
std::io::ErrorKind::WouldBlock
);
assert_eq!(
listener.accept().err().ok_or("door was asked")?.kind(),
std::io::ErrorKind::WouldBlock
);
assert_eq!(
store.accept().err().ok_or("store was asked")?.kind(),
std::io::ErrorKind::WouldBlock
);
assert!(!assistant.exists());
assert!(!fixture.root().join("records").exists());
Ok(())
}
fn program_refused(kind: &str) -> TestResult {
let mut fixture = Fixture::new()?;
let path = fixture.root().join("program");
match kind {
"directory" => fs::create_dir(&path)?,
"nonexecutable" => fs::write(&path, b"fixture")?,
"broken-symlink" => symlink(fixture.root().join("missing"), &path)?,
"missing" => {}
_ => return Err("unknown fixture kind".into()),
}
let healthy = fixture.child("healthy", RECORD_FIRST);
let mut broken = fixture.child("broken", RECORD_FIRST);
broken["program"] = json!(path);
fixture.config["children"] = json!([healthy, broken]);
let output = check(&fixture)?;
assert!(!fixture.root().join("records").exists());
assert_eq!(output.status.code(), Some(78), "{}", text(&output));
assert!(
text(&output).contains("door_child_program_missing: broken:"),
"{}",
text(&output)
);
Ok(())
}
#[test]
fn r1_check_missing_program_names_child_without_starting_any() -> TestResult {
program_refused("missing")
}
#[test]
fn r1_check_directory_program_is_refused() -> TestResult {
program_refused("directory")
}
#[test]
fn r1_check_nonexecutable_program_is_refused() -> TestResult {
program_refused("nonexecutable")
}
#[test]
fn r1_check_broken_program_symlink_is_refused() -> TestResult {
program_refused("broken-symlink")
}
#[test]
fn r1_check_resolves_executable_program_symlink() -> TestResult {
let mut fixture = Fixture::new()?;
let path = fixture.root().join("program");
symlink("/usr/bin/python3", &path)?;
let mut child = fixture.child("linked", RECORD_FIRST);
child["program"] = json!(path);
fixture.config["children"] = json!([child]);
let output = check(&fixture)?;
assert_eq!(output.status.code(), Some(0), "{}", text(&output));
assert!(!fixture.root().join("records").exists());
Ok(())
}
fn log_refused(readonly: bool) -> TestResult {
let mut fixture = Fixture::new()?;
let folder = fixture.root().join("logs");
if readonly {
fs::create_dir(&folder)?;
fs::set_permissions(&folder, fs::Permissions::from_mode(0o500))?;
}
let mut child = fixture.child("broken-log", RECORD_FIRST);
child["log"] = json!(folder.join("child.log"));
fixture.config["children"] = json!([fixture.child("healthy", RECORD_FIRST), child]);
let output = check(&fixture)?;
if readonly {
fs::set_permissions(&folder, fs::Permissions::from_mode(0o700))?;
}
assert!(!fixture.root().join("records").exists());
assert_eq!(output.status.code(), Some(78), "{}", text(&output));
assert!(
text(&output).contains("door_child_log_unwritable: broken-log:"),
"{}",
text(&output)
);
Ok(())
}
#[test]
fn r1_check_unwritable_log_names_child_without_starting_any() -> TestResult {
log_refused(true)
}
#[test]
fn r1_check_missing_log_folder_is_refused() -> TestResult {
log_refused(false)
}
#[test]
fn r1_check_no_upgrade_preserves_bytes_and_reports_no_plan() -> TestResult {
let fixture = Fixture::new()?;
let path = fixture.root().join("door.json");
let bytes = serde_json::to_vec_pretty(&fixture.config)?;
fs::write(&path, &bytes)?;
let before = listing(fixture.root())?;
let output = invoke(&path)?;
assert_eq!(output.status.code(), Some(0), "{}", text(&output));
assert!(!text(&output).contains("door_config_upgrade_needed"));
assert!(text(&output).contains("door_check_ok: 0 children, 1 stores"));
assert_eq!(fs::read(&path)?, bytes);
assert_eq!(listing(fixture.root())?, before);
Ok(())
}
fn invalid_config(bytes: &[u8], refusal: &str) -> TestResult {
let fixture = Fixture::new()?;
let path = fixture.root().join("door.json");
fs::write(&path, bytes)?;
let output = invoke(&path)?;
assert_eq!(output.status.code(), Some(78), "{}", text(&output));
assert!(text(&output).contains(refusal), "{}", text(&output));
assert_eq!(fs::read(&path)?, bytes);
Ok(())
}
#[test]
fn r1_check_preserves_invalid_json_loader_refusal() -> TestResult {
invalid_config(b"{invalid", "the door configuration is not valid:")
}
#[test]
fn r1_check_preserves_upgrade_planning_refusal() -> TestResult {
invalid_config(br#"{"children":[{}]}"#, "door_config_upgrade_failed:")
}
#[test]
fn r1_check_plan_refuses_readonly_configuration_folder() -> TestResult {
let mut fixture = Fixture::new()?;
let mut child = fixture.child("first", RECORD_FIRST);
child
.as_object_mut()
.ok_or("child missing")?
.remove("give_up_after");
fixture.config["children"] = json!([child]);
let folder = fixture.root().join("readonly");
fs::create_dir(&folder)?;
let path = folder.join("door.json");
let bytes = serde_json::to_vec_pretty(&fixture.config)?;
fs::write(&path, &bytes)?;
fs::set_permissions(&folder, fs::Permissions::from_mode(0o500))?;
let output = invoke(&path)?;
fs::set_permissions(&folder, fs::Permissions::from_mode(0o700))?;
assert_eq!(output.status.code(), Some(78), "{}", text(&output));
assert!(text(&output).contains("door_config_upgrade_failed:"));
assert_eq!(fs::read(&path)?, bytes);
assert_eq!(
listing(&folder)?,
vec![std::ffi::OsString::from("door.json")]
);
Ok(())
}
#[test]
fn r1_check_config_symlink_has_start_paths_named_refusal() -> TestResult {
let fixture = Fixture::new()?;
let source = fixture.root().join("source.json");
let link = fixture.root().join("door.json");
let bytes = serde_json::to_vec(&fixture.config)?;
fs::write(&source, &bytes)?;
symlink(&source, &link)?;
let output = invoke(&link)?;
assert_eq!(output.status.code(), Some(78), "{}", text(&output));
assert!(text(&output).contains("door_config_upgrade_failed: configuration is a symlink:"));
assert_eq!(fs::read_link(&link)?, source);
assert_eq!(fs::read(&source)?, bytes);
Ok(())
}
#[test]
fn r1_check_uses_start_paths_configuration_byte_limit() -> TestResult {
let fixture = Fixture::new()?;
let mut bytes = serde_json::to_vec(&fixture.config)?;
bytes.resize(1_048_577, b' ');
invalid_config(&bytes, "the door configuration exceeds its byte limit")?;
Ok(())
}
fn same_start_refusal(log_failure: bool) -> TestResult {
let mut fixture = Fixture::new()?;
let folder = fixture.root().join("readonly-log");
let mut child = fixture.child("broken", RECORD_FIRST);
let expected = if log_failure {
fs::create_dir(&folder)?;
fs::set_permissions(&folder, fs::Permissions::from_mode(0o500))?;
child["log"] = json!(folder.join("child.log"));
"door_child_log_unwritable: broken:"
} else {
child["program"] = json!(fixture.root().join("missing-program"));
"door_child_program_missing: broken:"
};
fixture.config["children"] = json!([child]);
let mut door = fixture.start()?;
let line = door.until_any(&[
expected,
"door_child_start_failed:",
"door_child_log_failed:",
])?;
drop(door);
let output = check(&fixture)?;
if log_failure {
fs::set_permissions(&folder, fs::Permissions::from_mode(0o700))?;
}
assert!(
line.starts_with(expected),
"start used a different refusal: {line}"
);
assert_eq!(output.status.code(), Some(78), "{}", text(&output));
assert!(text(&output).contains(expected), "{}", text(&output));
assert!(!fixture.root().join("records").exists());
Ok(())
}
#[test]
fn r1_start_and_check_share_program_prerequisite_refusal() -> TestResult {
same_start_refusal(false)
}
#[test]
fn r1_start_and_check_share_log_prerequisite_refusal() -> TestResult {
same_start_refusal(true)
}