1use 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
37pub const DEFAULT_TTL_SECS: u32 = 4 * 7 * 24 * 60 * 60;
44
45const MAX_CONCURRENT_DELIVERIES: usize = 8;
53
54const MAX_CONCURRENT_PRINCIPALS: usize = 4;
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PushMessage {
67 pub title: String,
69 pub body: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
75 pub url: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub icon: Option<String>,
79}
80
81impl PushMessage {
82 #[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 #[must_use]
95 pub fn url(mut self, url: impl Into<String>) -> Self {
96 self.url = Some(url.into());
97 self
98 }
99
100 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct PushDeliveryReport {
119 pub delivered: usize,
121 pub pruned: Vec<String>,
124 pub failed: usize,
128}
129
130impl PushDeliveryReport {
131 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#[derive(Clone)]
154pub struct WebPush {
155 store: Arc<dyn BoxedPushSubscriptionStore>,
156 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 #[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 #[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 #[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 #[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 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 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 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 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 let vapid = self.vapid.as_ref().ok_or(PushError::NotConfigured)?;
312 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 tracing::debug!(
333 principal = %principal,
334 "no push subscriptions for this principal; nothing dispatched"
335 );
336 }
337
338 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 report.pruned.sort();
363 Ok(report)
364 }
365
366 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 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 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 report.pruned.sort();
410 Ok(report)
411 }
412
413 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 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 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 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 match status {
490 200..=299 => Ok(PushDeliveryReport {
491 delivered: 1,
492 ..Default::default()
493 }),
494 404 | 410 => {
495 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 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
574impl WebPush {
577 fn default_for(state: &AppState) -> Self {
584 let config = state.config();
585 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 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 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 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 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 if let Some(existing) = state.extension::<Self>() {
671 return Ok((*existing).clone());
672 }
673
674 let resolved = Self::default_for(state);
689 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 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 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 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 #[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 #[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 #[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 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 #[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 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 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 use std::sync::Arc;
1217 use std::sync::atomic::{AtomicUsize, Ordering};
1218
1219 #[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 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 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 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 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 let transport = RecordingPushTransport::new();
1329 let push = WebPush::new(
1330 MemoryPushSubscriptionStore::new(),
1331 VapidKey::generate(),
1332 "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 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 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 #[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 #[tokio::test]
1620 async fn ttl_defaults_to_the_documented_value() {
1621 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 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 #[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 #[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 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 #[tokio::test]
1800 async fn each_endpoint_gets_a_jwt_audienced_to_its_own_push_service() {
1801 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 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}