1#![doc = include_str!("../README.md")]
74#![forbid(unsafe_code)]
75
76use std::fmt;
77use std::sync::Arc;
78
79use bytes::Bytes;
80use cratefield_adapter_apns::{Apns, ApnsConfigError, ApnsCredentials, ApnsHost};
81use cratefield_adapter_fcm::{Fcm, FcmConfigError};
82use cratefield_adapter_webpush::vapid::{VapidError, VapidKeys};
83use cratefield_adapter_webpush::{WebPush, WebPushConfigError};
84use cratefield_core::{
85 Clock, Config, HttpClient, HttpError, Platform, Push, RoutingPush, SystemClock, VentureEnv,
86};
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum PushKey {
103 ApnsKeyP8,
105 ApnsKeyId,
107 ApnsTeamId,
109 ApnsTopic,
111 ApnsHost,
113 FcmServiceAccountJson,
115 VapidPrivateKey,
117 VapidSubject,
119}
120
121impl PushKey {
122 #[must_use]
128 pub const fn name(self) -> &'static str {
129 match self {
130 PushKey::ApnsKeyP8 => "APNS_KEY_P8",
131 PushKey::ApnsKeyId => "APNS_KEY_ID",
132 PushKey::ApnsTeamId => "APNS_TEAM_ID",
133 PushKey::ApnsTopic => "APNS_TOPIC",
134 PushKey::ApnsHost => "APNS_HOST",
135 PushKey::FcmServiceAccountJson => "FCM_SERVICE_ACCOUNT_JSON",
136 PushKey::VapidPrivateKey => "VAPID_PRIVATE_KEY",
137 PushKey::VapidSubject => "VAPID_SUBJECT",
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct PushVar {
145 pub key: PushKey,
148 pub transport: Platform,
150 pub required: bool,
152 pub secret: bool,
155 pub purpose: &'static str,
157}
158
159impl PushVar {
160 #[must_use]
162 pub const fn name(&self) -> &'static str {
163 self.key.name()
164 }
165}
166
167pub const PUSH_ENV: &[PushVar] = &[
173 PushVar {
174 key: PushKey::ApnsKeyP8,
175 transport: Platform::Ios,
176 required: true,
177 secret: true,
178 purpose: "The `.p8` provider key from the Apple developer portal, PKCS#8 PEM, whole.",
179 },
180 PushVar {
181 key: PushKey::ApnsKeyId,
182 transport: Platform::Ios,
183 required: true,
184 secret: false,
185 purpose: "The key's 10-character id (the `.p8` filename suffix).",
186 },
187 PushVar {
188 key: PushKey::ApnsTeamId,
189 transport: Platform::Ios,
190 required: true,
191 secret: false,
192 purpose: "The 10-character Apple team id; the provider JWT's issuer.",
193 },
194 PushVar {
195 key: PushKey::ApnsTopic,
196 transport: Platform::Ios,
197 required: true,
198 secret: false,
199 purpose: "The app's bundle id, sent as `apns-topic`.",
200 },
201 PushVar {
202 key: PushKey::ApnsHost,
203 transport: Platform::Ios,
204 required: false,
205 secret: false,
206 purpose: "`production` or `sandbox`. Optional; defaults to `sandbox`, because a \
207 development build's token is a sandbox token and sending it to production \
208 fails every time.",
209 },
210 PushVar {
211 key: PushKey::FcmServiceAccountJson,
212 transport: Platform::Android,
213 required: true,
214 secret: true,
215 purpose: "The Google service-account JSON Firebase hands over, whole. `client_email`, \
216 `private_key`, `project_id` and `token_uri` are read out of it, so there is \
217 no second variable to keep in step.",
218 },
219 PushVar {
220 key: PushKey::VapidPrivateKey,
221 transport: Platform::Web,
222 required: true,
223 secret: true,
224 purpose: "The VAPID private key: a PKCS#8 PEM, or the bare 32-byte P-256 scalar \
225 base64url. The public key is derived, never configured.",
226 },
227 PushVar {
228 key: PushKey::VapidSubject,
229 transport: Platform::Web,
230 required: true,
231 secret: false,
232 purpose: "RFC 8292 §2.1 `sub`: a `mailto:` or `https:` contact URI a push service can \
233 reach the operator at.",
234 },
235];
236
237pub const TRANSPORTS: [Platform; 3] = [Platform::Ios, Platform::Android, Platform::Web];
239
240#[must_use]
243pub fn transport_key(transport: Platform) -> &'static str {
244 match transport {
245 Platform::Ios => "apns",
246 Platform::Android => "fcm",
247 Platform::Web => "web_push",
248 }
249}
250
251#[must_use]
253pub fn transport_name(transport: Platform) -> &'static str {
254 match transport {
255 Platform::Ios => "APNs",
256 Platform::Android => "FCM",
257 Platform::Web => "Web Push",
258 }
259}
260
261pub fn vars_for(transport: Platform) -> impl Iterator<Item = &'static PushVar> {
263 PUSH_ENV
264 .iter()
265 .filter(move |var| var.transport == transport)
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Default)]
270pub enum TransportWiring {
271 #[default]
275 Absent,
276 Configured,
278 Partial {
282 present: Vec<&'static str>,
284 missing: Vec<&'static str>,
286 },
287 Invalid {
289 reason: String,
296 },
297}
298
299impl TransportWiring {
300 #[must_use]
303 pub fn verdict(&self) -> &'static str {
304 match self {
305 TransportWiring::Absent => "absent",
306 TransportWiring::Configured => "configured",
307 TransportWiring::Partial { .. } => "partial",
308 TransportWiring::Invalid { .. } => "invalid",
309 }
310 }
311
312 #[must_use]
317 pub fn is_routed(&self) -> bool {
318 matches!(self, TransportWiring::Configured)
319 }
320
321 #[must_use]
323 pub fn is_problem(&self) -> bool {
324 matches!(
325 self,
326 TransportWiring::Partial { .. } | TransportWiring::Invalid { .. }
327 )
328 }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum WiringSeverity {
334 Ok,
336 Warning,
340 Error,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Default)]
351pub struct PushWiring {
352 apns: TransportWiring,
353 fcm: TransportWiring,
354 web_push: TransportWiring,
355}
356
357impl PushWiring {
358 #[must_use]
360 pub fn get(&self, transport: Platform) -> &TransportWiring {
361 match transport {
362 Platform::Ios => &self.apns,
363 Platform::Android => &self.fcm,
364 Platform::Web => &self.web_push,
365 }
366 }
367
368 pub fn iter(&self) -> impl Iterator<Item = (Platform, &TransportWiring)> {
370 TRANSPORTS
371 .into_iter()
372 .map(move |transport| (transport, self.get(transport)))
373 }
374
375 #[must_use]
379 pub fn summary(&self) -> String {
380 use fmt::Write as _;
381 let mut out = String::from("push wiring:");
382 for (transport, wiring) in self.iter() {
383 let _ = write!(out, " {}={}", transport_key(transport), wiring.verdict());
384 match wiring {
385 TransportWiring::Partial { present, missing } => {
386 let _ = write!(
387 out,
388 " (set: {}; unset: {})",
389 present.join(", "),
390 missing.join(", ")
391 );
392 }
393 TransportWiring::Invalid { reason } => {
394 let _ = write!(out, " ({reason})");
395 }
396 TransportWiring::Absent | TransportWiring::Configured => {}
397 }
398 }
399 out
400 }
401
402 #[must_use]
405 pub fn problems(&self) -> Vec<String> {
406 let mut problems = Vec::new();
407 for (transport, wiring) in self.iter() {
408 let name = transport_name(transport);
409 match wiring {
410 TransportWiring::Partial { present, missing } => problems.push(format!(
411 "{name} is partially configured: {} {} set; {} {} not. \
412 {name} is left unrouted, so every {name} send reports NotConfigured and \
413 nothing is delivered. Set the missing variables, or unset the rest to \
414 say this transport is deliberately off.",
415 present.join(", "),
416 is_are(present.len()),
417 missing.join(", "),
418 is_are(missing.len()),
419 )),
420 TransportWiring::Invalid { reason } => problems.push(format!(
421 "{name} is configured but its credentials were refused: {reason}. \
422 {name} is left unrouted, so every {name} send reports NotConfigured."
423 )),
424 TransportWiring::Absent | TransportWiring::Configured => {}
425 }
426 }
427 problems
428 }
429
430 #[must_use]
434 pub fn any_routed(&self) -> bool {
435 self.iter().any(|(_, wiring)| wiring.is_routed())
436 }
437
438 #[must_use]
444 pub fn severity(&self, env: VentureEnv) -> WiringSeverity {
445 if !self.iter().any(|(_, wiring)| wiring.is_problem()) {
446 WiringSeverity::Ok
447 } else if env == VentureEnv::Production {
448 WiringSeverity::Error
449 } else {
450 WiringSeverity::Warning
451 }
452 }
453
454 pub fn check(&self, env: VentureEnv) -> Result<(), String> {
463 match self.severity(env) {
464 WiringSeverity::Error => Err(self.problems().join("\n")),
465 WiringSeverity::Ok | WiringSeverity::Warning => Ok(()),
466 }
467 }
468}
469
470impl fmt::Display for PushWiring {
471 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472 f.write_str(&self.summary())
473 }
474}
475
476fn is_are(count: usize) -> &'static str {
479 if count == 1 { "is" } else { "are" }
480}
481
482enum Survey {
484 Absent,
485 Partial {
486 present: Vec<&'static str>,
487 missing: Vec<&'static str>,
488 },
489 Complete(Values),
490}
491
492struct Values(Vec<(PushKey, String)>);
496
497impl Values {
498 fn get(&self, key: PushKey) -> Option<&str> {
501 self.0
502 .iter()
503 .find(|(found, _)| *found == key)
504 .map(|(_, value)| value.as_str())
505 }
506}
507
508fn required(values: &Values, key: PushKey) -> Result<String, TransportWiring> {
515 values.get(key).map(ToOwned::to_owned).ok_or_else(|| {
516 invalid(format!(
517 "{} was read from the environment and then lost (a cratefield-push-wiring bug)",
518 key.name()
519 ))
520 })
521}
522
523fn value_of(config: &dyn Config, key: PushKey) -> Option<String> {
535 config
536 .get(key.name())
537 .map(|value| value.trim().to_owned())
538 .filter(|value| !value.is_empty())
539}
540
541fn survey(config: &dyn Config, transport: Platform) -> Survey {
542 let mut present = Vec::new();
543 let mut missing = Vec::new();
544 let mut values = Vec::new();
545 for var in vars_for(transport) {
546 match value_of(config, var.key) {
547 Some(value) => {
548 present.push(var.name());
549 values.push((var.key, value));
550 }
551 None if var.required => missing.push(var.name()),
552 None => {}
553 }
554 }
555 if present.is_empty() {
556 Survey::Absent
557 } else if missing.is_empty() {
558 Survey::Complete(Values(values))
559 } else {
560 Survey::Partial { present, missing }
561 }
562}
563
564#[must_use]
581pub fn build_push(
582 config: &dyn Config,
583 http: &Arc<dyn HttpClient>,
584 clock: &Arc<dyn Clock>,
585) -> (Arc<dyn Push>, PushWiring) {
586 let (apns_adapter, apns) = build_apns(config, http, clock);
587 let (fcm_adapter, fcm) = build_fcm(config, http, clock);
588 let (web_push_adapter, web_push) = build_web_push(config, http, clock);
589
590 let mut router = RoutingPush::new();
591 if let Some(adapter) = apns_adapter {
592 router = router.apns(adapter);
593 }
594 if let Some(adapter) = fcm_adapter {
595 router = router.fcm(adapter);
596 }
597 if let Some(adapter) = web_push_adapter {
598 router = router.web_push(adapter);
599 }
600
601 (
602 Arc::new(router),
603 PushWiring {
604 apns,
605 fcm,
606 web_push,
607 },
608 )
609}
610
611type Built = (Option<Arc<dyn Push>>, TransportWiring);
615
616fn invalid(reason: String) -> TransportWiring {
622 TransportWiring::Invalid { reason }
623}
624
625fn apns_refusal(err: &ApnsConfigError) -> TransportWiring {
628 match err {
629 ApnsConfigError::Key(_) => invalid(format!(
630 "{} is not a PKCS#8 PEM P-256 private key — paste the `.p8` file whole, \
631 `-----BEGIN PRIVATE KEY-----` line included",
632 PushKey::ApnsKeyP8.name()
633 )),
634 }
635}
636
637fn fcm_refusal(err: &FcmConfigError) -> TransportWiring {
640 let name = PushKey::FcmServiceAccountJson.name();
641 invalid(match err {
642 FcmConfigError::ServiceAccount(_) => format!(
643 "{name} is not the service-account JSON Firebase issues — it must parse as JSON \
644 and carry `project_id`, `private_key`, `client_email` and `token_uri`"
645 ),
646 FcmConfigError::Key(_) => {
647 format!("{name} carries a `private_key` that is not an RSA private key in PEM form")
648 }
649 FcmConfigError::ProjectId(_) => {
650 format!("{name} carries a `project_id` that is not a bare Firebase project id")
651 }
652 FcmConfigError::TokenUri(_) => {
653 format!("{name} carries a `token_uri` that is not an https URL")
654 }
655 })
656}
657
658fn web_push_refusal(err: &WebPushConfigError) -> TransportWiring {
667 invalid(match err {
668 WebPushConfigError::Vapid(VapidError::Key(_)) => format!(
669 "{} is neither a PKCS#8 PEM nor a base64url-encoded 32-byte P-256 scalar",
670 PushKey::VapidPrivateKey.name()
671 ),
672 WebPushConfigError::Vapid(VapidError::Subject(_)) => format!(
673 "{} is not a `mailto:` or `https:` contact URI (RFC 8292 §2.1). If it holds a \
674 key, it was swapped with {}",
675 PushKey::VapidSubject.name(),
676 PushKey::VapidPrivateKey.name()
677 ),
678 WebPushConfigError::Vapid(VapidError::Endpoint(_)) | WebPushConfigError::Ece(_) => format!(
681 "the Web Push adapter refused the configuration built from {} and {}",
682 PushKey::VapidPrivateKey.name(),
683 PushKey::VapidSubject.name()
684 ),
685 })
686}
687
688fn build_apns(config: &dyn Config, http: &Arc<dyn HttpClient>, clock: &Arc<dyn Clock>) -> Built {
689 let values = match survey(config, Platform::Ios) {
690 Survey::Absent => return (None, TransportWiring::Absent),
691 Survey::Partial { present, missing } => {
692 return (None, TransportWiring::Partial { present, missing });
693 }
694 Survey::Complete(values) => values,
695 };
696
697 let host = match values.get(PushKey::ApnsHost) {
701 None => ApnsHost::Sandbox,
702 Some(raw) => match ApnsHost::parse(raw) {
703 Some(host) => host,
704 None => {
705 return (
711 None,
712 invalid(format!(
713 "{} is neither `production` nor `sandbox`",
714 PushKey::ApnsHost.name()
715 )),
716 );
717 }
718 },
719 };
720
721 let creds = match apns_credentials(&values, host) {
722 Ok(creds) => creds,
723 Err(wiring) => return (None, wiring),
724 };
725 match Apns::new(Arc::clone(http), Arc::clone(clock), creds) {
726 Ok(apns) => (Some(Arc::new(apns)), TransportWiring::Configured),
727 Err(err) => (None, apns_refusal(&err)),
728 }
729}
730
731fn apns_credentials(values: &Values, host: ApnsHost) -> Result<ApnsCredentials, TransportWiring> {
732 Ok(ApnsCredentials {
733 key_p8_pem: required(values, PushKey::ApnsKeyP8)?,
734 key_id: required(values, PushKey::ApnsKeyId)?,
735 team_id: required(values, PushKey::ApnsTeamId)?,
736 topic: required(values, PushKey::ApnsTopic)?,
737 host,
738 })
739}
740
741fn build_fcm(config: &dyn Config, http: &Arc<dyn HttpClient>, clock: &Arc<dyn Clock>) -> Built {
742 let values = match survey(config, Platform::Android) {
743 Survey::Absent => return (None, TransportWiring::Absent),
744 Survey::Partial { present, missing } => {
745 return (None, TransportWiring::Partial { present, missing });
746 }
747 Survey::Complete(values) => values,
748 };
749
750 let json = match required(&values, PushKey::FcmServiceAccountJson) {
751 Ok(json) => json,
752 Err(wiring) => return (None, wiring),
753 };
754 match Fcm::from_service_account_json(Arc::clone(http), Arc::clone(clock), &json) {
755 Ok(fcm) => (Some(Arc::new(fcm)), TransportWiring::Configured),
756 Err(err) => (None, fcm_refusal(&err)),
757 }
758}
759
760fn build_web_push(
761 config: &dyn Config,
762 http: &Arc<dyn HttpClient>,
763 clock: &Arc<dyn Clock>,
764) -> Built {
765 let values = match survey(config, Platform::Web) {
766 Survey::Absent => return (None, TransportWiring::Absent),
767 Survey::Partial { present, missing } => {
768 return (None, TransportWiring::Partial { present, missing });
769 }
770 Survey::Complete(values) => values,
771 };
772
773 let keys = match vapid_keys(&values) {
774 Ok(keys) => keys,
775 Err(wiring) => return (None, wiring),
776 };
777 match WebPush::new(Arc::clone(http), Arc::clone(clock), keys) {
778 Ok(web_push) => (Some(Arc::new(web_push)), TransportWiring::Configured),
779 Err(err) => (None, web_push_refusal(&err)),
780 }
781}
782
783fn vapid_keys(values: &Values) -> Result<VapidKeys, TransportWiring> {
784 Ok(VapidKeys {
785 private_key: required(values, PushKey::VapidPrivateKey)?,
786 subject: required(values, PushKey::VapidSubject)?,
787 })
788}
789
790#[must_use]
801pub fn inspect_push(config: &dyn Config) -> PushWiring {
802 let http: Arc<dyn HttpClient> = Arc::new(OfflineHttpClient);
803 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
804 let (_router, report) = build_push(config, &http, &clock);
805 report
806}
807
808#[must_use]
822pub fn vapid_public_key(config: &dyn Config) -> Option<String> {
823 let Survey::Complete(values) = survey(config, Platform::Web) else {
824 return None;
825 };
826 let keys = vapid_keys(&values).ok()?;
827 let http: Arc<dyn HttpClient> = Arc::new(OfflineHttpClient);
828 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
829 WebPush::new(http, clock, keys)
830 .ok()?
831 .public_key()
832 .map(str::to_owned)
833}
834
835struct OfflineHttpClient;
839
840#[async_trait::async_trait]
841impl HttpClient for OfflineHttpClient {
842 async fn send(
843 &self,
844 _request: http::Request<Bytes>,
845 ) -> Result<http::Response<Bytes>, HttpError> {
846 Err(HttpError::Transport(
847 "this push adapter was built for inspection only and cannot send".to_owned(),
848 ))
849 }
850}
851
852#[must_use]
857pub fn notifications_doc() -> String {
858 use fmt::Write as _;
859
860 let mut out = String::new();
861 out.push_str("# The push environment\n\n");
862 out.push_str(
863 "*Generated reference. The hand-written notifications guide (push, in-app, \
864email and languages, with the per-platform client sections) is \
865`docs/NOTIFICATIONS.md`, issue #185, which links here rather than repeating this \
866table.*\n\n",
867 );
868 out.push_str(
869 "Every environment variable the push adapters read, generated from\n\
870 `cratefield-push-wiring`'s `PUSH_ENV` table by `cargo run -p\n\
871 cratefield-push-wiring --example push-env-doc` and checked in CI for\n\
872 drift (issue #191).\n\n\
873 One function reads these names — `build_push` — so no two callers can\n\
874 drift apart on a variable name. `serve()` reaches it through\n\
875 `push_from_env()` on either runtime, and `fz doctor` through\n\
876 `inspect_push`; `fz push` (issue #184) calls it directly when it lands.\n\
877 `crates/cli-acceptance/tests/push_env_guard.rs` fails the build if any\n\
878 other Rust source names one of them.\n\n\
879 Keys are read through the `Config` port, so on Workers a secret and a\n\
880 `[vars]` entry both work (a secret wins), and natively they are process\n\
881 environment variables. A variable set to the empty string counts as unset.\n\n",
882 );
883
884 for transport in TRANSPORTS {
885 let _ = writeln!(
886 out,
887 "## {} (`{}`)\n",
888 transport_name(transport),
889 transport_key(transport)
890 );
891 out.push_str("| Variable | Required | Secret | Purpose |\n");
892 out.push_str("|---|---|---|---|\n");
893 for var in vars_for(transport) {
894 let _ = writeln!(
895 out,
896 "| `{}` | {} | {} | {} |",
897 var.name(),
898 if var.required { "yes" } else { "no" },
899 if var.secret { "yes" } else { "no" },
900 var.purpose,
901 );
902 }
903 out.push('\n');
904 }
905
906 out.push_str(
907 "## Verdicts\n\n\
908 `build_push` returns a `PushWiring` report next to the router, and each\n\
909 runtime logs it once at cold start. Per transport:\n\n\
910 | Verdict | When | Consequence |\n\
911 |---|---|---|\n\
912 | `configured` | every required variable is set and the adapter accepted \
913 them | the transport is routed |\n\
914 | `absent` | not one of its variables is set | the transport is not routed; \
915 its recipients answer `NotConfigured`. A choice, not a defect |\n\
916 | `partial` | some of its variables are set and some are not | the transport \
917 is **not** routed. An error in production, a warning elsewhere |\n\
918 | `invalid` | all are set and the adapter refused them | the transport is \
919 **not** routed. An error in production, a warning elsewhere |\n\n\
920 Partial is the case this exists for: a venture that meant to enable FCM and\n\
921 mistyped one variable must not boot into a state where every Android send\n\
922 silently answers `NotConfigured`. In production that fails `fz doctor` and\n\
923 is logged at error; in development and staging it is a warning, because\n\
924 wiring a transport one variable at a time is what development looks like.\n\n\
925 The report holds variable **names** and verdicts only, never a value, so it\n\
926 is safe to log whole.\n",
927 );
928
929 out
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935 use cratefield_core::MapConfig;
936
937 #[test]
938 fn every_variable_belongs_to_exactly_one_transport_and_is_unique() {
939 let mut names: Vec<&str> = PUSH_ENV.iter().map(PushVar::name).collect();
940 names.sort_unstable();
941 let count = names.len();
942 names.dedup();
943 assert_eq!(names.len(), count, "a variable is listed twice");
944 }
945
946 #[test]
947 fn every_key_appears_in_the_table_exactly_once() {
948 let keys: Vec<PushKey> = PUSH_ENV.iter().map(|var| var.key).collect();
954 for key in [
955 PushKey::ApnsKeyP8,
956 PushKey::ApnsKeyId,
957 PushKey::ApnsTeamId,
958 PushKey::ApnsTopic,
959 PushKey::ApnsHost,
960 PushKey::FcmServiceAccountJson,
961 PushKey::VapidPrivateKey,
962 PushKey::VapidSubject,
963 ] {
964 assert_eq!(
965 keys.iter().filter(|found| **found == key).count(),
966 1,
967 "{} is not in PUSH_ENV exactly once",
968 key.name()
969 );
970 }
971 assert_eq!(
972 keys.len(),
973 8,
974 "a key was added to the table, not to this test"
975 );
976 }
977
978 #[test]
979 fn a_value_is_trimmed_before_it_reaches_an_adapter() {
980 let config = MapConfig::from_pairs([(
984 PushKey::ApnsKeyId.name().to_owned(),
985 " ABCDE12345\n".to_owned(),
986 )]);
987 assert_eq!(
988 value_of(&config, PushKey::ApnsKeyId).as_deref(),
989 Some("ABCDE12345")
990 );
991 }
992
993 #[test]
994 fn a_blank_value_is_unset() {
995 let config =
998 MapConfig::from_pairs([(PushKey::ApnsKeyId.name().to_owned(), " \n".to_owned())]);
999 assert_eq!(value_of(&config, PushKey::ApnsKeyId), None);
1000 }
1001
1002 #[test]
1003 fn every_transport_has_at_least_one_required_variable() {
1004 for transport in TRANSPORTS {
1007 assert!(
1008 vars_for(transport).any(|var| var.required),
1009 "{} has no required variable",
1010 transport_key(transport)
1011 );
1012 }
1013 }
1014
1015 #[test]
1016 fn severity_does_not_format_the_problems_to_find_out_whether_there_are_any() {
1017 for config in [
1021 MapConfig::from_pairs(Vec::<(String, String)>::new()),
1022 MapConfig::from_pairs([(
1023 PushKey::VapidSubject.name().to_owned(),
1024 "mailto:ops@example.test".to_owned(),
1025 )]),
1026 ] {
1027 let wiring = inspect_push(&config);
1028 assert_eq!(
1029 wiring.severity(VentureEnv::Production) == WiringSeverity::Ok,
1030 wiring.problems().is_empty(),
1031 "{}",
1032 wiring.summary()
1033 );
1034 }
1035 }
1036}