Skip to content

Commit

Permalink
Create smoke tests for database dump (#21)
Browse files Browse the repository at this point in the history
crates.io provides a database dump that can be downloaded by users. The
database dump is too large to serve through Fastly's Compute@Edge
platform, which is why we redirect all requests to CloudFront. This
functionality is now being tested by a smoke test.
  • Loading branch information
jdno authored Apr 11, 2024
1 parent 9218fe3 commit b3ba02d
Show file tree
Hide file tree
Showing 5 changed files with 440 additions and 0 deletions.
130 changes: 130 additions & 0 deletions src/crates/db_dump/cloudfront.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Test that CloudFront serves the database dump
use async_trait::async_trait;
use reqwest::Client;

use crate::test::{Test, TestResult};

use super::config::Config;

/// The name of the test
const NAME: &str = "CloudFront";

/// Test that CloudFront serves the database dump
///
/// The database dump cannot be served directly from Fastly, so it is served from CloudFront.
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 response = match Client::builder()
.build()
.expect("failed to build reqwest client")
.head(format!("{}/db-dump.tar.gz", self.config.cloudfront_url()))
.send()
.await
{
Ok(response) => response,
Err(error) => {
return TestResult::builder()
.name(NAME)
.success(false)
.message(Some(error.to_string()))
.build()
}
};

if response.status().is_success() {
TestResult::builder().name(NAME).success(true).build()
} else {
TestResult::builder()
.name(NAME)
.success(false)
.message(Some(format!(
"Expected HTTP 200, got HTTP {}",
response.status()
)))
.build()
}
}
}

#[cfg(test)]
mod tests {
use mockito::ServerGuard;

use crate::test_utils::*;

use super::*;

pub async fn setup() -> (ServerGuard, Config) {
let server = mockito::Server::new_async().await;

let config = Config::builder()
.cloudfront_url(server.url())
.fastly_url(server.url())
.build();

(server, config)
}

#[tokio::test]
async fn succeeds_with_http_200_response() {
let (mut server, config) = setup().await;

let mock = server
.mock("HEAD", "/db-dump.tar.gz")
.with_status(200)
.create();

let result = CloudFront::new(&config).run().await;

// Assert that the mock was called
mock.assert();

assert!(result.success());
}

#[tokio::test]
async fn fails_with_other_http_responses() {
let (mut server, config) = setup().await;

let mock = server
.mock("HEAD", "/db-dump.tar.gz")
.with_status(500)
.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::<CloudFront>();
}

#[test]
fn trait_sync() {
assert_sync::<CloudFront>();
}

#[test]
fn trait_unpin() {
assert_unpin::<CloudFront>();
}
}
61 changes: 61 additions & 0 deletions src/crates/db_dump/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Configuration to test the database dump
use getset::Getters;
#[cfg(test)]
use typed_builder::TypedBuilder;

use crate::environment::Environment;

/// Configuration to test the database dump
///
/// The smoke tests request the database dump from Fastly and CloudFront and check for the correct
/// response.
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Getters)]
#[cfg_attr(test, derive(TypedBuilder))]
pub struct Config {
/// 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 {
cloudfront_url: "https://cloudfront-static.staging.crates.io".into(),
fastly_url: "https://fastly-static.staging.crates.io".into(),
},
Environment::Production => Self {
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::<Config>();
}

#[test]
fn trait_sync() {
assert_sync::<Config>();
}

#[test]
fn trait_unpin() {
assert_unpin::<Config>();
}
}
148 changes: 148 additions & 0 deletions src/crates/db_dump/fastly.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! Test that Fastly redirects to CloudFront
use async_trait::async_trait;
use reqwest::redirect::Policy;
use reqwest::{Client, Response};

use crate::test::{Test, TestResult};

use super::config::Config;

/// The name of the test
const NAME: &str = "Fastly";

/// Test that Fastly redirects to CloudFront
///
/// The database dump cannot be served directly from Fastly, so it should redirect to CloudFront.
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 }
}

/// Check if a response is a redirect
fn is_redirect(&self, response: &Response) -> bool {
response.status().is_redirection()
}

/// Check if a response redirects to the given URL
fn redirects_to(&self, response: &Response, url: &str) -> bool {
response
.headers()
.get("Location")
.and_then(|header| header.to_str().ok())
.is_some_and(|location| location == url)
}
}

#[async_trait]
impl<'a> Test for Fastly<'a> {
async fn run(&self) -> TestResult {
let response = match Client::builder()
// Don't follow the redirect, we want to check the redirect location
.redirect(Policy::none())
.build()
.expect("failed to build reqwest client")
.head(format!("{}/db-dump.tar.gz", self.config.fastly_url()))
.send()
.await
{
Ok(response) => response,
Err(error) => {
return TestResult::builder()
.name(NAME)
.success(false)
.message(Some(error.to_string()))
.build()
}
};

let expected_location = format!("{}/db-dump.tar.gz", self.config.cloudfront_url());

if self.is_redirect(&response) && self.redirects_to(&response, &expected_location) {
TestResult::builder().name(NAME).success(true).build()
} else {
TestResult::builder()
.name(NAME)
.success(false)
.message(Some(format!(
"Expected a redirect to {}, got {}",
expected_location,
response.url().as_str()
)))
.build()
}
}
}

#[cfg(test)]
mod tests {
use crate::test_utils::*;

use super::*;

#[tokio::test]
async fn succeeds_with_redirect() {
let mut server = mockito::Server::new_async().await;

let config = Config::builder()
.cloudfront_url("https://cloudfront".into())
.fastly_url(server.url())
.build();

let mock = server
.mock("HEAD", "/db-dump.tar.gz")
.with_status(307)
.with_header("Location", "https://cloudfront/db-dump.tar.gz")
.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_redirect() {
let mut server = mockito::Server::new_async().await;

let config = Config::builder()
.cloudfront_url(server.url())
.fastly_url(server.url())
.build();

let mock = server
.mock("HEAD", "/db-dump.tar.gz")
.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::<Fastly>();
}

#[test]
fn trait_sync() {
assert_sync::<Fastly>();
}

#[test]
fn trait_unpin() {
assert_unpin::<Fastly>();
}
}
Loading

0 comments on commit b3ba02d

Please sign in to comment.