1use std::sync::Arc;
17use std::time::Duration;
18
19use async_trait::async_trait;
20use http::HeaderMap;
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23use zeroize::Zeroizing;
24
25use crate::webhook_signature::WebhookVerifier;
26
27#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Destination {
53 GitHub {
55 owner: String,
57 repo: String,
59 },
60 Jira {
62 site: String,
66 project: String,
68 },
69 Linear {
71 team: String,
73 },
74 Zendesk {
76 subdomain: String,
78 },
79 Freshdesk {
81 domain: String,
83 },
84 Intercom,
86 Salesforce {
88 instance: String,
90 },
91 HubSpot,
93 Slack {
95 channel: String,
97 },
98 Webhook {
102 url: String,
104 },
105 Colonizer {
109 repo: String,
111 },
112}
113
114impl Destination {
115 #[must_use]
120 pub fn kind(&self) -> &'static str {
121 match self {
122 Destination::GitHub { .. } => "github",
123 Destination::Jira { .. } => "jira",
124 Destination::Linear { .. } => "linear",
125 Destination::Zendesk { .. } => "zendesk",
126 Destination::Freshdesk { .. } => "freshdesk",
127 Destination::Intercom => "intercom",
128 Destination::Salesforce { .. } => "salesforce",
129 Destination::HubSpot => "hubspot",
130 Destination::Slack { .. } => "slack",
131 Destination::Webhook { .. } => "webhook",
132 Destination::Colonizer { .. } => "colonizer",
133 }
134 }
135}
136
137impl std::fmt::Debug for Destination {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 Destination::GitHub { owner, repo } => write!(
144 f,
145 "Destination::GitHub {{ owner: {owner:?}, repo: {repo:?} }}"
146 ),
147 Destination::Jira { site, project } => write!(
148 f,
149 "Destination::Jira {{ site: {site:?}, project: {project:?} }}"
150 ),
151 Destination::Linear { team } => {
152 write!(f, "Destination::Linear {{ team: {team:?} }}")
153 }
154 Destination::Zendesk { subdomain } => {
155 write!(f, "Destination::Zendesk {{ subdomain: {subdomain:?} }}")
156 }
157 Destination::Freshdesk { domain } => {
158 write!(f, "Destination::Freshdesk {{ domain: {domain:?} }}")
159 }
160 Destination::Intercom => f.write_str("Destination::Intercom"),
161 Destination::Salesforce { instance } => {
162 write!(f, "Destination::Salesforce {{ instance: {instance:?} }}")
163 }
164 Destination::HubSpot => f.write_str("Destination::HubSpot"),
165 Destination::Slack { channel } => {
166 write!(f, "Destination::Slack {{ channel: {channel:?} }}")
167 }
168 Destination::Webhook { .. } => f.write_str("Destination::Webhook { url: [redacted] }"),
171 Destination::Colonizer { repo } => {
172 write!(f, "Destination::Colonizer {{ repo: {repo:?} }}")
173 }
174 }
175 }
176}
177
178#[derive(Clone)]
200pub struct Credential(Zeroizing<String>);
201
202impl Credential {
203 #[must_use]
205 pub fn new(secret: impl Into<String>) -> Self {
206 Self(Zeroizing::new(secret.into()))
207 }
208
209 #[must_use]
215 pub fn expose(&self) -> &str {
216 &self.0
217 }
218}
219
220impl std::fmt::Debug for Credential {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.write_str("Credential([redacted])")
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum Severity {
232 Info,
234 Warning,
236 Error,
238 Critical,
240}
241
242impl Severity {
243 pub fn name(&self) -> &'static str {
245 match self {
246 Severity::Info => "info",
247 Severity::Warning => "warning",
248 Severity::Error => "error",
249 Severity::Critical => "critical",
250 }
251 }
252}
253
254impl std::fmt::Display for Severity {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.write_str(self.name())
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct TicketDraft {
267 pub idempotency_key: String,
272 pub title: String,
274 pub body_markdown: String,
277 pub severity: Severity,
279 pub labels: Vec<String>,
281 pub environment: Option<String>,
284}
285
286impl TicketDraft {
287 #[must_use]
290 pub fn new(
291 idempotency_key: impl Into<String>,
292 title: impl Into<String>,
293 body_markdown: impl Into<String>,
294 severity: Severity,
295 ) -> Self {
296 Self {
297 idempotency_key: idempotency_key.into(),
298 title: title.into(),
299 body_markdown: body_markdown.into(),
300 severity,
301 labels: Vec::new(),
302 environment: None,
303 }
304 }
305
306 #[must_use]
308 pub fn labels(mut self, labels: Vec<String>) -> Self {
309 self.labels = labels;
310 self
311 }
312
313 #[must_use]
315 pub fn environment(mut self, environment: impl Into<String>) -> Self {
316 self.environment = Some(environment.into());
317 self
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct Filed {
326 pub external_id: String,
329 pub url: String,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct TicketComment {
339 pub idempotency_key: String,
342 pub body_markdown: String,
345 pub link: Option<String>,
348}
349
350impl TicketComment {
351 #[must_use]
354 pub fn new(idempotency_key: impl Into<String>, body_markdown: impl Into<String>) -> Self {
355 Self {
356 idempotency_key: idempotency_key.into(),
357 body_markdown: body_markdown.into(),
358 link: None,
359 }
360 }
361
362 #[must_use]
364 pub fn with_link(mut self, url: impl Into<String>) -> Self {
365 self.link = Some(url.into());
366 self
367 }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum TicketState {
382 Open,
384 InProgress,
386 Resolved,
389 Closed,
391 Unknown,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct TicketStatus {
398 pub external_id: String,
400 pub state: TicketState,
402 pub url: Option<String>,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Error)]
423pub enum TrackerError {
424 #[error("tracker is not configured")]
427 NotConfigured,
428 #[error("tracker refused the credential")]
436 Unauthorized,
437 #[error("tracker rejected the request: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
441 Rejected(String),
442 #[error(
446 "tracker request failed, retryable: {delay}",
447 delay = retry_after_description(.retry_after)
448 )]
449 Transient {
450 retry_after: Option<Duration>,
452 },
453}
454
455impl TrackerError {
456 #[must_use]
459 pub fn unsupported_destination(dest: &Destination) -> Self {
460 TrackerError::Rejected(format!(
461 "unsupported destination: this adapter does not serve {}",
462 dest.kind()
463 ))
464 }
465
466 #[must_use]
468 pub fn retry_after(&self) -> Option<Duration> {
469 match self {
470 TrackerError::Transient { retry_after } => *retry_after,
471 _ => None,
472 }
473 }
474}
475
476#[allow(clippy::ref_option)]
483fn retry_after_description(retry_after: &Option<Duration>) -> String {
484 match retry_after {
485 Some(after) => format!("retry after {}s", after.as_secs()),
486 None => "no delay was stated".to_owned(),
487 }
488}
489
490#[async_trait]
508pub trait Tracker: Send + Sync {
509 async fn file(
521 &self,
522 dest: &Destination,
523 cred: &Credential,
524 draft: &TicketDraft,
525 ) -> Result<Filed, TrackerError>;
526
527 async fn status(
533 &self,
534 dest: &Destination,
535 cred: &Credential,
536 external_id: &str,
537 ) -> Result<TicketStatus, TrackerError>;
538
539 async fn comment(
553 &self,
554 dest: &Destination,
555 _cred: &Credential,
556 _external_id: &str,
557 _comment: &TicketComment,
558 ) -> Result<(), TrackerError> {
559 Err(TrackerError::Rejected(format!(
560 "this adapter does not support comments on {} tickets",
561 dest.kind()
562 )))
563 }
564}
565
566#[derive(Default, Clone)]
576pub struct RoutingTracker {
577 github: Option<Arc<dyn Tracker>>,
578 jira: Option<Arc<dyn Tracker>>,
579 linear: Option<Arc<dyn Tracker>>,
580 zendesk: Option<Arc<dyn Tracker>>,
581 freshdesk: Option<Arc<dyn Tracker>>,
582 intercom: Option<Arc<dyn Tracker>>,
583 salesforce: Option<Arc<dyn Tracker>>,
584 hubspot: Option<Arc<dyn Tracker>>,
585 slack: Option<Arc<dyn Tracker>>,
586 webhook: Option<Arc<dyn Tracker>>,
587 colonizer: Option<Arc<dyn Tracker>>,
588}
589
590impl RoutingTracker {
591 #[must_use]
593 pub fn new() -> Self {
594 Self::default()
595 }
596
597 #[must_use]
599 pub fn github(mut self, tracker: Arc<dyn Tracker>) -> Self {
600 self.github = Some(tracker);
601 self
602 }
603
604 #[must_use]
606 pub fn jira(mut self, tracker: Arc<dyn Tracker>) -> Self {
607 self.jira = Some(tracker);
608 self
609 }
610
611 #[must_use]
613 pub fn linear(mut self, tracker: Arc<dyn Tracker>) -> Self {
614 self.linear = Some(tracker);
615 self
616 }
617
618 #[must_use]
620 pub fn zendesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
621 self.zendesk = Some(tracker);
622 self
623 }
624
625 #[must_use]
627 pub fn freshdesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
628 self.freshdesk = Some(tracker);
629 self
630 }
631
632 #[must_use]
634 pub fn intercom(mut self, tracker: Arc<dyn Tracker>) -> Self {
635 self.intercom = Some(tracker);
636 self
637 }
638
639 #[must_use]
641 pub fn salesforce(mut self, tracker: Arc<dyn Tracker>) -> Self {
642 self.salesforce = Some(tracker);
643 self
644 }
645
646 #[must_use]
648 pub fn hubspot(mut self, tracker: Arc<dyn Tracker>) -> Self {
649 self.hubspot = Some(tracker);
650 self
651 }
652
653 #[must_use]
655 pub fn slack(mut self, tracker: Arc<dyn Tracker>) -> Self {
656 self.slack = Some(tracker);
657 self
658 }
659
660 #[must_use]
662 pub fn webhook(mut self, tracker: Arc<dyn Tracker>) -> Self {
663 self.webhook = Some(tracker);
664 self
665 }
666
667 #[must_use]
669 pub fn colonizer(mut self, tracker: Arc<dyn Tracker>) -> Self {
670 self.colonizer = Some(tracker);
671 self
672 }
673
674 #[must_use]
676 pub fn route_for(&self, destination: &Destination) -> Option<&Arc<dyn Tracker>> {
677 match destination {
678 Destination::GitHub { .. } => self.github.as_ref(),
679 Destination::Jira { .. } => self.jira.as_ref(),
680 Destination::Linear { .. } => self.linear.as_ref(),
681 Destination::Zendesk { .. } => self.zendesk.as_ref(),
682 Destination::Freshdesk { .. } => self.freshdesk.as_ref(),
683 Destination::Intercom => self.intercom.as_ref(),
684 Destination::Salesforce { .. } => self.salesforce.as_ref(),
685 Destination::HubSpot => self.hubspot.as_ref(),
686 Destination::Slack { .. } => self.slack.as_ref(),
687 Destination::Webhook { .. } => self.webhook.as_ref(),
688 Destination::Colonizer { .. } => self.colonizer.as_ref(),
689 }
690 }
691}
692
693impl std::fmt::Debug for RoutingTracker {
694 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
695 f.debug_struct("RoutingTracker")
696 .field("github", &self.github.is_some())
697 .field("jira", &self.jira.is_some())
698 .field("linear", &self.linear.is_some())
699 .field("zendesk", &self.zendesk.is_some())
700 .field("freshdesk", &self.freshdesk.is_some())
701 .field("intercom", &self.intercom.is_some())
702 .field("salesforce", &self.salesforce.is_some())
703 .field("hubspot", &self.hubspot.is_some())
704 .field("slack", &self.slack.is_some())
705 .field("webhook", &self.webhook.is_some())
706 .field("colonizer", &self.colonizer.is_some())
707 .finish()
708 }
709}
710
711#[async_trait]
712impl Tracker for RoutingTracker {
713 async fn file(
714 &self,
715 dest: &Destination,
716 cred: &Credential,
717 draft: &TicketDraft,
718 ) -> Result<Filed, TrackerError> {
719 match self.route_for(dest) {
720 Some(tracker) => tracker.file(dest, cred, draft).await,
721 None => Err(TrackerError::NotConfigured),
722 }
723 }
724
725 async fn status(
726 &self,
727 dest: &Destination,
728 cred: &Credential,
729 external_id: &str,
730 ) -> Result<TicketStatus, TrackerError> {
731 match self.route_for(dest) {
732 Some(tracker) => tracker.status(dest, cred, external_id).await,
733 None => Err(TrackerError::NotConfigured),
734 }
735 }
736
737 async fn comment(
738 &self,
739 dest: &Destination,
740 cred: &Credential,
741 external_id: &str,
742 comment: &TicketComment,
743 ) -> Result<(), TrackerError> {
744 match self.route_for(dest) {
745 Some(tracker) => tracker.comment(dest, cred, external_id, comment).await,
746 None => Err(TrackerError::NotConfigured),
747 }
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759pub struct StatusUpdate {
760 pub external_id: String,
763 pub state: TicketState,
765 pub url: Option<String>,
767}
768
769pub trait StatusWebhook: Send + Sync {
784 fn kind(&self) -> &'static str;
787
788 fn verifier(&self) -> WebhookVerifier;
793
794 fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError>;
804}
805
806#[derive(Debug, Clone, PartialEq, Eq, Error)]
813pub enum InboundStatusError {
814 #[error("status webhook signature verification failed")]
818 Signature,
819 #[error("status webhook body is malformed: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
822 Malformed(String),
823}
824
825pub fn receive_status(
839 hook: &dyn StatusWebhook,
840 secret: &str,
841 headers: &HeaderMap,
842 body: &[u8],
843 now_unix: i64,
844) -> Result<Option<StatusUpdate>, InboundStatusError> {
845 if !hook.verifier().verify(secret, headers, body, now_unix) {
846 return Err(InboundStatusError::Signature);
847 }
848 hook.parse(body)
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use crate::webhook_signature::{ProviderScheme, SignatureEncoding};
855
856 #[test]
860 fn a_destination_round_trips_through_json() {
861 for dest in all_destinations() {
862 let json = serde_json::to_string(&dest).expect("serialises");
863 let back: Destination = serde_json::from_str(&json).expect("deserialises");
864 assert_eq!(dest, back);
865 }
866 }
867
868 fn all_destinations() -> Vec<Destination> {
870 vec![
871 Destination::GitHub {
872 owner: "acme".to_owned(),
873 repo: "api".to_owned(),
874 },
875 Destination::Jira {
876 site: "acme".to_owned(),
877 project: "PROJ".to_owned(),
878 },
879 Destination::Linear {
880 team: "Platform".to_owned(),
881 },
882 Destination::Zendesk {
883 subdomain: "acme".to_owned(),
884 },
885 Destination::Freshdesk {
886 domain: "acme.freshdesk.com".to_owned(),
887 },
888 Destination::Intercom,
889 Destination::Salesforce {
890 instance: "acme.my.salesforce.com".to_owned(),
891 },
892 Destination::HubSpot,
893 Destination::Slack {
894 channel: "C0123456789".to_owned(),
895 },
896 Destination::Webhook {
897 url: "https://hooks.example.test/services/TOKEN/SECRET".to_owned(),
898 },
899 Destination::Colonizer {
900 repo: "acme/owlpost".to_owned(),
901 },
902 ]
903 }
904
905 #[test]
906 fn every_destination_kind_names_itself() {
907 let kinds: Vec<&'static str> = all_destinations().iter().map(Destination::kind).collect();
908 assert_eq!(
909 kinds,
910 [
911 "github",
912 "jira",
913 "linear",
914 "zendesk",
915 "freshdesk",
916 "intercom",
917 "salesforce",
918 "hubspot",
919 "slack",
920 "webhook",
921 "colonizer",
922 ]
923 );
924 }
925
926 #[test]
927 fn the_webhook_debug_prints_no_url() {
928 #[derive(Debug)]
931 struct Row {
932 dest: Destination,
933 }
934
935 let row = Row {
940 dest: Destination::Webhook {
941 url: "https://hooks.example.test/services/TOKEN/SECRET".to_owned(),
942 },
943 };
944 let printed = format!("{row:?}");
945 assert!(printed.contains("Webhook"), "{printed}");
946 assert!(printed.contains("[redacted]"), "{printed}");
947 assert!(!printed.contains("hooks.example.test"), "{printed}");
948 assert!(!printed.contains("TOKEN"), "{printed}");
949 assert!(!printed.contains("SECRET"), "{printed}");
950 assert!(!printed.contains("https"), "{printed}");
951 assert!(matches!(row.dest, Destination::Webhook { .. }));
952 }
953
954 #[test]
955 fn the_other_destinations_debug_print_their_routing_fields() {
956 let printed = format!(
959 "{:?}",
960 Destination::GitHub {
961 owner: "acme".to_owned(),
962 repo: "api".to_owned(),
963 }
964 );
965 assert!(printed.contains("acme"), "{printed}");
966 assert!(printed.contains("api"), "{printed}");
967
968 let printed = format!("{:?}", Destination::Intercom);
969 assert_eq!(printed, "Destination::Intercom");
970 let printed = format!("{:?}", Destination::HubSpot);
971 assert_eq!(printed, "Destination::HubSpot");
972
973 let printed = format!(
975 "{:?}",
976 Destination::Freshdesk {
977 domain: "acme.freshdesk.com".to_owned(),
978 }
979 );
980 assert!(printed.contains("acme.freshdesk.com"), "{printed}");
981 }
982
983 #[test]
984 fn a_colonizer_destination_round_trips_and_prints_its_repo() {
985 let dest = Destination::Colonizer {
990 repo: "acme/owlpost".to_owned(),
991 };
992
993 let json = serde_json::to_value(&dest).expect("serialises");
994 assert_eq!(
995 json,
996 serde_json::json!({ "colonizer": { "repo": "acme/owlpost" } })
997 );
998 let back: Destination = serde_json::from_value(json).expect("deserialises");
999 assert_eq!(back, dest);
1000
1001 assert_eq!(dest.kind(), "colonizer");
1002 assert_eq!(
1003 format!("{dest:?}"),
1004 r#"Destination::Colonizer { repo: "acme/owlpost" }"#
1005 );
1006 }
1007
1008 #[test]
1012 fn the_credential_debug_prints_nothing_but_redacted() {
1013 #[derive(Debug)]
1015 struct Row {
1016 cred: Credential,
1017 }
1018
1019 let row = Row {
1022 cred: Credential::new("ghp_4a1b2c3d4e5fREALtokenVALUEnobodyshouldsee"),
1023 };
1024 let printed = format!("{row:?}");
1025 assert!(!printed.contains("ghp_"), "{printed}");
1026 assert!(!printed.contains("REALtokenVALUE"), "{printed}");
1027 assert!(printed.contains("Credential([redacted])"), "{printed}");
1028 assert_eq!(
1029 row.cred.expose(),
1030 "ghp_4a1b2c3d4e5fREALtokenVALUEnobodyshouldsee"
1031 );
1032 }
1033
1034 #[test]
1035 fn the_credential_exposes_its_text_once_wrapped() {
1036 let cred = Credential::new("token-from-secrets");
1037 assert_eq!(cred.expose(), "token-from-secrets");
1038 let copy = cred.clone();
1041 assert_eq!(copy.expose(), "token-from-secrets");
1042 }
1043
1044 #[test]
1048 fn a_draft_builds_through_the_chaining_setters() {
1049 let draft = TicketDraft::new(
1050 "outbox-42",
1051 "Checkout failing",
1052 "Stripe 500s on `/v1/charges`.",
1053 Severity::Error,
1054 )
1055 .labels(vec!["billing".to_owned(), "incident".to_owned()])
1056 .environment("production");
1057
1058 assert_eq!(draft.idempotency_key, "outbox-42");
1059 assert_eq!(draft.title, "Checkout failing");
1060 assert_eq!(draft.severity, Severity::Error);
1061 assert_eq!(draft.labels, ["billing", "incident"]);
1062 assert_eq!(draft.environment.as_deref(), Some("production"));
1063 }
1064
1065 #[test]
1066 fn a_minimal_draft_has_no_labels_or_environment() {
1067 let draft = TicketDraft::new("k", "t", "b", Severity::Info);
1068 assert!(draft.labels.is_empty());
1069 assert_eq!(draft.environment, None);
1070 }
1071
1072 #[test]
1073 fn severity_names_are_snake_case_on_the_wire_and_in_display() {
1074 for (severity, name) in [
1075 (Severity::Info, "info"),
1076 (Severity::Warning, "warning"),
1077 (Severity::Error, "error"),
1078 (Severity::Critical, "critical"),
1079 ] {
1080 assert_eq!(severity.name(), name);
1081 assert_eq!(severity.to_string(), name);
1082 let json = serde_json::to_value(severity).unwrap();
1083 assert_eq!(json, name);
1084 let back: Severity = serde_json::from_value(json).unwrap();
1085 assert_eq!(back, severity);
1086 }
1087 }
1088
1089 #[test]
1093 fn transient_carries_an_optional_retry_after() {
1094 let plain = TrackerError::Transient { retry_after: None };
1095 assert_eq!(plain.retry_after(), None);
1096 let throttled = TrackerError::Transient {
1097 retry_after: Some(Duration::from_secs(30)),
1098 };
1099 assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
1100 assert_eq!(TrackerError::NotConfigured.retry_after(), None);
1102 assert_eq!(TrackerError::Unauthorized.retry_after(), None);
1103 assert_eq!(
1104 TrackerError::Rejected("jira 401".to_owned()).retry_after(),
1105 None
1106 );
1107 }
1108
1109 #[test]
1110 fn the_transient_display_states_the_delay_where_one_was_given() {
1111 let error = TrackerError::Transient {
1112 retry_after: Some(Duration::from_secs(30)),
1113 };
1114 let text = error.to_string();
1115 assert!(text.contains("retryable"), "{text}");
1116 assert!(text.contains("30s"), "{text}");
1117
1118 let text = TrackerError::Transient { retry_after: None }.to_string();
1119 assert!(text.contains("retryable"), "{text}");
1120 assert!(text.contains("no delay"), "{text}");
1121 }
1122
1123 #[test]
1124 fn display_sanitizes_the_rejection_text() {
1125 let error = TrackerError::Rejected(
1130 "webhook 400 for https://hooks.example.test/services/TOKEN?token=SECRET".to_owned(),
1131 );
1132 let text = error.to_string();
1133 assert!(!text.contains("SECRET"), "{text}");
1134 assert!(text.contains("?[redacted]"), "{text}");
1135
1136 let error = TrackerError::Rejected("jira 403 for devops@acme.test".to_owned());
1139 let text = error.to_string();
1140 assert!(!text.contains('@'), "{text}");
1141 assert!(text.contains("[subject_hash:"), "{text}");
1142
1143 assert!(format!("{error:?}").contains("devops@acme.test"));
1145
1146 assert_eq!(
1147 TrackerError::NotConfigured.to_string(),
1148 "tracker is not configured"
1149 );
1150 }
1151
1152 #[test]
1153 fn an_unsupported_destination_names_the_kind() {
1154 let error = TrackerError::unsupported_destination(&Destination::Slack {
1155 channel: "C0123456789".to_owned(),
1156 });
1157 let message = error.to_string();
1158 assert!(message.contains("unsupported destination"), "{message}");
1159 assert!(message.contains("slack"), "{message}");
1160 }
1161
1162 #[test]
1163 fn an_unauthorized_credential_is_distinct_from_a_rejected_ticket() {
1164 let error = TrackerError::Unauthorized;
1169 assert_eq!(error.to_string(), "tracker refused the credential");
1170 assert_eq!(error.retry_after(), None, "an auth failure names no delay");
1171 assert_ne!(error, TrackerError::Rejected("401".to_owned()));
1172 assert!(format!("{error:?}").contains("Unauthorized"));
1175 }
1176
1177 struct Recording {
1181 seen: std::sync::atomic::AtomicUsize,
1182 noted: std::sync::atomic::AtomicUsize,
1183 }
1184
1185 impl Recording {
1186 fn new() -> Arc<Self> {
1187 Arc::new(Self {
1188 seen: std::sync::atomic::AtomicUsize::new(0),
1189 noted: std::sync::atomic::AtomicUsize::new(0),
1190 })
1191 }
1192 fn count(&self) -> usize {
1193 self.seen.load(std::sync::atomic::Ordering::Relaxed)
1194 }
1195 fn note_count(&self) -> usize {
1196 self.noted.load(std::sync::atomic::Ordering::Relaxed)
1197 }
1198 }
1199
1200 #[async_trait]
1201 impl Tracker for Recording {
1202 async fn file(
1203 &self,
1204 _dest: &Destination,
1205 _cred: &Credential,
1206 _draft: &TicketDraft,
1207 ) -> Result<Filed, TrackerError> {
1208 self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1209 Ok(Filed {
1210 external_id: "PROJ-7".to_owned(),
1211 url: "https://tracker.example.test/browse/PROJ-7".to_owned(),
1212 })
1213 }
1214
1215 async fn status(
1216 &self,
1217 _dest: &Destination,
1218 _cred: &Credential,
1219 external_id: &str,
1220 ) -> Result<TicketStatus, TrackerError> {
1221 self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1222 Ok(TicketStatus {
1223 external_id: external_id.to_owned(),
1224 state: TicketState::InProgress,
1225 url: None,
1226 })
1227 }
1228
1229 async fn comment(
1230 &self,
1231 _dest: &Destination,
1232 _cred: &Credential,
1233 _external_id: &str,
1234 _comment: &TicketComment,
1235 ) -> Result<(), TrackerError> {
1236 self.noted
1237 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1238 Ok(())
1239 }
1240 }
1241
1242 struct FileOnly;
1246
1247 #[async_trait]
1248 impl Tracker for FileOnly {
1249 async fn file(
1250 &self,
1251 _: &Destination,
1252 _: &Credential,
1253 _: &TicketDraft,
1254 ) -> Result<Filed, TrackerError> {
1255 Err(TrackerError::NotConfigured)
1256 }
1257
1258 async fn status(
1259 &self,
1260 _: &Destination,
1261 _: &Credential,
1262 _: &str,
1263 ) -> Result<TicketStatus, TrackerError> {
1264 Err(TrackerError::NotConfigured)
1265 }
1266 }
1267
1268 fn a_draft() -> TicketDraft {
1269 TicketDraft::new("k", "t", "b", Severity::Info)
1270 }
1271
1272 fn a_comment() -> TicketComment {
1273 TicketComment::new("outbox-43", "Duplicate of the checkout 500s.")
1274 .with_link("https://reports.example.test/43")
1275 }
1276
1277 fn a_freshdesk() -> Destination {
1278 Destination::Freshdesk {
1279 domain: "acme.freshdesk.com".to_owned(),
1280 }
1281 }
1282
1283 #[test]
1284 fn the_default_comment_impl_refuses_by_name() {
1285 let error = pollster::block_on(FileOnly.comment(
1286 &a_freshdesk(),
1287 &Credential::new("t"),
1288 "PROJ-7",
1289 &a_comment(),
1290 ))
1291 .unwrap_err();
1292 assert!(matches!(error, TrackerError::Rejected(_)));
1293 let text = error.to_string();
1294 assert!(text.contains("does not support comments"), "{text}");
1295 assert!(text.contains("freshdesk"), "{text}");
1296 }
1297
1298 #[test]
1299 fn a_wired_router_forwards_comment_and_an_empty_slot_refuses() {
1300 let freshdesk = Recording::new();
1301 let router = RoutingTracker::new().freshdesk(freshdesk.clone());
1302 pollster::block_on(router.comment(
1303 &a_freshdesk(),
1304 &Credential::new("t"),
1305 "PROJ-7",
1306 &a_comment(),
1307 ))
1308 .expect("the wired adapter answers");
1309 assert_eq!(freshdesk.note_count(), 1);
1310
1311 let other = Destination::Zendesk {
1314 subdomain: "acme".to_owned(),
1315 };
1316 let error = pollster::block_on(router.comment(
1317 &other,
1318 &Credential::new("t"),
1319 "PROJ-7",
1320 &a_comment(),
1321 ))
1322 .unwrap_err();
1323 assert_eq!(error, TrackerError::NotConfigured);
1324 assert_eq!(
1325 freshdesk.note_count(),
1326 1,
1327 "the wired adapter was not called"
1328 );
1329 }
1330
1331 #[test]
1332 fn an_empty_router_is_not_configured_for_every_destination() {
1333 let router = RoutingTracker::new();
1334 for dest in all_destinations() {
1335 let filed = pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()));
1336 assert_eq!(filed.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
1337 let status = pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"));
1338 assert_eq!(status.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
1339 let noted = pollster::block_on(router.comment(
1340 &dest,
1341 &Credential::new("t"),
1342 "PROJ-7",
1343 &a_comment(),
1344 ));
1345 assert_eq!(noted.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
1346 assert!(router.route_for(&dest).is_none());
1347 }
1348 }
1349
1350 #[test]
1351 fn a_wired_slot_reaches_its_adapter_and_the_rest_stay_not_configured() {
1352 let github = Recording::new();
1353 let router = RoutingTracker::new().github(github.clone());
1354
1355 let dest = Destination::GitHub {
1356 owner: "acme".to_owned(),
1357 repo: "api".to_owned(),
1358 };
1359 let filed = pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()))
1360 .expect("the wired adapter answers");
1361 assert_eq!(filed.external_id, "PROJ-7");
1362 let status = pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"))
1363 .expect("the wired adapter answers");
1364 assert_eq!(status.state, TicketState::InProgress);
1365 assert_eq!(github.count(), 2);
1366 assert!(router.route_for(&dest).is_some());
1367
1368 let other = Destination::Slack {
1371 channel: "C0123456789".to_owned(),
1372 };
1373 let error =
1374 pollster::block_on(router.file(&other, &Credential::new("t"), &a_draft())).unwrap_err();
1375 assert_eq!(error, TrackerError::NotConfigured);
1376 assert_eq!(github.count(), 2, "the wired adapter was not called");
1377 assert!(router.route_for(&other).is_none());
1378 }
1379
1380 #[test]
1381 fn every_destination_dispatches_to_the_adapter_wired_for_its_slot() {
1382 let github = Recording::new();
1391 let jira = Recording::new();
1392 let linear = Recording::new();
1393 let zendesk = Recording::new();
1394 let freshdesk = Recording::new();
1395 let intercom = Recording::new();
1396 let salesforce = Recording::new();
1397 let hubspot = Recording::new();
1398 let slack = Recording::new();
1399 let webhook = Recording::new();
1400 let colonizer = Recording::new();
1401
1402 let router = RoutingTracker::new()
1403 .github(github.clone())
1404 .jira(jira.clone())
1405 .linear(linear.clone())
1406 .zendesk(zendesk.clone())
1407 .freshdesk(freshdesk.clone())
1408 .intercom(intercom.clone())
1409 .salesforce(salesforce.clone())
1410 .hubspot(hubspot.clone())
1411 .slack(slack.clone())
1412 .webhook(webhook.clone())
1413 .colonizer(colonizer.clone());
1414
1415 let slots = [
1418 github, jira, linear, zendesk, freshdesk, intercom, salesforce, hubspot, slack,
1419 webhook, colonizer,
1420 ];
1421
1422 for (dest, adapter) in all_destinations().into_iter().zip(slots) {
1423 let wired: Arc<dyn Tracker> = adapter.clone();
1427 assert!(
1428 Arc::ptr_eq(router.route_for(&dest).expect("wired"), &wired),
1429 "{dest:?} routed to the wrong slot",
1430 );
1431
1432 pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()))
1433 .expect("the wired adapter answers");
1434 assert_eq!(adapter.count(), 1, "{dest:?} reached the wrong adapter");
1435 pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"))
1436 .expect("the wired adapter answers");
1437 assert_eq!(adapter.count(), 2, "status did not reuse the slot");
1438 }
1439 }
1440
1441 #[test]
1442 fn the_router_debug_prints_only_wiring_not_adapters() {
1443 let router = RoutingTracker::new().jira(Recording::new());
1444 let printed = format!("{router:?}");
1445 assert!(printed.contains("RoutingTracker"), "{printed}");
1446 assert!(printed.contains("jira: true"), "{printed}");
1447 assert!(printed.contains("github: false"), "{printed}");
1448 assert!(printed.contains("webhook: false"), "{printed}");
1449 }
1450
1451 struct JiraHook {
1459 parse_saw_bytes: std::sync::atomic::AtomicBool,
1460 }
1461
1462 impl StatusWebhook for JiraHook {
1463 fn kind(&self) -> &'static str {
1464 "jira"
1465 }
1466
1467 fn verifier(&self) -> WebhookVerifier {
1468 WebhookVerifier::new(ProviderScheme {
1469 signature: "X-Hub-Signature",
1470 encoding: SignatureEncoding::Hex,
1471 prefix: Some("sha256="),
1472 timestamp: None,
1473 })
1474 }
1475
1476 fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError> {
1477 self.parse_saw_bytes
1478 .store(true, std::sync::atomic::Ordering::Relaxed);
1479 let event: serde_json::Value = serde_json::from_slice(body)
1480 .map_err(|error| InboundStatusError::Malformed(error.to_string()))?;
1481 let Some(status) = event.get("status").and_then(serde_json::Value::as_str) else {
1482 return Ok(None);
1485 };
1486 Ok(Some(StatusUpdate {
1487 external_id: event["ticket"].as_str().unwrap_or_default().to_owned(),
1488 state: if status == "done" {
1489 TicketState::Closed
1490 } else {
1491 TicketState::Unknown
1492 },
1493 url: event["url"].as_str().map(ToOwned::to_owned),
1494 }))
1495 }
1496 }
1497
1498 fn jira_signature(secret: &str, body: &[u8]) -> String {
1500 use hmac::{Hmac, KeyInit, Mac};
1501 use sha2::Sha256;
1502 use std::fmt::Write as _;
1503
1504 let mut mac =
1505 <Hmac<Sha256> as KeyInit>::new_from_slice(secret.as_bytes()).expect("any key length");
1506 mac.update(body);
1507 mac.finalize()
1508 .into_bytes()
1509 .iter()
1510 .fold(String::new(), |mut out, byte| {
1511 let _ = write!(out, "{byte:02x}");
1512 out
1513 })
1514 }
1515
1516 fn sig_headers(name: &str, value: &str) -> HeaderMap {
1517 [(
1518 name.parse::<http::header::HeaderName>()
1519 .expect("test header name"),
1520 value
1521 .parse::<http::HeaderValue>()
1522 .expect("test header value"),
1523 )]
1524 .into_iter()
1525 .collect()
1526 }
1527
1528 #[test]
1529 fn receive_status_refuses_a_bad_signature_without_parsing() {
1530 let hook = JiraHook {
1531 parse_saw_bytes: std::sync::atomic::AtomicBool::new(false),
1532 };
1533 let body = br#"{"ticket":"PROJ-7","status":"done"}"#;
1534 let signed = sig_headers(
1535 "X-Hub-Signature",
1536 &format!("sha256={}", jira_signature("right-secret", body)),
1537 );
1538
1539 assert_eq!(
1542 receive_status(&hook, "wrong-secret", &signed, body, 0).unwrap_err(),
1543 InboundStatusError::Signature
1544 );
1545 let tampered = receive_status(&hook, "right-secret", &signed, b"{}", 0).unwrap_err();
1546 assert_eq!(tampered, InboundStatusError::Signature);
1547 let unsigned =
1548 receive_status(&hook, "right-secret", &HeaderMap::new(), body, 0).unwrap_err();
1549 assert_eq!(unsigned, InboundStatusError::Signature);
1550 assert!(
1551 !hook
1552 .parse_saw_bytes
1553 .load(std::sync::atomic::Ordering::Relaxed)
1554 );
1555 }
1556
1557 #[test]
1558 fn receive_status_parses_a_verified_state_change() {
1559 let hook = JiraHook {
1560 parse_saw_bytes: std::sync::atomic::AtomicBool::new(false),
1561 };
1562 let body = br#"{"ticket":"PROJ-7","status":"done","url":"https://jira.example.test/browse/PROJ-7"}"#;
1563 let signed = sig_headers(
1564 "X-Hub-Signature",
1565 &format!("sha256={}", jira_signature("right-secret", body)),
1566 );
1567 let update = receive_status(&hook, "right-secret", &signed, body, 0)
1568 .expect("the signature verifies");
1569 assert_eq!(
1570 update,
1571 Some(StatusUpdate {
1572 external_id: "PROJ-7".to_owned(),
1573 state: TicketState::Closed,
1574 url: Some("https://jira.example.test/browse/PROJ-7".to_owned()),
1575 })
1576 );
1577 assert!(
1578 hook.parse_saw_bytes
1579 .load(std::sync::atomic::Ordering::Relaxed)
1580 );
1581
1582 let ping = br#"{"kind":"ping"}"#;
1584 let pinged = sig_headers(
1585 "X-Hub-Signature",
1586 &format!("sha256={}", jira_signature("right-secret", ping)),
1587 );
1588 assert_eq!(
1589 receive_status(&hook, "right-secret", &pinged, ping, 0).expect("verified"),
1590 None
1591 );
1592 }
1593
1594 #[test]
1595 fn receive_status_maps_a_verified_but_unreadable_body_to_malformed() {
1596 let hook = JiraHook {
1597 parse_saw_bytes: std::sync::atomic::AtomicBool::new(false),
1598 };
1599 let body = br"not json at all - https://hooks.example.test/TOKEN?token=SECRET";
1600 let signed = sig_headers(
1601 "X-Hub-Signature",
1602 &format!("sha256={}", jira_signature("right-secret", body)),
1603 );
1604 let error = receive_status(&hook, "right-secret", &signed, body, 0).unwrap_err();
1605 assert!(matches!(error, InboundStatusError::Malformed(_)));
1606 assert!(error.to_string().contains("malformed"));
1607
1608 let error = InboundStatusError::Malformed(
1613 "vendor said: https://hooks.example.test/TOKEN?token=SECRET".to_owned(),
1614 );
1615 let text = error.to_string();
1616 assert!(!text.contains("SECRET"), "{text}");
1617 assert!(format!("{error:?}").contains("SECRET"));
1618 }
1619}