Skip to main content

cratefield_core/ports/
push.rs

1//! The `Push` port (issues #104, #177): a notification to a device, whatever
2//! carries it. Mail reaches an inbox through [`Mailer`](crate::Mailer);
3//! nothing else reached a device until this. APNs today, FCM and Web Push
4//! next (ADR 0015).
5//!
6//! A recipient is a [`Recipient`], not a token string: a Web Push
7//! subscription is `{ endpoint, p256dh, auth }` and never fits in one opaque
8//! string, and JSON-in-a-string would make every adapter parse and every
9//! mistake silent. [`Notification`] is transport-neutral — each adapter maps
10//! the fields its protocol has and documents what it drops.
11//!
12//! The device-token registry is venture code — each venture decides what a
13//! recipient belongs to — but the port defines the [`PushError::Unregistered`]
14//! contract so a module knows when to prune a dead one.
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23/// The transport a [`Recipient`] is reached over, which is **not** a
24/// statement about the device: a UnifiedPush endpoint is
25/// [`Platform::Web`] on an Android phone (ADR 0015).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Platform {
29    /// Apple Push Notification service.
30    Ios,
31    /// Firebase Cloud Messaging.
32    Android,
33    /// Web Push (RFC 8030), browser or UnifiedPush.
34    Web,
35}
36
37impl Platform {
38    /// The name used in errors and logs.
39    pub fn name(&self) -> &'static str {
40        match self {
41            Platform::Ios => "ios",
42            Platform::Android => "android",
43            Platform::Web => "web",
44        }
45    }
46}
47
48impl std::fmt::Display for Platform {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.name())
51    }
52}
53
54/// Where a notification is sent. One variant per push transport, because the
55/// three do not share a shape: APNs and FCM take an opaque token, Web Push
56/// takes a subscription of three parts (RFC 8291).
57///
58/// **Every variant holds credential material.** A device token and an FCM
59/// registration token address one device; a Web Push `endpoint` is a bearer
60/// capability URL (whoever holds it can push to that browser) and `auth` is
61/// the RFC 8291 shared secret the payload is encrypted under. Hence the
62/// hand-written [`Debug`](#impl-Debug-for-Recipient), which prints
63/// fingerprints and never the values: core's log redaction keys off the
64/// *field name* ([`is_secret_field`](crate::is_secret_field)) and cannot see
65/// inside a `{:?}` of this enum.
66///
67/// [`Serialize`]/[`Deserialize`] stay, because a subscription has to be
68/// persisted and read back — but a serialised `Recipient` **is** the
69/// credential: store it where secrets go, never in a log, an event payload,
70/// or an error body.
71#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum Recipient {
74    /// An APNs device token (hex, from `application:didRegister...`).
75    Apns { device_token: String },
76    /// An FCM registration token.
77    Fcm { registration_token: String },
78    /// A Web Push subscription: the push service URL plus the two RFC 8291
79    /// keys, both base64url as the browser hands them over.
80    WebPush {
81        endpoint: String,
82        p256dh: String,
83        auth: String,
84    },
85}
86
87impl Recipient {
88    /// An APNs recipient.
89    pub fn apns(device_token: impl Into<String>) -> Self {
90        Recipient::Apns {
91            device_token: device_token.into(),
92        }
93    }
94
95    /// An FCM recipient.
96    pub fn fcm(registration_token: impl Into<String>) -> Self {
97        Recipient::Fcm {
98            registration_token: registration_token.into(),
99        }
100    }
101
102    /// A Web Push recipient (RFC 8291 keys, base64url).
103    pub fn web_push(
104        endpoint: impl Into<String>,
105        p256dh: impl Into<String>,
106        auth: impl Into<String>,
107    ) -> Self {
108        Recipient::WebPush {
109            endpoint: endpoint.into(),
110            p256dh: p256dh.into(),
111            auth: auth.into(),
112        }
113    }
114
115    /// The transport this recipient is reached over. A *transport* fact, not
116    /// a device fact (ADR 0015).
117    pub fn platform(&self) -> Platform {
118        match self {
119            Recipient::Apns { .. } => Platform::Ios,
120            Recipient::Fcm { .. } => Platform::Android,
121            Recipient::WebPush { .. } => Platform::Web,
122        }
123    }
124}
125
126/// A short, non-reversible stand-in for credential material in `Debug`
127/// output: the first six bytes of its SHA-256, hex. Enough to tell two log
128/// lines about the same recipient apart, useless for reaching a device — the
129/// inputs (a 32-byte device token, a random endpoint path, a 16-byte auth
130/// secret) have far too much entropy to guess back through a digest.
131fn fingerprint(value: &str) -> String {
132    use sha2::{Digest as _, Sha256};
133    use std::fmt::Write as _;
134
135    let digest = Sha256::digest(value.as_bytes());
136    let mut hex = String::with_capacity(12);
137    for byte in digest.iter().take(6) {
138        // Writing to a String cannot fail.
139        let _ = write!(hex, "{byte:02x}");
140    }
141    hex
142}
143
144/// Prints the transport and a short fingerprint of each part, never the parts
145/// themselves — the same rule `Es256Signer`, `Rs256Signer` and `CachedToken`
146/// follow in `cratefield-push-auth`. The RFC 8291 `auth` secret is not even
147/// fingerprinted: nothing about it is safe to correlate on.
148impl std::fmt::Debug for Recipient {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            Recipient::Apns { device_token } => write!(
152                f,
153                "Recipient::Apns {{ device_token: fp:{} }}",
154                fingerprint(device_token)
155            ),
156            Recipient::Fcm { registration_token } => write!(
157                f,
158                "Recipient::Fcm {{ registration_token: fp:{} }}",
159                fingerprint(registration_token)
160            ),
161            // The endpoint is fingerprinted whole, host included: a
162            // self-hosted UnifiedPush host is itself identifying.
163            Recipient::WebPush {
164                endpoint, p256dh, ..
165            } => write!(
166                f,
167                "Recipient::WebPush {{ endpoint: fp:{}, p256dh: fp:{}, auth: [redacted] }}",
168                fingerprint(endpoint),
169                fingerprint(p256dh)
170            ),
171        }
172    }
173}
174
175/// How urgently the notification should be delivered. Maps to APNs priority
176/// `10` (deliver now, may wake the device) and `5` (deliver to save power);
177/// to `android.priority` `HIGH`/`NORMAL`; to the Web Push `Urgency` header
178/// `high`/`normal`.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum Priority {
182    /// Deliver immediately.
183    #[default]
184    Immediate,
185    /// Deliver when convenient, to conserve power.
186    Conserve,
187}
188
189/// Native localisation keys. The strings are looked up in the app's own
190/// catalogue by the operating system, so a notification can be localised
191/// without the server knowing the device's language. Reserved here and
192/// mapped by the adapters; rendering is the i18n child's job.
193#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
194pub struct LocKeys {
195    /// Key for the title (`aps.alert.title-loc-key`).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub title_loc_key: Option<String>,
198    /// Substitutions for the title key.
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub title_loc_args: Vec<String>,
201    /// Key for the body (`aps.alert.loc-key`).
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub body_loc_key: Option<String>,
204    /// Substitutions for the body key.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub body_loc_args: Vec<String>,
207}
208
209/// One notification, in transport-neutral terms. `data` is the custom
210/// key-value payload the app reads; `collapse_id` coalesces notifications the
211/// user has not seen yet.
212///
213/// Every optional field is a *request*: an adapter maps what its protocol
214/// has and documents what it drops (APNs has no `icon`, Web Push has no
215/// `badge`).
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Notification {
218    pub title: String,
219    pub body: String,
220    /// The notification category (an app-defined action group).
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub category: Option<String>,
223    /// Groups related notifications in the UI.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub thread_id: Option<String>,
226    /// Custom payload the app reads; merged alongside the `aps` block.
227    #[serde(default)]
228    pub data: Value,
229    /// Coalesces with any undelivered notification carrying the same id.
230    /// `apns-collapse-id` / `android.collapse_key` / the Web Push `Topic`
231    /// header (≤32 URL-safe characters, so a Web Push adapter shortens it
232    /// and documents how).
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub collapse_id: Option<String>,
235    #[serde(default)]
236    pub priority: Priority,
237    /// An icon for the notification (Web Push `icon`; ignored by APNs, which
238    /// takes its icon from the app bundle).
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub icon: Option<String>,
241    /// Where a tap should take the user: the Web Push click target, FCM's
242    /// `click_action`, and a top-level `url` in the APNs payload.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub url: Option<String>,
245    /// How long the push service may hold the notification while the device
246    /// is offline. Serialised as whole seconds, through [`ttl_secs`].
247    ///
248    /// Note the wire forms differ: `apns-expiration` is an **absolute** UNIX
249    /// epoch, so the APNs adapter sends `now + ttl`; `android.ttl` is
250    /// `"<s>s"`; Web Push `TTL` is seconds. A zero TTL means "deliver now or
251    /// drop" in all three.
252    ///
253    /// **Sub-second TTLs round up to one second**, on the wire and in every
254    /// adapter: `Duration::from_millis(900)` is a request to hold the
255    /// notification *briefly*, and truncating it to `0` would turn it into
256    /// the opposite instruction — drop it the moment the device is offline —
257    /// silently, and again on every serde round trip. Only
258    /// `Duration::ZERO` means "drop if undeliverable now".
259    #[serde(
260        default,
261        skip_serializing_if = "Option::is_none",
262        with = "duration_secs"
263    )]
264    pub ttl: Option<Duration>,
265    /// The badge number to show on the app icon (iOS). Ignored elsewhere.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub badge: Option<u32>,
268    /// A data-only notification: nothing is shown, the app is woken to do
269    /// work (`content-available` / FCM data message).
270    #[serde(default, skip_serializing_if = "is_false")]
271    pub silent: bool,
272    /// Native localisation keys, in place of `title`/`body`, where the
273    /// platform supports them.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub loc: Option<LocKeys>,
276}
277
278#[allow(clippy::trivially_copy_pass_by_ref)] // serde's skip_serializing_if shape
279fn is_false(value: &bool) -> bool {
280    !*value
281}
282
283/// A [`Notification::ttl`] as the whole seconds every push protocol takes.
284///
285/// A non-zero TTL shorter than a second rounds **up** to one. Truncating it
286/// would say "deliver now or drop" — the opposite of what a caller asking
287/// for 900 ms meant — and would do so silently, including across a serde
288/// round trip, which is why the rounding lives here and is applied by the
289/// serialiser as well as by each adapter. `Duration::ZERO` is left alone:
290/// that one really does mean "drop if undeliverable now".
291#[must_use]
292pub fn ttl_secs(ttl: Duration) -> u64 {
293    match ttl.as_secs() {
294        0 if !ttl.is_zero() => 1,
295        secs => secs,
296    }
297}
298
299/// The header name, so no caller spells it.
300const RETRY_AFTER: &str = "retry-after";
301
302/// The IMF-fixdate description, parsed once.
303///
304/// Version 2 of the format-description syntax, pinned explicitly: `parse`
305/// without a version is deprecated precisely because the unversioned
306/// form's meaning can shift under a `time` upgrade.
307///
308/// Built once rather than per call. `Retry-After` is read on the
309/// throttling path, which by definition fires in bursts, so re-parsing a
310/// fixed description on every throttled send is work done once per process
311/// here instead.
312static IMF_FIXDATE: std::sync::LazyLock<
313    Vec<time::format_description::BorrowedFormatItem<'static>>,
314> = std::sync::LazyLock::new(|| {
315    time::format_description::parse_borrowed::<2>(
316        "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT",
317    )
318    .expect("a format description that is a literal in this file")
319});
320
321/// An IMF-fixdate, the one form RFC 9110 §5.6.7 allows a sender to
322/// generate: `Sun, 06 Nov 1994 08:49:37 GMT`.
323///
324/// The two obsolete forms (RFC 850 and asctime) are deliberately not
325/// parsed. A recipient is required to accept them, but nothing in front of
326/// a push service or a mail API emits them, and mis-reading a two-digit
327/// year is worse than falling back to the caller's own schedule.
328fn parse_http_date(value: &str) -> Option<time::OffsetDateTime> {
329    time::PrimitiveDateTime::parse(value, IMF_FIXDATE.as_slice())
330        .ok()
331        .map(time::PrimitiveDateTime::assume_utc)
332}
333
334/// `Retry-After` (RFC 9110 §10.2.3) as a duration, so a caller can honour
335/// what a provider asked for.
336///
337/// **Both forms are read**, which is the point of it living here. The
338/// delta-seconds form is the common one; the HTTP-date form is equally
339/// legal and is what CDNs in front of a provider emit. Ignoring the date
340/// form turns "come back in an hour" into an immediate retry, straight
341/// back into the rate limiter that sent it.
342///
343/// This replaces four independent parsers — APNs, Resend, the LinkedIn
344/// client and Web Push — of which only the last read the date form, so the
345/// bug was live in three (issue #214). The APNs copy's own comment said
346/// the date form "needs a parsed clock the port does not promise";
347/// [`Clock`](crate::Clock)
348/// is a port every one of those callers already holds, which is what makes
349/// one parser possible.
350///
351/// A date already in the past is [`Duration::ZERO`] — "retry now" — and
352/// not `None`. The two mean different things to a caller: `None` is "no
353/// delay was stated", and falling back to a default schedule for a header
354/// that said "now" would be slower than the provider asked for.
355///
356/// Returns `None` when the header is absent, unreadable, or in neither
357/// form.
358#[must_use]
359pub fn retry_after(headers: &http::HeaderMap, clock: &dyn crate::Clock) -> Option<Duration> {
360    let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
361    if let Ok(seconds) = value.parse::<u64>() {
362        return Some(Duration::from_secs(seconds));
363    }
364    let when = parse_http_date(value)?;
365    let delta = when - clock.now();
366    if delta.is_negative() {
367        return Some(Duration::ZERO);
368    }
369    Duration::try_from(delta).ok()
370}
371
372/// `Option<Duration>` on the wire as whole seconds, so a notification
373/// round-trips as `{"ttl": 3600}` and not serde's `{"secs":…,"nanos":…}`.
374mod duration_secs {
375    use serde::{Deserialize, Deserializer, Serializer};
376    use std::time::Duration;
377
378    // serde's `with` module fixes this signature; `Option<&Duration>` does
379    // not compile as a serializer here.
380    #[allow(clippy::ref_option)]
381    pub fn serialize<S: Serializer>(
382        value: &Option<Duration>,
383        serializer: S,
384    ) -> Result<S::Ok, S::Error> {
385        match value {
386            // `ttl_secs`, not `as_secs`: a sub-second TTL must not round-trip
387            // into `Duration::ZERO`, which means the opposite thing.
388            Some(duration) => serializer.serialize_some(&super::ttl_secs(*duration)),
389            None => serializer.serialize_none(),
390        }
391    }
392
393    pub fn deserialize<'de, D: Deserializer<'de>>(
394        deserializer: D,
395    ) -> Result<Option<Duration>, D::Error> {
396        Ok(Option::<u64>::deserialize(deserializer)?.map(Duration::from_secs))
397    }
398}
399
400impl Notification {
401    /// A minimal notification with a title and body.
402    #[must_use]
403    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
404        Self {
405            title: title.into(),
406            body: body.into(),
407            category: None,
408            thread_id: None,
409            data: Value::Null,
410            collapse_id: None,
411            priority: Priority::Immediate,
412            icon: None,
413            url: None,
414            ttl: None,
415            badge: None,
416            silent: false,
417            loc: None,
418        }
419    }
420}
421
422/// The result of a send that the provider accepted.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum PushOutcome {
425    /// Accepted; carries the provider's id where it gives one (`apns-id`).
426    Delivered { id: Option<String> },
427    /// The adapter is not configured (no key / unverified). The caller can
428    /// degrade rather than treating it as a failure, the same way
429    /// [`SendOutcome::NotConfigured`](crate::SendOutcome) works for mail.
430    /// [`RoutingPush`] also answers this for a recipient no adapter serves.
431    NotConfigured,
432}
433
434/// Push failures. `Unregistered` is separated because the caller must act on
435/// it — the recipient is dead and should be pruned — where the others are
436/// transient or a bad request.
437///
438/// The two variants that carry provider text are sanitized in `Display`,
439/// the same way [`DbError`](crate::DbError)'s and
440/// [`MailError`](crate::MailError)'s are (issue #235). What an adapter
441/// wraps is the provider's own words, and a push endpoint or a device
442/// token is exactly the kind of value that rides in them. `Display`
443/// therefore runs it through [`crate::logging::scrub_text`]; `Debug`
444/// still shows the raw string for tests.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub enum PushError {
447    /// The provider says the recipient is no longer valid (APNs `410`, Web
448    /// Push `410`, FCM `UNREGISTERED`): delete it.
449    ///
450    /// This is a **delete instruction**, not a failed send, and an adapter
451    /// should map only a status whose sole meaning is "gone" onto it. A
452    /// status a misrouted proxy can also produce — Web Push `404`, which
453    /// RFC 8030 never defines as gone — belongs in
454    /// [`Transient`](Self::Transient): the cost of a wrong `Transient` is
455    /// some wasted sends, and a wrongly-pruned Web Push subscription cannot
456    /// be recreated server-side at all, only by the browser subscribing
457    /// again.
458    Unregistered,
459    /// The provider rejected the request (a `4xx` that is not `410`), or the
460    /// adapter does not serve this recipient's transport; not retryable
461    /// without a change.
462    Rejected(String),
463    /// A transient failure (a `5xx`, a `429`, a transport error): retry
464    /// later, and not before `retry_after` when the provider named one
465    /// (APNs `429`, FCM `RESOURCE_EXHAUSTED`/`UNAVAILABLE`, Web Push
466    /// `429`/`503` — all carry `Retry-After`).
467    Transient {
468        message: String,
469        retry_after: Option<Duration>,
470    },
471}
472
473impl std::fmt::Display for PushError {
474    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        let scrub = crate::logging::scrub_text;
476        match self {
477            Self::Unregistered => f.write_str("device token is no longer registered; delete it"),
478            Self::Rejected(message) => write!(f, "push rejected: {}", scrub(message)),
479            Self::Transient { message, .. } => {
480                write!(f, "push failed, retryable: {}", scrub(message))
481            }
482        }
483    }
484}
485
486impl std::error::Error for PushError {}
487
488impl PushError {
489    /// A retryable failure with no provider-supplied delay.
490    pub fn transient(message: impl Into<String>) -> Self {
491        PushError::Transient {
492            message: message.into(),
493            retry_after: None,
494        }
495    }
496
497    /// A retryable failure the provider asked us to hold off on.
498    pub fn transient_after(message: impl Into<String>, retry_after: Option<Duration>) -> Self {
499        PushError::Transient {
500            message: message.into(),
501            retry_after,
502        }
503    }
504
505    /// The rejection an adapter returns for a transport it does not serve.
506    /// [`RoutingPush`] exists so a venture never has to see this.
507    pub fn unsupported_recipient(recipient: &Recipient) -> Self {
508        PushError::Rejected(format!(
509            "unsupported recipient: this adapter does not serve {}",
510            recipient.platform()
511        ))
512    }
513
514    /// How long the provider asked the caller to wait, where it said.
515    pub fn retry_after(&self) -> Option<Duration> {
516        match self {
517            PushError::Transient { retry_after, .. } => *retry_after,
518            _ => None,
519        }
520    }
521}
522
523/// Sends notifications to a device, over whichever transport the
524/// [`Recipient`] names. An adapter serves one transport and returns
525/// [`PushError::unsupported_recipient`] for the rest; [`RoutingPush`] fans a
526/// mixed set of recipients out across the adapters a venture configured.
527#[async_trait]
528pub trait Push: Send + Sync {
529    /// Sends `notification` to `to`.
530    async fn send(
531        &self,
532        to: &Recipient,
533        notification: &Notification,
534    ) -> Result<PushOutcome, PushError>;
535}
536
537/// Dispatches by [`Recipient`] variant to the adapter a venture configured
538/// for that transport, so venture code holds one `Arc<dyn Push>` and never
539/// matches on the transport itself (ADR 0015).
540///
541/// A recipient whose transport has no adapter is
542/// [`PushOutcome::NotConfigured`] — the same answer an unconfigured adapter
543/// gives, and deliberately not [`PushError::Rejected`]: nothing is wrong with
544/// the recipient, the venture simply did not wire that transport.
545#[derive(Default, Clone)]
546pub struct RoutingPush {
547    apns: Option<Arc<dyn Push>>,
548    fcm: Option<Arc<dyn Push>>,
549    web_push: Option<Arc<dyn Push>>,
550}
551
552impl RoutingPush {
553    /// A router with no adapters: every recipient is `NotConfigured`.
554    #[must_use]
555    pub fn new() -> Self {
556        Self::default()
557    }
558
559    /// The adapter for [`Recipient::Apns`].
560    #[must_use]
561    pub fn apns(mut self, push: Arc<dyn Push>) -> Self {
562        self.apns = Some(push);
563        self
564    }
565
566    /// The adapter for [`Recipient::Fcm`].
567    #[must_use]
568    pub fn fcm(mut self, push: Arc<dyn Push>) -> Self {
569        self.fcm = Some(push);
570        self
571    }
572
573    /// The adapter for [`Recipient::WebPush`].
574    #[must_use]
575    pub fn web_push(mut self, push: Arc<dyn Push>) -> Self {
576        self.web_push = Some(push);
577        self
578    }
579
580    /// The adapter that serves `recipient`, if one is configured.
581    #[must_use]
582    pub fn route_for(&self, recipient: &Recipient) -> Option<&Arc<dyn Push>> {
583        match recipient {
584            Recipient::Apns { .. } => self.apns.as_ref(),
585            Recipient::Fcm { .. } => self.fcm.as_ref(),
586            Recipient::WebPush { .. } => self.web_push.as_ref(),
587        }
588    }
589}
590
591impl std::fmt::Debug for RoutingPush {
592    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593        f.debug_struct("RoutingPush")
594            .field("apns", &self.apns.is_some())
595            .field("fcm", &self.fcm.is_some())
596            .field("web_push", &self.web_push.is_some())
597            .finish()
598    }
599}
600
601#[async_trait]
602impl Push for RoutingPush {
603    async fn send(
604        &self,
605        to: &Recipient,
606        notification: &Notification,
607    ) -> Result<PushOutcome, PushError> {
608        match self.route_for(to) {
609            Some(push) => push.send(to, notification).await,
610            None => Ok(PushOutcome::NotConfigured),
611        }
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn a_recipient_round_trips_through_json() {
621        for recipient in [
622            Recipient::apns("devicetoken"),
623            Recipient::fcm("registration"),
624            Recipient::web_push("https://fcm.googleapis.com/wp/x", "p256dh-key", "auth-key"),
625        ] {
626            let json = serde_json::to_string(&recipient).expect("serialises");
627            let back: Recipient = serde_json::from_str(&json).expect("deserialises");
628            assert_eq!(recipient, back);
629        }
630    }
631
632    #[test]
633    fn the_recipient_tag_is_the_transport_name() {
634        let json = serde_json::to_value(Recipient::web_push("https://e", "p", "a")).unwrap();
635        assert_eq!(json["web_push"]["endpoint"], "https://e");
636        assert_eq!(json["web_push"]["p256dh"], "p");
637        assert_eq!(json["web_push"]["auth"], "a");
638    }
639
640    #[test]
641    fn debug_prints_no_credential_material() {
642        // The Web Push `auth` secret, the endpoint capability URL and the
643        // device token must never reach a log through `{:?}` — core's
644        // redaction is by field name and cannot see inside this enum.
645        let web = Recipient::web_push(
646            "https://fcm.googleapis.com/wp/cAPABILITYtokenPATH",
647            "BP256dhPublicKeyValue",
648            "AuthSecretValue",
649        );
650        let printed = format!("{web:?}");
651        assert!(printed.contains("WebPush"), "{printed}");
652        assert!(!printed.contains("AuthSecretValue"), "{printed}");
653        assert!(!printed.contains("cAPABILITYtokenPATH"), "{printed}");
654        assert!(!printed.contains("fcm.googleapis.com"), "{printed}");
655        assert!(!printed.contains("BP256dhPublicKeyValue"), "{printed}");
656
657        let apns = format!("{:?}", Recipient::apns("deadbeefDEVICEtoken"));
658        assert!(apns.contains("Apns"), "{apns}");
659        assert!(!apns.contains("deadbeefDEVICEtoken"), "{apns}");
660
661        let fcm = format!("{:?}", Recipient::fcm("REGISTRATIONtokenValue"));
662        assert!(fcm.contains("Fcm"), "{fcm}");
663        assert!(!fcm.contains("REGISTRATIONtokenValue"), "{fcm}");
664
665        // A fingerprint still correlates: same recipient, same line.
666        assert_eq!(printed, format!("{web:?}"));
667        assert_ne!(
668            printed,
669            format!("{:?}", Recipient::web_push("https://other", "p", "a"))
670        );
671    }
672
673    #[test]
674    fn a_recipient_inside_a_struct_is_redacted_too() {
675        // The realistic leak: `tracing::error!(?recipient)` on a struct that
676        // merely contains one, where the derived Debug delegates to ours.
677        #[derive(Debug)]
678        struct Row {
679            recipient: Recipient,
680        }
681        let row = Row {
682            recipient: Recipient::apns("deadbeefDEVICEtoken"),
683        };
684        let printed = format!("{row:?}");
685        assert!(!printed.contains("deadbeefDEVICEtoken"), "{printed}");
686        assert_eq!(row.recipient.platform(), Platform::Ios);
687    }
688
689    #[test]
690    fn platform_is_a_transport_fact() {
691        assert_eq!(Recipient::apns("t").platform(), Platform::Ios);
692        assert_eq!(Recipient::fcm("t").platform(), Platform::Android);
693        // A UnifiedPush endpoint on an Android phone is still Web Push.
694        assert_eq!(
695            Recipient::web_push("https://ntfy.sh/x", "p", "a").platform(),
696            Platform::Web
697        );
698    }
699
700    #[test]
701    fn a_minimal_notification_serialises_to_title_body_data_priority() {
702        let json = serde_json::to_value(Notification::new("Hi", "there")).unwrap();
703        let object = json.as_object().expect("object");
704        let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
705        keys.sort_unstable();
706        assert_eq!(keys, ["body", "data", "priority", "title"]);
707    }
708
709    #[test]
710    fn a_full_notification_round_trips_and_ttl_is_seconds() {
711        let mut notification = Notification::new("Room starting", "Yoga in 10 min");
712        notification.category = Some("SESSION".to_owned());
713        notification.thread_id = Some("room-42".to_owned());
714        notification.data = serde_json::json!({ "room_id": "42" });
715        notification.collapse_id = Some("room-42".to_owned());
716        notification.priority = Priority::Conserve;
717        notification.icon = Some("https://example.test/icon.png".to_owned());
718        notification.url = Some("https://example.test/rooms/42".to_owned());
719        notification.ttl = Some(Duration::from_secs(3_600));
720        notification.badge = Some(3);
721        notification.silent = true;
722        notification.loc = Some(LocKeys {
723            title_loc_key: Some("ROOM_STARTING".to_owned()),
724            title_loc_args: vec!["Yoga".to_owned()],
725            body_loc_key: Some("ROOM_BODY".to_owned()),
726            body_loc_args: vec!["10".to_owned()],
727        });
728
729        let json = serde_json::to_value(&notification).unwrap();
730        assert_eq!(json["ttl"], 3_600, "a TTL is whole seconds on the wire");
731        assert_eq!(json["silent"], true);
732        assert_eq!(json["loc"]["title_loc_key"], "ROOM_STARTING");
733
734        let back: Notification = serde_json::from_value(json).unwrap();
735        assert_eq!(back, notification);
736    }
737
738    #[test]
739    fn a_notification_from_the_pre_recipient_wire_form_still_parses() {
740        // Every field #177 added is optional, so a payload written against
741        // the old shape (an outbox row, say) still deserialises.
742        let old = serde_json::json!({
743            "title": "Hi",
744            "body": "there",
745            "data": {},
746            "priority": "immediate"
747        });
748        let notification: Notification = serde_json::from_value(old).unwrap();
749        assert_eq!(notification.title, "Hi");
750        assert_eq!(notification.ttl, None);
751        assert!(!notification.silent);
752    }
753
754    #[test]
755    fn a_sub_second_ttl_rounds_up_and_never_becomes_drop_now() {
756        // 900ms is "hold it briefly", not "drop it the moment the device is
757        // offline" — and truncating to 0 would silently say the latter, on
758        // the wire and on every round trip through it.
759        assert_eq!(ttl_secs(Duration::from_millis(900)), 1);
760        assert_eq!(ttl_secs(Duration::from_nanos(1)), 1);
761        assert_eq!(ttl_secs(Duration::ZERO), 0, "zero is the deliberate drop");
762        assert_eq!(ttl_secs(Duration::from_secs(3_600)), 3_600);
763        assert_eq!(
764            ttl_secs(Duration::from_millis(1_900)),
765            1,
766            "whole seconds otherwise truncate, as every protocol does"
767        );
768
769        let mut notification = Notification::new("a", "b");
770        notification.ttl = Some(Duration::from_millis(900));
771        let json = serde_json::to_value(&notification).unwrap();
772        assert_eq!(json["ttl"], 1, "sub-second serialises as one second");
773        let back: Notification = serde_json::from_value(json).unwrap();
774        assert_eq!(
775            back.ttl,
776            Some(Duration::from_secs(1)),
777            "and comes back meaning one second, not Duration::ZERO"
778        );
779
780        let mut zero = Notification::new("a", "b");
781        zero.ttl = Some(Duration::ZERO);
782        let json = serde_json::to_value(&zero).unwrap();
783        assert_eq!(json["ttl"], 0);
784        let back: Notification = serde_json::from_value(json).unwrap();
785        assert_eq!(back.ttl, Some(Duration::ZERO));
786    }
787
788    #[test]
789    fn transient_carries_an_optional_retry_after() {
790        let plain = PushError::transient("apns 503");
791        assert_eq!(plain.retry_after(), None);
792        let throttled = PushError::transient_after("apns 429", Some(Duration::from_secs(30)));
793        assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
794        assert_eq!(PushError::Unregistered.retry_after(), None);
795    }
796
797    #[test]
798    fn an_unsupported_recipient_names_the_transport() {
799        let error = PushError::unsupported_recipient(&Recipient::fcm("t"));
800        let message = error.to_string();
801        assert!(message.contains("unsupported recipient"), "{message}");
802        assert!(message.contains("android"), "{message}");
803    }
804
805    // -----------------------------------------------------------------
806    // RoutingPush
807
808    struct Recording {
809        label: &'static str,
810        seen: std::sync::atomic::AtomicUsize,
811    }
812
813    impl Recording {
814        fn new(label: &'static str) -> Arc<Self> {
815            Arc::new(Self {
816                label,
817                seen: std::sync::atomic::AtomicUsize::new(0),
818            })
819        }
820        fn count(&self) -> usize {
821            self.seen.load(std::sync::atomic::Ordering::Relaxed)
822        }
823    }
824
825    #[async_trait]
826    impl Push for Recording {
827        async fn send(
828            &self,
829            _to: &Recipient,
830            _notification: &Notification,
831        ) -> Result<PushOutcome, PushError> {
832            self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
833            Ok(PushOutcome::Delivered {
834                id: Some(self.label.to_owned()),
835            })
836        }
837    }
838
839    #[test]
840    fn the_router_dispatches_by_variant() {
841        let apns = Recording::new("apns");
842        let web = Recording::new("web");
843        let router = RoutingPush::new().apns(apns.clone()).web_push(web.clone());
844        let notification = Notification::new("a", "b");
845
846        let outcome =
847            pollster::block_on(router.send(&Recipient::apns("t"), &notification)).unwrap();
848        assert_eq!(
849            outcome,
850            PushOutcome::Delivered {
851                id: Some("apns".to_owned())
852            }
853        );
854        let outcome = pollster::block_on(
855            router.send(&Recipient::web_push("https://e", "p", "a"), &notification),
856        )
857        .unwrap();
858        assert_eq!(
859            outcome,
860            PushOutcome::Delivered {
861                id: Some("web".to_owned())
862            }
863        );
864        assert_eq!(apns.count(), 1);
865        assert_eq!(web.count(), 1);
866    }
867
868    #[test]
869    fn a_transport_with_no_adapter_is_not_configured_not_rejected() {
870        let router = RoutingPush::new().apns(Recording::new("apns"));
871        let outcome =
872            pollster::block_on(router.send(&Recipient::fcm("t"), &Notification::new("a", "b")))
873                .unwrap();
874        assert_eq!(outcome, PushOutcome::NotConfigured);
875    }
876
877    #[test]
878    fn an_empty_router_is_not_configured_for_every_transport() {
879        let router = RoutingPush::new();
880        for recipient in [
881            Recipient::apns("t"),
882            Recipient::fcm("t"),
883            Recipient::web_push("https://e", "p", "a"),
884        ] {
885            let outcome =
886                pollster::block_on(router.send(&recipient, &Notification::new("a", "b"))).unwrap();
887            assert_eq!(outcome, PushOutcome::NotConfigured);
888            assert!(router.route_for(&recipient).is_none());
889        }
890    }
891
892    #[test]
893    fn display_sanitizes_the_provider_text() {
894        // Issue #235. The text an adapter wraps is the push service's own
895        // words, and a Web Push endpoint is a bearer capability URL: the
896        // token it carries in its query must not survive into a log line
897        // or a dead-letter row.
898        let error = PushError::Rejected(
899            "web push 400 for https://push.example.test/wp/alice?auth=cap-abcdef".to_owned(),
900        );
901        let text = error.to_string();
902        assert!(!text.contains("cap-abcdef"), "{text}");
903        assert!(text.contains("?[redacted]"), "{text}");
904
905        // A provider that quotes the account it bounced on quotes an
906        // address, and `Transient` carries provider text just as
907        // `Rejected` does.
908        let error = PushError::transient("fcm 503 for alice@example.test");
909        let text = error.to_string();
910        assert!(!text.contains('@'), "{text}");
911        assert!(text.contains("[subject_hash:"), "{text}");
912
913        assert_eq!(
914            PushError::Unregistered.to_string(),
915            "device token is no longer registered; delete it"
916        );
917
918        // `Debug` still shows the raw string for a failing test to read.
919        assert!(format!("{error:?}").contains("alice@example.test"));
920    }
921}
922
923#[cfg(test)]
924mod retry_after_tests {
925    use super::{parse_http_date, retry_after};
926    use crate::Clock;
927    use std::time::Duration;
928
929    /// Fixed at `Sun, 06 Nov 1994 08:49:37 GMT`, so a date in the header
930    /// is a known distance away rather than a race against the wall clock.
931    struct FixedClock(time::OffsetDateTime);
932
933    #[async_trait::async_trait]
934    impl Clock for FixedClock {
935        fn now(&self) -> time::OffsetDateTime {
936            self.0
937        }
938    }
939
940    fn at(unix: i64) -> FixedClock {
941        FixedClock(time::OffsetDateTime::from_unix_timestamp(unix).expect("a valid instant"))
942    }
943
944    fn headers(value: &str) -> http::HeaderMap {
945        let mut headers = http::HeaderMap::new();
946        headers.insert("retry-after", value.parse().expect("a header value"));
947        headers
948    }
949
950    #[test]
951    fn an_http_date_parses_and_a_nonsense_one_does_not() {
952        // Moved here with the parser it tests (issue #214); the two
953        // obsolete forms stay unparsed on purpose.
954        let parsed = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").expect("IMF-fixdate");
955        assert_eq!(parsed.unix_timestamp(), 784_111_777);
956        assert!(parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").is_none());
957        assert!(parse_http_date("tomorrow").is_none());
958    }
959
960    #[test]
961    fn the_delta_seconds_form_is_read() {
962        assert_eq!(
963            retry_after(&headers("120"), &at(0)),
964            Some(Duration::from_secs(120))
965        );
966        assert_eq!(
967            retry_after(&headers("  30  "), &at(0)),
968            Some(Duration::from_secs(30)),
969            "surrounding space is not a parse failure"
970        );
971    }
972
973    #[test]
974    fn the_http_date_form_is_read_too() {
975        // The whole point of consolidating: three of the four copies read
976        // only the delta-seconds form, so a CDN's date turned an hour's
977        // wait into an immediate retry.
978        let an_hour_before = 784_111_777 - 3_600;
979        assert_eq!(
980            retry_after(
981                &headers("Sun, 06 Nov 1994 08:49:37 GMT"),
982                &at(an_hour_before)
983            ),
984            Some(Duration::from_secs(3_600))
985        );
986    }
987
988    #[test]
989    fn a_date_already_past_means_retry_now_not_no_delay() {
990        // `Duration::ZERO` and `None` say different things to a caller:
991        // "now" versus "nothing was stated, use your own schedule".
992        assert_eq!(
993            retry_after(
994                &headers("Sun, 06 Nov 1994 08:49:37 GMT"),
995                &at(784_111_777 + 60)
996            ),
997            Some(Duration::ZERO)
998        );
999    }
1000
1001    #[test]
1002    fn an_absent_or_unparseable_header_states_no_delay() {
1003        assert_eq!(retry_after(&http::HeaderMap::new(), &at(0)), None);
1004        assert_eq!(retry_after(&headers("soon"), &at(0)), None);
1005        assert_eq!(
1006            retry_after(&headers("-5"), &at(0)),
1007            None,
1008            "a negative delta is not a duration"
1009        );
1010    }
1011}