use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode};
use pulpo_common::api::{
DiscordWebhookConfigResponse, ErrorResponse, NotificationsConfigResponse,
UpdateNotificationsRequest, WebhookEndpointConfigResponse,
};
type ApiError = (StatusCode, Json<ErrorResponse>);
fn internal_error(msg: &str) -> ApiError {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_owned(),
}),
)
}
fn to_response(config: &crate::config::NotificationsConfig) -> NotificationsConfigResponse {
NotificationsConfigResponse {
discord: config
.discord
.as_ref()
.map(|d| DiscordWebhookConfigResponse {
webhook_url: d.webhook_url.clone(),
events: d.events.clone(),
}),
webhooks: config
.webhooks
.iter()
.map(|w| WebhookEndpointConfigResponse {
name: w.name.clone(),
url: w.url.clone(),
events: w.events.clone(),
has_secret: w.secret.is_some(),
})
.collect(),
}
}
pub async fn get_notifications(
State(state): State<Arc<super::AppState>>,
) -> Result<Json<NotificationsConfigResponse>, ApiError> {
let config = state.config.read().await;
let resp = to_response(&config.notifications);
drop(config);
Ok(Json(resp))
}
pub async fn update_notifications(
State(state): State<Arc<super::AppState>>,
Json(req): Json<UpdateNotificationsRequest>,
) -> Result<Json<NotificationsConfigResponse>, ApiError> {
let mut config = state.config.write().await;
if let Some(discord) = req.discord {
if discord.webhook_url.is_empty() {
config.notifications.discord = None;
} else {
config.notifications.discord = Some(crate::config::DiscordWebhookConfig {
webhook_url: discord.webhook_url,
events: discord.events,
});
}
}
if let Some(webhooks) = req.webhooks {
config.notifications.webhooks = webhooks
.into_iter()
.map(|w| crate::config::WebhookEndpointConfig {
name: w.name,
url: w.url,
events: w.events,
secret: w.secret,
})
.collect();
}
if !state.config_path.as_os_str().is_empty() {
crate::config::save(&config, &state.config_path)
.map_err(|e| internal_error(&e.to_string()))?;
}
let resp = to_response(&config.notifications);
drop(config);
Ok(Json(resp))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::AppState;
use crate::backend::StubBackend;
use crate::config::{Config, NodeConfig};
use crate::peers::PeerRegistry;
use crate::session::manager::SessionManager;
use crate::store::Store;
use axum::extract::State;
use pulpo_common::api::{DiscordWebhookUpdateRequest, WebhookEndpointUpdateRequest};
use std::collections::HashMap;
async fn test_state() -> Arc<AppState> {
let tmpdir = tempfile::tempdir().unwrap();
let tmpdir = Box::leak(Box::new(tmpdir));
let store = Store::new(tmpdir.path().to_str().unwrap()).await.unwrap();
store.migrate().await.unwrap();
let backend = Arc::new(StubBackend);
let manager =
SessionManager::new(backend, store.clone(), HashMap::new(), None).with_no_stale_grace();
let peer_registry = PeerRegistry::new(&HashMap::new());
AppState::new(
Config {
node: NodeConfig {
name: "test-node".into(),
port: 7433,
data_dir: tmpdir.path().to_str().unwrap().into(),
..NodeConfig::default()
},
auth: crate::config::AuthConfig::default(),
peers: HashMap::new(),
watchdog: crate::config::WatchdogConfig::default(),
inks: HashMap::new(),
notifications: crate::config::NotificationsConfig::default(),
docker: crate::config::DockerConfig::default(),
controller: crate::config::ControllerConfig::default(),
},
manager,
peer_registry,
store,
)
}
async fn test_state_with_config_path() -> Arc<AppState> {
let tmpdir = tempfile::tempdir().unwrap();
let tmpdir = Box::leak(Box::new(tmpdir));
let store = Store::new(tmpdir.path().to_str().unwrap()).await.unwrap();
store.migrate().await.unwrap();
let backend = Arc::new(StubBackend);
let manager =
SessionManager::new(backend, store.clone(), HashMap::new(), None).with_no_stale_grace();
let peer_registry = PeerRegistry::new(&HashMap::new());
let config_path = tmpdir.path().join("config.toml");
let (event_tx, _) = tokio::sync::broadcast::channel(16);
AppState::with_event_tx(
Config {
node: NodeConfig {
name: "test-node".into(),
port: 7433,
data_dir: tmpdir.path().to_str().unwrap().into(),
..NodeConfig::default()
},
auth: crate::config::AuthConfig::default(),
peers: HashMap::new(),
watchdog: crate::config::WatchdogConfig::default(),
inks: HashMap::new(),
notifications: crate::config::NotificationsConfig::default(),
docker: crate::config::DockerConfig::default(),
controller: crate::config::ControllerConfig::default(),
},
config_path,
manager,
peer_registry,
event_tx,
store,
)
}
#[tokio::test]
async fn test_get_notifications_empty() {
let state = test_state().await;
let Json(resp) = get_notifications(State(state)).await.unwrap();
assert!(resp.discord.is_none());
assert!(resp.webhooks.is_empty());
}
#[tokio::test]
async fn test_update_notifications_set_discord() {
let state = test_state().await;
let req = UpdateNotificationsRequest {
discord: Some(DiscordWebhookUpdateRequest {
webhook_url: "https://discord.com/api/webhooks/test".into(),
events: vec!["active".into(), "killed".into()],
}),
webhooks: None,
};
let Json(resp) = update_notifications(State(state.clone()), Json(req))
.await
.unwrap();
let discord = resp.discord.as_ref().unwrap();
assert_eq!(discord.webhook_url, "https://discord.com/api/webhooks/test");
assert_eq!(discord.events, vec!["active", "killed"]);
let Json(current) = get_notifications(State(state)).await.unwrap();
assert!(current.discord.is_some());
}
#[tokio::test]
async fn test_update_notifications_clear_discord() {
let state = test_state().await;
let req = UpdateNotificationsRequest {
discord: Some(DiscordWebhookUpdateRequest {
webhook_url: "https://discord.com/api/webhooks/test".into(),
events: vec![],
}),
webhooks: None,
};
let _ = update_notifications(State(state.clone()), Json(req))
.await
.unwrap();
let req = UpdateNotificationsRequest {
discord: Some(DiscordWebhookUpdateRequest {
webhook_url: String::new(),
events: vec![],
}),
webhooks: None,
};
let Json(resp) = update_notifications(State(state), Json(req)).await.unwrap();
assert!(resp.discord.is_none());
}
#[tokio::test]
async fn test_update_notifications_set_webhooks() {
let state = test_state().await;
let req = UpdateNotificationsRequest {
discord: None,
webhooks: Some(vec![
WebhookEndpointUpdateRequest {
name: "ci-hook".into(),
url: "https://example.com/hook".into(),
events: vec!["ready".into()],
secret: Some("s3cret".into()),
},
WebhookEndpointUpdateRequest {
name: "logs-hook".into(),
url: "https://logs.example.com".into(),
events: vec![],
secret: None,
},
]),
};
let Json(resp) = update_notifications(State(state), Json(req)).await.unwrap();
assert_eq!(resp.webhooks.len(), 2);
assert_eq!(resp.webhooks[0].name, "ci-hook");
assert!(resp.webhooks[0].has_secret);
assert_eq!(resp.webhooks[1].name, "logs-hook");
assert!(!resp.webhooks[1].has_secret);
}
#[tokio::test]
async fn test_update_notifications_webhooks_replaces() {
let state = test_state().await;
let req = UpdateNotificationsRequest {
discord: None,
webhooks: Some(vec![WebhookEndpointUpdateRequest {
name: "old".into(),
url: "https://old.com".into(),
events: vec![],
secret: None,
}]),
};
let _ = update_notifications(State(state.clone()), Json(req))
.await
.unwrap();
let req = UpdateNotificationsRequest {
discord: None,
webhooks: Some(vec![WebhookEndpointUpdateRequest {
name: "new".into(),
url: "https://new.com".into(),
events: vec!["killed".into()],
secret: None,
}]),
};
let Json(resp) = update_notifications(State(state), Json(req)).await.unwrap();
assert_eq!(resp.webhooks.len(), 1);
assert_eq!(resp.webhooks[0].name, "new");
}
#[tokio::test]
async fn test_update_notifications_empty_webhooks_clears() {
let state = test_state().await;
let req = UpdateNotificationsRequest {
discord: None,
webhooks: Some(vec![WebhookEndpointUpdateRequest {
name: "hook".into(),
url: "https://a.com".into(),
events: vec![],
secret: None,
}]),
};
let _ = update_notifications(State(state.clone()), Json(req))
.await
.unwrap();
let req = UpdateNotificationsRequest {
discord: None,
webhooks: Some(vec![]),
};
let Json(resp) = update_notifications(State(state), Json(req)).await.unwrap();
assert!(resp.webhooks.is_empty());
}
#[tokio::test]
async fn test_update_notifications_empty_request() {
let state = test_state().await;
let req = UpdateNotificationsRequest::default();
let Json(resp) = update_notifications(State(state), Json(req)).await.unwrap();
assert!(resp.discord.is_none());
assert!(resp.webhooks.is_empty());
}
#[tokio::test]
async fn test_update_notifications_saves_to_disk() {
let state = test_state_with_config_path().await;
let req = UpdateNotificationsRequest {
discord: Some(DiscordWebhookUpdateRequest {
webhook_url: "https://discord.com/api/webhooks/save-test".into(),
events: vec!["active".into()],
}),
webhooks: None,
};
let _ = update_notifications(State(state.clone()), Json(req))
.await
.unwrap();
let loaded = crate::config::load(state.config_path.to_str().unwrap()).unwrap();
let discord = loaded.notifications.discord.unwrap();
assert_eq!(
discord.webhook_url,
"https://discord.com/api/webhooks/save-test"
);
}
#[test]
fn test_internal_error() {
let (status, Json(err)) = internal_error("boom");
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(err.error, "boom");
}
#[test]
fn test_to_response_with_all() {
let config = crate::config::NotificationsConfig {
discord: Some(crate::config::DiscordWebhookConfig {
webhook_url: "https://test.com".into(),
events: vec!["killed".into()],
}),
webhooks: vec![crate::config::WebhookEndpointConfig {
name: "hook".into(),
url: "https://hook.com".into(),
events: vec![],
secret: Some("key".into()),
}],
..Default::default()
};
let resp = to_response(&config);
let d = resp.discord.unwrap();
assert_eq!(d.webhook_url, "https://test.com");
assert_eq!(d.events, vec!["killed"]);
assert_eq!(resp.webhooks.len(), 1);
assert!(resp.webhooks[0].has_secret);
}
}