Some checks failed
Documentation Lint & Validation / Markdown Linting (push) Has been cancelled
Documentation Lint & Validation / Validate mdBook Configuration (push) Has been cancelled
Documentation Lint & Validation / Content & Structure Validation (push) Has been cancelled
Documentation Lint & Validation / Lint & Validation Summary (push) Has been cancelled
mdBook Build & Deploy / Build mdBook (push) Has been cancelled
mdBook Build & Deploy / Documentation Quality Check (push) Has been cancelled
mdBook Build & Deploy / Deploy to GitHub Pages (push) Has been cancelled
mdBook Build & Deploy / Notification (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
Add vapora-channels crate with trait-based Slack/Discord/Telegram webhook
delivery. ${VAR}/${VAR:-default} interpolation is mandatory inside
ChannelRegistry::from_config — callers cannot bypass secret resolution.
Fire-and-forget dispatch via tokio::spawn in both vapora-workflow-engine
(four lifecycle events) and vapora-backend (task Done, proposal approve/reject).
New REST endpoints: GET /channels, POST /channels/:name/test.
dispatch_notifications extracted as pub(crate) fn for inline testability;
5 handler tests + 6 workflow engine tests + 7 secret resolution unit tests.
Closes: vapora-channels bootstrap, notification gap in workflow/backend layer
ADR: docs/adrs/0035-notification-channels.md
286 lines
8.2 KiB
Rust
286 lines
8.2 KiB
Rust
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use vapora_channels::{
|
|
config::{DiscordConfig, SlackConfig, TelegramConfig},
|
|
discord::DiscordChannel,
|
|
error::ChannelError,
|
|
message::Message,
|
|
registry::ChannelRegistry,
|
|
slack::SlackChannel,
|
|
telegram::TelegramChannel,
|
|
NotificationChannel, Result,
|
|
};
|
|
use wiremock::{
|
|
matchers::{method, path},
|
|
Mock, MockServer, ResponseTemplate,
|
|
};
|
|
|
|
// ── Slack ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn slack_send_returns_ok_on_200() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/hooks/slack"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let cfg = SlackConfig {
|
|
webhook_url: format!("{}/hooks/slack", server.uri()),
|
|
channel: None,
|
|
username: None,
|
|
};
|
|
let channel = SlackChannel::new("slack", cfg, reqwest::Client::new());
|
|
let msg = Message::success("Deploy complete", "v1.2.0 → production");
|
|
|
|
channel.send(&msg).await.expect("should succeed on 200");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn slack_send_returns_api_error_on_500() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/hooks/slack"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("internal_error"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let cfg = SlackConfig {
|
|
webhook_url: format!("{}/hooks/slack", server.uri()),
|
|
channel: None,
|
|
username: None,
|
|
};
|
|
let channel = SlackChannel::new("slack", cfg, reqwest::Client::new());
|
|
let err = channel
|
|
.send(&Message::info("Test", "Body"))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
ChannelError::ApiError {
|
|
status: 500,
|
|
ref body,
|
|
..
|
|
} if body == "internal_error"
|
|
),
|
|
"unexpected error variant: {err}"
|
|
);
|
|
}
|
|
|
|
// ── Discord
|
|
// ───────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn discord_send_returns_ok_on_204() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/webhooks/discord"))
|
|
.respond_with(ResponseTemplate::new(204))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let cfg = DiscordConfig {
|
|
webhook_url: format!("{}/webhooks/discord", server.uri()),
|
|
username: None,
|
|
avatar_url: None,
|
|
};
|
|
let channel = DiscordChannel::new("discord", cfg, reqwest::Client::new());
|
|
|
|
channel
|
|
.send(&Message::warning("High latency", "p99 > 500 ms"))
|
|
.await
|
|
.expect("should succeed on 204");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discord_send_returns_api_error_on_400() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/webhooks/discord"))
|
|
.respond_with(ResponseTemplate::new(400).set_body_string("{\"code\":50006}"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let cfg = DiscordConfig {
|
|
webhook_url: format!("{}/webhooks/discord", server.uri()),
|
|
username: None,
|
|
avatar_url: None,
|
|
};
|
|
let channel = DiscordChannel::new("discord", cfg, reqwest::Client::new());
|
|
let err = channel
|
|
.send(&Message::info("Test", "Body"))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
matches!(err, ChannelError::ApiError { status: 400, .. }),
|
|
"unexpected error variant: {err}"
|
|
);
|
|
}
|
|
|
|
// ── Telegram
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn telegram_send_returns_ok_on_200() {
|
|
let server = MockServer::start().await;
|
|
|
|
// Telegram returns {"ok": true, "result": {...}} with HTTP 200.
|
|
Mock::given(method("POST"))
|
|
.and(path("/botTEST_TOKEN/sendMessage"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_string(r#"{"ok":true,"result":{"message_id":1}}"#),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let cfg = TelegramConfig {
|
|
bot_token: "TEST_TOKEN".to_string(),
|
|
chat_id: "-100999".to_string(),
|
|
api_base: Some(server.uri()),
|
|
};
|
|
let channel = TelegramChannel::new("telegram", cfg, reqwest::Client::new());
|
|
|
|
channel
|
|
.send(&Message::error("Service down", "Critical alert"))
|
|
.await
|
|
.expect("should succeed on 200");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn telegram_send_returns_api_error_on_400() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/botBAD_TOKEN/sendMessage"))
|
|
.respond_with(
|
|
ResponseTemplate::new(400)
|
|
.set_body_string(r#"{"ok":false,"description":"Unauthorized"}"#),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let cfg = TelegramConfig {
|
|
bot_token: "BAD_TOKEN".to_string(),
|
|
chat_id: "-100".to_string(),
|
|
api_base: Some(server.uri()),
|
|
};
|
|
let channel = TelegramChannel::new("telegram", cfg, reqwest::Client::new());
|
|
let err = channel
|
|
.send(&Message::info("Test", "Body"))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
matches!(err, ChannelError::ApiError { status: 400, .. }),
|
|
"unexpected error variant: {err}"
|
|
);
|
|
}
|
|
|
|
// ── Registry
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
struct AlwaysOkChannel {
|
|
name: String,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl NotificationChannel for AlwaysOkChannel {
|
|
fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
async fn send(&self, _msg: &Message) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct AlwaysFailChannel {
|
|
name: String,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl NotificationChannel for AlwaysFailChannel {
|
|
fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
async fn send(&self, _msg: &Message) -> Result<()> {
|
|
Err(ChannelError::ApiError {
|
|
channel: self.name.clone(),
|
|
status: 503,
|
|
body: "unavailable".to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_send_routes_to_named_channel() {
|
|
let mut registry = ChannelRegistry::new();
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
|
name: "ok-channel".to_string(),
|
|
}));
|
|
|
|
registry
|
|
.send("ok-channel", Message::info("Test", "Body"))
|
|
.await
|
|
.expect("should route to ok-channel and succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_send_returns_not_found_for_unknown_channel() {
|
|
let registry = ChannelRegistry::new();
|
|
let err = registry
|
|
.send("does-not-exist", Message::info("Test", "Body"))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
matches!(err, ChannelError::NotFound(ref n) if n == "does-not-exist"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_broadcast_delivers_to_all_channels() {
|
|
let mut registry = ChannelRegistry::new();
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
|
name: "ch-a".to_string(),
|
|
}));
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
|
name: "ch-b".to_string(),
|
|
}));
|
|
|
|
let results = registry
|
|
.broadcast(Message::success("All systems green", ""))
|
|
.await;
|
|
|
|
assert_eq!(results.len(), 2);
|
|
assert!(results.iter().all(|(_, r)| r.is_ok()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_broadcast_continues_after_partial_failure() {
|
|
let mut registry = ChannelRegistry::new();
|
|
registry.register(Arc::new(AlwaysOkChannel {
|
|
name: "good".to_string(),
|
|
}));
|
|
registry.register(Arc::new(AlwaysFailChannel {
|
|
name: "bad".to_string(),
|
|
}));
|
|
|
|
let results = registry.broadcast(Message::info("Test", "Body")).await;
|
|
|
|
assert_eq!(results.len(), 2);
|
|
let ok_count = results.iter().filter(|(_, r)| r.is_ok()).count();
|
|
let err_count = results.iter().filter(|(_, r)| r.is_err()).count();
|
|
assert_eq!(ok_count, 1);
|
|
assert_eq!(err_count, 1);
|
|
}
|