#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use speccoder_runtime::hooks;
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
.args([
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn repo(hooks: &str) -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("speccoder.toml"),
format!("[model]\nprovider = \"openai\"\nmodel = \"m\"\n{hooks}"),
)
.unwrap();
git(d.path(), &["init", "-q"]);
git(d.path(), &["add", "-A"]);
git(d.path(), &["commit", "-q", "-m", "init"]);
git(d.path(), &["branch", "speccoder/001-x"]);
d
}
fn tick(repo: &Path, task: &str) -> String {
git(repo, &["checkout", "-q", "speccoder/001-x"]);
let msg = format!(
"{task} Do it\n\nDo it in src/secret_place.rs\n\nSpec-Task: 001-x/{task}\nVerdict: verified\nScenarios: US1-1@aa\nRun: run_01\n"
);
git(repo, &["commit", "-q", "--allow-empty", "-m", &msg]);
let sha = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "main"]);
sha
}
fn trust_all(repo: &Path, state: &Path) {
let r = hooks::report::trust(repo, state, None, "terminal", &mut |_| true);
assert_eq!(
r.status(),
speccoder_core::CommandStatus::Ok,
"{}",
r.message()
);
}
type Request = (Vec<(String, String)>, Vec<u8>);
#[derive(Clone, Default)]
struct Got(Arc<Mutex<Vec<Request>>>);
fn receiver(status: &'static str) -> (String, Got) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!(
"http://127.0.0.1:{}/hook",
listener.local_addr().unwrap().port()
);
let got = Got::default();
let keep = got.clone();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut headers = Vec::new();
let mut len = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
if let Some((k, v)) = line.trim_end().split_once(": ") {
if k.eq_ignore_ascii_case("content-length") {
len = v.parse().unwrap_or(0);
}
headers.push((k.to_ascii_lowercase(), v.to_owned()));
}
}
let mut body = vec![0; len];
reader.read_exact(&mut body).unwrap();
keep.0.lock().unwrap().push((headers, body));
let mut s = stream;
let _ = write!(
s,
"HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
);
}
});
(url, got)
}
const SECRET: &str = "a-signing-key-of-at-least-24-bytes";
#[test]
fn a_webhook_gets_each_tick_once_signed_and_schema_shaped() {
let (url, got) = receiver("200 OK");
let r = repo(&format!(
"\n[hooks.chat]\non = [\"task.ticked\"]\nurl = \"{url}\"\nsecret_env = \"SPECCODER_TEST_HOOK_SECRET\"\n"
));
let state = tempfile::tempdir().unwrap();
if std::env::var("SPECCODER_TEST_HOOK_SECRET").as_deref() != Ok(SECRET) {
let me = std::env::current_exe().unwrap();
let out = Command::new(me)
.args([
"--exact",
"a_webhook_gets_each_tick_once_signed_and_schema_shaped",
"--nocapture",
])
.env("SPECCODER_TEST_HOOK_SECRET", SECRET)
.output()
.unwrap();
assert!(
out.status.success(),
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
return;
}
tick(r.path(), "T001");
trust_all(r.path(), state.path());
let t2 = tick(r.path(), "T002");
let done = hooks::report::deliver(r.path(), state.path());
assert_eq!(
done.status(),
speccoder_core::CommandStatus::Ok,
"{}",
done.message()
);
let first = got.0.lock().unwrap().clone();
assert_eq!(first.len(), 1, "{first:?}");
let (headers, body) = &first[0];
let event: Value = serde_json::from_slice(body).unwrap();
assert_eq!(event["id"], format!("tick:{t2}"));
assert_eq!(event["type"], "dev.speccoder.task.ticked");
assert_eq!(
event["data"]["summary"],
"T002 verified in 001-x — 1 scenario"
);
assert!(!String::from_utf8_lossy(body).contains("secret_place"));
let h = |k: &str| {
headers
.iter()
.find(|(n, _)| n == k)
.map(|(_, v)| v.as_str())
.unwrap()
};
assert_eq!(h("webhook-id"), format!("tick:{t2}"));
let verifier =
agentplane::push::WebhookVerifier::new(&agentplane::core::Secret::new(SECRET)).unwrap();
verifier
.verify_parts(
h("webhook-id"),
h("webhook-timestamp"),
h("webhook-signature"),
body,
agentplane::core::Timestamp::now_utc(),
)
.unwrap();
let again = hooks::report::deliver(r.path(), state.path());
assert!(
again.message().starts_with("delivered 0 events"),
"{}",
again.message()
);
assert_eq!(got.0.lock().unwrap().len(), 1);
}
#[test]
fn an_event_a_receiver_refused_waits_and_goes_out_later_with_its_id() {
let (bad, refused) = receiver("500 Internal Server Error");
let r = repo(&format!("\n[hooks.chat]\non = [\"*\"]\nurl = \"{bad}\"\n"));
let state = tempfile::tempdir().unwrap();
trust_all(r.path(), state.path());
let t1 = tick(r.path(), "T001");
let first = hooks::report::deliver(r.path(), state.path());
assert_eq!(
first.status(),
speccoder_core::CommandStatus::Error,
"{}",
first.message()
);
assert!(
first.message().contains("answered 500"),
"{}",
first.message()
);
assert_eq!(refused.0.lock().unwrap().len(), 1);
let (good, took) = receiver("204 No Content");
std::fs::write(
r.path().join("speccoder.toml"),
format!("[model]\nprovider = \"openai\"\nmodel = \"m\"\n\n[hooks.chat]\non = [\"*\"]\nurl = \"{good}\"\n"),
)
.unwrap();
trust_all(r.path(), state.path());
let later = hooks::report::deliver(r.path(), state.path());
assert_eq!(
later.status(),
speccoder_core::CommandStatus::Ok,
"{}",
later.message()
);
let got = took.0.lock().unwrap().clone();
assert_eq!(got.len(), 1);
let event: Value = serde_json::from_slice(&got[0].1).unwrap();
assert_eq!(event["id"], format!("tick:{t1}"));
}
#[test]
fn a_command_observer_reads_the_event_on_stdin() {
if speccoder_runtime::sandbox::backend().is_err() {
return;
}
let r = repo(
"\n[hooks.log]\non = [\"task.ticked\"]\nargv = [\"sh\", \"-c\", \"read line; case \\\"$line\\\" in *dev.speccoder.task.ticked*T001*) exit 0;; *) echo \\\"got: $line\\\" >&2; exit 3;; esac\"]\n",
);
let state = tempfile::tempdir().unwrap();
trust_all(r.path(), state.path());
tick(r.path(), "T001");
let done = hooks::report::deliver(r.path(), state.path());
assert_eq!(
done.status(),
speccoder_core::CommandStatus::Ok,
"{}",
done.message()
);
assert!(
done.message().starts_with("delivered 1 event"),
"{}",
done.message()
);
}
#[test]
fn an_untrusted_observer_hears_nothing_and_says_so() {
let (url, got) = receiver("200 OK");
let r = repo(&format!("\n[hooks.chat]\non = [\"*\"]\nurl = \"{url}\"\n"));
let state = tempfile::tempdir().unwrap();
tick(r.path(), "T001");
let done = hooks::report::deliver(r.path(), state.path());
assert!(
done.message().contains("skipped (not trusted): chat"),
"{}",
done.message()
);
assert!(got.0.lock().unwrap().is_empty());
assert!(
hooks::report::untrusted_notice(r.path(), state.path())
.unwrap()
.starts_with("chat not trusted")
);
}