feat: add integrations

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-09 22:34:04 +01:00
parent 646581ff44
commit a6401e3b79
26 changed files with 3207 additions and 244 deletions

View File

@@ -69,19 +69,28 @@ impl FromRequestParts<AppState> for Session {
.get_user(&session_data.access_token)
.await
{
let orgs = state
// Preserve existing orgs on failure — a transient gRPC error
// should not wipe the cached org list.
let previous_orgs = session_data
.user
.as_ref()
.map(|u| u.orgs.clone())
.unwrap_or_default();
let orgs = match state
.platform_client
.list_my_organisations(&session_data.access_token)
.await
.ok()
.unwrap_or_default()
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect();
{
Ok(fresh) => fresh
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect(),
Err(_) => previous_orgs,
};
session_data.user = Some(CachedUser {
user_id: user.user_id.clone(),
username: user.username.clone(),
@@ -99,11 +108,46 @@ impl FromRequestParts<AppState> for Session {
}
}
} else {
// Throttle last_seen_at writes: only update if older than 5 minutes
let now = chrono::Utc::now();
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
session_data.last_seen_at = now;
let _ = state.sessions.update(&session_id, session_data.clone()).await;
// Backfill: if we have a user but empty orgs, try to fetch them.
// This handles the case where list_my_organisations failed during login.
let needs_org_backfill = session_data
.user
.as_ref()
.is_some_and(|u| u.orgs.is_empty());
if needs_org_backfill {
if let Ok(orgs) = state
.platform_client
.list_my_organisations(&session_data.access_token)
.await
{
if !orgs.is_empty() {
if let Some(ref mut user) = session_data.user {
tracing::info!(
user_id = %user.user_id,
org_count = orgs.len(),
"backfilled empty org list"
);
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect();
}
session_data.last_seen_at = chrono::Utc::now();
let _ = state.sessions.update(&session_id, session_data.clone()).await;
}
}
} else {
// Throttle last_seen_at writes: only update if older than 5 minutes
let now = chrono::Utc::now();
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
session_data.last_seen_at = now;
let _ = state.sessions.update(&session_id, session_data.clone()).await;
}
}
}

View File

@@ -98,6 +98,45 @@ impl GrpcForestClient {
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
bearer_request(access_token, msg).map_err(AuthError::Other)
}
/// Fetch release intent states using a service token (for background workers).
pub async fn get_release_intent_states_with_token(
&self,
service_token: &str,
organisation: &str,
project: Option<&str>,
include_completed: bool,
) -> Result<Vec<forage_core::platform::ReleaseIntentState>, String> {
let req = bearer_request(
service_token,
forage_grpc::GetReleaseIntentStatesRequest {
organisation: organisation.into(),
project: project.map(|p| p.into()),
include_completed,
},
)
.map_err(|e| format!("invalid token: {e}"))?;
let resp = self
.release_client()
.get_release_intent_states(req)
.await
.map_err(|e| format!("gRPC: {e}"))?;
Ok(resp
.into_inner()
.release_intents
.into_iter()
.map(|ri| forage_core::platform::ReleaseIntentState {
release_intent_id: ri.release_intent_id,
artifact_id: ri.artifact_id,
project: ri.project,
created_at: ri.created_at,
stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(),
steps: ri.steps.into_iter().map(convert_release_step_state).collect(),
})
.collect())
}
}
fn map_status(status: tonic::Status) -> AuthError {

View File

@@ -167,13 +167,13 @@ async fn main() -> anyhow::Result<()> {
std::env::var("SLACK_CLIENT_ID"),
std::env::var("SLACK_CLIENT_SECRET"),
) {
let base_url = std::env::var("FORAGE_BASE_URL")
let redirect_host = std::env::var("SLACK_REDIRECT_HOST")
.unwrap_or_else(|_| format!("http://localhost:{port}"));
tracing::info!("Slack OAuth enabled");
state = state.with_slack_config(crate::state::SlackConfig {
client_id,
client_secret,
base_url,
redirect_host,
});
}
@@ -197,9 +197,15 @@ async fn main() -> anyhow::Result<()> {
state = state.with_integration_store(store.clone());
if let Ok(service_token) = std::env::var("FORAGE_SERVICE_TOKEN") {
let forage_url = std::env::var("FORAGE_URL")
.or_else(|_| std::env::var("SLACK_REDIRECT_HOST"))
.unwrap_or_else(|_| format!("http://localhost:{port}"));
if let Some(ref js) = nats_jetstream {
// JetStream mode: ingester publishes, consumer dispatches
tracing::info!("starting notification pipeline (JetStream)");
let grpc_for_consumer = forest_client.clone();
let token_for_consumer = service_token.clone();
mad.add(notification_ingester::NotificationIngester {
grpc: forest_client,
jetstream: js.clone(),
@@ -208,6 +214,9 @@ async fn main() -> anyhow::Result<()> {
mad.add(notification_consumer::NotificationConsumer {
jetstream: js.clone(),
store: store.clone(),
forage_url,
grpc: grpc_for_consumer,
service_token: token_for_consumer,
});
} else {
// Fallback: direct dispatch (no durability)
@@ -216,6 +225,7 @@ async fn main() -> anyhow::Result<()> {
grpc: forest_client,
store: store.clone(),
service_token,
forage_url,
});
}
} else {

View File

@@ -10,6 +10,7 @@ use forage_core::integrations::IntegrationStore;
use notmad::{Component, ComponentInfo, MadError};
use tokio_util::sync::CancellationToken;
use crate::forest_client::GrpcForestClient;
use crate::notification_worker::NotificationDispatcher;
/// Background component that pulls notification events from NATS JetStream
@@ -17,6 +18,9 @@ use crate::notification_worker::NotificationDispatcher;
pub struct NotificationConsumer {
pub jetstream: jetstream::Context,
pub store: Arc<dyn IntegrationStore>,
pub forage_url: String,
pub grpc: Arc<GrpcForestClient>,
pub service_token: String,
}
impl Component for NotificationConsumer {
@@ -25,7 +29,10 @@ impl Component for NotificationConsumer {
}
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone()));
let dispatcher = Arc::new(
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
.with_grpc(self.grpc.clone(), self.service_token.clone()),
);
let mut backoff = 1u64;

View File

@@ -15,15 +15,26 @@ use crate::forest_client::GrpcForestClient;
pub struct NotificationDispatcher {
http: reqwest::Client,
store: Arc<dyn IntegrationStore>,
forage_url: String,
/// gRPC client for querying pipeline state (optional — absent in tests).
grpc: Option<Arc<GrpcForestClient>>,
/// Service token for authenticating gRPC calls to fetch pipeline state.
service_token: String,
}
impl NotificationDispatcher {
pub fn new(store: Arc<dyn IntegrationStore>) -> Self {
pub fn new(store: Arc<dyn IntegrationStore>, forage_url: String) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("failed to build reqwest client");
Self { http, store }
Self { http, store, forage_url, grpc: None, service_token: String::new() }
}
pub fn with_grpc(mut self, grpc: Arc<GrpcForestClient>, service_token: String) -> Self {
self.grpc = Some(grpc);
self.service_token = service_token;
self
}
/// Execute a dispatch task with retry (3 attempts, exponential backoff).
@@ -35,8 +46,15 @@ impl NotificationDispatcher {
..
} => (integration_id.clone(), payload.notification_id.clone()),
DispatchTask::Slack {
integration_id, ..
} => (integration_id.clone(), String::new()),
integration_id,
notification_id,
..
} => (integration_id.clone(), notification_id.clone()),
DispatchTask::SlackDm {
integration_id,
notification_id,
..
} => (integration_id.clone(), notification_id.clone()),
};
let delays = [1, 5, 25]; // seconds
@@ -55,6 +73,26 @@ impl NotificationDispatcher {
return;
}
Err(e) => {
// Don't retry errors that will never succeed
let non_retryable = is_non_retryable_error(&e);
if non_retryable {
tracing::error!(
integration_id = %integration_id,
error = %e,
"non-retryable delivery error"
);
let _ = self
.store
.record_delivery(
&integration_id,
&notification_id,
DeliveryStatus::Failed,
Some(&e),
)
.await;
return;
}
tracing::warn!(
integration_id = %integration_id,
attempt = attempt + 1,
@@ -125,38 +163,371 @@ impl NotificationDispatcher {
}
}
DispatchTask::Slack {
integration_id,
webhook_url,
access_token,
channel_id,
release_id,
event_type,
event,
message,
..
} => {
// Use Block Kit attachments for rich formatting
let payload = serde_json::json!({
"text": message.text,
"attachments": [{
"color": message.color,
"blocks": message.blocks,
}]
});
let resp = self
.http
.post(webhook_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
// If we have a bot token, use chat.postMessage/chat.update for update-in-place
if !access_token.is_empty() && !channel_id.is_empty() && !release_id.is_empty() {
self.dispatch_slack_bot(
integration_id,
access_token,
channel_id,
release_id,
event_type,
event,
)
.await
.map_err(|e| format!("slack http: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("Slack HTTP {status}: {body}"))
// Fallback: webhook URL (no update-in-place possible)
self.dispatch_slack_webhook(webhook_url, message).await
}
}
DispatchTask::SlackDm {
integration_id,
access_token,
slack_user_id,
release_id,
event_type,
event,
message: _,
..
} => {
// DM uses the same bot token post/update pattern, but channel = user ID.
// Prefix release_id so the message ref is distinct from channel messages.
let dm_release_id = format!("dm:{slack_user_id}:{release_id}");
self.dispatch_slack_bot(
integration_id,
access_token,
slack_user_id, // Slack accepts user ID as channel for DMs
&dm_release_id,
event_type,
event,
)
.await
}
}
}
/// Post or update a Slack message via the bot token API.
/// Merges per-destination status into the message ref and rebuilds the message.
async fn dispatch_slack_bot(
&self,
integration_id: &str,
access_token: &str,
channel: &str,
release_id: &str,
event_type: &str,
event: &forage_core::integrations::router::NotificationEvent,
) -> Result<(), String> {
use forage_core::integrations::{DestinationStatus, SlackMessageRef};
use forage_core::integrations::router::format_slack_message;
// Get existing ref (with accumulated destinations) if we already posted
let existing_ref = self
.store
.get_slack_message_ref(integration_id, release_id)
.await
.unwrap_or(None);
// Merge this notification's destination into the accumulated map
let mut destinations = existing_ref
.as_ref()
.map(|r| r.destinations.clone())
.unwrap_or_default();
if let Some(ref r) = event.release {
if !r.destination.is_empty() {
let status = match event_type {
"release_started" => "started",
"release_succeeded" => "succeeded",
"release_failed" => "failed",
_ => "started",
};
destinations.insert(
r.destination.clone(),
DestinationStatus {
environment: r.environment.clone(),
status: status.to_string(),
error: r.error_message.clone(),
},
);
}
}
// Build the message with the full accumulated state
let mut message = format_slack_message(event, &destinations, &self.forage_url);
// Query pipeline stages and insert before destinations
if let Some(ref r) = event.release {
if !r.release_intent_id.is_empty() {
if let Some(stages) = self
.fetch_pipeline_stages(&event.organisation, &event.project, &r.release_intent_id)
.await
{
let pipeline_blocks =
forage_core::integrations::router::format_pipeline_blocks(&stages);
if !pipeline_blocks.is_empty() {
// Insert pipeline before the destination section.
// Find the last "context" block (metadata); pipeline goes right after it,
// pushing destinations and errors down.
let insert_at = message
.blocks
.iter()
.rposition(|b| b["type"] == "context")
.map(|i| i + 1)
.unwrap_or(message.blocks.len());
for (i, block) in pipeline_blocks.into_iter().enumerate() {
message.blocks.insert(insert_at + i, block);
}
}
}
}
}
let release_title = event
.release
.as_ref()
.filter(|r| !r.context_title.is_empty())
.map(|r| r.context_title.clone())
.or_else(|| existing_ref.as_ref().map(|r| r.release_title.clone()))
.unwrap_or_else(|| event.title.clone());
let blocks_payload = serde_json::json!([{
"color": message.color,
"blocks": message.blocks,
}]);
// The `text` field is a fallback for notifications/accessibility only.
// Slack renders it above attachments in some clients, causing duplication.
// Use a minimal fallback; the attachment blocks carry the rich content.
let fallback_text = format!("Release update: {}/{}", event.organisation, event.project);
if let Some(ref msg_ref) = existing_ref {
// Update existing message
let payload = serde_json::json!({
"channel": msg_ref.channel_id,
"ts": msg_ref.message_ts,
"text": fallback_text,
"attachments": blocks_payload,
});
let resp = self
.http
.post("https://slack.com/api/chat.update")
.bearer_auth(access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("slack chat.update http: {e}"))?;
let body: serde_json::Value =
resp.json().await.map_err(|e| format!("slack chat.update parse: {e}"))?;
if body["ok"].as_bool() != Some(true) {
let err = body["error"].as_str().unwrap_or("unknown");
if err == "message_not_found" {
tracing::warn!(
integration_id = %integration_id,
release_id = %release_id,
"slack message not found, posting new one"
);
// Fall through to post a new one
} else {
return Err(format!("slack chat.update: {err}"));
}
} else {
// Update the ref with merged destinations
let updated = SlackMessageRef {
id: msg_ref.id.clone(),
integration_id: integration_id.to_string(),
release_id: release_id.to_string(),
channel_id: msg_ref.channel_id.clone(),
message_ts: msg_ref.message_ts.clone(),
last_event_type: event_type.to_string(),
destinations,
release_title,
created_at: msg_ref.created_at.clone(),
updated_at: chrono::Utc::now().to_rfc3339(),
};
let _ = self.store.upsert_slack_message_ref(&updated).await;
return Ok(());
}
}
// Try to join the channel first
let _ = self.slack_join_channel(access_token, channel).await;
// Post new message
let payload = serde_json::json!({
"channel": channel,
"text": fallback_text,
"attachments": blocks_payload,
});
let resp = self
.http
.post("https://slack.com/api/chat.postMessage")
.bearer_auth(access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("slack chat.postMessage http: {e}"))?;
let body: serde_json::Value =
resp.json().await.map_err(|e| format!("slack chat.postMessage parse: {e}"))?;
if body["ok"].as_bool() != Some(true) {
let err = body["error"].as_str().unwrap_or("unknown");
return Err(format!("slack chat.postMessage: {err}"));
}
// Store the message ref with initial destinations
let ts = body["ts"].as_str().unwrap_or_default();
let posted_channel = body["channel"].as_str().unwrap_or(channel);
if !ts.is_empty() && !release_id.is_empty() {
let msg_ref = SlackMessageRef {
id: uuid::Uuid::new_v4().to_string(),
integration_id: integration_id.to_string(),
release_id: release_id.to_string(),
channel_id: posted_channel.to_string(),
message_ts: ts.to_string(),
last_event_type: event_type.to_string(),
destinations,
release_title,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
};
let _ = self.store.upsert_slack_message_ref(&msg_ref).await;
}
Ok(())
}
/// Try to join a Slack channel. Silently succeeds if already a member or channel is private.
async fn slack_join_channel(&self, access_token: &str, channel: &str) -> Result<(), String> {
let payload = serde_json::json!({ "channel": channel });
let resp = self
.http
.post("https://slack.com/api/conversations.join")
.bearer_auth(access_token)
.json(&payload)
.send()
.await
.map_err(|e| format!("slack conversations.join http: {e}"))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("slack conversations.join parse: {e}"))?;
if body["ok"].as_bool() == Some(true) {
tracing::info!(channel = %channel, "bot joined slack channel");
} else {
let err = body["error"].as_str().unwrap_or("unknown");
// channel_not_found, method_not_supported_for_channel_type (private), already_in_channel
// These are all acceptable — we tried our best
tracing::debug!(channel = %channel, error = %err, "conversations.join failed (may be private channel)");
}
Ok(())
}
/// Fetch pipeline stages for a release intent via gRPC.
/// Returns None if gRPC is not configured or the call fails.
async fn fetch_pipeline_stages(
&self,
organisation: &str,
project: &str,
release_intent_id: &str,
) -> Option<Vec<forage_core::platform::PipelineRunStageState>> {
let grpc = self.grpc.as_ref()?;
if self.service_token.is_empty() {
return None;
}
match grpc
.get_release_intent_states_with_token(
&self.service_token,
organisation,
Some(project),
true, // include_completed so we get the current intent
)
.await
{
Ok(intents) => {
// Find the matching release intent
intents
.into_iter()
.find(|i| i.release_intent_id == release_intent_id)
.map(|i| i.stages)
}
Err(e) => {
tracing::warn!(
release_intent_id = %release_intent_id,
error = %e,
"failed to fetch pipeline stages"
);
None
}
}
}
/// Fallback: post via incoming webhook URL (no update-in-place).
async fn dispatch_slack_webhook(
&self,
webhook_url: &str,
message: &forage_core::integrations::router::SlackMessage,
) -> Result<(), String> {
let payload = serde_json::json!({
"text": message.text,
"attachments": [{
"color": message.color,
"blocks": message.blocks,
}]
});
let resp = self
.http
.post(webhook_url)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| format!("slack http: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("Slack HTTP {status}: {body}"))
}
}
}
/// Slack API errors that will never succeed on retry.
fn is_non_retryable_error(err: &str) -> bool {
const NON_RETRYABLE: &[&str] = &[
"channel_not_found",
"not_in_channel",
"is_archived",
"invalid_auth",
"token_revoked",
"account_inactive",
"no_permission",
"missing_scope",
"not_authed",
"invalid_arguments",
];
NON_RETRYABLE.iter().any(|code| err.contains(code))
}
// ── Proto conversion ────────────────────────────────────────────────
@@ -174,11 +545,16 @@ pub fn proto_to_event(n: &forage_grpc::Notification) -> NotificationEvent {
let release = n.release_context.as_ref().map(|r| ReleaseContext {
slug: r.slug.clone(),
artifact_id: r.artifact_id.clone(),
release_intent_id: r.release_intent_id.clone(),
destination: r.destination.clone(),
environment: r.environment.clone(),
source_username: r.source_username.clone(),
source_user_id: r.source_user_id.clone(),
commit_sha: r.commit_sha.clone(),
commit_branch: r.commit_branch.clone(),
context_title: r.context_title.clone(),
context_web: r.context_web.clone(),
destination_count: r.destination_count,
error_message: if r.error_message.is_empty() {
None
} else {
@@ -207,6 +583,8 @@ pub struct NotificationListener {
pub store: Arc<dyn IntegrationStore>,
/// Service token (PAT) for authenticating with forest-server's NotificationService.
pub service_token: String,
/// Base URL of the Forage web UI for deep links (e.g. "https://forage.example.com").
pub forage_url: String,
}
impl Component for NotificationListener {
@@ -215,7 +593,10 @@ impl Component for NotificationListener {
}
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
let dispatcher = Arc::new(NotificationDispatcher::new(self.store.clone()));
let dispatcher = Arc::new(
NotificationDispatcher::new(self.store.clone(), self.forage_url.clone())
.with_grpc(self.grpc.clone(), self.service_token.clone()),
);
// For now, listen on the global stream (no org filter).
// Forest's ListenNotifications with no org filter returns all notifications

View File

@@ -1,9 +1,10 @@
use axum::extract::State;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Form, Router};
use chrono::Utc;
use forage_core::integrations::SlackUserLink;
use minijinja::context;
use serde::Deserialize;
@@ -35,6 +36,18 @@ pub fn router() -> Router<AppState> {
"/settings/account/notifications",
post(update_notification_preference),
)
.route(
"/settings/account/slack/connect",
get(slack_connect),
)
.route(
"/settings/account/slack/callback",
get(slack_user_callback),
)
.route(
"/settings/account/slack/disconnect",
post(slack_disconnect),
)
}
// ─── Signup ─────────────────────────────────────────────────────────
@@ -95,34 +108,42 @@ async fn signup_submit(
{
Ok(tokens) => {
// Fetch user info for the session cache
let mut user_cache = state
let user_cache = match state
.forest_client
.get_user(&tokens.access_token)
.await
.ok()
.map(|u| CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs: vec![],
});
// Cache org memberships in the session
if let Some(ref mut user) = user_cache
&& let Ok(orgs) = state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
Ok(u) => {
let orgs = match state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
Ok(orgs) => orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch orgs during signup");
vec![]
}
};
Some(CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs,
})
.collect();
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch user during signup");
None
}
};
let now = Utc::now();
let session_data = SessionData {
@@ -247,34 +268,42 @@ async fn login_submit(
.await
{
Ok(tokens) => {
let mut user_cache = state
let user_cache = match state
.forest_client
.get_user(&tokens.access_token)
.await
.ok()
.map(|u| CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs: vec![],
});
// Cache org memberships in the session
if let Some(ref mut user) = user_cache
&& let Ok(orgs) = state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
Ok(u) => {
let orgs = match state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
Ok(orgs) => orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
.collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to fetch orgs during login");
vec![]
}
};
Some(CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs,
})
.collect();
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch user during login");
None
}
};
let now = Utc::now();
let session_data = SessionData {
@@ -495,7 +524,17 @@ async fn account_page(
.get_notification_preferences(&session.access_token)
.await
.unwrap_or_default();
render_account(&state, &session, None, &prefs)
let slack_links = if let Some(store) = state.integration_store.as_ref() {
store
.list_slack_user_links(&session.user.user_id)
.await
.unwrap_or_default()
} else {
vec![]
};
render_account(&state, &session, None, &prefs, &slack_links)
}
#[allow(clippy::result_large_err)]
@@ -504,6 +543,7 @@ fn render_account(
session: &Session,
error: Option<&str>,
notification_prefs: &[forage_core::platform::NotificationPreference],
slack_links: &[SlackUserLink],
) -> Result<Response, Response> {
let html = state
.templates
@@ -529,6 +569,14 @@ fn render_account(
.filter(|p| p.enabled)
.map(|p| format!("{}|{}", p.notification_type, p.channel))
.collect::<Vec<_>>(),
has_slack_oauth => state.slack_config.is_some(),
slack_links => slack_links.iter().map(|l| context! {
id => &l.id,
team_id => &l.team_id,
team_name => &l.team_name,
slack_user_id => &l.slack_user_id,
slack_username => &l.slack_username,
}).collect::<Vec<_>>(),
},
)
.map_err(|e| {
@@ -559,7 +607,7 @@ async fn update_username_submit(
}
if let Err(e) = validate_username(&form.username) {
return render_account(&state, &session, Some(&e.0), &[]);
return render_account(&state, &session, Some(&e.0), &[], &[]);
}
match state
@@ -581,11 +629,11 @@ async fn update_username_submit(
Ok(Redirect::to("/settings/account").into_response())
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
render_account(&state, &session, Some("Username is already taken."), &[])
render_account(&state, &session, Some("Username is already taken."), &[], &[])
}
Err(e) => {
tracing::error!("failed to update username: {e}");
render_account(&state, &session, Some("Could not update username. Please try again."), &[])
render_account(&state, &session, Some("Could not update username. Please try again."), &[], &[])
}
}
}
@@ -613,11 +661,11 @@ async fn change_password_submit(
}
if form.new_password != form.new_password_confirm {
return render_account(&state, &session, Some("New passwords do not match."), &[]);
return render_account(&state, &session, Some("New passwords do not match."), &[], &[]);
}
if let Err(e) = validate_password(&form.new_password) {
return render_account(&state, &session, Some(&e.0), &[]);
return render_account(&state, &session, Some(&e.0), &[], &[]);
}
match state
@@ -632,11 +680,11 @@ async fn change_password_submit(
{
Ok(()) => Ok(Redirect::to("/settings/account").into_response()),
Err(forage_core::auth::AuthError::InvalidCredentials) => {
render_account(&state, &session, Some("Current password is incorrect."), &[])
render_account(&state, &session, Some("Current password is incorrect."), &[], &[])
}
Err(e) => {
tracing::error!("failed to change password: {e}");
render_account(&state, &session, Some("Could not change password. Please try again."), &[])
render_account(&state, &session, Some("Could not change password. Please try again."), &[], &[])
}
}
}
@@ -662,7 +710,7 @@ async fn add_email_submit(
}
if let Err(e) = validate_email(&form.email) {
return render_account(&state, &session, Some(&e.0), &[]);
return render_account(&state, &session, Some(&e.0), &[], &[]);
}
match state
@@ -687,11 +735,11 @@ async fn add_email_submit(
Ok(Redirect::to("/settings/account").into_response())
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
render_account(&state, &session, Some("Email is already registered."), &[])
render_account(&state, &session, Some("Email is already registered."), &[], &[])
}
Err(e) => {
tracing::error!("failed to add email: {e}");
render_account(&state, &session, Some("Could not add email. Please try again."), &[])
render_account(&state, &session, Some("Could not add email. Please try again."), &[], &[])
}
}
}
@@ -736,7 +784,7 @@ async fn remove_email_submit(
}
Err(e) => {
tracing::error!("failed to remove email: {e}");
render_account(&state, &session, Some("Could not remove email. Please try again."), &[])
render_account(&state, &session, Some("Could not remove email. Please try again."), &[], &[])
}
}
}
@@ -780,3 +828,225 @@ async fn update_notification_preference(
Ok(Redirect::to("/settings/account").into_response())
}
// ─── Slack user enrollment ────────────────────────────────────────────
async fn slack_connect(
State(state): State<AppState>,
session: Session,
) -> Result<impl IntoResponse, Response> {
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Slack not configured",
"Slack OAuth is not configured on this server.",
)
})?;
let redirect_uri = format!(
"{}/settings/account/slack/callback",
slack_config.redirect_host
);
let url = format!(
"https://slack.com/oauth/v2/authorize?client_id={}&user_scope=identity.basic&redirect_uri={}&state={}",
urlencoding::encode(&slack_config.client_id),
urlencoding::encode(&redirect_uri),
urlencoding::encode(&session.user.user_id),
);
Ok(Redirect::to(&url))
}
#[derive(Deserialize)]
struct SlackUserCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn slack_user_callback(
State(state): State<AppState>,
session: Session,
Query(query): Query<SlackUserCallbackQuery>,
) -> Result<Response, Response> {
// Handle user-denied case
if let Some(err) = query.error {
tracing::warn!("Slack user OAuth denied: {err}");
return Ok(Redirect::to("/settings/account").into_response());
}
let code = query.code.ok_or_else(|| {
error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Missing authorization code from Slack.",
)
})?;
// Verify state matches our user_id to prevent CSRF
let state_param = query.state.unwrap_or_default();
if state_param != session.user.user_id {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"State parameter mismatch. Please try connecting again.",
));
}
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Not configured",
"Slack OAuth is not configured.",
)
})?;
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Not available",
"Slack account linking requires a database. Set DATABASE_URL to enable.",
)
})?;
let redirect_uri = format!(
"{}/settings/account/slack/callback",
slack_config.redirect_host
);
// Exchange the authorization code for a user access token
let http = reqwest::Client::new();
let token_resp = http
.post("https://slack.com/api/oauth.v2.access")
.form(&[
("client_id", slack_config.client_id.as_str()),
("client_secret", slack_config.client_secret.as_str()),
("code", &code),
("redirect_uri", &redirect_uri),
])
.send()
.await
.map_err(|e| {
internal_error(
&state,
"slack user oauth",
&format!("Failed to contact Slack: {e}"),
)
})?;
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
internal_error(
&state,
"slack user oauth",
&format!("Failed to parse Slack response: {e}"),
)
})?;
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let err_msg = resp_body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
tracing::error!("Slack user OAuth error: {err_msg}");
return Err(error_page(
&state,
StatusCode::BAD_GATEWAY,
"Slack error",
&format!("Slack returned an error: {err_msg}"),
));
}
// For user-scoped OAuth, the user token is nested under authed_user
let authed_user = resp_body.get("authed_user").ok_or_else(|| {
internal_error(
&state,
"slack user oauth",
&"Missing authed_user in Slack response",
)
})?;
let slack_user_id = authed_user["id"].as_str().unwrap_or("").to_string();
let user_access_token = authed_user["access_token"].as_str().unwrap_or("").to_string();
let team_id = resp_body["team"]["id"].as_str().unwrap_or("").to_string();
let team_name = resp_body["team"]["name"].as_str().unwrap_or("").to_string();
// Fetch display name via users.identity (requires identity.basic user scope)
let slack_username = if user_access_token.is_empty() {
slack_user_id.clone()
} else {
let identity_name: Option<String> = async {
let r = http
.get("https://slack.com/api/users.identity")
.bearer_auth(&user_access_token)
.send()
.await
.ok()?;
let body: serde_json::Value = r.json().await.ok()?;
let name = body["user"]["name"].as_str()?.to_string();
if name.is_empty() { None } else { Some(name) }
}
.await;
identity_name.unwrap_or_else(|| slack_user_id.clone())
};
let now = chrono::Utc::now().to_rfc3339();
let link = SlackUserLink {
id: uuid::Uuid::new_v4().to_string(),
user_id: session.user.user_id.clone(),
team_id,
team_name,
slack_user_id,
slack_username,
created_at: now,
};
integration_store
.upsert_slack_user_link(&link)
.await
.map_err(|e| internal_error(&state, "upsert slack user link", &e))?;
Ok(Redirect::to("/settings/account").into_response())
}
#[derive(Deserialize)]
struct SlackDisconnectForm {
team_id: String,
_csrf: String,
}
async fn slack_disconnect(
State(state): State<AppState>,
session: Session,
Form(form): Form<SlackDisconnectForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed.",
));
}
let integration_store = state.integration_store.as_ref().ok_or_else(|| {
error_page(
&state,
StatusCode::SERVICE_UNAVAILABLE,
"Not available",
"Slack account linking requires a database.",
)
})?;
integration_store
.delete_slack_user_link(&session.user.user_id, &form.team_id)
.await
.map_err(|e| internal_error(&state, "delete slack user link", &e))?;
Ok(Redirect::to("/settings/account").into_response())
}

View File

@@ -61,6 +61,10 @@ pub fn router() -> Router<AppState> {
"/orgs/{org}/settings/integrations/slack",
post(create_slack),
)
.route(
"/orgs/{org}/settings/integrations/{id}/reinstall",
post(reinstall_slack),
)
.route(
"/integrations/slack/callback",
get(slack_oauth_callback),
@@ -433,6 +437,7 @@ async fn integration_detail(
created_at => &integration.created_at,
},
config => config_display,
has_slack_oauth => state.slack_config.is_some(),
rules => rules_ctx,
deliveries => deliveries_ctx,
test_sent => query.test.is_some(),
@@ -575,17 +580,22 @@ async fn test_integration(
release: Some(ReleaseContext {
slug: "test-release".into(),
artifact_id: "art_test".into(),
release_intent_id: String::new(),
destination: "staging".into(),
environment: "staging".into(),
source_username: session.user.username.clone(),
source_user_id: session.user.user_id.clone(),
commit_sha: "abc1234".into(),
commit_branch: "main".into(),
context_title: "Test notification from Forage".into(),
context_web: String::new(),
destination_count: 1,
error_message: None,
}),
};
let tasks = forage_core::integrations::router::route_notification(&test_event, &[integration]);
let dispatcher = NotificationDispatcher::new(Arc::clone(store));
let dispatcher = NotificationDispatcher::new(Arc::clone(store), String::new());
for task in &tasks {
dispatcher.dispatch(task).await;
}
@@ -597,6 +607,409 @@ async fn test_integration(
.into_response())
}
// ─── Install Slack page ─────────────────────────────────────────────
async fn install_slack_page(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
Query(query): Query<ListQuery>,
) -> Result<Response, Response> {
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
let slack_oauth_url = state.slack_config.as_ref().map(|sc| {
format!(
"https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}/integrations/slack/callback&state={}",
urlencoding::encode(&sc.client_id),
urlencoding::encode(&sc.redirect_host),
urlencoding::encode(&org),
)
});
let html = state
.templates
.render(
"pages/install_slack.html.jinja",
context! {
title => format!("Install Slack - {} - Forage", org),
description => "Set up a Slack integration",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
},
current_org => &org,
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
active_tab => "integrations",
error => query.error,
slack_oauth_url => slack_oauth_url,
has_slack_oauth => state.slack_config.is_some(),
},
)
.map_err(|e| internal_error(&state, "template error", &e))?;
Ok(Html(html).into_response())
}
// ─── Create Slack (manual webhook URL fallback) ──────────────────────
#[derive(Deserialize)]
struct CreateSlackForm {
_csrf: String,
name: String,
webhook_url: String,
#[serde(default)]
channel_name: String,
}
async fn create_slack(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
Form(form): Form<CreateSlackForm>,
) -> Result<Response, Response> {
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
validate_csrf(&session, &form._csrf)?;
if let Err(e) = validate_integration_name(&form.name) {
return Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/install/slack?error={}",
org,
urlencoding::encode(&e.to_string())
))
.into_response());
}
if let Err(e) = validate_slack_webhook_url(&form.webhook_url) {
return Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/install/slack?error={}",
org,
urlencoding::encode(&e)
))
.into_response());
}
let channel = if form.channel_name.is_empty() {
"#general".to_string()
} else {
form.channel_name
};
let config = IntegrationConfig::Slack {
team_id: String::new(),
team_name: String::new(),
channel_id: String::new(),
channel_name: channel,
access_token: String::new(),
webhook_url: form.webhook_url,
};
let store = state.integration_store.as_ref().unwrap();
let created = store
.create_integration(&CreateIntegrationInput {
organisation: org.clone(),
integration_type: IntegrationType::Slack,
name: form.name,
config,
created_by: session.user.user_id.clone(),
})
.await
.map_err(|e| internal_error(&state, "create slack", &e))?;
let html = state
.templates
.render(
"pages/integration_installed.html.jinja",
context! {
title => format!("{} installed - Forage", created.name),
description => "Integration installed successfully",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
},
current_org => &org,
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
active_tab => "integrations",
integration => context! {
id => &created.id,
name => &created.name,
type_display => created.integration_type.display_name(),
},
api_token => created.api_token,
},
)
.map_err(|e| internal_error(&state, "template error", &e))?;
Ok(Html(html).into_response())
}
// ─── Reinstall Slack ─────────────────────────────────────────────────
#[derive(Deserialize)]
struct ReinstallForm {
_csrf: String,
}
async fn reinstall_slack(
State(state): State<AppState>,
session: Session,
Path((org, id)): Path<(String, String)>,
Form(form): Form<ReinstallForm>,
) -> Result<Response, Response> {
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
validate_csrf(&session, &form._csrf)?;
// Verify the integration exists and is a Slack integration
let store = state.integration_store.as_ref().unwrap();
let integration = store.get_integration(&org, &id).await.map_err(|e| {
error_page(
&state,
axum::http::StatusCode::NOT_FOUND,
"Not found",
&format!("Integration not found: {e}"),
)
})?;
if integration.integration_type != IntegrationType::Slack {
return Err(error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Only Slack integrations can be reinstalled via OAuth.",
));
}
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"Not configured",
"Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.",
)
})?;
// Encode org + integration ID in state so callback can update instead of create
let oauth_state = format!("{}:reinstall:{}", org, id);
let redirect_uri = format!(
"{}/integrations/slack/callback",
slack_config.redirect_host
);
let url = format!(
"https://slack.com/oauth/v2/authorize?client_id={}&scope=assistant:write,channels:join,chat:write,chat:write.public,im:history,im:read,im:write,incoming-webhook,links:read,links:write,reactions:write,users:read,users:read.email&redirect_uri={}&state={}",
urlencoding::encode(&slack_config.client_id),
urlencoding::encode(&redirect_uri),
urlencoding::encode(&oauth_state),
);
Ok(Redirect::to(&url).into_response())
}
// ─── Slack OAuth callback ────────────────────────────────────────────
#[derive(Deserialize)]
struct SlackCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn slack_oauth_callback(
State(state): State<AppState>,
session: Session,
Query(query): Query<SlackCallbackQuery>,
) -> Result<Response, Response> {
let raw_state = query.state.ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Missing state parameter from Slack callback.",
)
})?;
// Parse state: either "{org}" (new install) or "{org}:reinstall:{id}" (reinstall)
let (org, reinstall_id) = if raw_state.contains(":reinstall:") {
let parts: Vec<&str> = raw_state.splitn(2, ":reinstall:").collect();
(parts[0].to_string(), Some(parts[1].to_string()))
} else {
(raw_state, None)
};
// If Slack returned an error (user denied)
if let Some(err) = query.error {
let redirect_to = if let Some(ref rid) = reinstall_id {
format!("/orgs/{}/settings/integrations/{}?error={}", org, rid, urlencoding::encode(&format!("Slack authorization denied: {err}")))
} else {
format!("/orgs/{}/settings/integrations/install/slack?error={}", org, urlencoding::encode(&format!("Slack authorization denied: {err}")))
};
return Ok(Redirect::to(&redirect_to).into_response());
}
let code = query.code.ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::BAD_REQUEST,
"Invalid request",
"Missing authorization code from Slack.",
)
})?;
let cached_org = require_org_membership(&state, &session.user.orgs, &org)?;
require_admin(&state, cached_org)?;
require_integration_store(&state)?;
let slack_config = state.slack_config.as_ref().ok_or_else(|| {
error_page(
&state,
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"Not configured",
"Slack OAuth is not configured. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET.",
)
})?;
// Exchange code for token
let http = reqwest::Client::new();
let token_resp = http
.post("https://slack.com/api/oauth.v2.access")
.form(&[
("client_id", slack_config.client_id.as_str()),
("client_secret", slack_config.client_secret.as_str()),
("code", &code),
(
"redirect_uri",
&format!("{}/integrations/slack/callback", slack_config.redirect_host),
),
])
.send()
.await
.map_err(|e| {
internal_error(&state, "slack oauth", &format!("Failed to contact Slack: {e}"))
})?;
let resp_body: serde_json::Value = token_resp.json().await.map_err(|e| {
internal_error(
&state,
"slack oauth",
&format!("Failed to parse Slack response: {e}"),
)
})?;
if resp_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let err_msg = resp_body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/install/slack?error={}",
org,
urlencoding::encode(&format!("Slack error: {err_msg}"))
))
.into_response());
}
// Extract fields from Slack response
let team_id = resp_body["team"]["id"]
.as_str()
.unwrap_or("")
.to_string();
let team_name = resp_body["team"]["name"]
.as_str()
.unwrap_or("")
.to_string();
let access_token = resp_body["access_token"]
.as_str()
.unwrap_or("")
.to_string();
let (channel_id, channel_name, webhook_url) =
if let Some(wh) = resp_body.get("incoming_webhook") {
(
wh["channel_id"].as_str().unwrap_or("").to_string(),
wh["channel"].as_str().unwrap_or("").to_string(),
wh["url"].as_str().unwrap_or("").to_string(),
)
} else {
(String::new(), String::new(), String::new())
};
let integration_name = if channel_name.is_empty() {
format!("Slack - {team_name}")
} else {
format!("Slack - {channel_name}")
};
let config = IntegrationConfig::Slack {
team_id,
team_name,
channel_id,
channel_name,
access_token,
webhook_url,
};
let store = state.integration_store.as_ref().unwrap();
if let Some(ref existing_id) = reinstall_id {
// Reinstall: update existing integration's config
store
.update_integration_config(&org, existing_id, &integration_name, &config)
.await
.map_err(|e| internal_error(&state, "reinstall slack", &e))?;
Ok(Redirect::to(&format!(
"/orgs/{}/settings/integrations/{}",
org, existing_id
))
.into_response())
} else {
// New install: create integration
let created = store
.create_integration(&CreateIntegrationInput {
organisation: org.clone(),
integration_type: IntegrationType::Slack,
name: integration_name,
config,
created_by: session.user.user_id.clone(),
})
.await
.map_err(|e| internal_error(&state, "create slack", &e))?;
let html = state
.templates
.render(
"pages/integration_installed.html.jinja",
context! {
title => format!("{} installed - Forage", created.name),
description => "Integration installed successfully",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
},
current_org => &org,
orgs => session.user.orgs.iter().map(|o| context! { name => &o.name, role => &o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
active_tab => "integrations",
integration => context! {
id => &created.id,
name => &created.name,
type_display => created.integration_type.display_name(),
},
api_token => created.api_token,
},
)
.map_err(|e| internal_error(&state, "template error", &e))?;
Ok(Html(html).into_response())
}
}
// ─── Helpers ────────────────────────────────────────────────────────
fn notification_type_label(nt: &str) -> &str {
@@ -608,3 +1021,14 @@ fn notification_type_label(nt: &str) -> &str {
other => other,
}
}
fn validate_slack_webhook_url(url: &str) -> Result<(), String> {
if url.starts_with("https://hooks.slack.com/")
|| url.starts_with("http://localhost")
|| url.starts_with("http://127.0.0.1")
{
Ok(())
} else {
Err("Slack webhook URL must start with https://hooks.slack.com/".to_string())
}
}

View File

@@ -12,7 +12,7 @@ use forage_core::session::SessionStore;
pub struct SlackConfig {
pub client_id: String,
pub client_secret: String,
pub base_url: String,
pub redirect_host: String,
}
#[derive(Clone)]

View File

@@ -4,5 +4,6 @@ mod integration_tests;
mod nats_tests;
mod pages_tests;
mod platform_tests;
mod slack_tests;
mod token_tests;
mod webhook_delivery_tests;

View File

@@ -86,11 +86,16 @@ fn test_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext {
slug: "v3.0".into(),
artifact_id: "art_nats".into(),
release_intent_id: "ri_nats".into(),
destination: "prod".into(),
environment: "production".into(),
source_username: "alice".into(),
source_user_id: String::new(),
commit_sha: "aabbccdd".into(),
commit_branch: "main".into(),
context_title: "Deploy v3.0 succeeded".into(),
context_web: String::new(),
destination_count: 1,
error_message: None,
}),
}
@@ -108,11 +113,16 @@ fn failed_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext {
slug: "v3.0".into(),
artifact_id: "art_nats".into(),
release_intent_id: "ri_nats".into(),
destination: "prod".into(),
environment: "production".into(),
source_username: "bob".into(),
source_user_id: String::new(),
commit_sha: "deadbeef".into(),
commit_branch: "hotfix".into(),
context_title: "Deploy v3.0 failed".into(),
context_web: String::new(),
destination_count: 1,
error_message: Some("OOM killed".into()),
}),
}
@@ -144,7 +154,7 @@ async fn process_payload_routes_and_dispatches_to_webhook() {
let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await
.unwrap();
@@ -170,7 +180,7 @@ async fn process_payload_skips_when_no_matching_integrations() {
let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let result = NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher).await;
assert!(result.is_ok(), "should succeed with no matching integrations");
}
@@ -178,7 +188,7 @@ async fn process_payload_skips_when_no_matching_integrations() {
#[tokio::test]
async fn process_payload_rejects_invalid_json() {
let store = Arc::new(InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let result =
NotificationConsumer::process_payload(b"not-json", store.as_ref(), &dispatcher).await;
@@ -219,7 +229,7 @@ async fn process_payload_respects_disabled_rules() {
let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await
.unwrap();
@@ -285,7 +295,7 @@ async fn process_payload_dispatches_to_multiple_integrations() {
let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await
.unwrap();
@@ -318,7 +328,7 @@ async fn process_payload_records_delivery_status() {
let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await
.unwrap();
@@ -354,7 +364,7 @@ async fn process_payload_records_failed_delivery() {
let envelope = NotificationEnvelope::from(&event);
let payload = serde_json::to_vec(&envelope).unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&payload, store.as_ref(), &dispatcher)
.await
.unwrap();
@@ -463,7 +473,7 @@ async fn jetstream_publish_and_consume_delivers_webhook() {
.expect("message error");
// Process through the consumer logic
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
.await
.unwrap();
@@ -542,7 +552,7 @@ async fn jetstream_multiple_messages_all_delivered() {
.unwrap();
let mut messages = pull_consumer.messages().await.unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
for _ in 0..3 {
let msg = tokio::time::timeout(Duration::from_secs(5), messages.next())
@@ -632,7 +642,7 @@ async fn jetstream_message_for_wrong_org_skips_dispatch() {
.unwrap()
.unwrap();
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
NotificationConsumer::process_payload(&msg.payload, store.as_ref(), &dispatcher)
.await
.unwrap();

View File

@@ -0,0 +1,624 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::integrations::{
CreateIntegrationInput, IntegrationConfig, IntegrationStore, IntegrationType,
};
use tower::ServiceExt;
use crate::test_support::*;
fn build_app_with_integrations() -> (
axum::Router,
std::sync::Arc<forage_core::session::InMemorySessionStore>,
std::sync::Arc<forage_core::integrations::InMemoryIntegrationStore>,
) {
let (state, sessions, integrations) =
test_state_with_integrations(MockForestClient::new(), MockPlatformClient::new());
let app = crate::build_router(state);
(app, sessions, integrations)
}
// ─── Install Slack page ─────────────────────────────────────────────
#[tokio::test]
async fn install_slack_page_returns_200() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/integrations/install/slack")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(text.contains("Install Slack"));
assert!(text.contains("Webhook URL"));
}
#[tokio::test]
async fn install_slack_page_returns_403_for_non_admin() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session_member(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/integrations/install/slack")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn install_slack_page_shows_manual_form_without_oauth() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/integrations/install/slack")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
// Should show manual webhook URL form
assert!(text.contains("hooks.slack.com"));
// Should NOT show "Add to Slack" button (no OAuth configured)
assert!(!text.contains("Add to Slack"));
}
// ─── Create Slack (manual webhook URL) ──────────────────────────────
#[tokio::test]
async fn create_slack_success_shows_installed_page() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=%23deploys";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/integrations/slack")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(text.contains("installed"));
assert!(text.contains("fgi_")); // API token shown
assert!(text.contains("#deploys"));
// Verify it was created as Slack type
let all = integrations.list_integrations("testorg").await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].name, "#deploys");
match &all[0].config {
IntegrationConfig::Slack { channel_name, webhook_url, .. } => {
assert_eq!(channel_name, "#deploys");
assert!(webhook_url.contains("hooks.slack.com"));
}
_ => panic!("expected Slack config"),
}
}
#[tokio::test]
async fn create_slack_defaults_channel_to_general() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let body = "_csrf=test-csrf&name=alerts&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/integrations/slack")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let all = integrations.list_integrations("testorg").await.unwrap();
match &all[0].config {
IntegrationConfig::Slack { channel_name, .. } => {
assert_eq!(channel_name, "#general");
}
_ => panic!("expected Slack config"),
}
}
#[tokio::test]
async fn create_slack_invalid_csrf_returns_403() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let body = "_csrf=wrong-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/integrations/slack")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_slack_rejects_non_slack_url() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fexample.com%2Fhook&channel_name=";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/integrations/slack")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// Should redirect back to install page with error
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("install/slack"));
assert!(location.contains("error="));
}
#[tokio::test]
async fn create_slack_non_admin_returns_403() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session_member(&sessions).await;
let body = "_csrf=test-csrf&name=%23deploys&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/integrations/slack")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_slack_rejects_empty_name() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let body = "_csrf=test-csrf&name=&webhook_url=https%3A%2F%2Fhooks.slack.com%2Fservices%2FT123%2FB456%2Fxyz&channel_name=";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/integrations/slack")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// Should redirect back with error
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("install/slack"));
assert!(location.contains("error="));
}
// ─── Slack integration detail ───────────────────────────────────────
#[tokio::test]
async fn slack_integration_detail_shows_config() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let created = integrations
.create_integration(&CreateIntegrationInput {
organisation: "testorg".into(),
integration_type: IntegrationType::Slack,
name: "#deploys".into(),
config: IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "My Team".into(),
channel_id: "C456".into(),
channel_name: "#deploys".into(),
access_token: "xoxb-test".into(),
webhook_url: "https://hooks.slack.com/test".into(),
},
created_by: "user-123".into(),
})
.await
.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri(&format!(
"/orgs/testorg/settings/integrations/{}",
created.id
))
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(text.contains("#deploys"));
assert!(text.contains("My Team"));
assert!(text.contains("Slack"));
}
#[tokio::test]
async fn slack_integration_detail_manual_mode_shows_webhook_url() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
// Manual mode: empty team_name
let created = integrations
.create_integration(&CreateIntegrationInput {
organisation: "testorg".into(),
integration_type: IntegrationType::Slack,
name: "manual-slack".into(),
config: IntegrationConfig::Slack {
team_id: String::new(),
team_name: String::new(),
channel_id: String::new(),
channel_name: "#deploys".into(),
access_token: String::new(),
webhook_url: "https://hooks.slack.com/services/T123/B456/xyz".into(),
},
created_by: "user-123".into(),
})
.await
.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri(&format!(
"/orgs/testorg/settings/integrations/{}",
created.id
))
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(text.contains("hooks.slack.com"));
}
// ─── Slack in integrations catalog ──────────────────────────────────
#[tokio::test]
async fn integrations_page_shows_slack_as_available() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/integrations")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
// Slack should be a clickable link to install page
assert!(text.contains("install/slack"));
}
// ─── Slack shows in installed list ──────────────────────────────────
#[tokio::test]
async fn integrations_page_shows_installed_slack() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
integrations
.create_integration(&CreateIntegrationInput {
organisation: "testorg".into(),
integration_type: IntegrationType::Slack,
name: "#alerts".into(),
config: IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "Test".into(),
channel_id: "C456".into(),
channel_name: "#alerts".into(),
access_token: "xoxb-test".into(),
webhook_url: "https://hooks.slack.com/test".into(),
},
created_by: "user-123".into(),
})
.await
.unwrap();
let resp = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/integrations")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(text.contains("#alerts"));
assert!(text.contains("Slack"));
}
// ─── Slack OAuth callback without session ───────────────────────────
#[tokio::test]
async fn slack_callback_without_state_returns_error() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/integrations/slack/callback?code=test-code")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn slack_callback_with_error_redirects() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/integrations/slack/callback?state=testorg&error=access_denied")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("install/slack"));
assert!(location.contains("error="));
assert!(location.contains("access_denied"));
}
#[tokio::test]
async fn slack_callback_without_oauth_config_returns_503() {
let (app, sessions, _) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let resp = app
.oneshot(
Request::builder()
.uri("/integrations/slack/callback?code=test-code&state=testorg")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// No SlackConfig set, so should return 503
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
// ─── Reinstall Slack ─────────────────────────────────────────────────
#[tokio::test]
async fn reinstall_slack_redirects_to_oauth_error_without_slack_config() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let created = integrations
.create_integration(&CreateIntegrationInput {
organisation: "testorg".into(),
integration_type: IntegrationType::Slack,
name: "#deploys".into(),
config: IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "My Team".into(),
channel_id: "C456".into(),
channel_name: "#deploys".into(),
access_token: "xoxb-test".into(),
webhook_url: "https://hooks.slack.com/test".into(),
},
created_by: "user-123".into(),
})
.await
.unwrap();
let body = format!("_csrf=test-csrf");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(&format!(
"/orgs/testorg/settings/integrations/{}/reinstall",
created.id
))
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// No SlackConfig set → 503
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn reinstall_slack_non_admin_returns_403() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session_member(&sessions).await;
let created = integrations
.create_integration(&CreateIntegrationInput {
organisation: "testorg".into(),
integration_type: IntegrationType::Slack,
name: "#deploys".into(),
config: IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "My Team".into(),
channel_id: "C456".into(),
channel_name: "#deploys".into(),
access_token: "xoxb-test".into(),
webhook_url: "https://hooks.slack.com/test".into(),
},
created_by: "user-123".into(),
})
.await
.unwrap();
let body = format!("_csrf=test-csrf");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(&format!(
"/orgs/testorg/settings/integrations/{}/reinstall",
created.id
))
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn reinstall_slack_invalid_csrf_returns_403() {
let (app, sessions, integrations) = build_app_with_integrations();
let cookie = create_test_session(&sessions).await;
let created = integrations
.create_integration(&CreateIntegrationInput {
organisation: "testorg".into(),
integration_type: IntegrationType::Slack,
name: "#deploys".into(),
config: IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "My Team".into(),
channel_id: "C456".into(),
channel_name: "#deploys".into(),
access_token: "xoxb-test".into(),
webhook_url: "https://hooks.slack.com/test".into(),
},
created_by: "user-123".into(),
})
.await
.unwrap();
let body = format!("_csrf=wrong-csrf");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(&format!(
"/orgs/testorg/settings/integrations/{}/reinstall",
created.id
))
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

View File

@@ -105,11 +105,16 @@ fn test_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext {
slug: "my-api-v2".into(),
artifact_id: "art_abc".into(),
release_intent_id: "ri_1".into(),
destination: "prod-eu".into(),
environment: "production".into(),
source_username: "alice".into(),
source_user_id: String::new(),
commit_sha: "deadbeef1234567".into(),
commit_branch: "main".into(),
context_title: "Deploy v2.0 succeeded".into(),
context_web: String::new(),
destination_count: 1,
error_message: None,
}),
}
@@ -127,11 +132,16 @@ fn failed_event(org: &str) -> NotificationEvent {
release: Some(ReleaseContext {
slug: "my-api-v2".into(),
artifact_id: "art_abc".into(),
release_intent_id: "ri_2".into(),
destination: "prod-eu".into(),
environment: "production".into(),
source_username: "bob".into(),
source_user_id: String::new(),
commit_sha: "cafebabe0000000".into(),
commit_branch: "hotfix/fix-crash".into(),
context_title: "Deploy v2.0 failed".into(),
context_web: String::new(),
destination_count: 1,
error_message: Some("container exited with code 137".into()),
}),
}
@@ -143,7 +153,7 @@ fn failed_event(org: &str) -> NotificationEvent {
async fn dispatcher_delivers_webhook_to_http_server() {
let (url, receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let event = test_event("testorg");
let integration = store
@@ -195,7 +205,7 @@ async fn dispatcher_delivers_webhook_to_http_server() {
async fn dispatcher_signs_webhook_with_hmac() {
let (url, receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let secret = "webhook-secret-42";
let event = test_event("testorg");
@@ -236,7 +246,7 @@ async fn dispatcher_signs_webhook_with_hmac() {
async fn dispatcher_delivers_failed_event_with_error_message() {
let (url, receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let event = failed_event("testorg");
let integration = store
@@ -275,7 +285,7 @@ async fn dispatcher_delivers_failed_event_with_error_message() {
async fn dispatcher_records_successful_delivery() {
let (url, _receiver) = start_receiver().await;
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let event = test_event("testorg");
let integration = store
@@ -313,7 +323,7 @@ async fn dispatcher_retries_on_server_error() {
*receiver.force_status.lock().unwrap() = Some(StatusCode::INTERNAL_SERVER_ERROR);
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let event = test_event("testorg");
let integration = store
@@ -351,7 +361,7 @@ async fn dispatcher_retries_on_server_error() {
async fn dispatcher_handles_unreachable_url() {
// Port 1 is almost certainly not listening
let store = Arc::new(forage_core::integrations::InMemoryIntegrationStore::new());
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
let event = test_event("testorg");
let integration = store
@@ -421,7 +431,7 @@ async fn full_flow_event_routes_and_delivers() {
// Should only match testorg's integration (not otherorg's)
assert_eq!(tasks.len(), 1);
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
for task in &tasks {
dispatcher.dispatch(task).await;
}
@@ -516,7 +526,7 @@ async fn disabled_rule_filters_event_type() {
assert_eq!(tasks.len(), 1, "release_failed should still match");
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
dispatcher.dispatch(&tasks[0]).await;
let deliveries = receiver.deliveries.lock().unwrap();
@@ -566,7 +576,7 @@ async fn multiple_integrations_all_receive_same_event() {
forage_core::integrations::router::route_notification_for_org(store.as_ref(), &event).await;
assert_eq!(tasks.len(), 2);
let dispatcher = NotificationDispatcher::new(store.clone());
let dispatcher = NotificationDispatcher::new(store.clone(), String::new());
for task in &tasks {
dispatcher.dispatch(task).await;
}