use super::*;
use std::io::Write as _;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn a_key() -> Arc<ssh_key::PrivateKey> {
Arc::new(
ssh_key::PrivateKey::from_openssh(gunnar_core::mint_ed25519().expect("mint"))
.expect("parse"),
)
}
#[derive(Debug, Clone)]
struct Captured {
body: Vec<u8>,
signature_header: String,
fingerprint_header: String,
namespace_header: String,
event_header: String,
}
async fn spawn_sink() -> (String, tokio::sync::oneshot::Receiver<Captured>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
let mut header_end = None;
let mut content_len = 0usize;
loop {
if header_end.is_none() {
if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
header_end = Some(pos + 4);
let head = String::from_utf8_lossy(&buf[..pos]).to_lowercase();
for line in head.lines() {
if let Some(v) = line.strip_prefix("content-length:") {
content_len = v.trim().parse().unwrap_or(0);
}
}
}
}
if let Some(start) = header_end {
if buf.len() >= start + content_len {
break;
}
}
let n = socket.read(&mut tmp).await.unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
}
let start = header_end.unwrap_or(buf.len());
let body = buf[start..start + content_len.min(buf.len() - start)].to_vec();
let head = String::from_utf8_lossy(&buf[..start]).to_string();
let get = |name: &str| -> String {
head.lines()
.find(|l| l.to_lowercase().starts_with(&format!("{}:", name.to_lowercase())))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
.unwrap_or_default()
};
let captured = Captured {
body,
signature_header: get(gunnar_core::webhook::SIGNATURE_HEADER),
fingerprint_header: get(gunnar_core::webhook::KEY_FINGERPRINT_HEADER),
namespace_header: get(gunnar_core::webhook::NAMESPACE_HEADER),
event_header: get(gunnar_core::webhook::EVENT_HEADER),
};
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
let _ = tx.send(captured);
});
(format!("http://{addr}/hook"), rx)
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn repo_deleted_event(seq: u64) -> Arc<Event> {
Arc::new(Event::new(
seq,
1,
event::Payload::RepoDeleted(gunnar_proto::RepoDeleted {
repo: Some(RepoPath {
namespace: "alice".into(),
repo: "edda".into(),
}),
principal_id: "p-1".into(),
}),
))
}
fn endpoint(name: &str, url: &str) -> Endpoint {
Endpoint {
name: name.into(),
url: url.into(),
kinds: Vec::new(),
namespace: String::new(),
repo: None,
secret: None,
added_at_unix_ms: 0,
}
}
#[tokio::test]
async fn a_real_event_reaches_the_sink_and_the_received_body_verifies() {
let (url, rx) = spawn_sink().await;
let key = a_key();
let bus = Arc::new(EventBus::new(64));
let config = WebhookConfig {
endpoints: vec![endpoint("ci", &url)],
};
let delivery = WebhookDelivery::new(Arc::clone(&bus), Arc::clone(&key), config);
let _tasks = delivery.spawn();
tokio::time::sleep(Duration::from_millis(50)).await;
bus.publish(event::Payload::RepoDeleted(gunnar_proto::RepoDeleted {
repo: Some(RepoPath {
namespace: "alice".into(),
repo: "edda".into(),
}),
principal_id: "p-1".into(),
}));
let captured = tokio::time::timeout(Duration::from_secs(5), rx)
.await
.expect("the sink must receive a delivery")
.expect("sink channel");
assert_eq!(captured.event_header, "repo.deleted");
assert_eq!(
captured.namespace_header,
gunnar_core::webhook::WEBHOOK_NAMESPACE
);
let body: serde_json::Value = serde_json::from_slice(&captured.body).expect("body is json");
assert_eq!(body["kind"], "repo.deleted");
assert_eq!(body["repo"], "edda");
gunnar_core::webhook::verify_delivery(
key.public_key(),
&captured.body,
&captured.signature_header,
&captured.fingerprint_header,
)
.expect("the received body must verify against the server's published key");
let attacker = a_key();
let err = gunnar_core::webhook::verify_delivery(
attacker.public_key(),
&captured.body,
&captured.signature_header,
&attacker
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
)
.expect_err("a delivery signed by gunnar must not verify against an attacker's key");
assert_eq!(err, gunnar_core::webhook::DeliveryProblem::NotVerified);
let mut tampered = captured.body.clone();
tampered[10] ^= 0x01;
let err = gunnar_core::webhook::verify_delivery(
key.public_key(),
&tampered,
&captured.signature_header,
&captured.fingerprint_header,
)
.expect_err("a tampered body must not verify");
assert_eq!(err, gunnar_core::webhook::DeliveryProblem::NotVerified);
}
#[tokio::test]
async fn a_dead_endpoint_does_not_stop_a_live_one() {
let (url, rx) = spawn_sink().await;
let key = a_key();
let bus = Arc::new(EventBus::new(64));
let config = WebhookConfig {
endpoints: vec![
endpoint("dead", "http://127.0.0.1:1/hook"),
endpoint("live", &url),
],
};
let delivery = WebhookDelivery::new(Arc::clone(&bus), key, config);
let _tasks = delivery.spawn();
tokio::time::sleep(Duration::from_millis(50)).await;
bus.publish(event::Payload::RepoDeleted(gunnar_proto::RepoDeleted {
repo: Some(RepoPath {
namespace: "alice".into(),
repo: "edda".into(),
}),
principal_id: "p-1".into(),
}));
tokio::time::timeout(Duration::from_secs(5), rx)
.await
.expect("the live endpoint must deliver despite the dead sibling")
.expect("sink channel");
}
#[tokio::test]
async fn enqueue_is_bounded_and_never_blocks_when_nothing_drains() {
let runner = Runner::new(endpoint("x", "http://127.0.0.1:1/hook"));
let flood = DEFAULT_QUEUE_CAPACITY + 500;
let start = std::time::Instant::now();
for seq in 0..flood as u64 {
runner.enqueue(repo_deleted_event(seq));
}
assert!(
start.elapsed() < Duration::from_secs(2),
"enqueue must not block — a synchronous deliver would have here"
);
assert_eq!(
runner.queue_len(),
DEFAULT_QUEUE_CAPACITY,
"the queue is bounded: the overflow is dropped, not buffered"
);
assert_eq!(
runner.counters.dropped.load(Ordering::Relaxed),
500,
"every dropped event is counted, so the receiver can see it fell behind"
);
}
#[test]
fn config_survives_a_reboot_but_an_in_memory_one_does_not() {
let dir = tempdir();
let ephemeral = WebhookConfig {
endpoints: vec![endpoint("ci", "https://ci.example/hook")],
};
assert_eq!(ephemeral.endpoints.len(), 1);
let reloaded_without_saving = WebhookConfig::load(&dir).expect("load");
assert!(
reloaded_without_saving.endpoints.is_empty(),
"a config that was only ever in memory is gone after a reboot"
);
ephemeral.save(&dir).expect("save");
assert!(
dir.join(WEBHOOK_CONFIG).is_file(),
"the config must be a real file on the data path"
);
let reloaded = WebhookConfig::load(&dir).expect("load after save");
assert_eq!(reloaded.endpoints.len(), 1);
assert_eq!(reloaded.endpoints[0].name, "ci");
assert_eq!(reloaded.endpoints[0].url, "https://ci.example/hook");
}
#[test]
fn an_empty_config_file_loads_as_no_webhooks_not_a_boot_failure() {
let dir = tempdir();
let mut f = std::fs::File::create(dir.join(WEBHOOK_CONFIG)).unwrap();
f.write_all(b" \n").unwrap();
drop(f);
assert!(WebhookConfig::load(&dir).expect("empty loads").endpoints.is_empty());
std::fs::write(dir.join(WEBHOOK_CONFIG), b"{ not json").unwrap();
assert!(
WebhookConfig::load(&dir).is_err(),
"a non-empty malformed config is an operator typo and must be refused"
);
}
#[test]
fn a_kinds_typo_is_refused_at_add_time() {
assert!(kind_from_name("ref.updated").is_some());
assert!(
kind_from_name("ref.updatd").is_none(),
"a typo'd kind name selects nothing and must be catchable, not silently deliver nothing"
);
}
struct TempDir(std::path::PathBuf);
impl std::ops::Deref for TempDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TempDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tempdir() -> TempDir {
let base = std::env::temp_dir().join(format!(
"gunnar-webhook-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&base).unwrap();
TempDir(base)
}
static NEXT: AtomicU64 = AtomicU64::new(0);