mod common;
use std::path::Path;
use std::process::Command;
fn workdir(tag: &str) -> String {
let dir = common::unique_path(tag, "dir");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create workdir");
for f in ["cert.pem", "key.pem"] {
std::fs::write(Path::new(&dir).join(f), b"").expect("write tls stub");
}
dir
}
fn validate(dir: &str, yaml: &str) -> (i32, String) {
let path = Path::new(dir).join("agentd.yaml");
std::fs::write(&path, yaml).expect("write config");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_agentd"));
cmd.current_dir(dir)
.args(["--config", &path.to_string_lossy(), "--validate-config"]);
for (k, _) in std::env::vars() {
if k.starts_with("AGENTD_") || k.starts_with("AGENT_") {
cmd.env_remove(k);
}
}
cmd.env("HOOK_SECRET", "topsecret");
let out = cmd.output().expect("spawn agentd");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn config(listen: &str, listener_auth: &str, node_auth: &str) -> String {
format!(
"config_version: \"2\"\n\
agent:\n name: hook\n instruction: You handle webhooks.\n preflight: never\n\
intelligence:\n endpoints: [\"https://intel.invalid/v1\"]\n model: mock\n\
store: {{kind: memory}}\n\
webhooks:\n listen: \"{listen}\"\n tls: {{cert: cert.pem, key: key.pem}}\n{listener_auth}\
workflows:\n - name: on-hook\n steps:\n\
\x20 h: {{kind: webhook, path: /hooks/deploy, methods: [POST]{node_auth}}}\n\
\x20 f: {{kind: finish, depends_on: [h]}}\n\
lifecycle: {{run_until: drained}}\n"
)
}
const HMAC: &str = ", auth: {hmac: {secret: \"{{secret:HOOK_SECRET}}\"}}";
#[test]
fn a_non_loopback_listener_with_no_auth_anywhere_is_refused_naming_what_to_set() {
let dir = workdir("wh-gate-open");
let (code, err) = validate(&dir, &config("https://0.0.0.0:8099", "", ""));
assert_eq!(
code, 2,
"an open public webhook listener must exit 2: {err}"
);
assert!(err.contains("config.invalid"), "{err}");
assert!(
err.contains("webhooks.listen on a non-loopback address needs auth"),
"{err}"
);
assert!(err.contains("webhooks.default_auth"), "{err}");
assert!(err.contains("`auth`"), "{err}");
assert!(err.contains("on-hook/h"), "{err}");
}
#[test]
fn a_listener_default_auth_satisfies_the_gate() {
let dir = workdir("wh-gate-default");
let listener_auth = " default_auth: {hmac: {secret: \"{{secret:HOOK_SECRET}}\"}}\n";
let (code, err) = validate(&dir, &config("https://0.0.0.0:8099", listener_auth, ""));
assert_eq!(code, 0, "webhooks.default_auth must validate: {err}");
assert!(err.contains("config.valid"), "{err}");
}
#[test]
fn every_node_carrying_its_own_auth_satisfies_the_gate() {
let dir = workdir("wh-gate-node");
let (code, err) = validate(&dir, &config("https://0.0.0.0:8099", "", HMAC));
assert_eq!(code, 0, "a node's own auth must validate: {err}");
assert!(err.contains("config.valid"), "{err}");
}
#[test]
fn a_loopback_listener_with_no_auth_still_validates() {
let dir = workdir("wh-gate-loopback");
let (code, err) = validate(&dir, &config("http://127.0.0.1:8099", "", ""));
assert_eq!(code, 0, "loopback dev must keep working: {err}");
assert!(err.contains("config.valid"), "{err}");
}
#[test]
fn an_explicit_none_auth_does_not_open_a_public_bind() {
let dir = workdir("wh-gate-none");
let (code, err) = validate(
&dir,
&config("https://0.0.0.0:8099", "", ", auth: {none: true}"),
);
assert_eq!(code, 2, "auth:{{none:true}} is not authentication: {err}");
assert!(err.contains("on-hook/h"), "{err}");
let dir = workdir("wh-gate-none-default");
let (code, err) = validate(
&dir,
&config("https://0.0.0.0:8099", " default_auth: {none: true}\n", ""),
);
assert_eq!(code, 2, "default_auth:{{none:true}} is not auth: {err}");
assert!(err.contains("on-hook/h"), "{err}");
}
#[test]
fn a_wait_on_webhook_callback_is_covered_by_the_gate() {
let dir = workdir("wh-gate-wait");
let yaml = "config_version: \"2\"\n\
agent:\n name: hook\n instruction: You handle webhooks.\n preflight: never\n\
intelligence:\n endpoints: [\"https://intel.invalid/v1\"]\n model: mock\n\
store: {kind: memory}\n\
webhooks:\n listen: \"https://0.0.0.0:8099\"\n tls: {cert: cert.pem, key: key.pem}\n\
workflows:\n - name: on-cb\n steps:\n\
\x20 s: {kind: once}\n\
\x20 w: {kind: wait, on: webhook, webhook: {path: /hooks/cb}, depends_on: [s], timeout: 30s}\n\
\x20 f: {kind: finish, depends_on: [w]}\n\
lifecycle: {run_until: drained}\n";
let (code, err) = validate(&dir, yaml);
assert_eq!(code, 2, "an open callback route must exit 2: {err}");
assert!(err.contains("on-cb/w"), "{err}");
let signed = yaml.replace(
"webhook: {path: /hooks/cb}",
"webhook: {path: /hooks/cb, auth: {hmac: {secret: \"{{secret:HOOK_SECRET}}\"}}}",
);
let dir = workdir("wh-gate-wait-signed");
let (code, err) = validate(&dir, &signed);
assert_eq!(code, 0, "a signed callback must validate: {err}");
assert!(err.contains("config.valid"), "{err}");
}