use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use http::HeaderMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::Zeroizing;
use crate::webhook_signature::WebhookVerifier;
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Destination {
GitHub {
owner: String,
repo: String,
},
Jira {
site: String,
project: String,
},
Linear {
team: String,
},
Zendesk {
subdomain: String,
},
Freshdesk {
domain: String,
},
Intercom,
Salesforce {
instance: String,
},
HubSpot,
Slack {
channel: String,
},
Webhook {
url: String,
},
Colonizer {
repo: String,
},
}
impl Destination {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Destination::GitHub { .. } => "github",
Destination::Jira { .. } => "jira",
Destination::Linear { .. } => "linear",
Destination::Zendesk { .. } => "zendesk",
Destination::Freshdesk { .. } => "freshdesk",
Destination::Intercom => "intercom",
Destination::Salesforce { .. } => "salesforce",
Destination::HubSpot => "hubspot",
Destination::Slack { .. } => "slack",
Destination::Webhook { .. } => "webhook",
Destination::Colonizer { .. } => "colonizer",
}
}
}
impl std::fmt::Debug for Destination {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Destination::GitHub { owner, repo } => write!(
f,
"Destination::GitHub {{ owner: {owner:?}, repo: {repo:?} }}"
),
Destination::Jira { site, project } => write!(
f,
"Destination::Jira {{ site: {site:?}, project: {project:?} }}"
),
Destination::Linear { team } => {
write!(f, "Destination::Linear {{ team: {team:?} }}")
}
Destination::Zendesk { subdomain } => {
write!(f, "Destination::Zendesk {{ subdomain: {subdomain:?} }}")
}
Destination::Freshdesk { domain } => {
write!(f, "Destination::Freshdesk {{ domain: {domain:?} }}")
}
Destination::Intercom => f.write_str("Destination::Intercom"),
Destination::Salesforce { instance } => {
write!(f, "Destination::Salesforce {{ instance: {instance:?} }}")
}
Destination::HubSpot => f.write_str("Destination::HubSpot"),
Destination::Slack { channel } => {
write!(f, "Destination::Slack {{ channel: {channel:?} }}")
}
Destination::Webhook { .. } => f.write_str("Destination::Webhook { url: [redacted] }"),
Destination::Colonizer { repo } => {
write!(f, "Destination::Colonizer {{ repo: {repo:?} }}")
}
}
}
}
#[derive(Clone)]
pub struct Credential(Zeroizing<String>);
impl Credential {
#[must_use]
pub fn new(secret: impl Into<String>) -> Self {
Self(Zeroizing::new(secret.into()))
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Credential([redacted])")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warning,
Error,
Critical,
}
impl Severity {
pub fn name(&self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
Severity::Critical => "critical",
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketDraft {
pub idempotency_key: String,
pub title: String,
pub body_markdown: String,
pub severity: Severity,
pub labels: Vec<String>,
pub environment: Option<String>,
}
impl TicketDraft {
#[must_use]
pub fn new(
idempotency_key: impl Into<String>,
title: impl Into<String>,
body_markdown: impl Into<String>,
severity: Severity,
) -> Self {
Self {
idempotency_key: idempotency_key.into(),
title: title.into(),
body_markdown: body_markdown.into(),
severity,
labels: Vec::new(),
environment: None,
}
}
#[must_use]
pub fn labels(mut self, labels: Vec<String>) -> Self {
self.labels = labels;
self
}
#[must_use]
pub fn environment(mut self, environment: impl Into<String>) -> Self {
self.environment = Some(environment.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Filed {
pub external_id: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketComment {
pub idempotency_key: String,
pub body_markdown: String,
pub link: Option<String>,
}
impl TicketComment {
#[must_use]
pub fn new(idempotency_key: impl Into<String>, body_markdown: impl Into<String>) -> Self {
Self {
idempotency_key: idempotency_key.into(),
body_markdown: body_markdown.into(),
link: None,
}
}
#[must_use]
pub fn with_link(mut self, url: impl Into<String>) -> Self {
self.link = Some(url.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketState {
Open,
InProgress,
Resolved,
Closed,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketStatus {
pub external_id: String,
pub state: TicketState,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TrackerError {
#[error("tracker is not configured")]
NotConfigured,
#[error("tracker refused the credential")]
Unauthorized,
#[error("tracker rejected the request: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
Rejected(String),
#[error(
"tracker request failed, retryable: {delay}",
delay = retry_after_description(.retry_after)
)]
Transient {
retry_after: Option<Duration>,
},
}
impl TrackerError {
#[must_use]
pub fn unsupported_destination(dest: &Destination) -> Self {
TrackerError::Rejected(format!(
"unsupported destination: this adapter does not serve {}",
dest.kind()
))
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
match self {
TrackerError::Transient { retry_after } => *retry_after,
_ => None,
}
}
}
#[allow(clippy::ref_option)]
fn retry_after_description(retry_after: &Option<Duration>) -> String {
match retry_after {
Some(after) => format!("retry after {}s", after.as_secs()),
None => "no delay was stated".to_owned(),
}
}
#[async_trait]
pub trait Tracker: Send + Sync {
async fn file(
&self,
dest: &Destination,
cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError>;
async fn status(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError>;
async fn comment(
&self,
dest: &Destination,
_cred: &Credential,
_external_id: &str,
_comment: &TicketComment,
) -> Result<(), TrackerError> {
Err(TrackerError::Rejected(format!(
"this adapter does not support comments on {} tickets",
dest.kind()
)))
}
}
#[derive(Default, Clone)]
pub struct RoutingTracker {
github: Option<Arc<dyn Tracker>>,
jira: Option<Arc<dyn Tracker>>,
linear: Option<Arc<dyn Tracker>>,
zendesk: Option<Arc<dyn Tracker>>,
freshdesk: Option<Arc<dyn Tracker>>,
intercom: Option<Arc<dyn Tracker>>,
salesforce: Option<Arc<dyn Tracker>>,
hubspot: Option<Arc<dyn Tracker>>,
slack: Option<Arc<dyn Tracker>>,
webhook: Option<Arc<dyn Tracker>>,
colonizer: Option<Arc<dyn Tracker>>,
}
impl RoutingTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn github(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.github = Some(tracker);
self
}
#[must_use]
pub fn jira(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.jira = Some(tracker);
self
}
#[must_use]
pub fn linear(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.linear = Some(tracker);
self
}
#[must_use]
pub fn zendesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.zendesk = Some(tracker);
self
}
#[must_use]
pub fn freshdesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.freshdesk = Some(tracker);
self
}
#[must_use]
pub fn intercom(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.intercom = Some(tracker);
self
}
#[must_use]
pub fn salesforce(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.salesforce = Some(tracker);
self
}
#[must_use]
pub fn hubspot(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.hubspot = Some(tracker);
self
}
#[must_use]
pub fn slack(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.slack = Some(tracker);
self
}
#[must_use]
pub fn webhook(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.webhook = Some(tracker);
self
}
#[must_use]
pub fn colonizer(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.colonizer = Some(tracker);
self
}
#[must_use]
pub fn route_for(&self, destination: &Destination) -> Option<&Arc<dyn Tracker>> {
match destination {
Destination::GitHub { .. } => self.github.as_ref(),
Destination::Jira { .. } => self.jira.as_ref(),
Destination::Linear { .. } => self.linear.as_ref(),
Destination::Zendesk { .. } => self.zendesk.as_ref(),
Destination::Freshdesk { .. } => self.freshdesk.as_ref(),
Destination::Intercom => self.intercom.as_ref(),
Destination::Salesforce { .. } => self.salesforce.as_ref(),
Destination::HubSpot => self.hubspot.as_ref(),
Destination::Slack { .. } => self.slack.as_ref(),
Destination::Webhook { .. } => self.webhook.as_ref(),
Destination::Colonizer { .. } => self.colonizer.as_ref(),
}
}
}
impl std::fmt::Debug for RoutingTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RoutingTracker")
.field("github", &self.github.is_some())
.field("jira", &self.jira.is_some())
.field("linear", &self.linear.is_some())
.field("zendesk", &self.zendesk.is_some())
.field("freshdesk", &self.freshdesk.is_some())
.field("intercom", &self.intercom.is_some())
.field("salesforce", &self.salesforce.is_some())
.field("hubspot", &self.hubspot.is_some())
.field("slack", &self.slack.is_some())
.field("webhook", &self.webhook.is_some())
.field("colonizer", &self.colonizer.is_some())
.finish()
}
}
#[async_trait]
impl Tracker for RoutingTracker {
async fn file(
&self,
dest: &Destination,
cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
match self.route_for(dest) {
Some(tracker) => tracker.file(dest, cred, draft).await,
None => Err(TrackerError::NotConfigured),
}
}
async fn status(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError> {
match self.route_for(dest) {
Some(tracker) => tracker.status(dest, cred, external_id).await,
None => Err(TrackerError::NotConfigured),
}
}
async fn comment(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
comment: &TicketComment,
) -> Result<(), TrackerError> {
match self.route_for(dest) {
Some(tracker) => tracker.comment(dest, cred, external_id, comment).await,
None => Err(TrackerError::NotConfigured),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusUpdate {
pub external_id: String,
pub state: TicketState,
pub url: Option<String>,
}
pub trait StatusWebhook: Send + Sync {
fn kind(&self) -> &'static str;
fn verifier(&self) -> WebhookVerifier;
fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum InboundStatusError {
#[error("status webhook signature verification failed")]
Signature,
#[error("status webhook body is malformed: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
Malformed(String),
}
pub fn receive_status(
hook: &dyn StatusWebhook,
secret: &str,
headers: &HeaderMap,
body: &[u8],
now_unix: i64,
) -> Result<Option<StatusUpdate>, InboundStatusError> {
if !hook.verifier().verify(secret, headers, body, now_unix) {
return Err(InboundStatusError::Signature);
}
hook.parse(body)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_signature::{ProviderScheme, SignatureEncoding};
#[test]
fn a_destination_round_trips_through_json() {
for dest in all_destinations() {
let json = serde_json::to_string(&dest).expect("serialises");
let back: Destination = serde_json::from_str(&json).expect("deserialises");
assert_eq!(dest, back);
}
}
fn all_destinations() -> Vec<Destination> {
vec![
Destination::GitHub {
owner: "acme".to_owned(),
repo: "api".to_owned(),
},
Destination::Jira {
site: "acme".to_owned(),
project: "PROJ".to_owned(),
},
Destination::Linear {
team: "Platform".to_owned(),
},
Destination::Zendesk {
subdomain: "acme".to_owned(),
},
Destination::Freshdesk {
domain: "acme.freshdesk.com".to_owned(),
},
Destination::Intercom,
Destination::Salesforce {
instance: "acme.my.salesforce.com".to_owned(),
},
Destination::HubSpot,
Destination::Slack {
channel: "C0123456789".to_owned(),
},
Destination::Webhook {
url: "https://hooks.example.test/services/TOKEN/SECRET".to_owned(),
},
Destination::Colonizer {
repo: "acme/owlpost".to_owned(),
},
]
}
#[test]
fn every_destination_kind_names_itself() {
let kinds: Vec<&'static str> = all_destinations().iter().map(Destination::kind).collect();
assert_eq!(
kinds,
[
"github",
"jira",
"linear",
"zendesk",
"freshdesk",
"intercom",
"salesforce",
"hubspot",
"slack",
"webhook",
"colonizer",
]
);
}
#[test]
fn the_webhook_debug_prints_no_url() {
#[derive(Debug)]
struct Row {
dest: Destination,
}
let row = Row {
dest: Destination::Webhook {
url: "https://hooks.example.test/services/TOKEN/SECRET".to_owned(),
},
};
let printed = format!("{row:?}");
assert!(printed.contains("Webhook"), "{printed}");
assert!(printed.contains("[redacted]"), "{printed}");
assert!(!printed.contains("hooks.example.test"), "{printed}");
assert!(!printed.contains("TOKEN"), "{printed}");
assert!(!printed.contains("SECRET"), "{printed}");
assert!(!printed.contains("https"), "{printed}");
assert!(matches!(row.dest, Destination::Webhook { .. }));
}
#[test]
fn the_other_destinations_debug_print_their_routing_fields() {
let printed = format!(
"{:?}",
Destination::GitHub {
owner: "acme".to_owned(),
repo: "api".to_owned(),
}
);
assert!(printed.contains("acme"), "{printed}");
assert!(printed.contains("api"), "{printed}");
let printed = format!("{:?}", Destination::Intercom);
assert_eq!(printed, "Destination::Intercom");
let printed = format!("{:?}", Destination::HubSpot);
assert_eq!(printed, "Destination::HubSpot");
let printed = format!(
"{:?}",
Destination::Freshdesk {
domain: "acme.freshdesk.com".to_owned(),
}
);
assert!(printed.contains("acme.freshdesk.com"), "{printed}");
}
#[test]
fn a_colonizer_destination_round_trips_and_prints_its_repo() {
let dest = Destination::Colonizer {
repo: "acme/owlpost".to_owned(),
};
let json = serde_json::to_value(&dest).expect("serialises");
assert_eq!(
json,
serde_json::json!({ "colonizer": { "repo": "acme/owlpost" } })
);
let back: Destination = serde_json::from_value(json).expect("deserialises");
assert_eq!(back, dest);
assert_eq!(dest.kind(), "colonizer");
assert_eq!(
format!("{dest:?}"),
r#"Destination::Colonizer { repo: "acme/owlpost" }"#
);
}
#[test]
fn the_credential_debug_prints_nothing_but_redacted() {
#[derive(Debug)]
struct Row {
cred: Credential,
}
let row = Row {
cred: Credential::new("ghp_4a1b2c3d4e5fREALtokenVALUEnobodyshouldsee"),
};
let printed = format!("{row:?}");
assert!(!printed.contains("ghp_"), "{printed}");
assert!(!printed.contains("REALtokenVALUE"), "{printed}");
assert!(printed.contains("Credential([redacted])"), "{printed}");
assert_eq!(
row.cred.expose(),
"ghp_4a1b2c3d4e5fREALtokenVALUEnobodyshouldsee"
);
}
#[test]
fn the_credential_exposes_its_text_once_wrapped() {
let cred = Credential::new("token-from-secrets");
assert_eq!(cred.expose(), "token-from-secrets");
let copy = cred.clone();
assert_eq!(copy.expose(), "token-from-secrets");
}
#[test]
fn a_draft_builds_through_the_chaining_setters() {
let draft = TicketDraft::new(
"outbox-42",
"Checkout failing",
"Stripe 500s on `/v1/charges`.",
Severity::Error,
)
.labels(vec!["billing".to_owned(), "incident".to_owned()])
.environment("production");
assert_eq!(draft.idempotency_key, "outbox-42");
assert_eq!(draft.title, "Checkout failing");
assert_eq!(draft.severity, Severity::Error);
assert_eq!(draft.labels, ["billing", "incident"]);
assert_eq!(draft.environment.as_deref(), Some("production"));
}
#[test]
fn a_minimal_draft_has_no_labels_or_environment() {
let draft = TicketDraft::new("k", "t", "b", Severity::Info);
assert!(draft.labels.is_empty());
assert_eq!(draft.environment, None);
}
#[test]
fn severity_names_are_snake_case_on_the_wire_and_in_display() {
for (severity, name) in [
(Severity::Info, "info"),
(Severity::Warning, "warning"),
(Severity::Error, "error"),
(Severity::Critical, "critical"),
] {
assert_eq!(severity.name(), name);
assert_eq!(severity.to_string(), name);
let json = serde_json::to_value(severity).unwrap();
assert_eq!(json, name);
let back: Severity = serde_json::from_value(json).unwrap();
assert_eq!(back, severity);
}
}
#[test]
fn transient_carries_an_optional_retry_after() {
let plain = TrackerError::Transient { retry_after: None };
assert_eq!(plain.retry_after(), None);
let throttled = TrackerError::Transient {
retry_after: Some(Duration::from_secs(30)),
};
assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
assert_eq!(TrackerError::NotConfigured.retry_after(), None);
assert_eq!(TrackerError::Unauthorized.retry_after(), None);
assert_eq!(
TrackerError::Rejected("jira 401".to_owned()).retry_after(),
None
);
}
#[test]
fn the_transient_display_states_the_delay_where_one_was_given() {
let error = TrackerError::Transient {
retry_after: Some(Duration::from_secs(30)),
};
let text = error.to_string();
assert!(text.contains("retryable"), "{text}");
assert!(text.contains("30s"), "{text}");
let text = TrackerError::Transient { retry_after: None }.to_string();
assert!(text.contains("retryable"), "{text}");
assert!(text.contains("no delay"), "{text}");
}
#[test]
fn display_sanitizes_the_rejection_text() {
let error = TrackerError::Rejected(
"webhook 400 for https://hooks.example.test/services/TOKEN?token=SECRET".to_owned(),
);
let text = error.to_string();
assert!(!text.contains("SECRET"), "{text}");
assert!(text.contains("?[redacted]"), "{text}");
let error = TrackerError::Rejected("jira 403 for devops@acme.test".to_owned());
let text = error.to_string();
assert!(!text.contains('@'), "{text}");
assert!(text.contains("[subject_hash:"), "{text}");
assert!(format!("{error:?}").contains("devops@acme.test"));
assert_eq!(
TrackerError::NotConfigured.to_string(),
"tracker is not configured"
);
}
#[test]
fn an_unsupported_destination_names_the_kind() {
let error = TrackerError::unsupported_destination(&Destination::Slack {
channel: "C0123456789".to_owned(),
});
let message = error.to_string();
assert!(message.contains("unsupported destination"), "{message}");
assert!(message.contains("slack"), "{message}");
}
#[test]
fn an_unauthorized_credential_is_distinct_from_a_rejected_ticket() {
let error = TrackerError::Unauthorized;
assert_eq!(error.to_string(), "tracker refused the credential");
assert_eq!(error.retry_after(), None, "an auth failure names no delay");
assert_ne!(error, TrackerError::Rejected("401".to_owned()));
assert!(format!("{error:?}").contains("Unauthorized"));
}
struct Recording {
seen: std::sync::atomic::AtomicUsize,
noted: std::sync::atomic::AtomicUsize,
}
impl Recording {
fn new() -> Arc<Self> {
Arc::new(Self {
seen: std::sync::atomic::AtomicUsize::new(0),
noted: std::sync::atomic::AtomicUsize::new(0),
})
}
fn count(&self) -> usize {
self.seen.load(std::sync::atomic::Ordering::Relaxed)
}
fn note_count(&self) -> usize {
self.noted.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[async_trait]
impl Tracker for Recording {
async fn file(
&self,
_dest: &Destination,
_cred: &Credential,
_draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(Filed {
external_id: "PROJ-7".to_owned(),
url: "https://tracker.example.test/browse/PROJ-7".to_owned(),
})
}
async fn status(
&self,
_dest: &Destination,
_cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError> {
self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(TicketStatus {
external_id: external_id.to_owned(),
state: TicketState::InProgress,
url: None,
})
}
async fn comment(
&self,
_dest: &Destination,
_cred: &Credential,
_external_id: &str,
_comment: &TicketComment,
) -> Result<(), TrackerError> {
self.noted
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
}
struct FileOnly;
#[async_trait]
impl Tracker for FileOnly {
async fn file(
&self,
_: &Destination,
_: &Credential,
_: &TicketDraft,
) -> Result<Filed, TrackerError> {
Err(TrackerError::NotConfigured)
}
async fn status(
&self,
_: &Destination,
_: &Credential,
_: &str,
) -> Result<TicketStatus, TrackerError> {
Err(TrackerError::NotConfigured)
}
}
fn a_draft() -> TicketDraft {
TicketDraft::new("k", "t", "b", Severity::Info)
}
fn a_comment() -> TicketComment {
TicketComment::new("outbox-43", "Duplicate of the checkout 500s.")
.with_link("https://reports.example.test/43")
}
fn a_freshdesk() -> Destination {
Destination::Freshdesk {
domain: "acme.freshdesk.com".to_owned(),
}
}
#[test]
fn the_default_comment_impl_refuses_by_name() {
let error = pollster::block_on(FileOnly.comment(
&a_freshdesk(),
&Credential::new("t"),
"PROJ-7",
&a_comment(),
))
.unwrap_err();
assert!(matches!(error, TrackerError::Rejected(_)));
let text = error.to_string();
assert!(text.contains("does not support comments"), "{text}");
assert!(text.contains("freshdesk"), "{text}");
}
#[test]
fn a_wired_router_forwards_comment_and_an_empty_slot_refuses() {
let freshdesk = Recording::new();
let router = RoutingTracker::new().freshdesk(freshdesk.clone());
pollster::block_on(router.comment(
&a_freshdesk(),
&Credential::new("t"),
"PROJ-7",
&a_comment(),
))
.expect("the wired adapter answers");
assert_eq!(freshdesk.note_count(), 1);
let other = Destination::Zendesk {
subdomain: "acme".to_owned(),
};
let error = pollster::block_on(router.comment(
&other,
&Credential::new("t"),
"PROJ-7",
&a_comment(),
))
.unwrap_err();
assert_eq!(error, TrackerError::NotConfigured);
assert_eq!(
freshdesk.note_count(),
1,
"the wired adapter was not called"
);
}
#[test]
fn an_empty_router_is_not_configured_for_every_destination() {
let router = RoutingTracker::new();
for dest in all_destinations() {
let filed = pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()));
assert_eq!(filed.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
let status = pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"));
assert_eq!(status.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
let noted = pollster::block_on(router.comment(
&dest,
&Credential::new("t"),
"PROJ-7",
&a_comment(),
));
assert_eq!(noted.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
assert!(router.route_for(&dest).is_none());
}
}
#[test]
fn a_wired_slot_reaches_its_adapter_and_the_rest_stay_not_configured() {
let github = Recording::new();
let router = RoutingTracker::new().github(github.clone());
let dest = Destination::GitHub {
owner: "acme".to_owned(),
repo: "api".to_owned(),
};
let filed = pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()))
.expect("the wired adapter answers");
assert_eq!(filed.external_id, "PROJ-7");
let status = pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"))
.expect("the wired adapter answers");
assert_eq!(status.state, TicketState::InProgress);
assert_eq!(github.count(), 2);
assert!(router.route_for(&dest).is_some());
let other = Destination::Slack {
channel: "C0123456789".to_owned(),
};
let error =
pollster::block_on(router.file(&other, &Credential::new("t"), &a_draft())).unwrap_err();
assert_eq!(error, TrackerError::NotConfigured);
assert_eq!(github.count(), 2, "the wired adapter was not called");
assert!(router.route_for(&other).is_none());
}
#[test]
fn every_destination_dispatches_to_the_adapter_wired_for_its_slot() {
let github = Recording::new();
let jira = Recording::new();
let linear = Recording::new();
let zendesk = Recording::new();
let freshdesk = Recording::new();
let intercom = Recording::new();
let salesforce = Recording::new();
let hubspot = Recording::new();
let slack = Recording::new();
let webhook = Recording::new();
let colonizer = Recording::new();
let router = RoutingTracker::new()
.github(github.clone())
.jira(jira.clone())
.linear(linear.clone())
.zendesk(zendesk.clone())
.freshdesk(freshdesk.clone())
.intercom(intercom.clone())
.salesforce(salesforce.clone())
.hubspot(hubspot.clone())
.slack(slack.clone())
.webhook(webhook.clone())
.colonizer(colonizer.clone());
let slots = [
github, jira, linear, zendesk, freshdesk, intercom, salesforce, hubspot, slack,
webhook, colonizer,
];
for (dest, adapter) in all_destinations().into_iter().zip(slots) {
let wired: Arc<dyn Tracker> = adapter.clone();
assert!(
Arc::ptr_eq(router.route_for(&dest).expect("wired"), &wired),
"{dest:?} routed to the wrong slot",
);
pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()))
.expect("the wired adapter answers");
assert_eq!(adapter.count(), 1, "{dest:?} reached the wrong adapter");
pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"))
.expect("the wired adapter answers");
assert_eq!(adapter.count(), 2, "status did not reuse the slot");
}
}
#[test]
fn the_router_debug_prints_only_wiring_not_adapters() {
let router = RoutingTracker::new().jira(Recording::new());
let printed = format!("{router:?}");
assert!(printed.contains("RoutingTracker"), "{printed}");
assert!(printed.contains("jira: true"), "{printed}");
assert!(printed.contains("github: false"), "{printed}");
assert!(printed.contains("webhook: false"), "{printed}");
}
struct JiraHook {
parse_saw_bytes: std::sync::atomic::AtomicBool,
}
impl StatusWebhook for JiraHook {
fn kind(&self) -> &'static str {
"jira"
}
fn verifier(&self) -> WebhookVerifier {
WebhookVerifier::new(ProviderScheme {
signature: "X-Hub-Signature",
encoding: SignatureEncoding::Hex,
prefix: Some("sha256="),
timestamp: None,
})
}
fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError> {
self.parse_saw_bytes
.store(true, std::sync::atomic::Ordering::Relaxed);
let event: serde_json::Value = serde_json::from_slice(body)
.map_err(|error| InboundStatusError::Malformed(error.to_string()))?;
let Some(status) = event.get("status").and_then(serde_json::Value::as_str) else {
return Ok(None);
};
Ok(Some(StatusUpdate {
external_id: event["ticket"].as_str().unwrap_or_default().to_owned(),
state: if status == "done" {
TicketState::Closed
} else {
TicketState::Unknown
},
url: event["url"].as_str().map(ToOwned::to_owned),
}))
}
}
fn jira_signature(secret: &str, body: &[u8]) -> String {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::fmt::Write as _;
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(secret.as_bytes()).expect("any key length");
mac.update(body);
mac.finalize()
.into_bytes()
.iter()
.fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
fn sig_headers(name: &str, value: &str) -> HeaderMap {
[(
name.parse::<http::header::HeaderName>()
.expect("test header name"),
value
.parse::<http::HeaderValue>()
.expect("test header value"),
)]
.into_iter()
.collect()
}
#[test]
fn receive_status_refuses_a_bad_signature_without_parsing() {
let hook = JiraHook {
parse_saw_bytes: std::sync::atomic::AtomicBool::new(false),
};
let body = br#"{"ticket":"PROJ-7","status":"done"}"#;
let signed = sig_headers(
"X-Hub-Signature",
&format!("sha256={}", jira_signature("right-secret", body)),
);
assert_eq!(
receive_status(&hook, "wrong-secret", &signed, body, 0).unwrap_err(),
InboundStatusError::Signature
);
let tampered = receive_status(&hook, "right-secret", &signed, b"{}", 0).unwrap_err();
assert_eq!(tampered, InboundStatusError::Signature);
let unsigned =
receive_status(&hook, "right-secret", &HeaderMap::new(), body, 0).unwrap_err();
assert_eq!(unsigned, InboundStatusError::Signature);
assert!(
!hook
.parse_saw_bytes
.load(std::sync::atomic::Ordering::Relaxed)
);
}
#[test]
fn receive_status_parses_a_verified_state_change() {
let hook = JiraHook {
parse_saw_bytes: std::sync::atomic::AtomicBool::new(false),
};
let body = br#"{"ticket":"PROJ-7","status":"done","url":"https://jira.example.test/browse/PROJ-7"}"#;
let signed = sig_headers(
"X-Hub-Signature",
&format!("sha256={}", jira_signature("right-secret", body)),
);
let update = receive_status(&hook, "right-secret", &signed, body, 0)
.expect("the signature verifies");
assert_eq!(
update,
Some(StatusUpdate {
external_id: "PROJ-7".to_owned(),
state: TicketState::Closed,
url: Some("https://jira.example.test/browse/PROJ-7".to_owned()),
})
);
assert!(
hook.parse_saw_bytes
.load(std::sync::atomic::Ordering::Relaxed)
);
let ping = br#"{"kind":"ping"}"#;
let pinged = sig_headers(
"X-Hub-Signature",
&format!("sha256={}", jira_signature("right-secret", ping)),
);
assert_eq!(
receive_status(&hook, "right-secret", &pinged, ping, 0).expect("verified"),
None
);
}
#[test]
fn receive_status_maps_a_verified_but_unreadable_body_to_malformed() {
let hook = JiraHook {
parse_saw_bytes: std::sync::atomic::AtomicBool::new(false),
};
let body = br"not json at all - https://hooks.example.test/TOKEN?token=SECRET";
let signed = sig_headers(
"X-Hub-Signature",
&format!("sha256={}", jira_signature("right-secret", body)),
);
let error = receive_status(&hook, "right-secret", &signed, body, 0).unwrap_err();
assert!(matches!(error, InboundStatusError::Malformed(_)));
assert!(error.to_string().contains("malformed"));
let error = InboundStatusError::Malformed(
"vendor said: https://hooks.example.test/TOKEN?token=SECRET".to_owned(),
);
let text = error.to_string();
assert!(!text.contains("SECRET"), "{text}");
assert!(format!("{error:?}").contains("SECRET"));
}
}