Skip to main content

autumn_web/push/
service.rs

1//! The Web Push send API: [`WebPush`].
2//!
3//! This is the surface an application actually touches. Everything
4//! cryptographic — minting the VAPID JWT, deriving keys, encrypting the
5//! payload — happens below it, so sending a notification is one call:
6//!
7//! ```rust,ignore
8//! use autumn_web::push::{PushMessage, WebPush};
9//! use autumn_web::prelude::*;
10//!
11//! #[post("/builds/{id}/fail")]
12//! async fn build_failed(push: WebPush, id: Path<i64>) -> AutumnResult<&'static str> {
13//!     push.send(
14//!         owner_id,
15//!         &PushMessage::new("Build failed", "main is red").url(format!("/builds/{}", *id)),
16//!     )
17//!     .await?;
18//!     Ok("ok")
19//! }
20//! ```
21
22use std::sync::Arc;
23
24use futures::StreamExt as _;
25use serde::{Deserialize, Serialize};
26
27use super::PushError;
28use super::encryption;
29use super::store::{
30    BoxedPushSubscriptionStore, BrowserSubscription, PushPrincipal, PushSubscriptionStore,
31    StoredSubscription, endpoint_origin,
32};
33use super::transport::{PushRequest, PushTransport};
34use super::vapid::VapidKey;
35use crate::state::AppState;
36
37/// Default `TTL` (RFC 8030 §5.2) on a dispatched message: how long the push
38/// service may hold it for a device that is currently offline.
39///
40/// Four weeks matches what push services accept as an upper bound and suits
41/// the re-engagement case this exists for — a notification is still worth
42/// delivering when the user's laptop comes back online tomorrow.
43pub const DEFAULT_TTL_SECS: u32 = 4 * 7 * 24 * 60 * 60;
44
45/// How many of one principal's devices are dispatched to at once.
46///
47/// Push endpoints are client-chosen, so a device that accepts a connection and
48/// never answers is a case to design for, not an accident: delivered serially
49/// it would hold the whole send for the transport's timeout. Concurrency keeps
50/// a stalled device from blocking its live siblings, and the bound keeps a
51/// fan-out from opening one connection per subscription at once.
52const MAX_CONCURRENT_DELIVERIES: usize = 8;
53
54/// How many principals [`WebPush::send_many`] serves at once.
55const MAX_CONCURRENT_PRINCIPALS: usize = 4;
56
57// ── Message ─────────────────────────────────────────────────────────────────
58
59/// What the user sees: the payload delivered to the service worker's `push`
60/// event.
61///
62/// Serialized as JSON, so the generated service worker can read
63/// `event.data.json()` and hand `title`/`body`/`icon` straight to
64/// `showNotification`, with `url` driving the `notificationclick` handler.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PushMessage {
67    /// The notification's title line.
68    pub title: String,
69    /// The notification's body text.
70    pub body: String,
71    /// Where clicking the notification takes the user. Omitted when unset, so
72    /// the service worker's `notificationclick` handler can fall back to the
73    /// app root.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub url: Option<String>,
76    /// Icon URL shown alongside the notification. Omitted when unset.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub icon: Option<String>,
79}
80
81impl PushMessage {
82    /// A message with just a title and body.
83    #[must_use]
84    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
85        Self {
86            title: title.into(),
87            body: body.into(),
88            url: None,
89            icon: None,
90        }
91    }
92
93    /// Set where clicking the notification navigates.
94    #[must_use]
95    pub fn url(mut self, url: impl Into<String>) -> Self {
96        self.url = Some(url.into());
97        self
98    }
99
100    /// Set the notification's icon URL.
101    #[must_use]
102    pub fn icon(mut self, icon: impl Into<String>) -> Self {
103        self.icon = Some(icon.into());
104        self
105    }
106}
107
108// ── Delivery report ─────────────────────────────────────────────────────────
109
110/// What one [`WebPush::send`] (or [`send_many`](WebPush::send_many)) actually
111/// achieved.
112///
113/// A send never fails because one device is unreachable — a user with three
114/// devices, one of which has revoked permission, still gets the notification
115/// on the other two. This report is how that partial outcome is surfaced
116/// rather than swallowed.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct PushDeliveryReport {
119    /// Subscriptions the push service accepted the message for.
120    pub delivered: usize,
121    /// Endpoints the push service reported as gone (`404`/`410`); these have
122    /// been removed from the store and will not be tried again.
123    pub pruned: Vec<String>,
124    /// Subscriptions that failed for a reason that is **not** staleness (a
125    /// push service outage, a rate limit, a transport error). These are left
126    /// in the store to be retried.
127    pub failed: usize,
128}
129
130impl PushDeliveryReport {
131    /// Fold `other` into this report.
132    fn merge(&mut self, other: Self) {
133        self.delivered += other.delivered;
134        self.failed += other.failed;
135        self.pruned.extend(other.pruned);
136    }
137}
138
139// ── Service ─────────────────────────────────────────────────────────────────
140
141/// The Web Push service.
142///
143/// Declare it as a handler parameter (it implements `FromRequestParts`, like
144/// [`Notifications`](crate::notifications::Notifications)) or construct one
145/// directly with [`WebPush::new`] in tests and background jobs.
146///
147/// # Store and key resolution
148///
149/// As a handler extractor the store resolves the same way the notification
150/// feed's does — an explicitly registered store, else the database-backed
151/// store when a pool is configured, else the in-memory one — and the VAPID key
152/// comes from the `[push]` config block, validated once at boot.
153#[derive(Clone)]
154pub struct WebPush {
155    store: Arc<dyn BoxedPushSubscriptionStore>,
156    /// `None` when no VAPID key is configured. Sending then fails with
157    /// [`PushError::NotConfigured`] — never silently succeeds.
158    vapid: Option<Arc<VapidKey>>,
159    subject: Arc<str>,
160    transport: Arc<dyn PushTransport>,
161    ttl_secs: u32,
162    clock: Arc<dyn crate::time::ClockSource>,
163}
164
165impl WebPush {
166    /// Build a service over an explicit store, key, and transport.
167    ///
168    /// `subject` is the VAPID `sub` claim: a `mailto:` or `https:` URL a push
169    /// service operator can use to reach you about your traffic.
170    #[must_use]
171    pub fn new(
172        store: impl PushSubscriptionStore,
173        vapid: VapidKey,
174        subject: impl Into<String>,
175        transport: impl PushTransport,
176    ) -> Self {
177        Self {
178            store: Arc::new(store),
179            vapid: Some(Arc::new(vapid)),
180            subject: subject.into().trim().into(),
181            transport: Arc::new(transport),
182            ttl_secs: DEFAULT_TTL_SECS,
183            clock: Arc::new(crate::time::SystemClock),
184        }
185    }
186
187    /// A service with **no** VAPID key: subscriptions can still be recorded,
188    /// but every send fails with [`PushError::NotConfigured`].
189    ///
190    /// This is what an app that has generated a PWA but not yet configured
191    /// `[push]` gets. Recording subscriptions early is harmless and means the
192    /// app starts delivering the moment a key is configured; sending, by
193    /// contrast, must never look like it worked.
194    #[must_use]
195    pub fn without_vapid_key(
196        store: impl PushSubscriptionStore,
197        subject: impl Into<String>,
198        transport: impl PushTransport,
199    ) -> Self {
200        Self {
201            store: Arc::new(store),
202            vapid: None,
203            subject: subject.into().trim().into(),
204            transport: Arc::new(transport),
205            ttl_secs: DEFAULT_TTL_SECS,
206            clock: Arc::new(crate::time::SystemClock),
207        }
208    }
209
210    /// Override the `TTL` sent to the push service (default
211    /// [`DEFAULT_TTL_SECS`]).
212    #[must_use]
213    pub const fn with_ttl_secs(mut self, ttl_secs: u32) -> Self {
214        self.ttl_secs = ttl_secs;
215        self
216    }
217
218    /// Override the clock used for the VAPID JWT's `exp`.
219    #[must_use]
220    pub fn with_clock(mut self, clock: Arc<dyn crate::time::ClockSource>) -> Self {
221        self.clock = clock;
222        self
223    }
224
225    /// The `applicationServerKey` a browser needs to subscribe.
226    ///
227    /// Serve this to the client (the built-in [`router()`](super::router) does,
228    /// at `/push/vapid-public-key`). It is public key material — safe to
229    /// expose to any visitor.
230    ///
231    /// # Errors
232    ///
233    /// [`PushError::NotConfigured`] when no VAPID key is configured.
234    pub fn vapid_public_key(&self) -> Result<String, PushError> {
235        self.vapid
236            .as_ref()
237            .map(|key| key.public_key_base64url())
238            .ok_or(PushError::NotConfigured)
239    }
240
241    /// Validate a browser `PushSubscription` and record it for `principal`.
242    ///
243    /// Re-subscribing the same endpoint updates the existing row rather than
244    /// creating a second one, so a browser that re-subscribes on every page
245    /// load (which is the recommended client pattern) never accumulates
246    /// duplicates.
247    ///
248    /// # Errors
249    ///
250    /// [`PushError::InvalidEndpoint`] / [`PushError::InvalidSubscriptionKey`]
251    /// for a payload that fails validation, or [`PushError::Store`] when the
252    /// store fails.
253    pub async fn subscribe(
254        &self,
255        principal: impl Into<PushPrincipal> + Send,
256        subscription: &BrowserSubscription,
257    ) -> Result<StoredSubscription, PushError> {
258        let stored = subscription.decode(&principal.into())?;
259        self.store.boxed_save(stored.clone()).await?;
260        Ok(stored)
261    }
262
263    /// Remove `principal`'s subscription for `endpoint`.
264    ///
265    /// Scoped to the caller: an endpoint belonging to a different principal is
266    /// left untouched and reported as `false`, so a signed-in user can never
267    /// unsubscribe someone else's device. Idempotent — an endpoint that was
268    /// never subscribed is `Ok(false)`, not an error.
269    ///
270    /// # Errors
271    ///
272    /// [`PushError::Store`] when the store fails.
273    pub async fn unsubscribe(
274        &self,
275        principal: impl Into<PushPrincipal> + Send,
276        endpoint: &str,
277    ) -> Result<bool, PushError> {
278        let principal = principal.into();
279        let removed = self
280            .store
281            .boxed_remove(endpoint.to_owned(), Some(principal.as_str().to_owned()))
282            .await?;
283        Ok(removed > 0)
284    }
285
286    /// Deliver `message` to every device `principal` has subscribed.
287    ///
288    /// A principal with no subscriptions is not an error — it just means the
289    /// user never granted notification permission — and returns an empty
290    /// report. Per-device failures are reported, not raised: see
291    /// [`PushDeliveryReport`].
292    ///
293    /// # Errors
294    ///
295    /// - [`PushError::NotConfigured`] when no VAPID key is configured. This is
296    ///   raised **before** anything is dispatched, so an unconfigured app
297    ///   fails loudly rather than appearing to send.
298    /// - [`PushError::PayloadTooLarge`] when the encoded message exceeds what
299    ///   push services are required to accept — also raised before dispatch,
300    ///   since it would fail identically for every device.
301    /// - [`PushError::Store`] when the subscription store itself fails.
302    pub async fn send(
303        &self,
304        principal: impl Into<PushPrincipal> + Send,
305        message: &PushMessage,
306    ) -> Result<PushDeliveryReport, PushError> {
307        let principal = principal.into();
308        // Fail fast, before touching the store: a missing key, an unusable
309        // `sub`, or an oversize payload fails identically for every device, so
310        // discovering it per-device would just be N copies of the same error.
311        let vapid = self.vapid.as_ref().ok_or(PushError::NotConfigured)?;
312        // Checked here, not in `new`: the constructor is infallible (and takes
313        // `impl Into<String>`), so a service built by
314        // `AppBuilder::with_web_push` — from a secrets manager, say — never
315        // passes through the boot-time `[push]` validation. Without this an
316        // invalid `sub` would sign every request and every push service would
317        // refuse it, showing up only as `report.failed` with no cause named.
318        self.validate_subject()?;
319        let payload = Self::encode(message)?;
320
321        let subscriptions = self
322            .store
323            .boxed_list_for(principal.as_str().to_owned())
324            .await?;
325
326        if subscriptions.is_empty() {
327            // Usually benign (the user never granted permission), but it is
328            // also what a principal-namespace mismatch looks like — the
329            // subscribe route recorded `user:42` while the send asked for
330            // `42`. Naming the id that was looked up is the quickest way to
331            // spot that, and there is no other symptom.
332            tracing::debug!(
333                principal = %principal,
334                "no push subscriptions for this principal; nothing dispatched"
335            );
336        }
337
338        // Dispatched concurrently, with a bound.
339        //
340        // Serially, one device that accepts the connection and then never
341        // answers holds the whole send for the transport's full timeout, and
342        // `send_many` queues every later recipient behind it — a principal at
343        // the subscription cap could stall one notification for minutes.
344        // Since every endpoint is client-chosen, that is reachable on purpose.
345        //
346        // Bounded rather than unbounded: a fan-out is still someone's
347        // connection pool, and `MAX_CONCURRENT_DELIVERIES` at a time is plenty
348        // to keep a stalled device from blocking its live siblings.
349        let mut report = PushDeliveryReport::default();
350        let mut deliveries = futures::stream::iter(
351            subscriptions
352                .iter()
353                .map(|subscription| self.deliver_one(vapid, subscription, &payload)),
354        )
355        .buffer_unordered(MAX_CONCURRENT_DELIVERIES);
356        while let Some(outcome) = futures::StreamExt::next(&mut deliveries).await {
357            report.merge(outcome?);
358        }
359        drop(deliveries);
360        // Deterministic order regardless of which device answered first, so a
361        // caller (and a test) can compare reports.
362        report.pruned.sort();
363        Ok(report)
364    }
365
366    /// [`send`](Self::send) to many principals, aggregating one report.
367    ///
368    /// Deduplication is *not* performed: passing the same principal twice
369    /// sends twice. Principals with no subscriptions contribute nothing.
370    /// Up to four principals run at once. Every recipient finishes before an
371    /// error returns, and the error is the first one to occur.
372    ///
373    /// # Errors
374    ///
375    /// As [`send`](Self::send).
376    pub async fn send_many<P>(
377        &self,
378        principals: impl IntoIterator<Item = P> + Send,
379        message: &PushMessage,
380    ) -> Result<PushDeliveryReport, PushError>
381    where
382        P: Into<PushPrincipal> + Send,
383    {
384        // Bounded like the per-device fan-out in `send`. The limit is smaller,
385        // so total connections stay at most
386        // `MAX_CONCURRENT_PRINCIPALS * MAX_CONCURRENT_DELIVERIES`. A recipient
387        // whose endpoints all time out must not delay the others.
388        let mut report = PushDeliveryReport::default();
389        let mut first_error = None;
390        let mut sends = futures::stream::iter(
391            principals
392                .into_iter()
393                .map(|principal| self.send(principal, message)),
394        )
395        .buffer_unordered(MAX_CONCURRENT_PRINCIPALS);
396        while let Some(outcome) = futures::StreamExt::next(&mut sends).await {
397            // Let every recipient finish. An early `?` would drop the others
398            // mid-send and lose their pruning.
399            match outcome {
400                Ok(outcome) => report.merge(outcome),
401                Err(e) => first_error = first_error.or(Some(e)),
402            }
403        }
404        drop(sends);
405        if let Some(e) = first_error {
406            return Err(e);
407        }
408        // Same stable order as `send`, whichever recipient answered first.
409        report.pruned.sort();
410        Ok(report)
411    }
412
413    /// Check the `sub` claim against what RFC 8292 permits.
414    ///
415    /// # Errors
416    ///
417    /// [`PushError::InvalidConfig`] when the subject is neither a `mailto:`
418    /// nor an `https:` URI.
419    fn validate_subject(&self) -> Result<(), PushError> {
420        if super::config::is_valid_vapid_subject(&self.subject) {
421            return Ok(());
422        }
423        Err(PushError::InvalidConfig(format!(
424            "the VAPID `sub` claim must be a `mailto:` or `https:` URI (RFC 8292 §2.1), got \
425             `{}`. A push service may refuse every message signed with anything else.",
426            self.subject
427        )))
428    }
429
430    /// Serialize a message and check it against the payload ceiling.
431    fn encode(message: &PushMessage) -> Result<Vec<u8>, PushError> {
432        let payload =
433            serde_json::to_vec(message).map_err(|e| PushError::Serialization(e.to_string()))?;
434        if payload.len() > encryption::MAX_PLAINTEXT_LEN {
435            return Err(PushError::PayloadTooLarge {
436                len: payload.len(),
437                max: encryption::MAX_PLAINTEXT_LEN,
438            });
439        }
440        Ok(payload)
441    }
442
443    /// Encrypt, sign, dispatch, and interpret the result for one subscription.
444    async fn deliver_one(
445        &self,
446        vapid: &VapidKey,
447        subscription: &StoredSubscription,
448        payload: &[u8],
449    ) -> Result<PushDeliveryReport, PushError> {
450        let request = match self.build_request(vapid, subscription, payload) {
451            Ok(request) => request,
452            Err(e) => {
453                // A row that no longer encrypts (a key corrupted in storage,
454                // an endpoint that is no longer a valid URL) can never
455                // succeed, but it must not abort delivery to this principal's
456                // other devices either.
457                tracing::warn!(
458                    endpoint.origin = %endpoint_origin(subscription.endpoint()),
459                    error = %e,
460                    "skipping a push subscription that could not be encoded"
461                );
462                return Ok(PushDeliveryReport {
463                    failed: 1,
464                    ..Default::default()
465                });
466            }
467        };
468
469        let status = match self.transport.deliver(&request).await {
470            Ok(status) => status,
471            Err(e) => {
472                tracing::warn!(
473                    endpoint.origin = %endpoint_origin(subscription.endpoint()),
474                    error = %e,
475                    "push transport failed"
476                );
477                return Ok(PushDeliveryReport {
478                    failed: 1,
479                    ..Default::default()
480                });
481            }
482        };
483
484        // RFC 8030 §7.3: `404 Not Found` and `410 Gone` are the push service
485        // saying this subscription no longer exists. Every OTHER failure —
486        // including a 5xx outage or a 429 rate limit — is transient, and
487        // pruning on those would silently unsubscribe every user during an
488        // incident, unrecoverable without them re-granting permission.
489        match status {
490            200..=299 => Ok(PushDeliveryReport {
491                delivered: 1,
492                ..Default::default()
493            }),
494            404 | 410 => {
495                // Unscoped: the endpoint is dead for whoever owns it.
496                //
497                // A store failure here is NOT propagated. The message has
498                // already been dispatched and answered; failing the whole send
499                // now would discard the deliveries that already succeeded to
500                // this principal's other devices and abort the ones still to
501                // come — the exact opposite of "a dead device never blocks a
502                // live one". The row simply survives to be pruned on the next
503                // send.
504                match self
505                    .store
506                    .boxed_remove(subscription.endpoint().to_owned(), None)
507                    .await
508                {
509                    Ok(_) => {
510                        tracing::debug!(
511                            endpoint.origin = %endpoint_origin(subscription.endpoint()),
512                            status,
513                            "pruned a stale push subscription"
514                        );
515                        Ok(PushDeliveryReport {
516                            pruned: vec![subscription.endpoint().to_owned()],
517                            ..Default::default()
518                        })
519                    }
520                    Err(e) => {
521                        tracing::warn!(
522                            endpoint.origin = %endpoint_origin(subscription.endpoint()),
523                            error = %e,
524                            "could not prune a stale push subscription; it will be retried"
525                        );
526                        Ok(PushDeliveryReport {
527                            failed: 1,
528                            ..Default::default()
529                        })
530                    }
531                }
532            }
533            other => {
534                tracing::warn!(
535                    endpoint.origin = %endpoint_origin(subscription.endpoint()),
536                    status = other,
537                    "push service rejected a message; leaving the subscription in place"
538                );
539                Ok(PushDeliveryReport {
540                    failed: 1,
541                    ..Default::default()
542                })
543            }
544        }
545    }
546
547    /// Build the encrypted, VAPID-signed request for one subscription.
548    fn build_request(
549        &self,
550        vapid: &VapidKey,
551        subscription: &StoredSubscription,
552        payload: &[u8],
553    ) -> Result<PushRequest, PushError> {
554        let body = encryption::encrypt(payload, subscription.p256dh(), subscription.auth())?;
555        let issued_at = u64::try_from(self.clock.now().timestamp()).unwrap_or(0);
556        let authorization =
557            vapid.authorization_header(subscription.endpoint(), &self.subject, issued_at)?;
558        Ok(PushRequest {
559            endpoint: subscription.endpoint().to_owned(),
560            headers: vec![
561                ("authorization".to_owned(), authorization),
562                ("content-encoding".to_owned(), "aes128gcm".to_owned()),
563                (
564                    "content-type".to_owned(),
565                    "application/octet-stream".to_owned(),
566                ),
567                ("ttl".to_owned(), self.ttl_secs.to_string()),
568            ],
569            body,
570        })
571    }
572}
573
574// ── Extractor ───────────────────────────────────────────────────────────────
575
576impl WebPush {
577    /// The service an app that registered nothing gets.
578    ///
579    /// Resolution mirrors [`Notifications`](crate::notifications::Notifications):
580    /// the database-backed store when a pool is configured, the in-memory one
581    /// otherwise. The VAPID key and subject come from the validated `[push]`
582    /// config block.
583    fn default_for(state: &AppState) -> Self {
584        let config = state.config();
585        // Already validated at boot by `AppBuilder`, so a key that fails to
586        // load here means the config was replaced at runtime. Treating that as
587        // "unconfigured" is the safe reading: sends then fail loudly with
588        // `NotConfigured` rather than silently signing with a stale key.
589        let vapid = config.push.load_vapid_key().unwrap_or_else(|e| {
590            tracing::error!(error = %e, "the `[push]` VAPID key failed to load; sends will fail");
591            None
592        });
593        // Validated at boot; if it somehow fails here the configuration was
594        // replaced at runtime, and the documented default is the safe reading.
595        let subject = config
596            .push
597            .validated_subject()
598            .unwrap_or_else(|_| super::config::DEFAULT_VAPID_SUBJECT.to_owned());
599        let ttl_secs = config.push.ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
600
601        let transport = Self::default_transport(state);
602
603        #[cfg(feature = "db")]
604        let store: Arc<dyn BoxedPushSubscriptionStore> =
605            if let Some(pool) = crate::db::DbState::pool(state) {
606                Arc::new(super::store::DbPushSubscriptionStore::new(pool.clone()))
607            } else {
608                Arc::new(super::store::MemoryPushSubscriptionStore::new())
609            };
610        #[cfg(not(feature = "db"))]
611        let store: Arc<dyn BoxedPushSubscriptionStore> =
612            Arc::new(super::store::MemoryPushSubscriptionStore::new());
613
614        Self {
615            store,
616            vapid: vapid.map(Arc::new),
617            subject: subject.into(),
618            transport,
619            ttl_secs,
620            clock: state.clock_arc(),
621        }
622    }
623
624    /// [`default_for`](Self::default_for) with an explicitly registered store.
625    ///
626    /// Used by
627    /// [`AppBuilder::with_push_subscription_store`](crate::app::AppBuilder::with_push_subscription_store),
628    /// so a custom store still picks up the app's configured key, subject,
629    /// TTL, transport and clock.
630    pub(crate) fn from_state_with_store(
631        state: &AppState,
632        store: impl PushSubscriptionStore,
633    ) -> Self {
634        Self {
635            store: Arc::new(store),
636            ..Self::default_for(state)
637        }
638    }
639
640    /// The transport a resolved service uses: a real HTTP client when one can
641    /// be built, otherwise a transport that reports the failure rather than
642    /// pretending to deliver.
643    fn default_transport(state: &AppState) -> Arc<dyn PushTransport> {
644        #[cfg(feature = "http-client")]
645        {
646            Arc::new(super::transport::HttpPushTransport::new(
647                crate::http_client::Client::from_state(state),
648            ))
649        }
650        #[cfg(not(feature = "http-client"))]
651        {
652            let _ = state;
653            Arc::new(super::transport::UnavailablePushTransport)
654        }
655    }
656}
657
658impl axum::extract::FromRequestParts<AppState> for WebPush {
659    // Resolution always succeeds (a store fallback always exists, and a
660    // missing key surfaces from `send`, not extraction) — the same contract
661    // `Notifications` and `Session` have.
662    type Rejection = std::convert::Infallible;
663
664    async fn from_request_parts(
665        _parts: &mut axum::http::request::Parts,
666        state: &AppState,
667    ) -> Result<Self, Self::Rejection> {
668        // Fast path: a service registered by `AppBuilder::with_web_push` /
669        // `with_push_subscription_store`, or resolved by an earlier request.
670        if let Some(existing) = state.extension::<Self>() {
671            return Ok((*existing).clone());
672        }
673
674        // Resolve OUTSIDE `extension_or_insert_with`.
675        //
676        // That helper holds the extensions **write** lock while it calls its
677        // closure, and `default_for` reads other extensions off the same state
678        // (the shared outbound HTTP client, and the mock registry under test).
679        // A `std::sync::RwLock` is not reentrant, so resolving inside the
680        // closure takes a read lock on a lock this thread already holds for
681        // writing — and the request hangs forever. `Notifications` can resolve
682        // inline only because its closure touches nothing but the database
683        // pool.
684        //
685        // Building before the lock costs at most a redundant construction when
686        // two requests race; `extension_or_insert_with` re-checks under the
687        // lock, so exactly one is stored and the loser is dropped.
688        let resolved = Self::default_for(state);
689        // `extension_or_insert_with` keeps the resolved service (and thus the
690        // memory store's contents) stable across requests.
691        Ok((*state.extension_or_insert_with::<Self>(|| resolved)).clone())
692    }
693}
694
695impl std::fmt::Debug for WebPush {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        f.debug_struct("WebPush")
698            .field("configured", &self.vapid.is_some())
699            .field("subject", &self.subject)
700            .field("ttl_secs", &self.ttl_secs)
701            .finish_non_exhaustive()
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::push::encryption::hkdf_sha256;
709    use crate::push::store::{MemoryPushSubscriptionStore, SubscriptionKeys};
710    use crate::push::transport::RecordingPushTransport;
711    use crate::push::vapid::decode_base64url;
712    use aes_gcm::aead::{Aead, KeyInit, Payload};
713    use aes_gcm::{Aes128Gcm, Key, Nonce};
714    use base64::Engine as _;
715    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
716    use p256::elliptic_curve::sec1::ToEncodedPoint;
717
718    // The RFC 8291 §5 user agent, whose PRIVATE key lets these tests decrypt
719    // what the framework produced — proving the browser would really be able
720    // to read it, not just that some bytes were sent.
721    const UA_PRIVATE: &str = "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94";
722    const UA_PUBLIC: &str =
723        "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
724    const UA_AUTH: &str = "BTBZMqHH6r4Tts7J_aSIgg";
725
726    fn browser_subscription(endpoint: &str) -> BrowserSubscription {
727        BrowserSubscription {
728            endpoint: endpoint.to_owned(),
729            keys: SubscriptionKeys {
730                p256dh: UA_PUBLIC.to_owned(),
731                auth: UA_AUTH.to_owned(),
732            },
733        }
734    }
735
736    /// The receiving half of RFC 8291 — what a browser does with the body.
737    ///
738    /// Written out longhand (rather than reusing any of the sending code) so
739    /// it is an independent check: if the two ever disagree, this fails.
740    fn decrypt_as_the_browser_would(body: &[u8]) -> Vec<u8> {
741        let salt = &body[..16];
742        let id_len = body[20] as usize;
743        let as_public = &body[21..21 + id_len];
744        let ciphertext = &body[21 + id_len..];
745
746        let ua_private =
747            p256::SecretKey::from_slice(&decode_base64url(UA_PRIVATE).expect("ua private"))
748                .expect("parses");
749        let as_key = p256::PublicKey::from_sec1_bytes(as_public).expect("as public parses");
750        let shared = p256::ecdh::diffie_hellman(ua_private.to_nonzero_scalar(), as_key.as_affine());
751
752        let ua_public_bytes = ua_private.public_key().to_encoded_point(false);
753        let mut key_info = Vec::new();
754        key_info.extend_from_slice(b"WebPush: info\0");
755        key_info.extend_from_slice(ua_public_bytes.as_bytes());
756        key_info.extend_from_slice(as_public);
757
758        let auth = decode_base64url(UA_AUTH).expect("auth");
759        let ikm = hkdf_sha256(&auth, shared.raw_secret_bytes(), &key_info, 32);
760        let cek = hkdf_sha256(salt, &ikm, b"Content-Encoding: aes128gcm\0", 16);
761        let nonce = hkdf_sha256(salt, &ikm, b"Content-Encoding: nonce\0", 12);
762
763        let cipher = Aes128Gcm::new(<&Key<Aes128Gcm>>::from(cek.as_slice()));
764        let mut plaintext = cipher
765            .decrypt(
766                <&Nonce<_>>::from(nonce.as_slice()),
767                Payload {
768                    msg: ciphertext,
769                    aad: b"",
770                },
771            )
772            .expect("the browser must be able to decrypt what we sent");
773        // Strip the RFC 8188 last-record delimiter.
774        assert_eq!(plaintext.pop(), Some(0x02));
775        plaintext
776    }
777
778    fn web_push_with(transport: RecordingPushTransport) -> (WebPush, VapidKey) {
779        let vapid = VapidKey::generate();
780        let push = WebPush::new(
781            MemoryPushSubscriptionStore::new(),
782            vapid.clone(),
783            "mailto:ops@example.com",
784            transport,
785        );
786        (push, vapid)
787    }
788
789    // ── PushMessage ─────────────────────────────────────────────────────────
790
791    #[test]
792    fn message_serializes_only_the_fields_that_are_set() {
793        let json = serde_json::to_value(PushMessage::new("Hi", "There")).expect("serialize");
794        assert_eq!(json["title"], "Hi");
795        assert_eq!(json["body"], "There");
796        assert!(
797            json.get("url").is_none() && json.get("icon").is_none(),
798            "absent options must not appear as nulls the service worker has to guard: {json}"
799        );
800    }
801
802    #[test]
803    fn message_builder_sets_url_and_icon() {
804        let message = PushMessage::new("Hi", "There")
805            .url("/inbox/42")
806            .icon("/static/icons/icon.svg");
807        assert_eq!(message.url.as_deref(), Some("/inbox/42"));
808        assert_eq!(message.icon.as_deref(), Some("/static/icons/icon.svg"));
809    }
810
811    // ── Subscribe / unsubscribe ─────────────────────────────────────────────
812
813    #[tokio::test]
814    async fn subscribe_records_the_subscription_for_the_principal() {
815        let (push, _) = web_push_with(RecordingPushTransport::new());
816        let stored = push
817            .subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
818            .await
819            .expect("subscribe");
820        assert_eq!(stored.principal_id(), "7");
821        let report = push
822            .send(7_i64, &PushMessage::new("Hi", "There"))
823            .await
824            .expect("send");
825        assert_eq!(
826            report.delivered, 1,
827            "the recorded subscription must receive"
828        );
829    }
830
831    #[tokio::test]
832    async fn subscribe_rejects_a_malformed_payload_rather_than_storing_it() {
833        let (push, _) = web_push_with(RecordingPushTransport::new());
834        let mut bad = browser_subscription("https://push.example.com/a");
835        bad.keys.auth = URL_SAFE_NO_PAD.encode([0_u8; 4]);
836        let err = push.subscribe(7_i64, &bad).await.expect_err("rejected");
837        assert!(
838            matches!(err, PushError::InvalidSubscriptionKey(_)),
839            "{err:?}"
840        );
841    }
842
843    #[tokio::test]
844    async fn unsubscribe_removes_only_the_callers_own_endpoint() {
845        let (push, _) = web_push_with(RecordingPushTransport::new());
846        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
847            .await
848            .expect("subscribe");
849
850        assert!(
851            !push
852                .unsubscribe(8_i64, "https://push.example.com/a")
853                .await
854                .expect("unsubscribe"),
855            "another principal must not be able to unsubscribe this device"
856        );
857        assert!(
858            push.unsubscribe(7_i64, "https://push.example.com/a")
859                .await
860                .expect("unsubscribe"),
861            "the owner unsubscribes successfully"
862        );
863        assert_eq!(
864            push.send(7_i64, &PushMessage::new("Hi", "There"))
865                .await
866                .expect("send")
867                .delivered,
868            0
869        );
870    }
871
872    #[tokio::test]
873    async fn unsubscribing_an_unknown_endpoint_reports_false_not_an_error() {
874        let (push, _) = web_push_with(RecordingPushTransport::new());
875        assert!(
876            !push
877                .unsubscribe(7_i64, "https://push.example.com/never")
878                .await
879                .expect("unsubscribe is idempotent")
880        );
881    }
882
883    // ── Send: the dispatched request ────────────────────────────────────────
884
885    #[tokio::test]
886    async fn send_dispatches_to_the_recorded_endpoint() {
887        let transport = RecordingPushTransport::new();
888        let (push, _) = web_push_with(transport.clone());
889        push.subscribe(7_i64, &browser_subscription("https://push.example.com/abc"))
890            .await
891            .expect("subscribe");
892
893        let report = push
894            .send(7_i64, &PushMessage::new("Hi", "There"))
895            .await
896            .expect("send");
897
898        assert_eq!(report.delivered, 1);
899        assert!(report.pruned.is_empty());
900        assert_eq!(report.failed, 0);
901        let requests = transport.requests();
902        assert_eq!(requests.len(), 1);
903        assert_eq!(requests[0].endpoint, "https://push.example.com/abc");
904    }
905
906    #[tokio::test]
907    async fn dispatched_request_carries_a_verifiable_vapid_authorization_header() {
908        use p256::ecdsa::VerifyingKey;
909        use p256::ecdsa::signature::Verifier;
910
911        let transport = RecordingPushTransport::new();
912        let (push, vapid) = web_push_with(transport.clone());
913        push.subscribe(7_i64, &browser_subscription("https://push.example.com/abc"))
914            .await
915            .expect("subscribe");
916        push.send(7_i64, &PushMessage::new("Hi", "There"))
917            .await
918            .expect("send");
919
920        let requests = transport.requests();
921        let authorization = requests[0]
922            .header("authorization")
923            .expect("every push request carries an Authorization header");
924        assert!(authorization.starts_with("vapid t="), "{authorization}");
925
926        let jwt = authorization
927            .strip_prefix("vapid t=")
928            .and_then(|rest| rest.split_once(", k="))
929            .expect("`vapid t=…, k=…`")
930            .0;
931        let (signing_input, signature) = jwt.rsplit_once('.').expect("compact JWS");
932        let signature = p256::ecdsa::Signature::from_slice(
933            &URL_SAFE_NO_PAD
934                .decode(signature)
935                .expect("signature base64url"),
936        )
937        .expect("signature parses");
938        VerifyingKey::from_sec1_bytes(&vapid.public_key_bytes())
939            .expect("public key parses")
940            .verify(signing_input.as_bytes(), &signature)
941            .expect("the push service must be able to verify the signature");
942
943        // The audience must be this endpoint's origin — a JWT minted for a
944        // different push service is rejected by the real one.
945        let claims = URL_SAFE_NO_PAD
946            .decode(signing_input.split('.').nth(1).expect("claims"))
947            .expect("claims base64url");
948        let claims: serde_json::Value = serde_json::from_slice(&claims).expect("claims JSON");
949        assert_eq!(claims["aud"], "https://push.example.com");
950        assert_eq!(claims["sub"], "mailto:ops@example.com");
951    }
952
953    #[tokio::test]
954    async fn dispatched_request_carries_the_aes128gcm_content_headers() {
955        let transport = RecordingPushTransport::new();
956        let (push, _) = web_push_with(transport.clone());
957        push.subscribe(7_i64, &browser_subscription("https://push.example.com/abc"))
958            .await
959            .expect("subscribe");
960        push.send(7_i64, &PushMessage::new("Hi", "There"))
961            .await
962            .expect("send");
963
964        let requests = transport.requests();
965        assert_eq!(requests[0].header("content-encoding"), Some("aes128gcm"));
966        assert_eq!(
967            requests[0].header("content-type"),
968            Some("application/octet-stream")
969        );
970        let ttl = requests[0]
971            .header("ttl")
972            .expect("TTL is required by RFC 8030 §5.2");
973        assert!(
974            ttl.parse::<u32>().is_ok(),
975            "TTL must be a plain number of seconds, got {ttl}"
976        );
977    }
978
979    #[tokio::test]
980    async fn dispatched_body_decrypts_to_the_message_in_the_browser() {
981        let transport = RecordingPushTransport::new();
982        let (push, _) = web_push_with(transport.clone());
983        push.subscribe(7_i64, &browser_subscription("https://push.example.com/abc"))
984            .await
985            .expect("subscribe");
986        push.send(
987            7_i64,
988            &PushMessage::new("Build failed", "main is red").url("/builds/42"),
989        )
990        .await
991        .expect("send");
992
993        let requests = transport.requests();
994        let plaintext = decrypt_as_the_browser_would(&requests[0].body);
995        let message: serde_json::Value =
996            serde_json::from_slice(&plaintext).expect("the payload is JSON the SW can read");
997        assert_eq!(message["title"], "Build failed");
998        assert_eq!(message["body"], "main is red");
999        assert_eq!(message["url"], "/builds/42");
1000    }
1001
1002    #[tokio::test]
1003    async fn each_device_gets_its_own_request() {
1004        let transport = RecordingPushTransport::new();
1005        let (push, _) = web_push_with(transport.clone());
1006        push.subscribe(
1007            7_i64,
1008            &browser_subscription("https://push.example.com/laptop"),
1009        )
1010        .await
1011        .expect("subscribe");
1012        push.subscribe(
1013            7_i64,
1014            &browser_subscription("https://push.example.com/phone"),
1015        )
1016        .await
1017        .expect("subscribe");
1018
1019        let report = push
1020            .send(7_i64, &PushMessage::new("Hi", "There"))
1021            .await
1022            .expect("send");
1023        assert_eq!(report.delivered, 2);
1024        let mut endpoints: Vec<String> = transport
1025            .requests()
1026            .into_iter()
1027            .map(|r| r.endpoint)
1028            .collect();
1029        endpoints.sort();
1030        assert_eq!(
1031            endpoints,
1032            vec![
1033                "https://push.example.com/laptop".to_owned(),
1034                "https://push.example.com/phone".to_owned()
1035            ]
1036        );
1037    }
1038
1039    #[tokio::test]
1040    async fn every_message_gets_fresh_encryption_material() {
1041        let transport = RecordingPushTransport::new();
1042        let (push, _) = web_push_with(transport.clone());
1043        push.subscribe(7_i64, &browser_subscription("https://push.example.com/abc"))
1044            .await
1045            .expect("subscribe");
1046        for _ in 0..2 {
1047            push.send(7_i64, &PushMessage::new("Hi", "There"))
1048                .await
1049                .expect("send");
1050        }
1051        let requests = transport.requests();
1052        assert_ne!(
1053            requests[0].body[..16],
1054            requests[1].body[..16],
1055            "reusing a salt across messages repeats an AES-GCM (key, nonce) pair"
1056        );
1057    }
1058
1059    // ── Send: failure handling ──────────────────────────────────────────────
1060
1061    #[tokio::test]
1062    async fn a_410_gone_prunes_the_subscription() {
1063        let transport =
1064            RecordingPushTransport::new().responding_with("https://push.example.com/dead", 410);
1065        let (push, _) = web_push_with(transport.clone());
1066        push.subscribe(
1067            7_i64,
1068            &browser_subscription("https://push.example.com/dead"),
1069        )
1070        .await
1071        .expect("subscribe");
1072
1073        let report = push
1074            .send(7_i64, &PushMessage::new("Hi", "There"))
1075            .await
1076            .expect("a stale endpoint is not an error for the caller");
1077        assert_eq!(report.delivered, 0);
1078        assert_eq!(
1079            report.pruned,
1080            vec!["https://push.example.com/dead".to_owned()]
1081        );
1082
1083        // The whole point of pruning: the next send must not dispatch again.
1084        let second = push
1085            .send(7_i64, &PushMessage::new("Hi", "again"))
1086            .await
1087            .expect("send");
1088        assert_eq!(second.delivered, 0);
1089        assert!(second.pruned.is_empty());
1090        assert_eq!(
1091            transport.requests().len(),
1092            1,
1093            "a pruned endpoint must never be re-sent to"
1094        );
1095    }
1096
1097    #[tokio::test]
1098    async fn a_404_not_found_also_prunes() {
1099        let transport =
1100            RecordingPushTransport::new().responding_with("https://push.example.com/gone", 404);
1101        let (push, _) = web_push_with(transport.clone());
1102        push.subscribe(
1103            7_i64,
1104            &browser_subscription("https://push.example.com/gone"),
1105        )
1106        .await
1107        .expect("subscribe");
1108        let report = push
1109            .send(7_i64, &PushMessage::new("Hi", "There"))
1110            .await
1111            .expect("send");
1112        assert_eq!(
1113            report.pruned,
1114            vec!["https://push.example.com/gone".to_owned()]
1115        );
1116    }
1117
1118    #[tokio::test]
1119    async fn a_transient_5xx_is_counted_but_never_prunes() {
1120        // Pruning on a 500 would silently unsubscribe every user during a push
1121        // service outage — unrecoverable without the user re-granting
1122        // permission.
1123        let transport =
1124            RecordingPushTransport::new().responding_with("https://push.example.com/a", 503);
1125        let (push, _) = web_push_with(transport.clone());
1126        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1127            .await
1128            .expect("subscribe");
1129
1130        let report = push
1131            .send(7_i64, &PushMessage::new("Hi", "There"))
1132            .await
1133            .expect("send");
1134        assert_eq!(report.failed, 1);
1135        assert!(
1136            report.pruned.is_empty(),
1137            "a transient failure must never unsubscribe a live device"
1138        );
1139        assert_eq!(
1140            push.send(7_i64, &PushMessage::new("Hi", "again"))
1141                .await
1142                .expect("send")
1143                .failed,
1144            1,
1145            "the subscription must still be there to retry"
1146        );
1147    }
1148
1149    #[tokio::test]
1150    async fn one_dead_device_does_not_stop_delivery_to_the_others() {
1151        let transport =
1152            RecordingPushTransport::new().responding_with("https://push.example.com/dead", 410);
1153        let (push, _) = web_push_with(transport.clone());
1154        push.subscribe(
1155            7_i64,
1156            &browser_subscription("https://push.example.com/dead"),
1157        )
1158        .await
1159        .expect("subscribe");
1160        push.subscribe(
1161            7_i64,
1162            &browser_subscription("https://push.example.com/live"),
1163        )
1164        .await
1165        .expect("subscribe");
1166
1167        let report = push
1168            .send(7_i64, &PushMessage::new("Hi", "There"))
1169            .await
1170            .expect("send");
1171        assert_eq!(report.delivered, 1);
1172        assert_eq!(report.pruned.len(), 1);
1173    }
1174
1175    #[tokio::test]
1176    async fn sending_to_a_principal_with_no_subscriptions_is_a_no_op() {
1177        let transport = RecordingPushTransport::new();
1178        let (push, _) = web_push_with(transport.clone());
1179        let report = push
1180            .send(99_i64, &PushMessage::new("Hi", "There"))
1181            .await
1182            .expect("not an error — the user simply never granted permission");
1183        assert_eq!(report, PushDeliveryReport::default());
1184        assert!(transport.requests().is_empty());
1185    }
1186
1187    #[tokio::test]
1188    async fn sending_without_a_vapid_key_fails_loudly_and_never_silently_succeeds() {
1189        let transport = RecordingPushTransport::new();
1190        let push = WebPush::without_vapid_key(
1191            MemoryPushSubscriptionStore::new(),
1192            "mailto:ops@example.com",
1193            transport.clone(),
1194        );
1195        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1196            .await
1197            .expect("subscribe still works without a key");
1198
1199        let err = push
1200            .send(7_i64, &PushMessage::new("Hi", "There"))
1201            .await
1202            .expect_err("an unconfigured push must be an error, never a silent no-op");
1203        assert!(matches!(err, PushError::NotConfigured), "{err:?}");
1204        assert!(
1205            err.to_string().contains("private_key"),
1206            "the error must say how to fix it: {err}"
1207        );
1208        assert!(transport.requests().is_empty());
1209    }
1210
1211    #[tokio::test]
1212    async fn a_stalled_device_does_not_hold_up_its_live_siblings() {
1213        // Serially, one endpoint that accepts the connection and never answers
1214        // holds the whole send for the transport's full timeout. Every
1215        // endpoint is client-chosen, so this is reachable on purpose.
1216        use std::sync::Arc;
1217        use std::sync::atomic::{AtomicUsize, Ordering};
1218
1219        /// Blocks on the endpoint named `stall` until the others are done.
1220        #[derive(Debug)]
1221        struct StallingTransport {
1222            in_flight: Arc<AtomicUsize>,
1223        }
1224
1225        impl PushTransport for StallingTransport {
1226            fn deliver<'a>(
1227                &'a self,
1228                request: &'a crate::push::PushRequest,
1229            ) -> crate::push::transport::PushTransportFuture<'a> {
1230                Box::pin(async move {
1231                    if request.endpoint.contains("stall") {
1232                        // Resolves only once every other device has been
1233                        // dispatched — impossible unless they run
1234                        // concurrently with this one.
1235                        for _ in 0..1_000 {
1236                            if self.in_flight.load(Ordering::SeqCst) >= 2 {
1237                                break;
1238                            }
1239                            tokio::task::yield_now().await;
1240                        }
1241                        return Ok(201);
1242                    }
1243                    self.in_flight.fetch_add(1, Ordering::SeqCst);
1244                    Ok(201)
1245                })
1246            }
1247        }
1248
1249        let in_flight = Arc::new(AtomicUsize::new(0));
1250        let push = WebPush::new(
1251            MemoryPushSubscriptionStore::new(),
1252            VapidKey::generate(),
1253            "mailto:ops@example.com",
1254            StallingTransport {
1255                in_flight: in_flight.clone(),
1256            },
1257        );
1258        for endpoint in [
1259            "https://push.example.com/stall",
1260            "https://push.example.com/live-a",
1261            "https://push.example.com/live-b",
1262        ] {
1263            push.subscribe(7_i64, &browser_subscription(endpoint))
1264                .await
1265                .expect("subscribe");
1266        }
1267
1268        // Serial dispatch would deadlock here: the stalled device is first and
1269        // would wait forever for siblings that never start.
1270        let report = tokio::time::timeout(
1271            std::time::Duration::from_secs(5),
1272            push.send(7_i64, &PushMessage::new("Hi", "There")),
1273        )
1274        .await
1275        .expect("a stalled device must not block the others")
1276        .expect("send");
1277        assert_eq!(report.delivered, 3);
1278    }
1279
1280    #[tokio::test]
1281    async fn concurrent_delivery_still_prunes_and_reports_deterministically() {
1282        // Concurrency must not change what a send reports, only how fast it
1283        // gets there — including the order of `pruned`, which callers compare.
1284        let transport = RecordingPushTransport::new()
1285            .responding_with("https://push.example.com/dead-a", 410)
1286            .responding_with("https://push.example.com/dead-b", 404);
1287        let (push, _) = web_push_with(transport.clone());
1288        for endpoint in [
1289            "https://push.example.com/dead-b",
1290            "https://push.example.com/live",
1291            "https://push.example.com/dead-a",
1292        ] {
1293            push.subscribe(7_i64, &browser_subscription(endpoint))
1294                .await
1295                .expect("subscribe");
1296        }
1297
1298        let report = push
1299            .send(7_i64, &PushMessage::new("Hi", "There"))
1300            .await
1301            .expect("send");
1302        assert_eq!(report.delivered, 1);
1303        assert_eq!(
1304            report.pruned,
1305            vec![
1306                "https://push.example.com/dead-a".to_owned(),
1307                "https://push.example.com/dead-b".to_owned(),
1308            ],
1309            "pruned must be stably ordered whichever device answered first"
1310        );
1311        // And the pruning really happened.
1312        assert_eq!(
1313            push.send(7_i64, &PushMessage::new("Hi", "again"))
1314                .await
1315                .expect("send")
1316                .delivered,
1317            1
1318        );
1319    }
1320
1321    #[tokio::test]
1322    async fn an_invalid_subject_is_refused_before_any_dispatch() {
1323        // `WebPush::new` is infallible and takes `impl Into<String>`, so a
1324        // service registered through `AppBuilder::with_web_push` never passes
1325        // through the boot-time `[push]` validation. Without this check an
1326        // invalid `sub` would sign every request and every push service would
1327        // refuse it, showing up only as `report.failed` with no cause named.
1328        let transport = RecordingPushTransport::new();
1329        let push = WebPush::new(
1330            MemoryPushSubscriptionStore::new(),
1331            VapidKey::generate(),
1332            // A bare address, not a `mailto:` URI — the common mistake.
1333            "ops@example.com",
1334            transport.clone(),
1335        );
1336        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1337            .await
1338            .expect("subscribe");
1339
1340        let err = push
1341            .send(7_i64, &PushMessage::new("Hi", "There"))
1342            .await
1343            .expect_err("an unusable sub must be refused");
1344        assert!(matches!(err, PushError::InvalidConfig(_)), "{err:?}");
1345        assert!(
1346            transport.requests().is_empty(),
1347            "nothing may be dispatched with a sub every push service will refuse"
1348        );
1349    }
1350
1351    #[tokio::test]
1352    async fn a_padded_subject_is_signed_without_the_padding() {
1353        // A secret file or env var often ends in a newline. Validation trims,
1354        // so signing must see the same trimmed string.
1355        use base64::Engine as _;
1356        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1357
1358        let transport = RecordingPushTransport::new();
1359        let push = WebPush::new(
1360            MemoryPushSubscriptionStore::new(),
1361            VapidKey::generate(),
1362            "  mailto:ops@example.com\n",
1363            transport.clone(),
1364        );
1365        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1366            .await
1367            .expect("subscribe");
1368        push.send(7_i64, &PushMessage::new("Hi", "There"))
1369            .await
1370            .expect("send");
1371
1372        let requests = transport.requests();
1373        let authorization = requests[0].header("authorization").expect("authorization");
1374        let jwt = authorization
1375            .strip_prefix("vapid t=")
1376            .and_then(|rest| rest.split_once(','))
1377            .map(|(jwt, _)| jwt)
1378            .expect("vapid header");
1379        let claims = URL_SAFE_NO_PAD
1380            .decode(jwt.split('.').nth(1).expect("claims"))
1381            .expect("claims base64url");
1382        let claims: serde_json::Value = serde_json::from_slice(&claims).expect("json");
1383        assert_eq!(claims["sub"], "mailto:ops@example.com");
1384    }
1385
1386    #[tokio::test]
1387    async fn send_many_does_not_queue_principals_behind_a_stalled_one() {
1388        // One recipient whose device never answers must not hold the rest.
1389        use std::sync::Arc;
1390        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1391
1392        #[derive(Debug)]
1393        struct StallingTransport {
1394            others_done: Arc<AtomicUsize>,
1395            stall_saw_others: Arc<AtomicBool>,
1396        }
1397
1398        impl PushTransport for StallingTransport {
1399            fn deliver<'a>(
1400                &'a self,
1401                request: &'a crate::push::PushRequest,
1402            ) -> crate::push::transport::PushTransportFuture<'a> {
1403                Box::pin(async move {
1404                    if request.endpoint.contains("stall") {
1405                        for _ in 0..1_000 {
1406                            if self.others_done.load(Ordering::SeqCst) >= 2 {
1407                                self.stall_saw_others.store(true, Ordering::SeqCst);
1408                                break;
1409                            }
1410                            tokio::task::yield_now().await;
1411                        }
1412                        return Ok(201);
1413                    }
1414                    self.others_done.fetch_add(1, Ordering::SeqCst);
1415                    Ok(201)
1416                })
1417            }
1418        }
1419
1420        let saw = Arc::new(AtomicBool::new(false));
1421        let push = WebPush::new(
1422            MemoryPushSubscriptionStore::new(),
1423            VapidKey::generate(),
1424            "mailto:ops@example.com",
1425            StallingTransport {
1426                others_done: Arc::new(AtomicUsize::new(0)),
1427                stall_saw_others: saw.clone(),
1428            },
1429        );
1430        for (id, endpoint) in [
1431            (1_i64, "https://push.example.com/stall"),
1432            (2, "https://push.example.com/live-a"),
1433            (3, "https://push.example.com/live-b"),
1434        ] {
1435            push.subscribe(id, &browser_subscription(endpoint))
1436                .await
1437                .expect("subscribe");
1438        }
1439
1440        let report = push
1441            .send_many([1_i64, 2, 3], &PushMessage::new("Hi", "There"))
1442            .await
1443            .expect("send_many");
1444        assert_eq!(report.delivered, 3);
1445        assert!(
1446            saw.load(Ordering::SeqCst),
1447            "later principals must run while an earlier one is stalled"
1448        );
1449    }
1450
1451    #[tokio::test]
1452    async fn send_many_reports_pruned_endpoints_in_a_stable_order() {
1453        let transport = RecordingPushTransport::new()
1454            .responding_with("https://push.example.com/dead-a", 410)
1455            .responding_with("https://push.example.com/dead-b", 410);
1456        let (push, _) = web_push_with(transport);
1457        for (id, endpoint) in [
1458            (1_i64, "https://push.example.com/dead-b"),
1459            (2, "https://push.example.com/dead-a"),
1460        ] {
1461            push.subscribe(id, &browser_subscription(endpoint))
1462                .await
1463                .expect("subscribe");
1464        }
1465        let report = push
1466            .send_many([1_i64, 2], &PushMessage::new("Hi", "There"))
1467            .await
1468            .expect("send_many");
1469        assert_eq!(
1470            report.pruned,
1471            vec![
1472                "https://push.example.com/dead-a".to_owned(),
1473                "https://push.example.com/dead-b".to_owned(),
1474            ]
1475        );
1476    }
1477
1478    #[tokio::test]
1479    async fn a_valid_subject_still_sends() {
1480        for subject in ["mailto:ops@example.com", "https://example.com/contact"] {
1481            let transport = RecordingPushTransport::new();
1482            let push = WebPush::new(
1483                MemoryPushSubscriptionStore::new(),
1484                VapidKey::generate(),
1485                subject,
1486                transport.clone(),
1487            );
1488            push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1489                .await
1490                .expect("subscribe");
1491            assert_eq!(
1492                push.send(7_i64, &PushMessage::new("Hi", "There"))
1493                    .await
1494                    .unwrap_or_else(|e| panic!("{subject} must send, got {e}"))
1495                    .delivered,
1496                1
1497            );
1498        }
1499    }
1500
1501    #[tokio::test]
1502    async fn vapid_public_key_is_the_browsers_application_server_key() {
1503        let (push, vapid) = web_push_with(RecordingPushTransport::new());
1504        assert_eq!(
1505            push.vapid_public_key().expect("configured"),
1506            vapid.public_key_base64url()
1507        );
1508    }
1509
1510    #[tokio::test]
1511    async fn vapid_public_key_is_an_error_when_unconfigured() {
1512        let push = WebPush::without_vapid_key(
1513            MemoryPushSubscriptionStore::new(),
1514            "mailto:ops@example.com",
1515            RecordingPushTransport::new(),
1516        );
1517        assert!(matches!(
1518            push.vapid_public_key().expect_err("unconfigured"),
1519            PushError::NotConfigured
1520        ));
1521    }
1522
1523    #[tokio::test]
1524    async fn an_oversize_message_is_refused_before_any_dispatch() {
1525        let transport = RecordingPushTransport::new();
1526        let (push, _) = web_push_with(transport.clone());
1527        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1528            .await
1529            .expect("subscribe");
1530
1531        let err = push
1532            .send(7_i64, &PushMessage::new("Hi", "x".repeat(5000)))
1533            .await
1534            .expect_err("an oversize payload is refused");
1535        assert!(matches!(err, PushError::PayloadTooLarge { .. }), "{err:?}");
1536        assert!(
1537            transport.requests().is_empty(),
1538            "nothing may be dispatched when the payload cannot fit"
1539        );
1540    }
1541
1542    // ── Fan-out ─────────────────────────────────────────────────────────────
1543
1544    #[tokio::test]
1545    async fn send_many_fans_out_across_principals() {
1546        let transport = RecordingPushTransport::new();
1547        let (push, _) = web_push_with(transport.clone());
1548        push.subscribe(1_i64, &browser_subscription("https://push.example.com/one"))
1549            .await
1550            .expect("subscribe");
1551        push.subscribe(2_i64, &browser_subscription("https://push.example.com/two"))
1552            .await
1553            .expect("subscribe");
1554        push.subscribe(
1555            2_i64,
1556            &browser_subscription("https://push.example.com/two-b"),
1557        )
1558        .await
1559        .expect("subscribe");
1560
1561        let report = push
1562            .send_many([1_i64, 2, 3], &PushMessage::new("Deploy", "shipped"))
1563            .await
1564            .expect("send_many");
1565        assert_eq!(report.delivered, 3, "3 has no subscription and is skipped");
1566        assert_eq!(transport.requests().len(), 3);
1567    }
1568
1569    #[tokio::test]
1570    async fn send_many_aggregates_pruning_across_principals() {
1571        let transport =
1572            RecordingPushTransport::new().responding_with("https://push.example.com/dead", 410);
1573        let (push, _) = web_push_with(transport.clone());
1574        push.subscribe(
1575            1_i64,
1576            &browser_subscription("https://push.example.com/dead"),
1577        )
1578        .await
1579        .expect("subscribe");
1580        push.subscribe(
1581            2_i64,
1582            &browser_subscription("https://push.example.com/live"),
1583        )
1584        .await
1585        .expect("subscribe");
1586
1587        let report = push
1588            .send_many([1_i64, 2], &PushMessage::new("Deploy", "shipped"))
1589            .await
1590            .expect("send_many");
1591        assert_eq!(report.delivered, 1);
1592        assert_eq!(
1593            report.pruned,
1594            vec!["https://push.example.com/dead".to_owned()]
1595        );
1596    }
1597
1598    #[tokio::test]
1599    async fn send_many_accepts_string_principals_too() {
1600        let transport = RecordingPushTransport::new();
1601        let (push, _) = web_push_with(transport.clone());
1602        push.subscribe(
1603            "service:ci",
1604            &browser_subscription("https://push.example.com/ci"),
1605        )
1606        .await
1607        .expect("subscribe");
1608        assert_eq!(
1609            push.send_many(["service:ci"], &PushMessage::new("CI", "green"))
1610                .await
1611                .expect("send_many")
1612                .delivered,
1613            1
1614        );
1615    }
1616
1617    // ── TTL and clock ───────────────────────────────────────────────────────
1618
1619    #[tokio::test]
1620    async fn ttl_defaults_to_the_documented_value() {
1621        // `ttl.parse().is_ok()` would pass for `0`, which means
1622        // deliver-now-or-drop — a real behavioural difference for the offline
1623        // device this feature exists to reach.
1624        let transport = RecordingPushTransport::new();
1625        let (push, _) = web_push_with(transport.clone());
1626        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1627            .await
1628            .expect("subscribe");
1629        push.send(7_i64, &PushMessage::new("Hi", "There"))
1630            .await
1631            .expect("send");
1632        assert_eq!(
1633            transport.requests()[0].header("ttl"),
1634            Some(DEFAULT_TTL_SECS.to_string().as_str())
1635        );
1636    }
1637
1638    #[tokio::test]
1639    async fn a_configured_ttl_reaches_the_dispatched_header() {
1640        let transport = RecordingPushTransport::new();
1641        let (push, _) = web_push_with(transport.clone());
1642        let push = push.with_ttl_secs(60);
1643        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1644            .await
1645            .expect("subscribe");
1646        push.send(7_i64, &PushMessage::new("Hi", "There"))
1647            .await
1648            .expect("send");
1649        assert_eq!(transport.requests()[0].header("ttl"), Some("60"));
1650    }
1651
1652    #[tokio::test]
1653    async fn the_jwt_exp_is_anchored_to_the_frameworks_clock() {
1654        // A broken clock (or the `unwrap_or(0)` in `build_request` firing)
1655        // would put `exp` in 1970 and make every real send be rejected by the
1656        // push service — with signature verification still passing, so no
1657        // other test would notice.
1658        use chrono::{TimeZone as _, Utc};
1659
1660        let issued = Utc.timestamp_opt(1_800_000_000, 0).single().expect("time");
1661        let transport = RecordingPushTransport::new();
1662        let (push, _) = web_push_with(transport.clone());
1663        let push = push.with_clock(std::sync::Arc::new(crate::time::FixedClock::at(issued)));
1664        push.subscribe(7_i64, &browser_subscription("https://push.example.com/a"))
1665            .await
1666            .expect("subscribe");
1667        push.send(7_i64, &PushMessage::new("Hi", "There"))
1668            .await
1669            .expect("send");
1670
1671        let authorization = transport.requests()[0]
1672            .header("authorization")
1673            .expect("header")
1674            .to_owned();
1675        let jwt = authorization
1676            .strip_prefix("vapid t=")
1677            .and_then(|rest| rest.split_once(", k="))
1678            .expect("vapid header")
1679            .0
1680            .to_owned();
1681        let claims = URL_SAFE_NO_PAD
1682            .decode(jwt.split('.').nth(1).expect("claims"))
1683            .expect("base64url");
1684        let claims: serde_json::Value = serde_json::from_slice(&claims).expect("json");
1685        assert_eq!(
1686            claims["exp"].as_u64().expect("exp"),
1687            1_800_000_000 + crate::push::vapid::VAPID_TOKEN_TTL_SECS,
1688            "exp must be derived from the injected clock, not wall time or zero"
1689        );
1690    }
1691
1692    // ── Store failures ──────────────────────────────────────────────────────
1693
1694    /// A store whose every operation fails, for the paths
1695    /// [`MemoryPushSubscriptionStore`] can never reach.
1696    #[derive(Debug, Default)]
1697    struct BrokenStore;
1698
1699    impl PushSubscriptionStore for BrokenStore {
1700        async fn save(&self, _subscription: StoredSubscription) -> Result<(), PushError> {
1701            Err(PushError::Store("save exploded".to_owned()))
1702        }
1703        async fn list_for(
1704            &self,
1705            _principal_id: &str,
1706        ) -> Result<Vec<StoredSubscription>, PushError> {
1707            Err(PushError::Store("list exploded".to_owned()))
1708        }
1709        async fn remove(
1710            &self,
1711            _endpoint: &str,
1712            _principal_id: Option<&str>,
1713        ) -> Result<u64, PushError> {
1714            Err(PushError::Store("remove exploded".to_owned()))
1715        }
1716    }
1717
1718    /// A store that reads fine but cannot delete — the shape that makes a
1719    /// prune fail after the message has already been dispatched.
1720    #[derive(Debug)]
1721    struct UnprunableStore(MemoryPushSubscriptionStore);
1722
1723    impl PushSubscriptionStore for UnprunableStore {
1724        async fn save(&self, subscription: StoredSubscription) -> Result<(), PushError> {
1725            self.0.save(subscription).await
1726        }
1727        async fn list_for(&self, principal_id: &str) -> Result<Vec<StoredSubscription>, PushError> {
1728            self.0.list_for(principal_id).await
1729        }
1730        async fn remove(
1731            &self,
1732            _endpoint: &str,
1733            _principal_id: Option<&str>,
1734        ) -> Result<u64, PushError> {
1735            Err(PushError::Store("remove exploded".to_owned()))
1736        }
1737    }
1738
1739    #[tokio::test]
1740    async fn a_store_failure_on_read_surfaces_rather_than_reporting_zero_deliveries() {
1741        let push = WebPush::new(
1742            BrokenStore,
1743            VapidKey::generate(),
1744            "mailto:ops@example.com",
1745            RecordingPushTransport::new(),
1746        );
1747        let err = push
1748            .send(7_i64, &PushMessage::new("Hi", "There"))
1749            .await
1750            .expect_err("a broken store must not look like `no subscriptions`");
1751        assert!(matches!(err, PushError::Store(_)), "{err:?}");
1752    }
1753
1754    #[tokio::test]
1755    async fn a_failed_prune_does_not_abort_delivery_to_the_other_devices() {
1756        // The prune happens AFTER dispatch. Propagating a store failure there
1757        // would discard the deliveries that already succeeded and skip the
1758        // ones still to come — the opposite of "a dead device never blocks a
1759        // live one".
1760        let transport =
1761            RecordingPushTransport::new().responding_with("https://push.example.com/dead", 410);
1762        let push = WebPush::new(
1763            UnprunableStore(MemoryPushSubscriptionStore::new()),
1764            VapidKey::generate(),
1765            "mailto:ops@example.com",
1766            transport.clone(),
1767        );
1768        push.subscribe(
1769            7_i64,
1770            &browser_subscription("https://push.example.com/dead"),
1771        )
1772        .await
1773        .expect("subscribe");
1774        push.subscribe(
1775            7_i64,
1776            &browser_subscription("https://push.example.com/live"),
1777        )
1778        .await
1779        .expect("subscribe");
1780
1781        let report = push
1782            .send(7_i64, &PushMessage::new("Hi", "There"))
1783            .await
1784            .expect("a prune failure must not fail the send");
1785        assert_eq!(
1786            report.delivered, 1,
1787            "the live device must still be reported as delivered"
1788        );
1789        assert!(
1790            report.pruned.is_empty(),
1791            "nothing was actually pruned, so nothing may be reported as pruned"
1792        );
1793        assert_eq!(report.failed, 1);
1794        assert_eq!(transport.requests().len(), 2, "both devices were attempted");
1795    }
1796
1797    // ── Cross-origin fan-out ────────────────────────────────────────────────
1798
1799    #[tokio::test]
1800    async fn each_endpoint_gets_a_jwt_audienced_to_its_own_push_service() {
1801        // Real deployments mix FCM, Mozilla autopush and WNS. A bug that
1802        // computed `aud` once — or reused one Authorization header across
1803        // devices — would pass every single-origin test and then be rejected
1804        // by every push service but the first.
1805        let transport = RecordingPushTransport::new();
1806        let (push, _) = web_push_with(transport.clone());
1807        for endpoint in [
1808            "https://fcm.googleapis.com/fcm/send/abc",
1809            "https://updates.push.services.mozilla.com/wpush/v2/xyz",
1810        ] {
1811            push.subscribe(7_i64, &browser_subscription(endpoint))
1812                .await
1813                .expect("subscribe");
1814        }
1815        push.send(7_i64, &PushMessage::new("Hi", "There"))
1816            .await
1817            .expect("send");
1818
1819        for request in transport.requests() {
1820            let authorization = request.header("authorization").expect("header");
1821            let jwt = authorization
1822                .strip_prefix("vapid t=")
1823                .and_then(|rest| rest.split_once(", k="))
1824                .expect("vapid header")
1825                .0;
1826            let claims = URL_SAFE_NO_PAD
1827                .decode(jwt.split('.').nth(1).expect("claims"))
1828                .expect("base64url");
1829            let claims: serde_json::Value = serde_json::from_slice(&claims).expect("json");
1830            let expected = url::Url::parse(&request.endpoint)
1831                .expect("endpoint parses")
1832                .origin()
1833                .ascii_serialization();
1834            assert_eq!(
1835                claims["aud"], expected,
1836                "each request's aud must be ITS OWN endpoint's origin"
1837            );
1838        }
1839    }
1840
1841    #[tokio::test]
1842    async fn every_device_gets_its_own_encryption_material() {
1843        // One ephemeral key or salt reused across two subscriptions in the
1844        // same send would be an actual cryptographic break.
1845        let transport = RecordingPushTransport::new();
1846        let (push, _) = web_push_with(transport.clone());
1847        for endpoint in ["https://push.example.com/a", "https://push.example.com/b"] {
1848            push.subscribe(7_i64, &browser_subscription(endpoint))
1849                .await
1850                .expect("subscribe");
1851        }
1852        push.send(7_i64, &PushMessage::new("Hi", "There"))
1853            .await
1854            .expect("send");
1855
1856        let requests = transport.requests();
1857        assert_ne!(requests[0].body[..16], requests[1].body[..16], "salt");
1858        assert_ne!(
1859            requests[0].body[21..86],
1860            requests[1].body[21..86],
1861            "ephemeral key"
1862        );
1863    }
1864}