mod support;
use std::path::Path;
use support::{
code, gated_project, run_answering, run_env, stderr, stub_provider, text_reply, TempDir,
EXIT_OK,
};
struct Project {
dir: TempDir,
}
impl Project {
fn new(tag: &str) -> Project {
let dir = TempDir::new(tag);
gated_project(dir.path());
Project { dir }
}
fn path(&self) -> String {
self.dir.path().display().to_string()
}
fn written(&self) -> &Path {
self.dir.path()
}
}
fn args(project: &Project) -> Vec<String> {
vec![
"run".to_string(),
project.path(),
"--input".to_string(),
"note=the harbour".to_string(),
"--events".to_string(),
"json".to_string(),
"--approvals".to_string(),
"stdin".to_string(),
]
}
fn as_args(owned: &[String]) -> Vec<&str> {
owned.iter().map(String::as_str).collect()
}
fn stub_env(url: &str) -> Vec<(&str, &str)> {
vec![
("ANTHROPIC_API_KEY", "stub-key"),
("INGOT_ANTHROPIC_BASE_URL", url),
]
}
fn allow(node: &str) -> String {
format!(r#"{{"node":"{node}","allowed":true}}"#)
}
fn assert_refused_at_the_gate(project: &Project, output: &std::process::Output) {
let message = stderr(output);
assert_ne!(code(output), EXIT_OK, "{message}");
assert!(
message.contains("approval was refused at node"),
"the run had to stop at the gate rather than somewhere else: {message}"
);
assert!(
!project.written().join("data/out/note.md").exists(),
"the gate was not opened, so nothing may have been written"
);
}
#[test]
fn a_gate_answered_over_the_channel_lets_the_run_finish() {
let project = Project::new("gate-allow");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = args(&project);
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |node, _| {
Some(allow(node))
});
assert_eq!(code(&output), EXIT_OK, "{}", stderr(&output));
let written = project.written().join("data/out/note.md");
assert!(
written.is_file(),
"the gate was opened, so the write must have happened: {}",
stderr(&output)
);
assert!(
stderr(&output).contains(r#""event":"approvalDecided""#),
"the decision belongs in the record too: {}",
stderr(&output)
);
}
#[test]
fn without_the_channel_the_same_run_is_still_refused_at_the_gate() {
let project = Project::new("gate-default");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = vec![
"run".to_string(),
project.path(),
"--input".to_string(),
"note=the harbour".to_string(),
"--events".to_string(),
"json".to_string(),
];
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |node, _| {
Some(allow(node))
});
assert_refused_at_the_gate(&project, &output);
}
#[test]
fn a_gate_refused_over_the_channel_stops_the_run_before_the_effect() {
let project = Project::new("gate-deny");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = args(&project);
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |node, _| {
Some(format!(r#"{{"node":"{node}","allowed":false}}"#))
});
assert_refused_at_the_gate(&project, &output);
}
#[test]
fn an_answer_naming_another_gate_is_refused_rather_than_applied() {
let project = Project::new("gate-mismatch");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = args(&project);
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |_, _| {
Some(allow("some-other-node"))
});
assert_refused_at_the_gate(&project, &output);
assert!(
stderr(&output).contains("some-other-node"),
"the mismatch has to name the gate the answer claimed: {}",
stderr(&output)
);
}
#[test]
fn a_parent_that_goes_away_is_a_refusal_and_not_a_hang() {
let project = Project::new("gate-eof");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = args(&project);
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |_, _| None);
assert_refused_at_the_gate(&project, &output);
assert!(
stderr(&output).contains("closed before"),
"the run has to say why it refused: {}",
stderr(&output)
);
}
#[test]
fn an_unreadable_answer_is_refused_and_says_what_was_expected() {
let project = Project::new("gate-garbage");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = args(&project);
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |_, _| {
Some("yes please".to_string())
});
assert_refused_at_the_gate(&project, &output);
assert!(
stderr(&output).contains("unreadable answer"),
"the message has to show the shape it wanted: {}",
stderr(&output)
);
}
#[test]
fn an_answer_carrying_an_invented_field_is_refused() {
let project = Project::new("gate-extra");
let stub = stub_provider(vec![text_reply("A line about the harbour.\n")]);
let owned = args(&project);
let output = run_answering(&as_args(&owned), &stub_env(&stub.url), |node, _| {
Some(format!(
r#"{{"node":"{node}","allowed":true,"forever":true}}"#
))
});
assert_refused_at_the_gate(&project, &output);
}
#[test]
fn the_channel_is_refused_when_the_gate_could_not_be_seen() {
let project = Project::new("gate-unanswerable");
let output = run_env(
&[
"run",
&project.path(),
"--input",
"note=the harbour",
"--events",
"text",
"--approvals",
"stdin",
],
&[],
);
assert_ne!(code(&output), EXIT_OK);
let message = stderr(&output);
assert!(message.contains("--events json"), "{message}");
}
#[test]
fn a_blanket_yes_and_a_channel_cannot_be_asked_for_together() {
let project = Project::new("gate-both");
let output = run_env(
&["run", &project.path(), "--approvals", "stdin", "--yes"],
&[],
);
assert_ne!(code(&output), EXIT_OK);
let message = stderr(&output);
assert!(
message.contains("--yes") && message.contains("--approvals"),
"{message}"
);
}