Skip to main content

cratefield_module_webhooks/
lib.rs

1//! `cratefield-module-webhooks`: outbound webhook delivery.
2//!
3//! ```no_run
4//! use cratefield_module_webhooks::Webhooks;
5//!
6//! let module = Webhooks::new().max_attempts(5);
7//! ```
8//!
9//! Two things, said once here and once in the README's first section:
10//! this module ships **no HTTP routes of its own** — the Rust API is the
11//! surface — and [`Webhooks::create_endpoint`] hands back the endpoint's
12//! signing secret **exactly once**. The wire format, the delivery
13//! policy, the configuration and the worked examples are the README
14//! below.
15
16#![doc = include_str!("../README.md")]
17#![forbid(unsafe_code)]
18
19mod clock;
20mod deliver;
21mod store;
22
23pub use crate::deliver::{
24    DeliveryError, DrainReport, EVENT_ID_HEADER, EVENT_TYPE_HEADER, SIGNATURE_HEADER,
25};
26pub use crate::store::{DeadLetter, Delivery, Endpoint, TOPIC_DELIVER};
27use cratefield_core::{
28    AnyError, BoxFuture, Config, ConfigError, Database, DbError, IdGen, Migrations, Module,
29    ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration, UlidIdGen,
30};
31
32/// The name the module mounts under: `/v1/webhooks`.
33pub const MODULE_NAME: &str = "webhooks";
34
35/// The one migration: the endpoints, outbox, deliveries and dead-letter
36/// tables, in the portable SQL subset (ADR 0004).
37const MIGRATION_INIT: SqlMigration = SqlMigration::new(
38    "0001",
39    "init",
40    include_str!("../migrations/sqlite/0001_init.sql"),
41);
42
43/// The bounds the builder's clamp and `WEBHOOKS_MAX_ATTEMPTS` validation
44/// share, so the two can never drift apart.
45const MAX_ATTEMPTS_RANGE: std::ops::RangeInclusive<u32> = 1..=1_000;
46
47/// A module that delivers signed outbound webhooks.
48#[derive(Debug, Clone)]
49pub struct Webhooks {
50    /// Dead-letter a delivery after this many failed attempts. Overridden
51    /// per deployment by `WEBHOOKS_MAX_ATTEMPTS`.
52    max_attempts: u32,
53}
54
55impl Default for Webhooks {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl Webhooks {
62    /// Defaults: dead-letter after five failed attempts.
63    #[must_use]
64    pub fn new() -> Self {
65        Self { max_attempts: 5 }
66    }
67
68    /// Dead-letter a delivery after this many failed attempts instead of
69    /// five. Values outside `1..=1_000` clamp to the nearest bound — the
70    /// same range `WEBHOOKS_MAX_ATTEMPTS` must pass validation with.
71    /// `WEBHOOKS_MAX_ATTEMPTS` overrides this when set.
72    #[must_use]
73    pub fn max_attempts(mut self, max_attempts: u32) -> Self {
74        self.max_attempts =
75            max_attempts.clamp(*MAX_ATTEMPTS_RANGE.start(), *MAX_ATTEMPTS_RANGE.end());
76        self
77    }
78
79    /// Registers one delivery endpoint for `subject` and returns its
80    /// signing secret — **the only time the secret is ever handed back**.
81    /// List the endpoints later and the secret is not in the answer.
82    ///
83    /// `event_types` is a filter; an empty slice means "everything"
84    /// (`*`). A URL that is not http(s), or a filter naming an event type
85    /// that could never ride a header, is refused here rather than failed
86    /// at every delivery.
87    ///
88    /// # Errors
89    ///
90    /// [`EndpointError`] for a malformed URL or subject, or a filter
91    /// naming an unusable event type, and [`DbError`] when the insert
92    /// fails.
93    pub async fn create_endpoint(
94        &self,
95        db: &dyn Database,
96        subject: &str,
97        url: &str,
98        event_types: &[&str],
99        now: &str,
100    ) -> Result<CreatedEndpoint, EndpointError> {
101        if subject.trim().is_empty() || subject.len() > 255 {
102            return Err(EndpointError::InvalidSubject);
103        }
104        if !is_http_url(url) {
105            return Err(EndpointError::InvalidUrl(url.to_owned()));
106        }
107        for candidate in event_types {
108            let candidate = candidate.trim();
109            if !candidate.is_empty() && !is_valid_event_type(candidate) {
110                return Err(EndpointError::InvalidEventType(candidate.to_owned()));
111            }
112        }
113        let secret = generate_secret()?;
114        let id = UlidIdGen.ulid();
115        db.execute(&store::insert_endpoint_statement(
116            &id,
117            subject,
118            url,
119            &secret,
120            &normalize_event_types(event_types),
121            now,
122        ))
123        .await?;
124        Ok(CreatedEndpoint {
125            endpoint_id: id,
126            secret,
127        })
128    }
129
130    /// The endpoints registered for `subject`, creation order — without
131    /// their secrets.
132    ///
133    /// # Errors
134    ///
135    /// [`DbError`] when the read fails.
136    pub async fn endpoints(
137        &self,
138        db: &dyn Database,
139        subject: &str,
140    ) -> Result<Vec<Endpoint>, DbError> {
141        store::endpoints_for_subject(db, subject).await
142    }
143
144    /// Deletes one endpoint of one subject; `false` when no such endpoint
145    /// belongs to that subject. Events already queued for it are dropped
146    /// by the next drain — there is nobody left to deliver them to.
147    ///
148    /// # Errors
149    ///
150    /// [`DbError`] when the delete fails.
151    pub async fn delete_endpoint(
152        &self,
153        db: &dyn Database,
154        subject: &str,
155        endpoint_id: &str,
156    ) -> Result<bool, DbError> {
157        store::delete_endpoint(db, subject, endpoint_id).await
158    }
159
160    /// Fans one event out to every endpoint of `subject` whose filter
161    /// matches `event_type`, and returns the outbox `INSERT`s — append
162    /// `Published::into_statements` to the caller's **own**
163    /// `db.batch_atomic(..)`, so the event is durable exactly when the
164    /// state change that caused it is. Nothing is written until that
165    /// batch runs.
166    ///
167    /// An event no endpoint matches is `Published::endpoints == 0` and
168    /// nothing else — not an error: "nobody subscribed to that" is an
169    /// ordinary answer.
170    ///
171    /// # Errors
172    ///
173    /// [`PublishError::InvalidEventType`] for an event type that could
174    /// never ride the `Cratefield-Event-Type` header (refused before any
175    /// read, so it cannot burn attempts later), and [`DbError`] when the
176    /// endpoint read fails.
177    pub async fn publish(
178        &self,
179        db: &dyn Database,
180        subject: &str,
181        event_type: &str,
182        data: &serde_json::Value,
183        now: &str,
184    ) -> Result<Published, PublishError> {
185        if !is_valid_event_type(event_type) {
186            return Err(PublishError::InvalidEventType(event_type.to_owned()));
187        }
188        let endpoints = store::endpoints_for_subject(db, subject).await?;
189        let event_id = UlidIdGen.ulid();
190        let mut statements = Vec::new();
191        for endpoint in &endpoints {
192            if !matches_filter(&endpoint.event_types, event_type) {
193                continue;
194            }
195            statements.push(store::enqueue_statement(
196                &store::DeliveryJob {
197                    endpoint_id: endpoint.id.clone(),
198                    event_id: event_id.clone(),
199                    event_type: event_type.to_owned(),
200                    subject: subject.to_owned(),
201                    created_at: now.to_owned(),
202                    data: data.clone(),
203                },
204                subject,
205                now,
206            ));
207        }
208        Ok(Published {
209            event_id,
210            endpoints: statements.len(),
211            statements,
212        })
213    }
214
215    /// Delivers every due outbox row this pass can lease, through the
216    /// ports of `ctx`. Call it from the venture's **scheduled** entry
217    /// point — a cron invocation builds no router, so the context must be
218    /// the one the runtime hands in, not one parked at router-build time.
219    ///
220    /// Each row is signed and delivered once per pass: 2xx completes it,
221    /// anything else retries with exponential backoff (410 Gone and
222    /// SSRF-blocked destinations dead-letter immediately), and
223    /// `MAX_ATTEMPTS` failures dead-letter it for
224    /// [`Webhooks::replay`]. A per-row failure never aborts the pass.
225    ///
226    /// # Errors
227    ///
228    /// [`DeliveryError`] when a port is missing or the claim fails.
229    pub async fn drain_with(&self, ctx: &ModuleContext) -> Result<DrainReport, DeliveryError> {
230        deliver::drain(ctx, self.max_attempts).await
231    }
232
233    /// Re-enqueues one dead letter of `subject` — attempts reset to zero —
234    /// and removes it, in one batch. The delivery log keeps the history.
235    /// `false` — the letter left exactly where it was — when the id names
236    /// no dead letter of this subject, **or** when the letter's endpoint
237    /// is gone: a replay into a deleted endpoint would consume the letter
238    /// and then be dropped by the drain, destroying the event for good.
239    ///
240    /// # Errors
241    ///
242    /// [`DbError`] when the read or the batch fails.
243    pub async fn replay(
244        &self,
245        db: &dyn Database,
246        subject: &str,
247        dead_letter_id: &str,
248        now: &str,
249    ) -> Result<bool, DbError> {
250        store::replay(db, subject, dead_letter_id, now).await
251    }
252
253    /// A subject's delivery log, newest attempt first. `status_code` is
254    /// `None` for an attempt that never got an answer.
255    ///
256    /// # Errors
257    ///
258    /// [`DbError`] when the read fails.
259    pub async fn deliveries(
260        &self,
261        db: &dyn Database,
262        subject: &str,
263        limit: u64,
264    ) -> Result<Vec<Delivery>, DbError> {
265        store::deliveries(db, subject, limit).await
266    }
267
268    /// A subject's dead letters, oldest first — the ids
269    /// [`Webhooks::replay`] takes.
270    ///
271    /// # Errors
272    ///
273    /// [`DbError`] when the read fails.
274    pub async fn dead_letters(
275        &self,
276        db: &dyn Database,
277        subject: &str,
278        limit: u64,
279    ) -> Result<Vec<DeadLetter>, DbError> {
280        store::dead_letters(db, subject, limit).await
281    }
282}
283
284impl Module for Webhooks {
285    fn name(&self) -> &'static str {
286        MODULE_NAME
287    }
288
289    fn version(&self) -> &'static str {
290        env!("CARGO_PKG_VERSION")
291    }
292
293    fn requires(&self) -> &'static [Port] {
294        // `Clock` stamps both the stored timestamps and the signature; a
295        // webhook that cannot be delivered is not a delivery pipeline.
296        &[Port::Db, Port::HttpClient, Port::Clock]
297    }
298
299    fn tables(&self) -> &'static [&'static str] {
300        &[
301            store::ENDPOINTS,
302            store::OUTBOX,
303            store::DELIVERIES,
304            store::DEAD_LETTERS,
305        ]
306    }
307
308    /// Every table keys its rows on the subject the event (or endpoint)
309    /// belongs to, so erasure reaches all four; the endpoint `secret` is
310    /// credential material and is exported redacted.
311    fn personal_data(&self) -> &'static [PersonalDataSet] {
312        const SETS: &[PersonalDataSet] = &[
313            PersonalDataSet {
314                table: store::ENDPOINTS,
315                subject: "subject",
316                kind: cratefield_core::DataKind::Identifier,
317                disposition: cratefield_core::Disposition::Erase,
318                description: "The webhook URLs this venture delivers your events to, one row \
319                              each. Each row holds a signing secret, which is why it is \
320                              redacted here; deleting your account deletes the endpoints and \
321                              their secrets.",
322                redacted: &["secret"],
323                subject_via: None,
324            },
325            PersonalDataSet {
326                table: store::OUTBOX,
327                subject: "subject",
328                kind: cratefield_core::DataKind::Content,
329                disposition: cratefield_core::Disposition::Erase,
330                description: "Events queued for delivery to the endpoints above, with the \
331                              account they are about. A delivery is erased with the account \
332                              even if it has not been sent yet.",
333                redacted: &[],
334                subject_via: None,
335            },
336            PersonalDataSet {
337                table: store::DELIVERIES,
338                subject: "subject",
339                kind: cratefield_core::DataKind::Usage,
340                disposition: cratefield_core::Disposition::Erase,
341                description: "The log of delivery attempts: which endpoint, which event, what \
342                              status code came back, and when. It shows which of your events \
343                              left the venture, and nothing else.",
344                redacted: &[],
345                subject_via: None,
346            },
347            PersonalDataSet {
348                table: store::DEAD_LETTERS,
349                subject: "subject",
350                kind: cratefield_core::DataKind::Content,
351                disposition: cratefield_core::Disposition::Erase,
352                description: "Events that gave up after repeated failed deliveries, kept so \
353                              they can be replayed. They carry the same data as the queued \
354                              events above, and go when your account is deleted.",
355                redacted: &[],
356                subject_via: None,
357            },
358        ];
359        SETS
360    }
361
362    fn migrations(&self) -> Migrations {
363        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
364        // The array is the apply order; this refuses a gap, a duplicate
365        // or an entry out of order at build time (issue #27).
366        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
367        Migrations {
368            sqlite: &MIGRATIONS,
369            postgres: &[],
370        }
371    }
372
373    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
374        let module = ModuleConfig::new(MODULE_NAME, cfg);
375        let mut errors = ConfigError::default();
376        if let Some(raw) = cfg
377            .get(&module.key("MAX_ATTEMPTS"))
378            .map(|raw| raw.parse::<u32>())
379            && !raw.is_ok_and(|value| MAX_ATTEMPTS_RANGE.contains(&value))
380        {
381            errors.push(format!(
382                "webhooks: {} must be a whole number between 1 and 1000",
383                module.key("MAX_ATTEMPTS")
384            ));
385        }
386        errors.into_result()
387    }
388
389    /// Deliberately empty: see the crate documentation — the Rust API is
390    /// this module's surface, and a management route would need an auth
391    /// decision this crate does not make.
392    fn router(&self, _ctx: ModuleContext) -> axum::Router {
393        axum::Router::new()
394    }
395
396    /// The recovery half of the outbox contract: whatever a caller's
397    /// immediate drain never got to is delivered on the next tick, and
398    /// the dead letters pile up here for [`Webhooks::replay`].
399    fn scheduled<'a>(
400        &'a self,
401        ctx: &'a ModuleContext,
402        cron: &'a str,
403    ) -> BoxFuture<'a, Result<(), AnyError>> {
404        Box::pin(async move {
405            let report = self
406                .drain_with(ctx)
407                .await
408                .map_err(|err| Box::new(err) as AnyError)?;
409            if report.claimed > 0 {
410                tracing::info!(?report, cron, "drained the webhooks outbox");
411            }
412            Ok(())
413        })
414    }
415}
416
417/// What [`Webhooks::create_endpoint`] returns: the endpoint's id, and the
418/// signing secret — which is returned **once**. Store it now; it is never
419/// listed again.
420#[derive(Clone, PartialEq, Eq)]
421pub struct CreatedEndpoint {
422    pub endpoint_id: String,
423    pub secret: String,
424}
425
426// The `Debug` is manual because the struct carries a signing secret: a
427// `{:?}` in a log, a trace or a test failure must not print it (the
428// pattern `cratefield-secrets` uses for its own bytes).
429impl std::fmt::Debug for CreatedEndpoint {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.debug_struct("CreatedEndpoint")
432            .field("endpoint_id", &self.endpoint_id)
433            .field("secret", &"[redacted]")
434            .finish()
435    }
436}
437
438/// Why an endpoint was refused before the database was touched.
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub enum EndpointError {
441    /// The URL is not an http(s) URL, carries whitespace, or is longer
442    /// than 2048 bytes.
443    InvalidUrl(String),
444    /// The subject is empty or longer than 255 bytes.
445    InvalidSubject,
446    /// A filter named an event type no HTTP header could carry. Refused
447    /// here, because it would filter out every event: publishing such a
448    /// type fails too, so no match could ever happen.
449    InvalidEventType(String),
450    /// The operating system refused to give this process random bytes,
451    /// so no secret could be generated.
452    Entropy(String),
453    /// The insert failed.
454    Database(DbError),
455}
456
457impl std::fmt::Display for EndpointError {
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        match self {
460            EndpointError::InvalidUrl(url) => {
461                write!(f, "webhooks: not an http(s) URL: {url:?}")
462            }
463            EndpointError::InvalidSubject => {
464                f.write_str("webhooks: the subject must be 1..=255 bytes")
465            }
466            EndpointError::InvalidEventType(event_type) => {
467                write!(f, "webhooks: not a usable event type: {event_type:?}")
468            }
469            EndpointError::Entropy(error) => write!(f, "webhooks: {error}"),
470            EndpointError::Database(error) => write!(f, "webhooks: {error}"),
471        }
472    }
473}
474
475impl std::error::Error for EndpointError {}
476
477impl From<DbError> for EndpointError {
478    fn from(error: DbError) -> Self {
479        EndpointError::Database(error)
480    }
481}
482
483/// Why [`Webhooks::publish`] refused before writing anything.
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub enum PublishError {
486    /// The event type is not a header-safe token: empty, whitespace, or
487    /// anything outside visible ASCII. Refused here because such a type
488    /// would otherwise become an undeliverable `Cratefield-Event-Type`
489    /// header value that burns every retry.
490    InvalidEventType(String),
491    /// The endpoint read failed.
492    Database(DbError),
493}
494
495impl std::fmt::Display for PublishError {
496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497        match self {
498            PublishError::InvalidEventType(event_type) => {
499                write!(f, "webhooks: not a usable event type: {event_type:?}")
500            }
501            PublishError::Database(error) => write!(f, "webhooks: {error}"),
502        }
503    }
504}
505
506impl std::error::Error for PublishError {}
507
508impl From<DbError> for PublishError {
509    fn from(error: DbError) -> Self {
510        PublishError::Database(error)
511    }
512}
513
514/// What [`Webhooks::publish`] produced. Append
515/// `Published::into_statements` to the caller's own `db.batch_atomic(..)`;
516/// nothing is written until that batch runs.
517#[derive(Debug, Clone)]
518pub struct Published {
519    /// The event's identity, shared by every fan-out row of this one
520    /// event and sent as the `Cratefield-Event-Id` header, so a receiver
521    /// can dedupe the redeliveries at-least-once delivery promises.
522    pub event_id: String,
523    /// How many endpoints matched — how many statements there are, and
524    /// how many POSTs the drain will eventually make.
525    pub endpoints: usize,
526    statements: Vec<cratefield_core::Statement>,
527}
528
529impl Published {
530    /// The outbox `INSERT`s, for the caller's own
531    /// `db.batch_atomic(..)`.
532    #[must_use]
533    pub fn into_statements(self) -> Vec<cratefield_core::Statement> {
534        self.statements
535    }
536}
537
538/// Whether an event type can be both a filter entry and a header value:
539/// 1..=128 bytes of visible ASCII, no whitespace — the characters an
540/// HTTP header value survives verbatim. Enforced at `publish` and at
541/// `create_endpoint`, so no filter can name a type no event could ever
542/// carry.
543fn is_valid_event_type(event_type: &str) -> bool {
544    (1..=128).contains(&event_type.len()) && event_type.chars().all(|c| c.is_ascii_graphic())
545}
546
547/// Whether an endpoint's stored `event_types` filter matches an event.
548///
549/// `*` (what an empty filter normalises to) matches everything; anything
550/// else is a comma-separated list of exact event types, so `order.paid`
551/// does not match `order.paid.v2`.
552#[must_use]
553pub fn matches_filter(filter: &str, event_type: &str) -> bool {
554    let filter = filter.trim();
555    filter == "*"
556        || filter
557            .split(',')
558            .map(str::trim)
559            .any(|candidate| candidate == event_type)
560}
561
562/// Normalises the `create_endpoint` filter: trimmed, empty parts dropped,
563/// duplicates collapsed, and an empty result meaning "everything".
564#[must_use]
565pub fn normalize_event_types(event_types: &[&str]) -> String {
566    let mut seen = Vec::new();
567    for candidate in event_types {
568        let candidate = candidate.trim();
569        if !candidate.is_empty() && !seen.contains(&candidate) {
570            seen.push(candidate);
571        }
572    }
573    if seen.is_empty() {
574        "*".to_owned()
575    } else {
576        seen.join(",")
577    }
578}
579
580/// An http(s) URL with nowhere for whitespace to hide. The deep checks —
581/// loopback, private and metadata destinations — are the `HttpClient`
582/// port's contract, re-vetted per delivery and per redirect hop; this is
583/// the fast, obvious refusal a typo earns at registration.
584fn is_http_url(url: &str) -> bool {
585    let max = 2048;
586    (url.starts_with("https://") || url.starts_with("http://"))
587        && url.len() <= max
588        && !url.contains(char::is_whitespace)
589        && match url.split_once("://") {
590            // The authority must be non-empty: `http:///path` names no
591            // host at all.
592            Some((_, rest)) => {
593                let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
594                !authority.is_empty()
595            }
596            None => false,
597        }
598}
599
600/// 32 OS-random bytes, hex, with the `whsec_` prefix the in-repo fixtures
601/// use. A signing key, generated where it is stored: it crosses one
602/// response boundary (creation) and is then never handed back, never
603/// logged, and exported only redacted.
604fn generate_secret() -> Result<String, EndpointError> {
605    let mut bytes = [0_u8; 32];
606    getrandom::fill(&mut bytes).map_err(|error| EndpointError::Entropy(error.to_string()))?;
607    let mut hex = String::with_capacity(2 * bytes.len());
608    for byte in bytes {
609        // Writing to a String cannot fail.
610        use std::fmt::Write as _;
611        let _ = write!(hex, "{byte:02x}");
612    }
613    Ok(format!("whsec_{hex}"))
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn defaults_are_the_documented_ones() {
622        let module = Webhooks::new();
623        assert_eq!(module.name(), "webhooks");
624        assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
625        assert_eq!(module.requires(), [Port::Db, Port::HttpClient, Port::Clock]);
626        assert_eq!(
627            module.tables(),
628            [
629                "webhooks_endpoints",
630                "webhooks_outbox",
631                "webhooks_deliveries",
632                "webhooks_dead_letters"
633            ]
634        );
635        assert_eq!(module.max_attempts, 5);
636    }
637
638    #[test]
639    fn max_attempts_stays_within_the_range_the_config_must_pass() {
640        assert_eq!(Webhooks::new().max_attempts(0).max_attempts, 1);
641        assert_eq!(Webhooks::new().max_attempts(2).max_attempts, 2);
642        assert_eq!(Webhooks::new().max_attempts(1_000).max_attempts, 1_000);
643        assert_eq!(Webhooks::new().max_attempts(5_000).max_attempts, 1_000);
644    }
645
646    #[test]
647    fn a_created_endpoints_debug_never_carries_the_secret() {
648        let created = CreatedEndpoint {
649            endpoint_id: "ep_01HXYZ".to_owned(),
650            secret: "whsec_hunter2".to_owned(),
651        };
652        let rendered = format!("{created:?}");
653        assert!(rendered.contains("ep_01HXYZ"), "{rendered}");
654        assert!(rendered.contains("[redacted]"), "{rendered}");
655        assert!(!rendered.contains("hunter2"), "{rendered}");
656    }
657
658    #[test]
659    fn event_types_must_be_header_safe_tokens() {
660        assert!(is_valid_event_type("order.paid"));
661        assert!(is_valid_event_type("order-paid_v2"));
662        assert!(!is_valid_event_type(""), "empty");
663        assert!(!is_valid_event_type(" order.paid"), "leading space");
664        assert!(!is_valid_event_type("order paid"), "inner space");
665        assert!(!is_valid_event_type("command\ninjection"), "control char");
666        assert!(!is_valid_event_type("héllo"), "non-ASCII");
667        assert!(is_valid_event_type(&"x".repeat(128)), "the max is allowed");
668        assert!(!is_valid_event_type(&"x".repeat(129)), "over the max");
669    }
670
671    #[test]
672    fn a_star_filter_matches_everything_and_a_list_matches_exactly() {
673        assert!(matches_filter("*", "order.paid"));
674        assert!(matches_filter("*", ""));
675        assert!(matches_filter("order.paid", "order.paid"));
676        assert!(matches_filter(
677            " order.paid , order.refunded ",
678            "order.refunded"
679        ));
680        assert!(!matches_filter("order.paid", "order.paid.v2"));
681        assert!(!matches_filter("order.paid", "order.refunded"));
682        assert!(
683            !matches_filter("", "order.paid"),
684            "empty stores as *, never as nothing"
685        );
686    }
687
688    #[test]
689    fn filters_normalise_to_a_stored_list_or_a_star() {
690        assert_eq!(
691            normalize_event_types(&["order.paid", "order.refunded"]),
692            "order.paid,order.refunded"
693        );
694        assert_eq!(normalize_event_types(&[" order.paid ", ""]), "order.paid");
695        assert_eq!(normalize_event_types(&["a", "a", "b"]), "a,b");
696        assert_eq!(normalize_event_types(&[]), "*");
697        assert_eq!(normalize_event_types(&["", "  "]), "*");
698    }
699
700    #[test]
701    fn urls_must_be_http_s_with_nowhere_for_whitespace_to_hide() {
702        assert!(is_http_url("https://example.com/hook"));
703        assert!(is_http_url("http://example.com"));
704        assert!(!is_http_url("example.com/hook"));
705        assert!(!is_http_url("ftp://example.com"));
706        assert!(!is_http_url("https://"));
707        assert!(!is_http_url("http:///path"), "no host at all");
708        assert!(!is_http_url("https:///q?a=1"), "no host, only a query");
709        assert!(is_http_url("http://example.com?a=1"), "authority, no path");
710        assert!(!is_http_url("https://example.com/a b"));
711        assert!(!is_http_url(""));
712        assert!(!is_http_url("https://example.com/\n"));
713    }
714
715    #[test]
716    fn secrets_are_prefixed_hex_and_never_repeat() {
717        let a = generate_secret().expect("os randomness");
718        let b = generate_secret().expect("os randomness");
719        assert!(a.starts_with("whsec_"), "{a}");
720        let hex = a.strip_prefix("whsec_").expect("prefix");
721        assert_eq!(hex.len(), 64, "32 bytes in hex");
722        assert!(
723            hex.chars()
724                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
725        );
726        assert_ne!(a, b, "two secrets are two draws");
727    }
728
729    #[test]
730    fn a_bad_config_is_collected_not_thrown_one_at_a_time() {
731        let ok = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "3")]);
732        assert!(Webhooks::new().validate_config(&ok).is_ok());
733
734        let empty = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "")]);
735        let error = Webhooks::new()
736            .validate_config(&empty)
737            .expect_err("an unparsable attempt count is refused");
738        assert!(
739            error.to_string().contains("WEBHOOKS_MAX_ATTEMPTS"),
740            "{error}"
741        );
742
743        let zero = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "0")]);
744        assert!(Webhooks::new().validate_config(&zero).is_err());
745
746        // The range the builder clamps to is the range this accepts.
747        let ceiling = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1000")]);
748        assert!(Webhooks::new().validate_config(&ceiling).is_ok());
749        let past = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1001")]);
750        assert!(Webhooks::new().validate_config(&past).is_err());
751    }
752
753    use cratefield_core::MapConfig;
754}