From 151e161ab50c13920d85b68a6bf8124d339b45ca Mon Sep 17 00:00:00 2001 From: Jan David Date: Wed, 10 Apr 2024 15:51:45 +0200 Subject: [PATCH] Create smoke test for CORS headers (#19) When rolling out Fastly, rust-lang/crates.io#6164 reported an issue with missing CORS headers. A new smoke test has been implemented that checks that the correct header is set for both CloudFront and Fastly. --- src/crates/crates_6164/cloudfront.rs | 108 ++++++++++++++++++ src/crates/crates_6164/config.rs | 73 ++++++++++++ src/crates/crates_6164/fastly.rs | 108 ++++++++++++++++++ src/crates/crates_6164/mod.rs | 162 +++++++++++++++++++++++++++ src/crates/mod.rs | 10 +- src/test/test_group.rs | 2 +- 6 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 src/crates/crates_6164/cloudfront.rs create mode 100644 src/crates/crates_6164/config.rs create mode 100644 src/crates/crates_6164/fastly.rs create mode 100644 src/crates/crates_6164/mod.rs diff --git a/src/crates/crates_6164/cloudfront.rs b/src/crates/crates_6164/cloudfront.rs new file mode 100644 index 0000000..ee5a0b2 --- /dev/null +++ b/src/crates/crates_6164/cloudfront.rs @@ -0,0 +1,108 @@ +//! Test the CORS headers on CloudFront + +use async_trait::async_trait; + +use crate::crates::utils::crate_url; +use crate::test::{Test, TestResult}; + +use super::config::Config; +use super::request_url_and_expect_cors_header; + +/// The name of the test +const NAME: &str = "CloudFront"; + +/// Test the CORS headers on CloudFront +/// +/// This test requests a crate from CloudFront and expects the response to have the correct CORS +/// headers. +pub struct CloudFront<'a> { + /// Configuration for this test + config: &'a Config, +} + +impl<'a> CloudFront<'a> { + /// Create a new instance of the test + pub fn new(config: &'a Config) -> Self { + Self { config } + } +} + +#[async_trait] +impl<'a> Test for CloudFront<'a> { + async fn run(&self) -> TestResult { + let url = crate_url( + self.config.cloudfront_url(), + self.config.krate(), + self.config.version(), + ); + + request_url_and_expect_cors_header(NAME, &url).await + } +} + +#[cfg(test)] +mod tests { + use crate::crates::crates_6164::tests::setup; + use crate::test_utils::*; + + use super::*; + + const KRATE: &str = "crates-6164"; + const VERSION: &str = "1.0.0"; + + #[tokio::test] + async fn succeeds_with_cors_header() { + let (mut server, config) = setup(KRATE, VERSION).await; + + let mock = server + .mock( + "GET", + format!("/crates/{KRATE}/{KRATE}-{VERSION}.crate").as_str(), + ) + .with_status(200) + .with_header("Access-Control-Allow-Origin", "*") + .create(); + + let result = CloudFront::new(&config).run().await; + + // Assert that the mock was called + mock.assert(); + + assert!(result.success()); + } + + #[tokio::test] + async fn fails_without_cors_header() { + let (mut server, config) = setup(KRATE, VERSION).await; + + let mock = server + .mock( + "GET", + format!("/crates/{KRATE}/{KRATE}-{VERSION}.crate").as_str(), + ) + .with_status(200) + .create(); + + let result = CloudFront::new(&config).run().await; + + // Assert that the mock was called + mock.assert(); + + assert!(!result.success()); + } + + #[test] + fn trait_send() { + assert_send::(); + } + + #[test] + fn trait_sync() { + assert_sync::(); + } + + #[test] + fn trait_unpin() { + assert_unpin::(); + } +} diff --git a/src/crates/crates_6164/config.rs b/src/crates/crates_6164/config.rs new file mode 100644 index 0000000..c27e06b --- /dev/null +++ b/src/crates/crates_6164/config.rs @@ -0,0 +1,73 @@ +//! Configuration to test rust-lang/crates.io#6164 + +use getset::Getters; +#[cfg(test)] +use typed_builder::TypedBuilder; + +use crate::environment::Environment; + +/// Configuration to test rust-lang/crates.io#6164 +/// +/// The smoke tests try to download a crate from the different CDNs and check if the CORS headers +/// are set correctly. This requires knowing the respective base URLs, the crate, and its version. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Getters)] +#[cfg_attr(test, derive(TypedBuilder))] +pub struct Config { + /// The name of the crate + #[getset(get = "pub")] + krate: String, + + /// The version with the `+` character + #[getset(get = "pub")] + version: String, + + /// The URL for the CloudFront CDN + #[getset(get = "pub")] + cloudfront_url: String, + + /// The URL for the Fastly CDN + #[getset(get = "pub")] + fastly_url: String, +} + +impl Config { + /// Return the configuration for the given environment + pub fn for_env(env: Environment) -> Self { + match env { + Environment::Staging => Self { + krate: "crossbeam".into(), + version: "0.2.10".into(), + cloudfront_url: "https://cloudfront-static.staging.crates.io".into(), + fastly_url: "https://fastly-static.staging.crates.io".into(), + }, + Environment::Production => Self { + krate: "axum".into(), + version: "0.6.10".into(), + cloudfront_url: "https://cloudfront-static.crates.io".into(), + fastly_url: "https://fastly-static.crates.io".into(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use crate::test_utils::*; + + use super::*; + + #[test] + fn trait_send() { + assert_send::(); + } + + #[test] + fn trait_sync() { + assert_sync::(); + } + + #[test] + fn trait_unpin() { + assert_unpin::(); + } +} diff --git a/src/crates/crates_6164/fastly.rs b/src/crates/crates_6164/fastly.rs new file mode 100644 index 0000000..d160193 --- /dev/null +++ b/src/crates/crates_6164/fastly.rs @@ -0,0 +1,108 @@ +//! Test the CORS headers on Fastly + +use async_trait::async_trait; + +use crate::crates::utils::crate_url; +use crate::test::{Test, TestResult}; + +use super::config::Config; +use super::request_url_and_expect_cors_header; + +/// The name of the test +const NAME: &str = "Fastly"; + +/// Test the CORS headers on Fastly +/// +/// This test requests a crate from Fastly and expects the response to have the correct CORS +/// headers. +pub struct Fastly<'a> { + /// Configuration for this test + config: &'a Config, +} + +impl<'a> Fastly<'a> { + /// Create a new instance of the test + pub fn new(config: &'a Config) -> Self { + Self { config } + } +} + +#[async_trait] +impl<'a> Test for Fastly<'a> { + async fn run(&self) -> TestResult { + let url = crate_url( + self.config.fastly_url(), + self.config.krate(), + self.config.version(), + ); + + request_url_and_expect_cors_header(NAME, &url).await + } +} + +#[cfg(test)] +mod tests { + use crate::crates::crates_6164::tests::setup; + use crate::test_utils::*; + + use super::*; + + const KRATE: &str = "crates-6164"; + const VERSION: &str = "1.0.0"; + + #[tokio::test] + async fn succeeds_with_cors_header() { + let (mut server, config) = setup(KRATE, VERSION).await; + + let mock = server + .mock( + "GET", + format!("/crates/{KRATE}/{KRATE}-{VERSION}.crate").as_str(), + ) + .with_status(200) + .with_header("Access-Control-Allow-Origin", "*") + .create(); + + let result = Fastly::new(&config).run().await; + + // Assert that the mock was called + mock.assert(); + + assert!(result.success()); + } + + #[tokio::test] + async fn fails_without_cors_header() { + let (mut server, config) = setup(KRATE, VERSION).await; + + let mock = server + .mock( + "GET", + format!("/crates/{KRATE}/{KRATE}-{VERSION}.crate").as_str(), + ) + .with_status(200) + .create(); + + let result = Fastly::new(&config).run().await; + + // Assert that the mock was called + mock.assert(); + + assert!(!result.success()); + } + + #[test] + fn trait_send() { + assert_send::(); + } + + #[test] + fn trait_sync() { + assert_sync::(); + } + + #[test] + fn trait_unpin() { + assert_unpin::(); + } +} diff --git a/src/crates/crates_6164/mod.rs b/src/crates/crates_6164/mod.rs new file mode 100644 index 0000000..4495d7e --- /dev/null +++ b/src/crates/crates_6164/mod.rs @@ -0,0 +1,162 @@ +//! Missing CORS headers for crate downloads +//! +//! This module implements smoke tests for , +//! which reported an issue with missing CORS headers. + +use std::fmt::{Display, Formatter}; + +use async_trait::async_trait; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::Client; + +use crate::environment::Environment; +use crate::test::{Test, TestGroup, TestGroupResult, TestResult}; + +pub use self::cloudfront::CloudFront; +pub use self::config::Config; +pub use self::fastly::Fastly; + +mod cloudfront; +mod config; +mod fastly; + +/// The name of the test group +const NAME: &str = "rust-lang/crates.io#6164"; + +/// Missing CORS header for downloads +/// +/// The Fastly service for `static.crates.io` did not always set the `Access-Control-Allow-Origin` +/// header, which caused issues for some users. This test group ensures that the header is always +/// set. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Crates6164 { + /// Configuration for the test group + config: Config, +} + +impl Crates6164 { + /// Create a new instance of the test group + pub fn new(env: Environment) -> Self { + Self { + config: Config::for_env(env), + } + } +} + +impl Display for Crates6164 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", NAME) + } +} + +#[async_trait] +impl TestGroup for Crates6164 { + async fn run(&self) -> TestGroupResult { + let tests: Vec> = vec![ + Box::new(CloudFront::new(&self.config)), + Box::new(Fastly::new(&self.config)), + ]; + + let mut results = Vec::new(); + for test in tests { + results.push(test.run().await); + } + + TestGroupResult::builder() + .name(NAME) + .results(results) + .build() + } +} + +/// Test the given URL and expect the CORS header to be set +/// +/// This function sends a GET request to the given URL and expects the response to have the +/// `Access-Control-Allow-Origin` header set. +async fn request_url_and_expect_cors_header(name: &'static str, url: &str) -> TestResult { + let mut headers = HeaderMap::new(); + headers.insert( + "Origin", + HeaderValue::from_str("https://example.com").expect("failed to parse header value"), + ); + + let response = match Client::builder() + .default_headers(headers) + .build() + .expect("failed to build reqwest client") + .get(url) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return TestResult::builder() + .name(name) + .success(false) + .message(Some(error.to_string())) + .build() + } + }; + + if response + .headers() + .get("Access-Control-Allow-Origin") + .and_then(|header| header.to_str().ok()) + .is_some_and(|header| header == "*") + { + TestResult::builder().name(name).success(true).build() + } else { + TestResult::builder() + .name(name) + .success(false) + .message(Some( + "Expected the Access-Control-Allow-Origin header to be set to '*'".into(), + )) + .build() + } +} + +#[cfg(test)] +mod tests { + use mockito::ServerGuard; + use pretty_assertions::assert_eq; + + use crate::test_utils::*; + + use super::*; + + pub async fn setup(krate: &'static str, version: &'static str) -> (ServerGuard, Config) { + let server = mockito::Server::new_async().await; + + let config = Config::builder() + .krate(krate.into()) + .version(version.into()) + .cloudfront_url(server.url()) + .fastly_url(server.url()) + .build(); + + (server, config) + } + + #[test] + fn trait_display() { + let crates_6164 = Crates6164::new(Environment::Staging); + + assert_eq!("rust-lang/crates.io#6164", crates_6164.to_string()); + } + + #[test] + fn trait_send() { + assert_send::(); + } + + #[test] + fn trait_sync() { + assert_sync::(); + } + + #[test] + fn trait_unpin() { + assert_unpin::(); + } +} diff --git a/src/crates/mod.rs b/src/crates/mod.rs index 9e0d031..430b294 100644 --- a/src/crates/mod.rs +++ b/src/crates/mod.rs @@ -4,11 +4,14 @@ use std::fmt::{Display, Formatter}; use async_trait::async_trait; -use crate::crates::crates_4891::Crates4891; use crate::environment::Environment; use crate::test::{TestGroup, TestSuite, TestSuiteResult}; +use self::crates_4891::Crates4891; +use self::crates_6164::Crates6164; + mod crates_4891; +mod crates_6164; mod utils; /// Smoke tests for crates.io @@ -38,7 +41,10 @@ impl Display for Crates { #[async_trait] impl TestSuite for Crates { async fn run(&self) -> TestSuiteResult { - let groups = [Crates4891::new(self.env)]; + let groups: Vec> = vec![ + Box::new(Crates4891::new(self.env)), + Box::new(Crates6164::new(self.env)), + ]; let mut results = Vec::with_capacity(groups.len()); for group in &groups { diff --git a/src/test/test_group.rs b/src/test/test_group.rs index 27cfbb8..caf3990 100644 --- a/src/test/test_group.rs +++ b/src/test/test_group.rs @@ -10,7 +10,7 @@ use crate::test::TestGroupResult; /// might contain a few tests that together verify a particular feature of the system. The tests are /// run together and the results are aggregated to produce a single result for the group. #[async_trait] -pub trait TestGroup { +pub trait TestGroup: Send + Sync { /// Run the tests in this group async fn run(&self) -> TestGroupResult; }