cloudiful-notifier 0.2.1

Async notification delivery library for ntfy, generic webhooks, DingTalk robots, and SMTP email.
Documentation
use std::{
    collections::BTreeMap,
    io::{Read, Write},
    net::TcpListener,
    sync::mpsc,
    thread,
    time::Duration,
};

use crate::core::{DeliveryChannel, MessageEnvelope, NotifierError};
use serde_json::json;

use super::WebhookChannel;

fn spawn_server(response: &'static str) -> (String, mpsc::Receiver<String>) {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = format!("http://{}", listener.local_addr().unwrap());
    let (sender, receiver) = mpsc::channel();

    thread::spawn(move || {
        let (mut stream, _) = listener.accept().unwrap();
        stream
            .set_read_timeout(Some(Duration::from_millis(50)))
            .unwrap();
        let mut buffer = Vec::with_capacity(8192);
        let mut chunk = [0; 1024];

        loop {
            match stream.read(&mut chunk) {
                Ok(0) => break,
                Ok(size) => buffer.extend_from_slice(&chunk[..size]),
                Err(error)
                    if matches!(
                        error.kind(),
                        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
                    ) =>
                {
                    break;
                }
                Err(error) => panic!("failed to read request: {error}"),
            }
        }

        sender
            .send(String::from_utf8_lossy(&buffer).to_string())
            .unwrap();
        stream.write_all(response.as_bytes()).unwrap();
    });

    (address, receiver)
}

#[tokio::test]
async fn webhook_sends_generic_json_payload() {
    let (url, receiver) =
        spawn_server("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
    let mut extra_headers = BTreeMap::new();
    extra_headers.insert("x-trace-id".to_string(), "abc".to_string());
    let channel = WebhookChannel {
        url,
        bearer_token: Some("token-123".to_string()),
        extra_headers,
    };
    let mut message = MessageEnvelope::new("plain body").with_title("Important");
    message
        .metadata
        .insert("severity".to_string(), json!("critical"));

    let result = channel
        .deliver(&reqwest::Client::new(), &message)
        .await
        .unwrap();
    let request = receiver.recv().unwrap();
    let request_lower = request.to_ascii_lowercase();

    assert_eq!(result.http_status, Some(200));
    assert!(request.starts_with("POST / HTTP/1.1"));
    assert!(request_lower.contains("authorization: bearer token-123"));
    assert!(request_lower.contains("x-trace-id: abc"));
    assert!(request.contains("\"title\":\"Important\""));
    assert!(request.contains("\"body\":\"plain body\""));
    assert!(request.contains("\"metadata\":{\"severity\":\"critical\"}"));
}

#[tokio::test]
async fn webhook_rejects_reserved_headers() {
    let mut extra_headers = BTreeMap::new();
    extra_headers.insert("Authorization".to_string(), "override".to_string());
    let channel = WebhookChannel {
        url: "https://example.com".to_string(),
        bearer_token: None,
        extra_headers,
    };
    let message = MessageEnvelope::new("body");

    let error = channel
        .deliver(&reqwest::Client::new(), &message)
        .await
        .unwrap_err();

    assert_eq!(
        error,
        NotifierError::ReservedHeader {
            header: "Authorization".to_string(),
        }
    );
}