744
crates/forage-core/src/integrations/mod.rs
Normal file
744
crates/forage-core/src/integrations/mod.rs
Normal file
@@ -0,0 +1,744 @@
|
||||
pub mod nats;
|
||||
pub mod router;
|
||||
pub mod webhook;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── Integration types ────────────────────────────────────────────────
|
||||
|
||||
/// An org-level notification integration (Slack workspace, webhook URL, etc.).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Integration {
|
||||
pub id: String,
|
||||
pub organisation: String,
|
||||
pub integration_type: IntegrationType,
|
||||
pub name: String,
|
||||
pub config: IntegrationConfig,
|
||||
pub enabled: bool,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
/// The raw API token, only populated when the integration is first created.
|
||||
/// After creation, this is None (only the hash is stored).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Supported integration types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IntegrationType {
|
||||
Slack,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
impl IntegrationType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Slack => "slack",
|
||||
Self::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"slack" => Some(Self::Slack),
|
||||
"webhook" => Some(Self::Webhook),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Slack => "Slack",
|
||||
Self::Webhook => "Webhook",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-specific configuration for an integration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum IntegrationConfig {
|
||||
Slack {
|
||||
team_id: String,
|
||||
team_name: String,
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
access_token: String,
|
||||
webhook_url: String,
|
||||
},
|
||||
Webhook {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
secret: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Notification rules ───────────────────────────────────────────────
|
||||
|
||||
/// Which event types an integration should receive.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationRule {
|
||||
pub id: String,
|
||||
pub integration_id: String,
|
||||
pub notification_type: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Known notification event types.
|
||||
pub const NOTIFICATION_TYPES: &[&str] = &[
|
||||
"release_annotated",
|
||||
"release_started",
|
||||
"release_succeeded",
|
||||
"release_failed",
|
||||
];
|
||||
|
||||
// ── Delivery log ─────────────────────────────────────────────────────
|
||||
|
||||
/// Record of a notification delivery attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationDelivery {
|
||||
pub id: String,
|
||||
pub integration_id: String,
|
||||
pub notification_id: String,
|
||||
pub status: DeliveryStatus,
|
||||
pub error_message: Option<String>,
|
||||
pub attempted_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeliveryStatus {
|
||||
Delivered,
|
||||
Failed,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl DeliveryStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Delivered => "delivered",
|
||||
Self::Failed => "failed",
|
||||
Self::Pending => "pending",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"delivered" => Some(Self::Delivered),
|
||||
"failed" => Some(Self::Failed),
|
||||
"pending" => Some(Self::Pending),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create/Update inputs ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateIntegrationInput {
|
||||
pub organisation: String,
|
||||
pub integration_type: IntegrationType,
|
||||
pub name: String,
|
||||
pub config: IntegrationConfig,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
// ── Error type ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum IntegrationError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("duplicate: {0}")]
|
||||
Duplicate(String),
|
||||
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
|
||||
#[error("encryption error: {0}")]
|
||||
Encryption(String),
|
||||
}
|
||||
|
||||
// ── Repository trait ─────────────────────────────────────────────────
|
||||
|
||||
/// Persistence trait for integration management. Implemented by forage-db.
|
||||
#[async_trait::async_trait]
|
||||
pub trait IntegrationStore: Send + Sync {
|
||||
/// List all integrations for an organisation.
|
||||
async fn list_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError>;
|
||||
|
||||
/// Get a single integration by ID (must belong to the given org).
|
||||
async fn get_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
|
||||
/// Create a new integration with default notification rules (all enabled).
|
||||
async fn create_integration(
|
||||
&self,
|
||||
input: &CreateIntegrationInput,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
|
||||
/// Enable or disable an integration.
|
||||
async fn set_integration_enabled(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Delete an integration and its rules/deliveries (cascading).
|
||||
async fn delete_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// List notification rules for an integration.
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
) -> Result<Vec<NotificationRule>, IntegrationError>;
|
||||
|
||||
/// Set whether a specific notification type is enabled for an integration.
|
||||
async fn set_rule_enabled(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// Record a delivery attempt.
|
||||
async fn record_delivery(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_id: &str,
|
||||
status: DeliveryStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<(), IntegrationError>;
|
||||
|
||||
/// List enabled integrations for an org that have a matching rule for the given event type.
|
||||
async fn list_matching_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
notification_type: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError>;
|
||||
|
||||
/// List recent delivery attempts for an integration, newest first.
|
||||
async fn list_deliveries(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NotificationDelivery>, IntegrationError>;
|
||||
|
||||
/// Look up an integration by its API token hash. Used for API authentication.
|
||||
async fn get_integration_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Integration, IntegrationError>;
|
||||
}
|
||||
|
||||
// ── Token generation ────────────────────────────────────────────────
|
||||
|
||||
/// Generate a crypto-random API token for an integration.
|
||||
/// Format: `fgi_` prefix + 32 bytes hex-encoded.
|
||||
pub fn generate_api_token() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let encoded = hex_encode(&bytes);
|
||||
format!("fgi_{encoded}")
|
||||
}
|
||||
|
||||
/// SHA-256 hash of a token for storage. Only the hash is persisted.
|
||||
pub fn hash_api_token(token: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(token.as_bytes());
|
||||
hex_encode(&hash)
|
||||
}
|
||||
|
||||
fn hex_encode(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
/// Validate a webhook URL. Must be HTTPS (or localhost for development).
|
||||
pub fn validate_webhook_url(url: &str) -> Result<(), IntegrationError> {
|
||||
if url.starts_with("https://") {
|
||||
return Ok(());
|
||||
}
|
||||
if url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(IntegrationError::InvalidInput(
|
||||
"Webhook URL must use HTTPS".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate an integration name (reuse slug rules: lowercase alphanumeric + hyphens, max 64).
|
||||
pub fn validate_integration_name(name: &str) -> Result<(), IntegrationError> {
|
||||
if name.is_empty() {
|
||||
return Err(IntegrationError::InvalidInput(
|
||||
"Integration name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if name.len() > 64 {
|
||||
return Err(IntegrationError::InvalidInput(
|
||||
"Integration name too long (max 64 characters)".to_string(),
|
||||
));
|
||||
}
|
||||
// Allow more characters than slugs: spaces, #, etc. for human-readable names
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err(IntegrationError::InvalidInput(
|
||||
"Integration name contains invalid characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── In-memory store (for tests) ──────────────────────────────────────
|
||||
|
||||
/// In-memory integration store for testing. Not for production use.
|
||||
pub struct InMemoryIntegrationStore {
|
||||
integrations: std::sync::Mutex<Vec<Integration>>,
|
||||
rules: std::sync::Mutex<Vec<NotificationRule>>,
|
||||
deliveries: std::sync::Mutex<Vec<NotificationDelivery>>,
|
||||
/// Stores token_hash -> integration_id for lookup.
|
||||
token_hashes: std::sync::Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl InMemoryIntegrationStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
integrations: std::sync::Mutex::new(Vec::new()),
|
||||
rules: std::sync::Mutex::new(Vec::new()),
|
||||
deliveries: std::sync::Mutex::new(Vec::new()),
|
||||
token_hashes: std::sync::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryIntegrationStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix for integration API tokens.
|
||||
pub const TOKEN_PREFIX: &str = "fgi_";
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IntegrationStore for InMemoryIntegrationStore {
|
||||
async fn list_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError> {
|
||||
let store = self.integrations.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|i| i.organisation == organisation)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let store = self.integrations.lock().unwrap();
|
||||
store
|
||||
.iter()
|
||||
.find(|i| i.id == id && i.organisation == organisation)
|
||||
.cloned()
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
async fn create_integration(
|
||||
&self,
|
||||
input: &CreateIntegrationInput,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
if store
|
||||
.iter()
|
||||
.any(|i| i.organisation == input.organisation && i.name == input.name)
|
||||
{
|
||||
return Err(IntegrationError::Duplicate(format!(
|
||||
"Integration '{}' already exists",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let raw_token = generate_api_token();
|
||||
let token_hash = hash_api_token(&raw_token);
|
||||
|
||||
let integration = Integration {
|
||||
id: id.clone(),
|
||||
organisation: input.organisation.clone(),
|
||||
integration_type: input.integration_type,
|
||||
name: input.name.clone(),
|
||||
config: input.config.clone(),
|
||||
enabled: true,
|
||||
created_by: input.created_by.clone(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
api_token: Some(raw_token),
|
||||
};
|
||||
// Store without the raw token
|
||||
let stored = Integration { api_token: None, ..integration.clone() };
|
||||
store.push(stored);
|
||||
|
||||
// Store token hash
|
||||
self.token_hashes.lock().unwrap().insert(token_hash, id.clone());
|
||||
|
||||
// Create default rules
|
||||
let mut rules = self.rules.lock().unwrap();
|
||||
for nt in NOTIFICATION_TYPES {
|
||||
rules.push(NotificationRule {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: id.clone(),
|
||||
notification_type: nt.to_string(),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(integration)
|
||||
}
|
||||
|
||||
async fn set_integration_enabled(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
let integ = store
|
||||
.iter_mut()
|
||||
.find(|i| i.id == id && i.organisation == organisation)
|
||||
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
|
||||
integ.enabled = enabled;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_integration(
|
||||
&self,
|
||||
organisation: &str,
|
||||
id: &str,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut store = self.integrations.lock().unwrap();
|
||||
let len = store.len();
|
||||
store.retain(|i| !(i.id == id && i.organisation == organisation));
|
||||
if store.len() == len {
|
||||
return Err(IntegrationError::NotFound(id.to_string()));
|
||||
}
|
||||
// Cascade delete rules
|
||||
let mut rules = self.rules.lock().unwrap();
|
||||
rules.retain(|r| r.integration_id != id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_rules(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
) -> Result<Vec<NotificationRule>, IntegrationError> {
|
||||
let rules = self.rules.lock().unwrap();
|
||||
Ok(rules
|
||||
.iter()
|
||||
.filter(|r| r.integration_id == integration_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_rule_enabled(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut rules = self.rules.lock().unwrap();
|
||||
if let Some(rule) = rules
|
||||
.iter_mut()
|
||||
.find(|r| r.integration_id == integration_id && r.notification_type == notification_type)
|
||||
{
|
||||
rule.enabled = enabled;
|
||||
} else {
|
||||
rules.push(NotificationRule {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: integration_id.to_string(),
|
||||
notification_type: notification_type.to_string(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_delivery(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
notification_id: &str,
|
||||
status: DeliveryStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<(), IntegrationError> {
|
||||
let mut deliveries = self.deliveries.lock().unwrap();
|
||||
deliveries.push(NotificationDelivery {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
integration_id: integration_id.to_string(),
|
||||
notification_id: notification_id.to_string(),
|
||||
status,
|
||||
error_message: error_message.map(|s| s.to_string()),
|
||||
attempted_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_deliveries(
|
||||
&self,
|
||||
integration_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NotificationDelivery>, IntegrationError> {
|
||||
let deliveries = self.deliveries.lock().unwrap();
|
||||
let mut matching: Vec<_> = deliveries
|
||||
.iter()
|
||||
.filter(|d| d.integration_id == integration_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
// Sort newest first (by attempted_at descending)
|
||||
matching.sort_by(|a, b| b.attempted_at.cmp(&a.attempted_at));
|
||||
matching.truncate(limit);
|
||||
Ok(matching)
|
||||
}
|
||||
|
||||
async fn list_matching_integrations(
|
||||
&self,
|
||||
organisation: &str,
|
||||
notification_type: &str,
|
||||
) -> Result<Vec<Integration>, IntegrationError> {
|
||||
let store = self.integrations.lock().unwrap();
|
||||
let rules = self.rules.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.organisation == organisation
|
||||
&& i.enabled
|
||||
&& rules.iter().any(|r| {
|
||||
r.integration_id == i.id
|
||||
&& r.notification_type == notification_type
|
||||
&& r.enabled
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_integration_by_token_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Integration, IntegrationError> {
|
||||
let hashes = self.token_hashes.lock().unwrap();
|
||||
let id = hashes
|
||||
.get(token_hash)
|
||||
.ok_or_else(|| IntegrationError::NotFound("invalid token".to_string()))?
|
||||
.clone();
|
||||
drop(hashes);
|
||||
|
||||
let store = self.integrations.lock().unwrap();
|
||||
store
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.cloned()
|
||||
.ok_or(IntegrationError::NotFound(id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn integration_type_roundtrip() {
|
||||
for t in &[IntegrationType::Slack, IntegrationType::Webhook] {
|
||||
let s = t.as_str();
|
||||
assert_eq!(IntegrationType::parse(s), Some(*t));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_type_unknown_returns_none() {
|
||||
assert_eq!(IntegrationType::parse("discord"), None);
|
||||
assert_eq!(IntegrationType::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_status_roundtrip() {
|
||||
for s in &[
|
||||
DeliveryStatus::Delivered,
|
||||
DeliveryStatus::Failed,
|
||||
DeliveryStatus::Pending,
|
||||
] {
|
||||
let str = s.as_str();
|
||||
assert_eq!(DeliveryStatus::parse(str), Some(*s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_webhook_url_https() {
|
||||
assert!(validate_webhook_url("https://example.com/hook").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_webhook_url_localhost() {
|
||||
assert!(validate_webhook_url("http://localhost:8080/hook").is_ok());
|
||||
assert!(validate_webhook_url("http://127.0.0.1:8080/hook").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_webhook_url_http_rejected() {
|
||||
assert!(validate_webhook_url("http://example.com/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_valid() {
|
||||
assert!(validate_integration_name("my-slack").is_ok());
|
||||
assert!(validate_integration_name("#deploys").is_ok());
|
||||
assert!(validate_integration_name("Production alerts").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_empty() {
|
||||
assert!(validate_integration_name("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_too_long() {
|
||||
assert!(validate_integration_name(&"a".repeat(65)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_integration_name_control_chars() {
|
||||
assert!(validate_integration_name("bad\x00name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_config_slack_serde_roundtrip() {
|
||||
let config = IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "My Team".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-token".into(),
|
||||
webhook_url: "https://hooks.slack.com/...".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: IntegrationConfig = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
IntegrationConfig::Slack { team_id, .. } => assert_eq!(team_id, "T123"),
|
||||
_ => panic!("expected Slack config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_config_webhook_serde_roundtrip() {
|
||||
let config = IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: Some("s3cret".into()),
|
||||
headers: HashMap::from([("X-Custom".into(), "value".into())]),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: IntegrationConfig = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
IntegrationConfig::Webhook { url, secret, headers } => {
|
||||
assert_eq!(url, "https://example.com/hook");
|
||||
assert_eq!(secret.as_deref(), Some("s3cret"));
|
||||
assert_eq!(headers.get("X-Custom").map(|s| s.as_str()), Some("value"));
|
||||
}
|
||||
_ => panic!("expected Webhook config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_types_are_known() {
|
||||
assert_eq!(NOTIFICATION_TYPES.len(), 4);
|
||||
assert!(NOTIFICATION_TYPES.contains(&"release_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_token_has_prefix_and_length() {
|
||||
let token = generate_api_token();
|
||||
assert!(token.starts_with("fgi_"));
|
||||
// fgi_ (4) + 64 hex chars (32 bytes) = 68 total
|
||||
assert_eq!(token.len(), 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_token_is_unique() {
|
||||
let t1 = generate_api_token();
|
||||
let t2 = generate_api_token();
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_api_token_is_deterministic() {
|
||||
let token = "fgi_abcdef1234567890";
|
||||
let h1 = hash_api_token(token);
|
||||
let h2 = hash_api_token(token);
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_api_token_different_for_different_tokens() {
|
||||
let h1 = hash_api_token("fgi_token_one");
|
||||
let h2 = hash_api_token("fgi_token_two");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_memory_store_creates_with_api_token() {
|
||||
let store = InMemoryIntegrationStore::new();
|
||||
let created = store
|
||||
.create_integration(&CreateIntegrationInput {
|
||||
organisation: "myorg".into(),
|
||||
integration_type: IntegrationType::Webhook,
|
||||
name: "test-hook".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
created_by: "user-1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Token is returned on creation
|
||||
assert!(created.api_token.is_some());
|
||||
let token = created.api_token.unwrap();
|
||||
assert!(token.starts_with("fgi_"));
|
||||
|
||||
// Token lookup works
|
||||
let token_hash = hash_api_token(&token);
|
||||
let found = store.get_integration_by_token_hash(&token_hash).await.unwrap();
|
||||
assert_eq!(found.id, created.id);
|
||||
assert!(found.api_token.is_none()); // not stored in plaintext
|
||||
|
||||
// Stored integration doesn't have the raw token
|
||||
let listed = store.list_integrations("myorg").await.unwrap();
|
||||
assert!(listed[0].api_token.is_none());
|
||||
}
|
||||
}
|
||||
164
crates/forage-core/src/integrations/nats.rs
Normal file
164
crates/forage-core/src/integrations/nats.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::router::{NotificationEvent, ReleaseContext};
|
||||
|
||||
/// Wire format for notification events published to NATS JetStream.
|
||||
/// Mirrors `NotificationEvent` with serde support.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationEnvelope {
|
||||
pub id: String,
|
||||
pub notification_type: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub organisation: String,
|
||||
pub project: String,
|
||||
pub timestamp: String,
|
||||
pub release: Option<ReleaseContextEnvelope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleaseContextEnvelope {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&NotificationEvent> for NotificationEnvelope {
|
||||
fn from(e: &NotificationEvent) -> Self {
|
||||
Self {
|
||||
id: e.id.clone(),
|
||||
notification_type: e.notification_type.clone(),
|
||||
title: e.title.clone(),
|
||||
body: e.body.clone(),
|
||||
organisation: e.organisation.clone(),
|
||||
project: e.project.clone(),
|
||||
timestamp: e.timestamp.clone(),
|
||||
release: e.release.as_ref().map(|r| ReleaseContextEnvelope {
|
||||
slug: r.slug.clone(),
|
||||
artifact_id: r.artifact_id.clone(),
|
||||
destination: r.destination.clone(),
|
||||
environment: r.environment.clone(),
|
||||
source_username: r.source_username.clone(),
|
||||
commit_sha: r.commit_sha.clone(),
|
||||
commit_branch: r.commit_branch.clone(),
|
||||
error_message: r.error_message.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NotificationEnvelope> for NotificationEvent {
|
||||
fn from(e: NotificationEnvelope) -> Self {
|
||||
Self {
|
||||
id: e.id,
|
||||
notification_type: e.notification_type,
|
||||
title: e.title,
|
||||
body: e.body,
|
||||
organisation: e.organisation,
|
||||
project: e.project,
|
||||
timestamp: e.timestamp,
|
||||
release: e.release.map(|r| ReleaseContext {
|
||||
slug: r.slug,
|
||||
artifact_id: r.artifact_id,
|
||||
destination: r.destination,
|
||||
environment: r.environment,
|
||||
source_username: r.source_username,
|
||||
commit_sha: r.commit_sha,
|
||||
commit_branch: r.commit_branch,
|
||||
error_message: r.error_message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the NATS subject for a notification event.
|
||||
/// Format: `forage.notifications.{org}.{type}`
|
||||
pub fn notification_subject(organisation: &str, notification_type: &str) -> String {
|
||||
format!("forage.notifications.{organisation}.{notification_type}")
|
||||
}
|
||||
|
||||
/// The stream name used for notification delivery.
|
||||
pub const STREAM_NAME: &str = "FORAGE_NOTIFICATIONS";
|
||||
|
||||
/// Subject filter for the stream (captures all orgs and types).
|
||||
pub const STREAM_SUBJECTS: &str = "forage.notifications.>";
|
||||
|
||||
/// Durable consumer name for webhook dispatchers.
|
||||
pub const CONSUMER_NAME: &str = "forage-webhook-dispatcher";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_event() -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: "notif-1".into(),
|
||||
notification_type: "release_failed".into(),
|
||||
title: "Release failed".into(),
|
||||
body: "Container timeout".into(),
|
||||
organisation: "acme-corp".into(),
|
||||
project: "my-service".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "v1.2.3".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
commit_sha: "abc1234def".into(),
|
||||
commit_branch: "main".into(),
|
||||
error_message: Some("health check timeout".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_roundtrip() {
|
||||
let event = test_event();
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let json = serde_json::to_string(&envelope).unwrap();
|
||||
let parsed: NotificationEnvelope = serde_json::from_str(&json).unwrap();
|
||||
let restored: NotificationEvent = parsed.into();
|
||||
|
||||
assert_eq!(restored.id, event.id);
|
||||
assert_eq!(restored.notification_type, event.notification_type);
|
||||
assert_eq!(restored.organisation, event.organisation);
|
||||
assert_eq!(restored.project, event.project);
|
||||
let r = restored.release.unwrap();
|
||||
let orig = event.release.unwrap();
|
||||
assert_eq!(r.slug, orig.slug);
|
||||
assert_eq!(r.error_message, orig.error_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_without_release() {
|
||||
let event = NotificationEvent {
|
||||
id: "n2".into(),
|
||||
notification_type: "release_started".into(),
|
||||
title: "Starting".into(),
|
||||
body: String::new(),
|
||||
organisation: "org".into(),
|
||||
project: "proj".into(),
|
||||
timestamp: "2026-03-09T00:00:00Z".into(),
|
||||
release: None,
|
||||
};
|
||||
let envelope = NotificationEnvelope::from(&event);
|
||||
let json = serde_json::to_string(&envelope).unwrap();
|
||||
let parsed: NotificationEnvelope = serde_json::from_str(&json).unwrap();
|
||||
let restored: NotificationEvent = parsed.into();
|
||||
assert!(restored.release.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_subject_format() {
|
||||
assert_eq!(
|
||||
notification_subject("acme-corp", "release_failed"),
|
||||
"forage.notifications.acme-corp.release_failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
399
crates/forage-core/src/integrations/router.rs
Normal file
399
crates/forage-core/src/integrations/router.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
use super::{Integration, IntegrationConfig, IntegrationStore};
|
||||
use super::webhook::{ReleasePayload, WebhookPayload};
|
||||
|
||||
/// A notification event from Forest, normalized for routing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationEvent {
|
||||
pub id: String,
|
||||
pub notification_type: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub organisation: String,
|
||||
pub project: String,
|
||||
pub timestamp: String,
|
||||
pub release: Option<ReleaseContext>,
|
||||
}
|
||||
|
||||
/// Release context from the notification event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseContext {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// A dispatch task produced by the router: what to send where.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum DispatchTask {
|
||||
Webhook {
|
||||
integration_id: String,
|
||||
url: String,
|
||||
secret: Option<String>,
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
payload: WebhookPayload,
|
||||
},
|
||||
Slack {
|
||||
integration_id: String,
|
||||
webhook_url: String,
|
||||
message: SlackMessage,
|
||||
},
|
||||
}
|
||||
|
||||
/// A formatted Slack message (Block Kit compatible).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SlackMessage {
|
||||
pub text: String,
|
||||
pub color: String,
|
||||
pub blocks: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Route a notification event to dispatch tasks based on matching integrations.
|
||||
pub fn route_notification(
|
||||
event: &NotificationEvent,
|
||||
integrations: &[Integration],
|
||||
) -> Vec<DispatchTask> {
|
||||
let payload = build_webhook_payload(event);
|
||||
|
||||
integrations
|
||||
.iter()
|
||||
.map(|integration| match &integration.config {
|
||||
IntegrationConfig::Webhook {
|
||||
url,
|
||||
secret,
|
||||
headers,
|
||||
} => DispatchTask::Webhook {
|
||||
integration_id: integration.id.clone(),
|
||||
url: url.clone(),
|
||||
secret: secret.clone(),
|
||||
headers: headers.clone(),
|
||||
payload: payload.clone(),
|
||||
},
|
||||
IntegrationConfig::Slack { webhook_url, .. } => {
|
||||
let message = format_slack_message(event);
|
||||
DispatchTask::Slack {
|
||||
integration_id: integration.id.clone(),
|
||||
webhook_url: webhook_url.clone(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find matching integrations and produce dispatch tasks.
|
||||
pub async fn route_notification_for_org(
|
||||
store: &dyn IntegrationStore,
|
||||
event: &NotificationEvent,
|
||||
) -> Vec<DispatchTask> {
|
||||
match store
|
||||
.list_matching_integrations(&event.organisation, &event.notification_type)
|
||||
.await
|
||||
{
|
||||
Ok(integrations) => route_notification(event, &integrations),
|
||||
Err(e) => {
|
||||
tracing::error!(org = %event.organisation, error = %e, "failed to list matching integrations");
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload {
|
||||
WebhookPayload {
|
||||
event: event.notification_type.clone(),
|
||||
timestamp: event.timestamp.clone(),
|
||||
organisation: event.organisation.clone(),
|
||||
project: event.project.clone(),
|
||||
notification_id: event.id.clone(),
|
||||
title: event.title.clone(),
|
||||
body: event.body.clone(),
|
||||
release: event.release.as_ref().map(|r| ReleasePayload {
|
||||
slug: r.slug.clone(),
|
||||
artifact_id: r.artifact_id.clone(),
|
||||
destination: r.destination.clone(),
|
||||
environment: r.environment.clone(),
|
||||
source_username: r.source_username.clone(),
|
||||
commit_sha: r.commit_sha.clone(),
|
||||
commit_branch: r.commit_branch.clone(),
|
||||
error_message: r.error_message.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_slack_message(event: &NotificationEvent) -> SlackMessage {
|
||||
let color = match event.notification_type.as_str() {
|
||||
"release_succeeded" => "#36a64f",
|
||||
"release_failed" => "#dc3545",
|
||||
"release_started" => "#0d6efd",
|
||||
"release_annotated" => "#6c757d",
|
||||
_ => "#6c757d",
|
||||
};
|
||||
|
||||
let status_emoji = match event.notification_type.as_str() {
|
||||
"release_succeeded" => ":white_check_mark:",
|
||||
"release_failed" => ":x:",
|
||||
"release_started" => ":rocket:",
|
||||
"release_annotated" => ":memo:",
|
||||
_ => ":bell:",
|
||||
};
|
||||
|
||||
// Fallback text (shown in notifications/previews)
|
||||
let text = format!("{} {}", status_emoji, event.title);
|
||||
|
||||
// Build Block Kit blocks
|
||||
let mut blocks: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Header
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": event.title,
|
||||
"emoji": true
|
||||
}
|
||||
}));
|
||||
|
||||
// Body section (if present)
|
||||
if !event.body.is_empty() {
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": event.body
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Release metadata fields
|
||||
if let Some(ref r) = event.release {
|
||||
let mut fields = vec![
|
||||
serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Organisation*\n{}", event.organisation)
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Project*\n{}", event.project)
|
||||
}),
|
||||
];
|
||||
|
||||
if !r.destination.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Destination*\n`{}`", r.destination)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.environment.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Environment*\n{}", r.environment)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.commit_sha.is_empty() {
|
||||
let short_sha = &r.commit_sha[..r.commit_sha.len().min(7)];
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Commit*\n`{}`", short_sha)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.commit_branch.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Branch*\n`{}`", r.commit_branch)
|
||||
}));
|
||||
}
|
||||
|
||||
if !r.source_username.is_empty() {
|
||||
fields.push(serde_json::json!({
|
||||
"type": "mrkdwn",
|
||||
"text": format!("*Author*\n{}", r.source_username)
|
||||
}));
|
||||
}
|
||||
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"fields": fields
|
||||
}));
|
||||
|
||||
// Error message (if any)
|
||||
if let Some(ref err) = r.error_message {
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": format!(":warning: *Error:* {}", err)
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Context line with timestamp
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "context",
|
||||
"elements": [{
|
||||
"type": "mrkdwn",
|
||||
"text": format!("{} | {}", event.notification_type.replace('_', " "), event.timestamp)
|
||||
}]
|
||||
}));
|
||||
|
||||
SlackMessage {
|
||||
text,
|
||||
color: color.to_string(),
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn test_event() -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
id: "notif-1".into(),
|
||||
notification_type: "release_failed".into(),
|
||||
title: "Release failed".into(),
|
||||
body: "Container timeout".into(),
|
||||
organisation: "test-org".into(),
|
||||
project: "my-project".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
release: Some(ReleaseContext {
|
||||
slug: "test-release".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
commit_sha: "abc1234def".into(),
|
||||
commit_branch: "main".into(),
|
||||
error_message: Some("health check timeout".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn webhook_integration(id: &str) -> Integration {
|
||||
Integration {
|
||||
id: id.into(),
|
||||
organisation: "test-org".into(),
|
||||
integration_type: super::super::IntegrationType::Webhook,
|
||||
name: "prod-alerts".into(),
|
||||
config: IntegrationConfig::Webhook {
|
||||
url: "https://hooks.example.com/test".into(),
|
||||
secret: Some("s3cret".into()),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
enabled: true,
|
||||
created_by: "user-1".into(),
|
||||
created_at: "2026-03-09T00:00:00Z".into(),
|
||||
updated_at: "2026-03-09T00:00:00Z".into(),
|
||||
api_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slack_integration(id: &str) -> Integration {
|
||||
Integration {
|
||||
id: id.into(),
|
||||
organisation: "test-org".into(),
|
||||
integration_type: super::super::IntegrationType::Slack,
|
||||
name: "#deploys".into(),
|
||||
config: IntegrationConfig::Slack {
|
||||
team_id: "T123".into(),
|
||||
team_name: "Test".into(),
|
||||
channel_id: "C456".into(),
|
||||
channel_name: "#deploys".into(),
|
||||
access_token: "xoxb-test".into(),
|
||||
webhook_url: "https://hooks.slack.com/test".into(),
|
||||
},
|
||||
enabled: true,
|
||||
created_by: "user-1".into(),
|
||||
created_at: "2026-03-09T00:00:00Z".into(),
|
||||
updated_at: "2026-03-09T00:00:00Z".into(),
|
||||
api_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_to_webhook() {
|
||||
let event = test_event();
|
||||
let integrations = vec![webhook_integration("w1")];
|
||||
let tasks = route_notification(&event, &integrations);
|
||||
|
||||
assert_eq!(tasks.len(), 1);
|
||||
match &tasks[0] {
|
||||
DispatchTask::Webhook {
|
||||
integration_id,
|
||||
url,
|
||||
secret,
|
||||
payload,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(integration_id, "w1");
|
||||
assert_eq!(url, "https://hooks.example.com/test");
|
||||
assert_eq!(secret.as_deref(), Some("s3cret"));
|
||||
assert_eq!(payload.event, "release_failed");
|
||||
assert_eq!(payload.organisation, "test-org");
|
||||
}
|
||||
_ => panic!("expected Webhook task"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_to_slack() {
|
||||
let event = test_event();
|
||||
let integrations = vec![slack_integration("s1")];
|
||||
let tasks = route_notification(&event, &integrations);
|
||||
|
||||
assert_eq!(tasks.len(), 1);
|
||||
match &tasks[0] {
|
||||
DispatchTask::Slack {
|
||||
integration_id,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(integration_id, "s1");
|
||||
assert!(message.text.contains("Release failed"));
|
||||
assert_eq!(message.color, "#dc3545"); // red for failure
|
||||
}
|
||||
_ => panic!("expected Slack task"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_to_multiple_integrations() {
|
||||
let event = test_event();
|
||||
let integrations = vec![webhook_integration("w1"), slack_integration("s1")];
|
||||
let tasks = route_notification(&event, &integrations);
|
||||
assert_eq!(tasks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_to_empty_integrations() {
|
||||
let event = test_event();
|
||||
let tasks = route_notification(&event, &[]);
|
||||
assert!(tasks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_color_success() {
|
||||
let mut event = test_event();
|
||||
event.notification_type = "release_succeeded".into();
|
||||
let msg = format_slack_message(&event);
|
||||
assert_eq!(msg.color, "#36a64f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_message_includes_error() {
|
||||
let event = test_event();
|
||||
let msg = format_slack_message(&event);
|
||||
// Error message is rendered in blocks, not the fallback text field
|
||||
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
|
||||
assert!(blocks_str.contains("health check timeout"));
|
||||
}
|
||||
}
|
||||
116
crates/forage-core/src/integrations/webhook.rs
Normal file
116
crates/forage-core/src/integrations/webhook.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
/// The JSON payload delivered to webhook integrations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookPayload {
|
||||
pub event: String,
|
||||
pub timestamp: String,
|
||||
pub organisation: String,
|
||||
pub project: String,
|
||||
pub notification_id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub release: Option<ReleasePayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReleasePayload {
|
||||
pub slug: String,
|
||||
pub artifact_id: String,
|
||||
pub destination: String,
|
||||
pub environment: String,
|
||||
pub source_username: String,
|
||||
pub commit_sha: String,
|
||||
pub commit_branch: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Compute HMAC-SHA256 signature for a webhook payload.
|
||||
/// Returns hex-encoded signature prefixed with "sha256=".
|
||||
pub fn sign_payload(body: &[u8], secret: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(body);
|
||||
let result = mac.finalize().into_bytes();
|
||||
format!("sha256={}", hex_encode(&result))
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_payload_produces_hex_signature() {
|
||||
let sig = sign_payload(b"hello world", "my-secret");
|
||||
assert!(sig.starts_with("sha256="));
|
||||
assert_eq!(sig.len(), 7 + 64); // "sha256=" + 64 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_payload_deterministic() {
|
||||
let a = sign_payload(b"test body", "key");
|
||||
let b = sign_payload(b"test body", "key");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_payload_different_keys_differ() {
|
||||
let a = sign_payload(b"body", "key1");
|
||||
let b = sign_payload(b"body", "key2");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_payload_serializes() {
|
||||
let payload = WebhookPayload {
|
||||
event: "release_failed".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
organisation: "test-org".into(),
|
||||
project: "my-project".into(),
|
||||
notification_id: "notif-123".into(),
|
||||
title: "Release failed".into(),
|
||||
body: "Container health check timeout".into(),
|
||||
release: Some(ReleasePayload {
|
||||
slug: "test-release".into(),
|
||||
artifact_id: "art_123".into(),
|
||||
destination: "prod-eu".into(),
|
||||
environment: "production".into(),
|
||||
source_username: "alice".into(),
|
||||
commit_sha: "abc1234".into(),
|
||||
commit_branch: "main".into(),
|
||||
error_message: Some("timeout".into()),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains("release_failed"));
|
||||
assert!(json.contains("prod-eu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_payload_without_release() {
|
||||
let payload = WebhookPayload {
|
||||
event: "release_annotated".into(),
|
||||
timestamp: "2026-03-09T14:30:00Z".into(),
|
||||
organisation: "test-org".into(),
|
||||
project: "my-project".into(),
|
||||
notification_id: "notif-456".into(),
|
||||
title: "Annotated".into(),
|
||||
body: "A note".into(),
|
||||
release: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed["release"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod session;
|
||||
pub mod platform;
|
||||
pub mod integrations;
|
||||
pub mod registry;
|
||||
pub mod deployments;
|
||||
pub mod billing;
|
||||
|
||||
@@ -319,6 +319,14 @@ pub enum PlatformError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// A user's notification preference for a specific event type + channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationPreference {
|
||||
pub notification_type: String,
|
||||
pub channel: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Trait for platform data from forest-server (organisations, projects, artifacts).
|
||||
/// Separate from `ForestAuth` which handles identity.
|
||||
#[async_trait::async_trait]
|
||||
@@ -546,6 +554,19 @@ pub trait ForestPlatform: Send + Sync {
|
||||
access_token: &str,
|
||||
artifact_id: &str,
|
||||
) -> Result<String, PlatformError>;
|
||||
|
||||
async fn get_notification_preferences(
|
||||
&self,
|
||||
access_token: &str,
|
||||
) -> Result<Vec<NotificationPreference>, PlatformError>;
|
||||
|
||||
async fn set_notification_preference(
|
||||
&self,
|
||||
access_token: &str,
|
||||
notification_type: &str,
|
||||
channel: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), PlatformError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user