Skip to main content

cratefield_core/ports/
tracker.rs

1//! The `Tracker` port (issue #431): filing a ticket into *someone else's*
2//! tracker — a GitHub repo, a Jira site, a Linear team, a Zendesk subdomain,
3//! a Freshdesk portal, Intercom, a Salesforce instance, `HubSpot`, a Slack
4//! channel, a Colonizer repository, or a plain webhook — and asking how that
5//! ticket is doing afterwards.
6//!
7//! The reason this is a port and not a module detail is the deployment
8//! shape: one hosted Worker serves many customer companies, and each
9//! company files into *its own* tracker under *its own* API token. The
10//! destinations and the credentials are therefore tenant data, resolved per
11//! request — which is why every [`Tracker`] method takes a
12//! [`Destination`] and a [`Credential`] rather than the adapter holding
13//! either at construction (see the trait's docs for why that differs from
14//! the Resend/Stripe adapter shape).
15
16use 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/// Where a ticket is filed. One variant per supported tracker, because the
28/// eleven do not share a shape: a GitHub destination is an owner and repo, a
29/// webhook is a URL, and Intercom and `HubSpot` address nothing but the
30/// credential — the workspace/portal the token belongs to *is* the
31/// destination, so those variants carry no fields at all.
32///
33/// The fields name *where* to file, not *who may file*: an owner, a repo, a
34/// site, a channel are routing facts a venture may reasonably hold, log and
35/// persist. Most variants therefore hold no secret — `Webhook` is the
36/// exception, because its URL routinely carries a bearer token in its path
37/// or query — so the hand-written [`Debug`](#impl-Debug-for-Destination)
38/// renders a webhook URL as `[redacted]`, following the
39/// [`Recipient`](crate::Recipient) precedent: core's log redaction keys off
40/// the *field name* ([`is_secret_field`](crate::is_secret_field)) and
41/// cannot see inside a `{:?}` of this enum.
42///
43/// [`Serialize`]/[`Deserialize`] stay, because a destination has to be
44/// persisted and read back — it is tenant configuration (the credential
45/// proper is [`Credential`], which refuses both) — and `Serialize`
46/// deliberately does **not** redact, because round-tripping tenant
47/// configuration has to work. The consequence: a serialised
48/// `Destination::Webhook` **is** the credential — store it where secrets
49/// go, never in a log, an event payload, or an error body.
50#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Destination {
53    /// A repository on GitHub (or GitHub Enterprise).
54    GitHub {
55        /// The repository's owner (a user or organisation).
56        owner: String,
57        /// The repository name.
58        repo: String,
59    },
60    /// A project on a Jira site.
61    Jira {
62        /// The Jira site's bare hostname (`acme.atlassian.net`) — never a
63        /// scheme or path, so the Basic credential cannot be moved off the
64        /// host it names.
65        site: String,
66        /// The project key tickets are filed under (`PROJ`).
67        project: String,
68    },
69    /// A team on Linear.
70    Linear {
71        /// The team tickets are filed under.
72        team: String,
73    },
74    /// A Zendesk subdomain.
75    Zendesk {
76        /// The subdomain (`acme` for `acme.zendesk.com`).
77        subdomain: String,
78    },
79    /// A Freshdesk portal.
80    Freshdesk {
81        /// The portal's domain (`acme.freshdesk.com`).
82        domain: String,
83    },
84    /// Intercom. The workspace is identified by the credential.
85    Intercom,
86    /// A Salesforce instance.
87    Salesforce {
88        /// The instance's hostname (`acme.my.salesforce.com`).
89        instance: String,
90    },
91    /// `HubSpot`. The portal is identified by the credential.
92    HubSpot,
93    /// A Slack channel.
94    Slack {
95        /// The channel ID tickets are filed into (`C0123456789`).
96        channel: String,
97    },
98    /// A generic webhook. **The URL is credential material**: it is the
99    /// bearer capability that lets anyone holding it file tickets, so it is
100    /// redacted in `Debug` and adapters must treat it like a token.
101    Webhook {
102        /// The URL to `POST` the ticket to.
103        url: String,
104    },
105    /// A repository on the Colonizer automation. The repository is a routing
106    /// fact, not a secret — it names where tickets land — so it prints in
107    /// `Debug`, exactly as a GitHub repository does.
108    Colonizer {
109        /// The repository to file into.
110        repo: String,
111    },
112}
113
114impl Destination {
115    /// The kind of tracker this destination names, for logs, metrics and
116    /// error messages: `"github"`, `"jira"`, `"linear"`, `"zendesk"`,
117    /// `"freshdesk"`, `"intercom"`, `"salesforce"`, `"hubspot"`, `"slack"`,
118    /// `"webhook"`, `"colonizer"`.
119    #[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
137/// Prints the variant and its non-secret fields, never a webhook URL — a
138/// webhook endpoint is a bearer capability, and the token it carries in its
139/// path or query must not survive into a log line.
140impl 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            // The whole URL is withheld, host included: a webhook endpoint
169            // is itself the capability.
170            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/// The API credential the destination's tracker expects — the customer
179/// company's token, decrypted from `cratefield-secrets` immediately before
180/// the call and never stored anywhere else.
181///
182/// The bytes are zeroised on drop ([`Zeroizing`]), and the type implements
183/// neither `Display`, `Serialize`, `Deserialize` nor `PartialEq`: a secret
184/// that can be formatted, serialised or compared is a secret that ends up
185/// in a log line, a JSON body, or a database row nobody clears. `Debug` is
186/// implemented, and redacts:
187///
188/// ```
189/// # use cratefield_core::Credential;
190/// let cred = Credential::new("ghp_AAAAnobodyshouldseethis");
191/// assert_eq!(format!("{cred:?}"), "Credential([redacted])");
192/// ```
193///
194/// [`Clone`] stays: a copy is a second *buffer* holding the same secret —
195/// `Zeroizing`'s `Clone` clones the inner `String`, which allocates fresh —
196/// and each buffer is zeroised when its own handle drops. So no copy
197/// outlives unzeroised, but every clone is one more live copy of the
198/// secret to clear; clone deliberately.
199#[derive(Clone)]
200pub struct Credential(Zeroizing<String>);
201
202impl Credential {
203    /// Wraps a credential.
204    #[must_use]
205    pub fn new(secret: impl Into<String>) -> Self {
206        Self(Zeroizing::new(secret.into()))
207    }
208
209    /// The credential. Named `expose` so a reader has to notice — the same
210    /// rule `SecretBytes` in `cratefield-secrets` follows. Every use is
211    /// deliberate and auditable: an adapter reaches it once, to build the
212    /// one `Authorization` header the call needs, and nothing else in the
213    /// harness ever should.
214    #[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/// How urgently the ticket should be attended to. Adapters map this onto
227/// whatever their tracker has — a GitHub label, a Jira priority, a Slack
228/// `Notification-Alert` — and document what they drop.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum Severity {
232    /// Worth knowing. A note, not a page.
233    Info,
234    /// Wrong, and someone should look soon.
235    Warning,
236    /// Broken for users; file it and act on it today.
237    Error,
238    /// Data loss, security, or everything down; wake someone up.
239    Critical,
240}
241
242impl Severity {
243    /// The name used in errors, logs and labels.
244    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/// A ticket to file, in tracker-neutral terms: every adapter maps the
261/// fields its protocol has and documents what it drops. `title` and
262/// `body_markdown` are the only content every tracker takes; `labels` and
263/// `environment` are requests an adapter may flatten into the body, a
264/// label list, or nothing.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct TicketDraft {
267    /// Makes the file idempotent under retries; the caller owns its shape.
268    /// A tracker that has already accepted this key answers with the
269    /// existing ticket instead of a duplicate (an adapter may also enforce
270    /// it with an idempotency header where the API has one).
271    pub idempotency_key: String,
272    /// The ticket's title, one line.
273    pub title: String,
274    /// The ticket's body, Markdown. Adapters translate or quote it
275    /// verbatim per their tracker's format and say which.
276    pub body_markdown: String,
277    /// How urgent the ticket is.
278    pub severity: Severity,
279    /// Free-form labels. A tracker without labels carries them in the body.
280    pub labels: Vec<String>,
281    /// The deployment the ticket is about (`"production"`, `"eu-1"`), when
282    /// the caller knows one.
283    pub environment: Option<String>,
284}
285
286impl TicketDraft {
287    /// A minimal draft: the idempotency key, a title, a body and a
288    /// severity. Labels and environment are added by the chaining setters.
289    #[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    /// Sets the labels.
307    #[must_use]
308    pub fn labels(mut self, labels: Vec<String>) -> Self {
309        self.labels = labels;
310        self
311    }
312
313    /// Sets the environment the ticket is about.
314    #[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/// The result of a file that the tracker accepted: its id in the external
322/// tracker (persist it — [`Tracker::status`] is keyed by it) and the
323/// ticket's human-readable URL, where the tracker has one.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct Filed {
326    /// The ticket's id in the tracker it was filed into (`OWNER/REPO#42`,
327    /// `PROJ-7`, a webhook's delivery id).
328    pub external_id: String,
329    /// The ticket's URL, when the tracker renders one.
330    pub url: String,
331}
332
333/// A note added to an existing ticket — e.g. linking a duplicate report to
334/// the ticket it duplicates. Adapters map the fields their tracker has and
335/// document what they drop; `body_markdown` is the only content every
336/// tracker takes.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct TicketComment {
339    /// Makes the note idempotent under retries; the caller owns its shape,
340    /// exactly as [`TicketDraft::idempotency_key`] does for the file.
341    pub idempotency_key: String,
342    /// The note's body, Markdown. Adapters translate or quote it verbatim
343    /// per their tracker's format and say which.
344    pub body_markdown: String,
345    /// A URL the note points at — typically the duplicate report this
346    /// comment links into the existing ticket.
347    pub link: Option<String>,
348}
349
350impl TicketComment {
351    /// A bare note: the idempotency key and a body. The link is added by
352    /// the chaining setter.
353    #[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    /// Sets the URL the note links to.
363    #[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/// A ticket's state as the tracker last reported it. Adapters map their
371/// tracker's workflow states onto these five, and onto
372/// [`TicketState::Unknown`] anything they cannot name — they never invent
373/// a sixth state, and a caller that needs the tracker's own raw state name
374/// asks the tracker's API by `external_id`.
375///
376/// `Serialize`/`Deserialize` (snake case, the [`Severity`] precedent):
377/// a state arriving on an inbound status webhook crosses the same wire a
378/// `Severity` does leaving it.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum TicketState {
382    /// Filed, nobody has picked it up.
383    Open,
384    /// Someone is working on it.
385    InProgress,
386    /// The tracker reports it fixed or answered; the caller may want to
387    /// verify and close it.
388    Resolved,
389    /// Done, closed, will not be reopened.
390    Closed,
391    /// The tracker reported a state this port cannot name.
392    Unknown,
393}
394
395/// A ticket's current state and, where the tracker renders one, its URL.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct TicketStatus {
398    /// The ticket's id in the tracker it was filed into.
399    pub external_id: String,
400    /// The state the tracker reports.
401    pub state: TicketState,
402    /// The ticket's URL, when the tracker renders one.
403    pub url: Option<String>,
404}
405
406/// Tracker failures.
407///
408/// [`NotConfigured`](Self::NotConfigured) is an **error**, not an outcome:
409/// unlike `Push` — where "not configured" is a
410/// [`PushOutcome`](crate::PushOutcome) a caller can degrade around —
411/// `Tracker` answers `Result<Filed, TrackerError>`, and the venture code
412/// that files a ticket needs to know nobody is listening rather than be
413/// told it succeeded. [`PaymentsError::NotConfigured`](crate::PaymentsError)
414/// is the precedent.
415///
416/// The two variants that carry a message are sanitized in `Display` the
417/// way [`PushError`](crate::PushError)'s are (issue #235): what an adapter
418/// wraps is the tracker's own words, and a webhook URL or a workspace name
419/// is exactly the kind of value that rides in them. `Display` therefore
420/// runs it through [`crate::logging::scrub_text`]; `Debug` still shows the
421/// raw string for tests.
422#[derive(Debug, Clone, PartialEq, Eq, Error)]
423pub enum TrackerError {
424    /// No tracker adapter is configured for this destination, or the
425    /// adapter has no credential: the caller should degrade, not fail.
426    #[error("tracker is not configured")]
427    NotConfigured,
428    /// The tracker refused the **credential**: the per-tenant token was
429    /// wrong, expired, or lacked the scope the call needed (`401`/`403`),
430    /// so no retry and no redraft helps — a new credential has to be
431    /// issued. Distinct from [`Rejected`](Self::Rejected), which is the
432    /// tracker refusing the *ticket* (a `4xx` about the request, not
433    /// about who made it); conflating the two either pages on a bad draft
434    /// or files nothing while a tenant's token sits expired.
435    #[error("tracker refused the credential")]
436    Unauthorized,
437    /// The tracker refused the call — the destination was wrong, the
438    /// draft was rejected (`4xx`), or the adapter does not serve this
439    /// destination; not retryable without a change.
440    #[error("tracker rejected the request: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
441    Rejected(String),
442    /// A transient failure (a `5xx`, a `429`, a transport error): retry
443    /// later, and not before `retry_after` when the provider named a
444    /// delay.
445    #[error(
446        "tracker request failed, retryable: {delay}",
447        delay = retry_after_description(.retry_after)
448    )]
449    Transient {
450        /// How long the provider asked the caller to wait, where it said.
451        retry_after: Option<Duration>,
452    },
453}
454
455impl TrackerError {
456    /// The rejection an adapter returns for a destination kind it does not
457    /// serve. [`RoutingTracker`] exists so a venture never has to see this.
458    #[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    /// How long the provider asked the caller to wait, where it said.
467    #[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/// `TrackerError::Transient`'s `Display` tail: what the provider asked
477/// for, or the honest "nothing was stated" — `None` and a zero delay mean
478/// different things to a caller scheduling a retry.
479// thiserror's `#[error(…, arg = expr(.field))]` passes the matched field
480// by reference, so the signature is not ours to choose — the same reason
481// `push.rs`'s `duration_secs` carries this allow.
482#[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/// Files tickets into a tracker and reports their status.
491///
492/// **Why the credential is a per-call parameter, and not adapter
493/// construction state.** Every other adapter in this workspace is built
494/// once with its vendor's own key — the Resend `Mailer` holds the Resend
495/// API key, the Stripe `Payments` holds the Stripe secret, because on this
496/// harness the vendor account belongs to the *platform*. A tracker is the
497/// opposite: one hosted Worker serves many customer companies, each filing
498/// into **its own** tracker under **its own** token. Those per-tenant
499/// credentials live encrypted in `cratefield-secrets`, are decrypted for
500/// exactly one call, and are dropped — so the credential arrives with the
501/// call, and an adapter holds nothing between calls that a tenant change
502/// could leak. This is the one deliberate deviation from the
503/// Resend/Stripe adapter shape.
504///
505/// `dest` is a parameter for the same reason: which tracker to file into
506/// is tenant configuration, not deployment configuration.
507#[async_trait]
508pub trait Tracker: Send + Sync {
509    /// Files `draft` into `dest`, authenticating as the company `cred`
510    /// speaks for. Idempotent on `draft.idempotency_key` where the tracker
511    /// allows it.
512    ///
513    /// # Errors
514    ///
515    /// [`TrackerError::NotConfigured`] when no adapter is wired,
516    /// [`TrackerError::Unauthorized`] when the tracker refuses the
517    /// credential, [`TrackerError::Rejected`] when the tracker refuses
518    /// the call, and [`TrackerError::Transient`] with the provider's
519    /// delay, where it gave one, when the call can be retried.
520    async fn file(
521        &self,
522        dest: &Destination,
523        cred: &Credential,
524        draft: &TicketDraft,
525    ) -> Result<Filed, TrackerError>;
526
527    /// Reads one ticket's current state from `dest`.
528    ///
529    /// # Errors
530    ///
531    /// The same four as [`Tracker::file`].
532    async fn status(
533        &self,
534        dest: &Destination,
535        cred: &Credential,
536        external_id: &str,
537    ) -> Result<TicketStatus, TrackerError>;
538
539    /// Adds `comment` to the existing ticket `external_id` in `dest` —
540    /// e.g. linking a duplicate report to the ticket it duplicates, so the
541    /// tracker holds one ticket per defect rather than one per report.
542    ///
543    /// The default impl refuses: a tracker that cannot take notes says so
544    /// by name — the way the webhook tracker adapter refuses `status` —
545    /// rather than silently dropping the note. Adapters whose tracker has
546    /// a comment API override this.
547    ///
548    /// # Errors
549    ///
550    /// The same four as [`Tracker::file`], plus the default
551    /// [`TrackerError::Rejected`] when the adapter does not serve notes.
552    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/// Dispatches by [`Destination`] variant to the adapter a venture
567/// configured for that tracker, so venture code holds one
568/// `Arc<dyn Tracker>` and never matches on the destination itself.
569///
570/// A destination whose tracker has no adapter is
571/// [`TrackerError::NotConfigured`] — the same answer an unconfigured
572/// adapter gives, and deliberately an *error* rather than a silent success:
573/// a ticket that was never filed must look like a failure to the code that
574/// needed it filed (see [`TrackerError::NotConfigured`]).
575#[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    /// A router with no adapters: every destination is `NotConfigured`.
592    #[must_use]
593    pub fn new() -> Self {
594        Self::default()
595    }
596
597    /// The adapter for [`Destination::GitHub`].
598    #[must_use]
599    pub fn github(mut self, tracker: Arc<dyn Tracker>) -> Self {
600        self.github = Some(tracker);
601        self
602    }
603
604    /// The adapter for [`Destination::Jira`].
605    #[must_use]
606    pub fn jira(mut self, tracker: Arc<dyn Tracker>) -> Self {
607        self.jira = Some(tracker);
608        self
609    }
610
611    /// The adapter for [`Destination::Linear`].
612    #[must_use]
613    pub fn linear(mut self, tracker: Arc<dyn Tracker>) -> Self {
614        self.linear = Some(tracker);
615        self
616    }
617
618    /// The adapter for [`Destination::Zendesk`].
619    #[must_use]
620    pub fn zendesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
621        self.zendesk = Some(tracker);
622        self
623    }
624
625    /// The adapter for [`Destination::Freshdesk`].
626    #[must_use]
627    pub fn freshdesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
628        self.freshdesk = Some(tracker);
629        self
630    }
631
632    /// The adapter for [`Destination::Intercom`].
633    #[must_use]
634    pub fn intercom(mut self, tracker: Arc<dyn Tracker>) -> Self {
635        self.intercom = Some(tracker);
636        self
637    }
638
639    /// The adapter for [`Destination::Salesforce`].
640    #[must_use]
641    pub fn salesforce(mut self, tracker: Arc<dyn Tracker>) -> Self {
642        self.salesforce = Some(tracker);
643        self
644    }
645
646    /// The adapter for [`Destination::HubSpot`].
647    #[must_use]
648    pub fn hubspot(mut self, tracker: Arc<dyn Tracker>) -> Self {
649        self.hubspot = Some(tracker);
650        self
651    }
652
653    /// The adapter for [`Destination::Slack`].
654    #[must_use]
655    pub fn slack(mut self, tracker: Arc<dyn Tracker>) -> Self {
656        self.slack = Some(tracker);
657        self
658    }
659
660    /// The adapter for [`Destination::Webhook`].
661    #[must_use]
662    pub fn webhook(mut self, tracker: Arc<dyn Tracker>) -> Self {
663        self.webhook = Some(tracker);
664        self
665    }
666
667    /// The adapter for [`Destination::Colonizer`].
668    #[must_use]
669    pub fn colonizer(mut self, tracker: Arc<dyn Tracker>) -> Self {
670        self.colonizer = Some(tracker);
671        self
672    }
673
674    /// The adapter that serves `destination`, if one is configured.
675    #[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// ---------------------------------------------------------------------------
752// Inbound status webhooks: ticket state flowing back the other way.
753
754/// A ticket-state change a tracker reported on its inbound status webhook:
755/// the same shape [`Tracker::status`] answers with, pushed instead of
756/// pulled. `Ok(None)` from a parse means a verified event that carries no
757/// status change (a comment, a ping) — not an error.
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759pub struct StatusUpdate {
760    /// The ticket's id in the tracker it was filed into, as the vendor's
761    /// payload names it.
762    pub external_id: String,
763    /// The state the vendor reports, mapped onto the port's five.
764    pub state: TicketState,
765    /// The ticket's URL, when the vendor's payload carries one.
766    pub url: Option<String>,
767}
768
769/// A tracker's inbound status webhook, implemented per vendor by adapter
770/// crates: how to verify a delivery's signature and how to read a state
771/// change out of the verified body.
772///
773/// Verification is **not** reimplemented here: [`verifier`](Self::verifier)
774/// returns core's [`WebhookVerifier`] — constant-time compare, fail-closed
775/// on unreadable input and replay tolerance are inherited, never re-derived.
776/// The signature scheme a vendor signs with is expressed as one of core's
777/// [`SignatureScheme`](crate::SignatureScheme)s (`Svix`,
778/// [`StripeStyle`](crate::StripeStyle), or a configured
779/// [`ProviderScheme`](crate::ProviderScheme)); Jira Cloud, whose
780/// `X-Hub-Signature: sha256=<hex>` HMAC covers the raw body with no
781/// timestamp header, is
782/// `ProviderScheme { signature: "X-Hub-Signature", encoding: SignatureEncoding::Hex, prefix: Some("sha256="), timestamp: None }`.
783pub trait StatusWebhook: Send + Sync {
784    /// The vendor this webhook speaks for, for logs and metrics — the same
785    /// name [`Destination::kind`] uses for the outbound direction.
786    fn kind(&self) -> &'static str;
787
788    /// The verifier deliveries must pass before [`parse`](Self::parse) is
789    /// ever reached. Returned per call so an adapter may hold its config
790    /// without holding a verifier per tenant — the secret is a parameter
791    /// of [`receive_status`], never of the hook.
792    fn verifier(&self) -> WebhookVerifier;
793
794    /// Reads one **verified** body. `Ok(None)` for an event that carries
795    /// no status change; [`InboundStatusError::Malformed`] for one that
796    /// should have carried one and does not parse.
797    ///
798    /// # Errors
799    ///
800    /// [`InboundStatusError::Malformed`] when the verified body is not an
801    /// event this vendor sends. Never [`InboundStatusError::Signature`] —
802    /// that answer belongs to [`receive_status`], before bytes are parsed.
803    fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError>;
804}
805
806/// A status-webhook failure. The `Signature` variant carries no provider
807/// text: [`WebhookVerifier::verify`] answers a boolean — fail closed with
808/// no detail to leak — and the port keeps that property rather than
809/// inventing prose a log could leak. `Malformed` carries the adapter's own
810/// description, scrubbed in `Display` the way [`TrackerError::Rejected`]
811/// scrubs the tracker's words.
812#[derive(Debug, Clone, PartialEq, Eq, Error)]
813pub enum InboundStatusError {
814    /// The delivery's signature did not verify (or could not be read). The
815    /// body is never parsed, and the caller should answer `4xx` without
816    /// saying which part failed.
817    #[error("status webhook signature verification failed")]
818    Signature,
819    /// The delivery verified, but its body is not an event this vendor
820    /// sends. `Display` scrubs the text — a payload can quote tenant data.
821    #[error("status webhook body is malformed: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
822    Malformed(String),
823}
824
825/// Verifies a webhook delivery against `hook`'s verifier, and only then
826/// parses it: `parse` never sees unverified bytes. The parameter shapes are
827/// [`WebhookVerifier::verify`]'s — the endpoint's per-tenant `secret`, the
828/// request's `headers`, the **raw** `body` bytes (before anything parsed
829/// them) and the current `now_unix` for the replay tolerance, where the
830/// vendor's scheme carries a timestamp.
831///
832/// # Errors
833///
834/// [`InboundStatusError::Signature`] when the delivery does not verify;
835/// [`InboundStatusError::Malformed`] when it verifies but [`StatusWebhook::parse`]
836/// cannot read a status change out of it. `Ok(None)` is a verified event
837/// that carries no status change.
838pub 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    // -----------------------------------------------------------------
857    // Destination
858
859    #[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    /// Every variant, so a test that must cover all eleven cannot forget one.
869    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        // A destination inside a struct is redacted too: the derived Debug
929        // of a holder delegates to ours.
930        #[derive(Debug)]
931        struct Row {
932            dest: Destination,
933        }
934
935        // A webhook endpoint is a bearer capability: the token it carries
936        // in its path (Slack's do, CI services' do) must never reach a log
937        // through `{:?}` — core's redaction is by field name and cannot
938        // see inside this enum.
939        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        // The non-secret fields are the point of the hand-written Debug:
957        // "which repo did this go to" must stay answerable.
958        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        // Freshdesk's domain is a routing fact, not a secret: it prints.
974        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        // The repo is a routing fact, not a secret: it survives a serde
986        // round-trip under the snake_case `colonizer` key, names its kind,
987        // and — like a GitHub repo — prints in `Debug`, so "which repo did
988        // this go to" stays answerable.
989        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    // -----------------------------------------------------------------
1009    // Credential
1010
1011    #[test]
1012    fn the_credential_debug_prints_nothing_but_redacted() {
1013        // Inside a holder, the derived Debug delegates to ours.
1014        #[derive(Debug)]
1015        struct Row {
1016            cred: Credential,
1017        }
1018
1019        // The realistic token an adapter would be handed. Neither the
1020        // secret text nor any fragment of it may survive `{:?}`.
1021        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        // Cloning is a second buffer with the same secret; each is
1039        // zeroised when its own handle drops.
1040        let copy = cred.clone();
1041        assert_eq!(copy.expose(), "token-from-secrets");
1042    }
1043
1044    // -----------------------------------------------------------------
1045    // TicketDraft and Severity
1046
1047    #[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    // -----------------------------------------------------------------
1090    // TrackerError
1091
1092    #[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        // Only `Transient` can name a delay.
1101        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        // The text an adapter wraps is the tracker's own words, and a
1126        // webhook endpoint is a bearer capability URL: the token it
1127        // carries in its query must not survive into a log line or a
1128        // dead-letter row (issue #235's rule, same as `PushError`).
1129        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        // A tracker that quotes the account it bounced on quotes an
1137        // address, and that is scrubbed too.
1138        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        // `Debug` still shows the raw string for a failing test to read.
1144        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        // Issue #431's fourth variant: the per-tenant credential was
1165        // refused — wrong, expired, or lacking the scope the call needed —
1166        // which says something about *who* called, where `Rejected` is the
1167        // tracker refusing the *ticket* itself.
1168        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        // `Debug` names the variant, so a failing test can tell the two
1173        // apart.
1174        assert!(format!("{error:?}").contains("Unauthorized"));
1175    }
1176
1177    // -----------------------------------------------------------------
1178    // RoutingTracker
1179
1180    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    /// An adapter that serves `file`/`status` only and never overrides
1243    /// `comment` — the shape the existing adapters compile with, so every
1244    /// call reaches the trait's default impl.
1245    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        // A *different* destination on the same router is still
1312        // NotConfigured, and reaches nothing.
1313        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        // A *different* destination on the same router is still
1369        // NotConfigured, and reaches nothing.
1370        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        // A transposition is the one bug the tests above cannot see: the
1383        // empty router answers NotConfigured under any permutation of
1384        // `route_for`'s eleven arms, and the wiring test pins a single arm.
1385        // A hand-written eleven-way match is exactly where a swapped arm
1386        // hides, so this wires a distinct adapter into every slot and
1387        // requires each destination to reach *its own* adapter — the
1388        // identity assert names the pair, and a swap shows up as one
1389        // adapter at 0 and another at 2.
1390        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        // In `all_destinations()`'s order; the identity assert below fails
1416        // loudly if that helper's order and this list ever drift apart.
1417        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            // Identity, not mere presence: `route_for` must hand back this
1424            // exact adapter, so a destination cannot quietly borrow a
1425            // neighbour's slot.
1426            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    // -----------------------------------------------------------------
1452    // Inbound status webhooks
1453
1454    /// A Jira-Cloud-shaped hook, written with a plain `ProviderScheme` to
1455    /// prove a vendor whose signature needs **no timestamp header** is
1456    /// expressible without any new scheme code: `X-Hub-Signature:
1457    /// sha256=<hex>`, HMAC-SHA256 over the raw body.
1458    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                // A verified event with no status in it — a ping, a
1483                // comment — is a no-op, not an error.
1484                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    /// The `sha256=<hex>` signature Jira Cloud would send for this body.
1499    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        // A wrong secret, a tampered body, an unreadable header — all
1540        // `Signature`, and `parse` never ran on any of them.
1541        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        // A verified event carrying no status change is `Ok(None)`.
1583        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        // The text an adapter wraps can quote the payload it could not
1609        // read — a webhook URL's token, a tenant name — so `Display`
1610        // scrubs it the way `TrackerError::Rejected` does, and `Debug`
1611        // keeps the raw string for a failing test.
1612        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}