autoendpoint/extractors/
subscription.rs

1use std::borrow::Cow;
2use std::error::Error;
3
4use actix_web::{dev::Payload, web::Data, FromRequest, HttpRequest};
5use autopush_common::{
6    db::User,
7    metric_name::MetricName,
8    metrics::StatsdClientExt,
9    tags::Tags,
10    util::{b64_decode_std, b64_decode_url},
11};
12
13use cadence::StatsdClient;
14use futures::{future::LocalBoxFuture, FutureExt};
15use jsonwebtoken::{Algorithm, DecodingKey, Validation};
16use openssl::hash::MessageDigest;
17use uuid::Uuid;
18
19use crate::error::{ApiError, ApiErrorKind, ApiResult};
20use crate::extractors::{
21    token_info::{ApiVersion, TokenInfo},
22    user::validate_user,
23};
24use crate::headers::{
25    crypto_key::CryptoKeyHeader,
26    vapid::{VapidClaims, VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData},
27};
28use crate::server::AppState;
29
30use crate::settings::Settings;
31
32/// Extracts subscription data from `TokenInfo` and verifies auth/crypto headers
33#[derive(Clone, Debug)]
34pub struct Subscription {
35    pub user: User,
36    pub channel_id: Uuid,
37    pub vapid: Option<VapidHeaderWithKey>,
38    /// A stored value here indicates that the subscription update
39    /// should be tracked internally.
40    /// (This should ONLY be applied for messages that match known
41    /// Mozilla provided VAPID public keys.)
42    ///
43    pub reliability_id: Option<String>,
44}
45
46impl FromRequest for Subscription {
47    type Error = ApiError;
48    type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
49
50    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
51        let req = req.clone();
52
53        async move {
54            // Collect token info and server state
55            let token_info = TokenInfo::extract(&req).await?;
56            trace!("🔐 Token info: {:?}", &token_info);
57            let app_state: Data<AppState> =
58                Data::extract(&req).await.expect("No server state found");
59
60            // Decrypt the token
61            let token = app_state
62                .fernet
63                .decrypt(&repad_base64(&token_info.token))
64                .map_err(|e| {
65                    // Since we're decrypting and endpoint, we get a lot of spam links.
66                    // This can fill our logs.
67                    trace!("🔐 fernet: {:?}", e.to_string());
68                    ApiErrorKind::InvalidToken
69                })?;
70
71            // Parse VAPID and extract public key.
72            let vapid: Option<VapidHeaderWithKey> = parse_vapid(&token_info, &app_state.metrics)?
73                .map(|vapid| extract_public_key(vapid, &token_info))
74                .transpose()?;
75            trace!("🔍 raw vapid: {:?}", &vapid);
76            // Validate the VAPID JWT token, fetch the claims, and record the version
77            if let Some(with_key) = &vapid {
78                // Validate the VAPID JWT token and record the version
79                validate_vapid_jwt(with_key, &app_state.settings, &app_state.metrics)?;
80                // Use the UpdatesVapidDraft metric with a formatted version
81                let vapid_version_metric = format!(
82                    "{}{:02}",
83                    MetricName::UpdatesVapidDraft.as_ref(),
84                    with_key.vapid.version()
85                );
86                app_state.metrics.incr_raw(&vapid_version_metric)?;
87            };
88            // If this is a known VAPID key, create a reliability_id from
89            // either the content of the vapid assertions, or the request
90            // header value, or just make one up.
91            let reliability_id: Option<String> = vapid.as_ref().and_then(|v| {
92                app_state
93                    .reliability_filter
94                    .is_trackable(v)
95                    .then(|| app_state.reliability_filter.get_id(req.headers()))
96            });
97            trace!("🔍 track_id: {:?}", reliability_id);
98            app_state
99                .metrics
100                .incr_with_tags(MetricName::NotificationReceived)
101                .with_tag("trackable", &reliability_id.is_some().to_string())
102                .send();
103            // Capturing the vapid sub right now will cause too much cardinality. Instead,
104            // let's just capture if we have a valid VAPID, as well as what sort of bad sub
105            // values we get.
106            if let Some(header) = &vapid {
107                if let Some(sub) = header
108                    .vapid
109                    .insecure_sub()
110                    .inspect_err(|e: &VapidError| {
111                        // Capture the type of error and add it to metrics.
112                        let mut tags = Tags::default();
113                        tags.tags
114                            .insert("error".to_owned(), e.as_metric().to_owned());
115                        app_state
116                            .metrics
117                            .incr_with_tags(MetricName::NotificationAuthError)
118                            .with_tag("error", e.as_metric())
119                            .send();
120                    })
121                    .unwrap_or_default()
122                {
123                    info!("VAPID sub: {sub}");
124                };
125                // For now, record that we had a good (?) VAPID sub,
126                app_state.metrics.incr(MetricName::NotificationAuthOk)?;
127            };
128
129            match token_info.api_version {
130                ApiVersion::Version1 => version_1_validation(&token)?,
131                ApiVersion::Version2 => version_2_validation(&token, vapid.as_ref())?,
132            }
133
134            // Load and validate user data.
135            // Note: It is safe to unwrap the Uuid result because an error is
136            // only returned if the slice length is not 16.
137            let uaid = Uuid::from_slice(&token[..16]).unwrap();
138            let channel_id = Uuid::from_slice(&token[16..32]).unwrap();
139
140            trace!("UAID: {:?}, CHID: {:?}", uaid, channel_id);
141
142            let user = app_state
143                .db
144                .get_user(&uaid)
145                .await?
146                .ok_or(ApiErrorKind::NoSubscription)?;
147
148            trace!("user: {:?}", &user);
149            validate_user(&user, &channel_id, &app_state).await?;
150
151            Ok(Subscription {
152                user,
153                channel_id,
154                vapid,
155                reliability_id,
156            })
157        }
158        .boxed_local()
159    }
160}
161
162/// Add back padding to a base64 string
163fn repad_base64(data: &str) -> Cow<'_, str> {
164    let trailing_chars = data.len() % 4;
165
166    if trailing_chars != 0 {
167        let mut data = data.to_string();
168
169        for _ in trailing_chars..4 {
170            data.push('=');
171        }
172
173        Cow::Owned(data)
174    } else {
175        Cow::Borrowed(data)
176    }
177}
178
179/// Parse the authorization header for VAPID data and update metrics
180fn parse_vapid(token_info: &TokenInfo, metrics: &StatsdClient) -> ApiResult<Option<VapidHeader>> {
181    let auth_header = match token_info.auth_header.as_ref() {
182        Some(header) => header,
183        None => return Ok(None),
184    };
185
186    let vapid = VapidHeader::parse(auth_header).inspect_err(|e| {
187        metrics
188            .incr_with_tags(MetricName::NotificationAuthError)
189            .with_tag("error", e.as_metric())
190            .send();
191    })?;
192
193    metrics
194        .incr_with_tags(MetricName::NotificationAuth)
195        .with_tag("vapid", &vapid.version().to_string())
196        .with_tag("scheme", &vapid.scheme)
197        .send();
198
199    Ok(Some(vapid))
200}
201
202/// Extract the VAPID public key from the headers
203fn extract_public_key(vapid: VapidHeader, token_info: &TokenInfo) -> ApiResult<VapidHeaderWithKey> {
204    Ok(match vapid.version_data.clone() {
205        VapidVersionData::Version1 => {
206            // VAPID v1 stores the public key in the Crypto-Key header
207            let header = token_info.crypto_key_header.as_deref().ok_or_else(|| {
208                ApiErrorKind::InvalidEncryption("Missing Crypto-Key header".to_string())
209            })?;
210            let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
211                ApiErrorKind::InvalidEncryption("Invalid Crypto-Key header".to_string())
212            })?;
213            let public_key = header_data.get_by_key("p256ecdsa").ok_or_else(|| {
214                ApiErrorKind::InvalidEncryption(
215                    "Missing p256ecdsa in Crypto-Key header".to_string(),
216                )
217            })?;
218
219            VapidHeaderWithKey {
220                vapid,
221                public_key: public_key.to_string(),
222            }
223        }
224        VapidVersionData::Version2 { public_key } => VapidHeaderWithKey { vapid, public_key },
225    })
226}
227
228/// `/webpush/v1/` validations
229fn version_1_validation(token: &[u8]) -> ApiResult<()> {
230    if token.len() != 32 {
231        // Corrupted token
232        return Err(ApiErrorKind::InvalidToken.into());
233    }
234
235    Ok(())
236}
237
238/// Decode a public key string
239///
240/// NOTE: Some customers send a VAPID public key with incorrect padding and
241/// in standard base64 encoding. (Both of these violate the VAPID RFC)
242/// Prior python versions ignored these errors, so we should too.
243fn decode_public_key(public_key: &str) -> ApiResult<Vec<u8>> {
244    if public_key.contains(['/', '+']) {
245        b64_decode_std(public_key.trim_end_matches('='))
246    } else {
247        b64_decode_url(public_key.trim_end_matches('='))
248    }
249    .map_err(|e| {
250        error!("decode_public_key: {:?}", e);
251        VapidError::InvalidKey(e.to_string()).into()
252    })
253}
254
255/// `/webpush/v2/` validations
256fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> {
257    if token.len() != 64 {
258        // Corrupted token
259        return Err(ApiErrorKind::InvalidToken.into());
260    }
261
262    // Verify that the sender is authorized to send notifications.
263    // The last 32 bytes of the token is the hashed public key.
264    let token_key = &token[32..];
265    let public_key = &vapid.ok_or(VapidError::MissingKey)?.public_key;
266
267    // Hash the VAPID public key
268    let public_key = decode_public_key(public_key)?;
269    let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key)
270        .map_err(ApiErrorKind::TokenHashValidation)?;
271
272    // Verify that the VAPID public key equals the (expected) token public key
273    if !openssl::memcmp::eq(&key_hash, token_key) {
274        return Err(VapidError::KeyMismatch.into());
275    }
276
277    Ok(())
278}
279
280// Perform a very brain dead conversion of a string to a CamelCaseVersion
281fn term_to_label(term: &str) -> String {
282    term.split(' ').fold("".to_owned(), |prev, word: &str| {
283        format!(
284            "{}{}{}",
285            prev,
286            word.get(0..1).unwrap_or_default().to_ascii_uppercase(),
287            word.get(1..).unwrap_or_default()
288        )
289    })
290}
291
292/// Validate the VAPID JWT token. Specifically,
293/// - Check the signature
294/// - Make sure it hasn't expired
295/// - Make sure the expiration isn't too far into the future
296///
297/// This is mostly taken care of by the jsonwebtoken library
298fn validate_vapid_jwt(
299    vapid: &VapidHeaderWithKey,
300    settings: &Settings,
301    metrics: &StatsdClient,
302) -> ApiResult<VapidClaims> {
303    let VapidHeaderWithKey { vapid, public_key } = vapid;
304
305    let public_key = decode_public_key(public_key)?;
306    let mut validation = Validation::new(Algorithm::ES256);
307    // Set the audiences we allow. This obsoletes the need to manually match
308    // against values later.
309    validation.set_audience(&[settings.endpoint_url().origin().ascii_serialization()]);
310    validation.set_required_spec_claims(&["exp", "aud"]);
311
312    let token_data = match jsonwebtoken::decode::<VapidClaims>(
313        &vapid.token,
314        &DecodingKey::from_ec_der(&public_key),
315        &validation,
316    ) {
317        Ok(v) => v,
318        Err(e) => match e.kind() {
319            // NOTE: This will fail if `exp` is specified as anything instead of a numeric or if a required field is empty
320            jsonwebtoken::errors::ErrorKind::Json(e) => {
321                metrics
322                    .incr_with_tags(MetricName::NotificationAuthBadVapidJson)
323                    .with_tag(
324                        "error",
325                        match e.classify() {
326                            serde_json::error::Category::Io => "IO_ERROR",
327                            serde_json::error::Category::Syntax => "SYNTAX_ERROR",
328                            serde_json::error::Category::Data => "DATA_ERROR",
329                            serde_json::error::Category::Eof => "EOF_ERROR",
330                        },
331                    )
332                    .send();
333                if e.is_data() {
334                    debug!("VAPID data warning: {:?}", e);
335                    return Err(VapidError::InvalidVapid(
336                        "A value in the vapid claims is either missing or incorrectly specified (e.g. \"exp\":\"12345\" or \"sub\":null). Please correct and retry.".to_owned(),
337                    )
338                    .into());
339                }
340                // Other errors are always possible. Try to be helpful by returning
341                // the Json parse error.
342                return Err(VapidError::InvalidVapid(e.to_string()).into());
343            }
344            jsonwebtoken::errors::ErrorKind::InvalidAudience => {
345                return Err(VapidError::InvalidAudience.into());
346            }
347            jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(e) => {
348                return Err(VapidError::InvalidVapid(format!("Missing required {e}")).into());
349            }
350            _ => {
351                // Attempt to match up the majority of ErrorKind variants.
352                // The third-party errors all defer to the source, so we can
353                // use that to differentiate for actual errors.
354                let label = if e.source().is_none() {
355                    // These two have the most cardinality, so we need to handle
356                    // them separately.
357                    let mut label_name = e.to_string();
358                    if label_name.contains(':') {
359                        // if the error begins with a common tag e.g. "Missing required claim: ..."
360                        // then convert it to a less cardinal version. This is lossy, but acceptable.
361                        label_name =
362                            term_to_label(label_name.split(':').next().unwrap_or_default());
363                    } else if label_name.contains(' ') {
364                        // if a space still snuck through somehow, remove it.
365                        label_name = term_to_label(&label_name);
366                    }
367                    label_name
368                } else {
369                    // If you need to dig into these, there's always the logs.
370                    "Other".to_owned()
371                };
372                metrics
373                    .incr_with_tags(MetricName::NotificationAuthBadVapidOther)
374                    .with_tag("error", &label)
375                    .send();
376                error!("Bad Aud: Unexpected VAPID error: {:?}", &e);
377                return Err(e.into());
378            }
379        },
380    };
381
382    // Dump the claims.
383    // Note, this can produce a LOT of log messages if this feature is enabled.
384    #[cfg(feature = "log_vapid")]
385    if let Some(claims_str) = vapid.token.split('.').next() {
386        use base64::Engine;
387        info!(
388            "Vapid";
389            "sub" => &token_data.claims.sub,
390            "claims" => String::from_utf8(
391                base64::engine::general_purpose::URL_SAFE_NO_PAD
392                    .decode(claims_str)
393                    .unwrap_or_default()
394            )
395            .unwrap_or("UNKNOWN".to_owned())
396        );
397    };
398
399    if token_data.claims.exp > VapidClaims::default_exp() {
400        // The expiration is too far in the future
401        return Err(VapidError::FutureExpirationToken.into());
402    }
403
404    Ok(token_data.claims)
405}
406
407#[cfg(test)]
408pub mod tests {
409    use super::{term_to_label, validate_vapid_jwt, VapidClaims};
410    use crate::error::ApiErrorKind;
411    use crate::extractors::subscription::repad_base64;
412    use crate::headers::vapid::{VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData};
413    use crate::metrics::Metrics;
414    use crate::settings::Settings;
415
416    use autopush_common::util::b64_decode_std;
417    use lazy_static::lazy_static;
418    use serde::{Deserialize, Serialize};
419
420    pub const PUB_KEY: &str =
421        "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds";
422
423    lazy_static! {
424        static ref PRIV_KEY: Vec<u8> = b64_decode_std(
425            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
426                    oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
427                    bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
428        )
429        .unwrap();
430    }
431
432    /// Make a vapid header.
433    /// *NOTE*: This follows a python format where you only specify overrides. Any value not
434    /// specified will use a default value.
435    pub fn make_vapid(sub: &str, aud: &str, exp: u64, key: String) -> VapidHeaderWithKey {
436        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
437        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
438        let claims = VapidClaims {
439            exp,
440            aud: Some(aud.to_string()),
441            sub: Some(sub.to_string()),
442        };
443        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
444
445        VapidHeaderWithKey {
446            public_key: key.to_owned(),
447            vapid: VapidHeader {
448                scheme: "vapid".to_string(),
449                token,
450                version_data: VapidVersionData::Version1,
451            },
452        }
453    }
454
455    #[test]
456    fn repad_base64_1_padding() {
457        assert_eq!(repad_base64("Zm9vYmE"), "Zm9vYmE=")
458    }
459
460    #[test]
461    fn repad_base64_2_padding() {
462        assert_eq!(repad_base64("Zm9vYg"), "Zm9vYg==")
463    }
464
465    #[test]
466    fn vapid_aud_valid() {
467        // Specify a potentially invalid padding.
468        let public_key = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds==".to_owned();
469        let domain = "https://push.services.mozilla.org";
470        let test_settings = Settings {
471            endpoint_url: domain.to_owned(),
472            ..Default::default()
473        };
474
475        let header = make_vapid(
476            "mailto:admin@example.com",
477            domain,
478            VapidClaims::default_exp() - 100,
479            public_key,
480        );
481        let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
482        assert!(result.is_ok());
483    }
484
485    #[test]
486    fn vapid_aud_invalid() {
487        let domain = "https://push.services.mozilla.org";
488        let test_settings = Settings {
489            endpoint_url: domain.to_owned(),
490            ..Default::default()
491        };
492        let header = make_vapid(
493            "mailto:admin@example.com",
494            "https://example.com",
495            VapidClaims::default_exp() - 100,
496            PUB_KEY.to_owned(),
497        );
498        assert!(matches!(
499            validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
500                .unwrap_err()
501                .kind,
502            ApiErrorKind::VapidError(VapidError::InvalidAudience)
503        ));
504    }
505
506    #[test]
507    fn vapid_aud_valid_for_alternate_host() {
508        let domain = "https://example.org";
509        let test_settings = Settings {
510            endpoint_url: domain.to_owned(),
511            ..Default::default()
512        };
513        let header = make_vapid(
514            "mailto:admin@example.com",
515            domain,
516            VapidClaims::default_exp() - 100,
517            PUB_KEY.to_owned(),
518        );
519        let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
520        assert!(result.is_ok());
521    }
522
523    #[test]
524    fn vapid_exp_is_string() {
525        #[derive(Debug, Deserialize, Serialize)]
526        struct StrExpVapidClaims {
527            exp: String,
528            aud: String,
529            sub: String,
530        }
531
532        let domain = "https://push.services.mozilla.org";
533        let test_settings = Settings {
534            endpoint_url: domain.to_owned(),
535            ..Default::default()
536        };
537        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
538        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
539        let claims = StrExpVapidClaims {
540            exp: (VapidClaims::default_exp() - 100).to_string(),
541            aud: domain.to_owned(),
542            sub: "mailto:admin@example.com".to_owned(),
543        };
544        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
545        let header = VapidHeaderWithKey {
546            public_key: PUB_KEY.to_owned(),
547            vapid: VapidHeader {
548                scheme: "vapid".to_string(),
549                token,
550                version_data: VapidVersionData::Version1,
551            },
552        };
553        let vv = validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
554            .unwrap_err()
555            .kind;
556        assert!(matches![
557            vv,
558            ApiErrorKind::VapidError(VapidError::InvalidVapid(_))
559        ])
560    }
561
562    #[test]
563    fn vapid_public_key_variants() {
564        // pretty much matches the kind of key we get from some partners.
565        let public_key_standard = "BM3bVjW/wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf+1odb8hds=".to_owned();
566        let public_key_url_safe = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds=".to_owned();
567        let domain = "https://push.services.mozilla.org";
568        let test_settings = Settings {
569            endpoint_url: domain.to_owned(),
570            ..Default::default()
571        };
572        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
573        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
574        let claims = VapidClaims {
575            exp: VapidClaims::default_exp() - 100,
576            aud: Some(domain.to_owned()),
577            sub: Some("mailto:admin@example.com".to_owned()),
578        };
579        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
580        // try standard form with padding
581        let header = VapidHeaderWithKey {
582            public_key: public_key_standard.clone(),
583            vapid: VapidHeader {
584                scheme: "vapid".to_string(),
585                token: token.clone(),
586                version_data: VapidVersionData::Version1,
587            },
588        };
589        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
590        // try standard form with no padding
591        let header = VapidHeaderWithKey {
592            public_key: public_key_standard.trim_end_matches('=').to_owned(),
593            vapid: VapidHeader {
594                scheme: "vapid".to_string(),
595                token: token.clone(),
596                version_data: VapidVersionData::Version1,
597            },
598        };
599        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
600        // try URL safe form with padding
601        let header = VapidHeaderWithKey {
602            public_key: public_key_url_safe.clone(),
603            vapid: VapidHeader {
604                scheme: "vapid".to_string(),
605                token: token.clone(),
606                version_data: VapidVersionData::Version1,
607            },
608        };
609        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
610        // try URL safe form without padding
611        let header = VapidHeaderWithKey {
612            public_key: public_key_url_safe.trim_end_matches('=').to_owned(),
613            vapid: VapidHeader {
614                scheme: "vapid".to_string(),
615                token,
616                version_data: VapidVersionData::Version1,
617            },
618        };
619        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
620    }
621
622    #[test]
623    fn vapid_missing_sub() {
624        #[derive(Debug, Deserialize, Serialize)]
625        struct NoSubVapidClaims {
626            exp: u64,
627            aud: String,
628            sub: Option<String>,
629        }
630
631        let domain = "https://push.services.mozilla.org";
632        let test_settings = Settings {
633            endpoint_url: domain.to_owned(),
634            ..Default::default()
635        };
636        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
637        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
638        let claims = NoSubVapidClaims {
639            exp: VapidClaims::default_exp() - 100,
640            aud: domain.to_owned(),
641            sub: None,
642        };
643        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
644        let header = VapidHeaderWithKey {
645            public_key: PUB_KEY.to_owned(),
646            vapid: VapidHeader {
647                scheme: "vapid".to_string(),
648                token,
649                version_data: VapidVersionData::Version1,
650            },
651        };
652        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
653    }
654
655    #[test]
656    fn test_crapitalize() {
657        assert_eq!(
658            "LabelFieldWithoutData",
659            term_to_label("LabelField without data")
660        );
661        assert_eq!("UntouchedField", term_to_label("UntouchedField"));
662    }
663}