Compare commits

...

10 commits

Author SHA1 Message Date
Sandro Eiler
6e07d9c33a test: move logic to helpers
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2024-04-18 23:48:34 +02:00
Sandro Eiler
efd1137c71 docs: clean up repo and add doc strings 2024-04-18 23:24:11 +02:00
Sandro Eiler
e1c27ca308 fix: set route's method to GET 2024-04-18 23:00:50 +02:00
Sandro Eiler
8045eb979e feat: red test implementation of email confirm 2024-04-18 14:31:06 +02:00
Sandro Eiler
7eebcb12a2 feat: add confirmation 2024-03-09 14:06:47 +01:00
Sandro Eiler
1bc7ae4c7a chore: update validator to 0.17 2024-03-05 14:06:55 +01:00
Sandro Eiler
9a31080707 refactor: clean up subscription 2024-03-05 10:45:09 +01:00
Sandro Eiler
90fc6abf19 feat: add confirmation link to email 2024-03-05 10:12:34 +01:00
Sandro Eiler
0427df8656 feat: actually send email 2024-03-04 21:49:08 +01:00
Sandro Eiler
6dfc9a0f3e feat: add subscription_tokens table 2024-03-04 21:25:55 +01:00
15 changed files with 296 additions and 41 deletions

30
Cargo.lock generated
View file

@ -1012,6 +1012,16 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@ -1112,6 +1122,7 @@ dependencies = [
"config",
"fake",
"hyper 1.2.0",
"linkify",
"once_cell",
"quickcheck",
"quickcheck_macros",
@ -1165,6 +1176,15 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linkify"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
dependencies = [
"memchr",
]
[[package]]
name = "linux-raw-sys"
version = "0.3.8"
@ -2709,7 +2729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb"
dependencies = [
"form_urlencoded",
"idna",
"idna 0.4.0",
"percent-encoding",
]
@ -2731,12 +2751,12 @@ dependencies = [
[[package]]
name = "validator"
version = "0.16.1"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd"
checksum = "da339118f018cc70ebf01fafc103360528aad53717e4bf311db929cb01cb9345"
dependencies = [
"idna",
"lazy_static",
"idna 0.5.0",
"once_cell",
"regex",
"serde",
"serde_derive",

View file

@ -19,12 +19,10 @@ hyper = { version = "1.2.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
serde-aux = "4"
# serde_with = "3"
# Axum
axum = { version = "0.7" }
tower = { version = "0.4" }
tower-http = { version = "0.5", features = ["trace", "request-id", "util"] }
# tower-cookies = "0.10"
# Others
config = "0.14"
uuid = { version = "1", features = ["v4", "fast-rng"] }
@ -36,10 +34,7 @@ tracing-log = "0.2"
secrecy = { version = "0.8", features = ["serde"] }
unicode-segmentation = "1"
strum_macros = "0.26"
validator = "0.16"
# async-trait = "0.1"
# strum_macros = "0.25"
validator = "0.17"
[dependencies.sqlx]
version = "0.7"
@ -68,3 +63,4 @@ quickcheck_macros = "1.0.0"
rand = "0.8.5"
wiremock = "0.6.0"
serde_json = "1"
linkify = "0.10"

View file

@ -2,6 +2,8 @@ TODO: Add general information about this project
TODO: Explain usage of docker vs podman
TODO: add https://crates.io/crates/cargo-semver-checks
## TODO: explain DB migration
To migrate a already deployed and running database, use

View file

@ -1,4 +1,5 @@
application:
host: 127.0.0.1
base_url: "http://127.0.0.1"
database:
require_ssl: false

View file

@ -0,0 +1,6 @@
-- Create Subscription Tokens Table
CREATE TABLE subscription_tokens(
subscription_token TEXT NOT NULL,
subscriber_id uuid NOT NULL REFERENCES subscriptions (id),
PRIMARY KEY (subscription_token)
);

View file

@ -19,6 +19,12 @@ pub struct Settings {
}
#[derive(serde::Deserialize, Clone)]
/// The email client settings.
///
/// * `base_url`: The base URL for the email client
/// * `sender_email`: The email address of the sender
/// * `authorization_token`: The authorization token
/// * `timeout_milliseconds`: The timeout in milliseconds
pub struct EmailClientSettings {
pub base_url: String,
pub sender_email: String,
@ -31,10 +37,12 @@ pub struct EmailClientSettings {
///
/// * `port`: The port to listen on
/// * `host`: The host address to listen on
/// * `base_url`: The base URL for the application
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub base_url: String,
}
#[derive(serde::Deserialize, Clone)]

View file

@ -1,18 +1,25 @@
use validator::validate_email;
use validator::ValidateEmail;
#[derive(Debug, Clone)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
if validate_email(&s) {
Ok(Self(s))
let result = Self(s.clone());
if result.validate_email() {
Ok(result)
} else {
Err(format!("{} is not a valid subscriber email.", s))
}
}
}
impl ValidateEmail for SubscriberEmail {
fn as_email_string(&self) -> Option<std::borrow::Cow<str>> {
Some(self.0.as_str().into())
}
}
impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0

View file

@ -1,5 +1,7 @@
mod health_check;
mod subscriptions;
mod subscriptions_confirm;
pub use health_check::*;
pub use subscriptions::*;
pub use subscriptions_confirm::*;

View file

@ -1,5 +1,6 @@
use crate::domain::SubscriberEmail;
use crate::domain::{NewSubscriber, SubscriberName};
use crate::email_client::EmailClient;
use crate::startup::AppState;
use axum::extract::State;
use axum::routing::post;
@ -30,9 +31,37 @@ impl TryFrom<FormData> for NewSubscriber {
}
}
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: NewSubscriber,
base_url: &str,
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token=mytoken",
base_url
);
let plain_body = format!(
"Welcome to our newsletter! Visit {} to confirm your subscription.",
confirmation_link
);
let html_body = format!(
"Welcome to our newsletter!<br />\
Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
);
email_client
.send_email(new_subscriber.email, "Welcome!", &html_body, &plain_body)
.await
}
// TODO: remove request_id?
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, db_pool, email_client),
skip(form, db_pool, email_client, base_url),
fields(
request_id = %Uuid::new_v4(),
subscriber_email = %form.email,
@ -43,6 +72,7 @@ pub async fn subscribe(
State(AppState {
db_pool,
email_client,
base_url,
}): State<AppState>,
Form(form): Form<FormData>,
) -> Response {
@ -52,14 +82,16 @@ pub async fn subscribe(
return (StatusCode::BAD_REQUEST, "Invalid subscription.").into_response();
}
};
match insert_subscriber(&db_pool, &new_subscriber).await {
Ok(_) => {
return (StatusCode::OK,).into_response();
}
Err(_) => {
if insert_subscriber(&db_pool, &new_subscriber).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.").into_response();
}
if send_confirmation_email(&email_client, new_subscriber, &base_url.0)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.").into_response();
}
return (StatusCode::OK,).into_response();
}
#[tracing::instrument(
@ -73,15 +105,13 @@ pub async fn insert_subscriber(
let _ = sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'confirmed')
VALUES ($1, $2, $3, $4, 'pending_confirmation')
"#,
Uuid::new_v4(),
new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
Utc::now()
)
// We use `get_ref` to get an immutable reference to the `PgConnection`
// wrapped by `web::Data`.
.execute(db_pool)
.await
.map_err(|e| {
@ -91,8 +121,6 @@ pub async fn insert_subscriber(
Ok(())
}
pub fn routes_subscriptions(state: AppState) -> Router {
Router::new()
.route("/subscriptions", post(subscribe))
.with_state(state)
pub fn routes_subscriptions() -> Router<AppState> {
Router::new().route("/subscriptions", post(subscribe))
}

View file

@ -0,0 +1,30 @@
use crate::startup::AppState;
use axum::routing::get;
use axum::{
extract::Query,
http::StatusCode,
response::{IntoResponse, Response},
Router,
};
#[derive(serde::Deserialize)]
pub struct Parameters {
subscription_token: String,
}
#[tracing::instrument(name = "Confirm a pending subscriber", skip(_parameters))]
/// Confirm a pending subscriber.
/// # Parameters
/// - subscription_token: The subscription token.
/// # Returns
/// - 200 OK: The subscriber has been confirmed.
/// - 400 Bad Request: The subscription token is missing.
pub async fn confirm(Query(_parameters): Query<Parameters>) -> Response {
println!("subscription_token: {}", _parameters.subscription_token);
(StatusCode::OK,).into_response()
}
/// The routes for subscription confirmation.
pub fn routes_subscriptions_confirm() -> Router<AppState> {
Router::new().route("/subscriptions/confirm", get(confirm))
}

View file

@ -21,10 +21,14 @@ pub struct Application {
listener: TcpListener,
}
#[derive(Clone)]
pub struct ApplicationBaseUrl(pub String);
#[derive(Clone)]
pub struct AppState {
pub db_pool: PgPool,
pub email_client: EmailClient,
pub base_url: ApplicationBaseUrl,
}
// from https://docs.rs/tower-http/0.2.5/tower_http/request_id/index.html#using-uuids
@ -60,12 +64,16 @@ impl Application {
let listener = TcpListener::bind(&address).await?;
let state = AppState {
db_pool: connection_pool.clone(),
email_client: email_client.clone(),
db_pool: connection_pool,
email_client,
base_url: ApplicationBaseUrl(configuration.application.base_url),
};
let app = Router::new()
.merge(crate::routes::routes_subscriptions())
.merge(crate::routes::routes_subscriptions_confirm())
.with_state(state)
.merge(crate::routes::routes_health_check())
.merge(crate::routes::routes_subscriptions(state))
.layer(
// from https://docs.rs/tower-http/0.2.5/tower_http/request_id/index.html#using-trace
ServiceBuilder::new()

View file

@ -4,6 +4,7 @@ use learn_axum::telemetry::{get_subscriber, init_subscriber};
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
use wiremock::MockServer;
/// Ensure that the `tracing` stack is only initialised once using `once_cell`
static TRACING: Lazy<()> = Lazy::new(|| {
@ -22,9 +23,17 @@ static TRACING: Lazy<()> = Lazy::new(|| {
};
});
/// Confirmation links embedded in the request to the email API.
pub struct ConfirmationLinks {
pub html: reqwest::Url,
pub plain_text: reqwest::Url,
}
pub struct TestApp {
pub address: String,
pub port: u16,
pub db_pool: PgPool,
pub email_server: MockServer,
}
impl TestApp {
@ -44,6 +53,28 @@ impl TestApp {
.await
.expect("Failed to execute request.")
}
pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap();
// Extract the link from one of the request fields.
let get_link = |s: &str| {
let links: Vec<_> = linkify::LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
let raw_link = links[0].as_str().to_owned();
let mut confirmation_link = reqwest::Url::parse(&raw_link).unwrap();
// Let's make sure we don't call random APIs on the web
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
confirmation_link.set_port(Some(self.port)).unwrap();
confirmation_link
};
let html = get_link(&body["HtmlBody"].as_str().unwrap());
let plain_text = get_link(&body["TextBody"].as_str().unwrap());
ConfirmationLinks { html, plain_text }
}
}
pub async fn spawn_app() -> TestApp {
@ -51,9 +82,8 @@ pub async fn spawn_app() -> TestApp {
// All other invocations will instead skip execution.
Lazy::force(&TRACING);
// TODO:
// // Launch a mock server to stand in for Postmark's API
// let email_server = MockServer::start().await;
// Launch a mock server to stand in for Postmark's API
let email_server = MockServer::start().await;
// Randomise configuration to ensure test isolation
let configuration = {
@ -62,6 +92,8 @@ pub async fn spawn_app() -> TestApp {
c.database.name = Uuid::new_v4().to_string();
// Use a random OS port
c.application.port = 0;
// Use the mock server as email API
c.email_client.base_url = email_server.uri();
c
};
@ -72,22 +104,29 @@ pub async fn spawn_app() -> TestApp {
.await
.expect("Failed to build application.");
// Get the port before spawning the application
let address = format!("http://127.0.0.1:{}", application.port());
let application_port = application.port();
let address = format!("http://127.0.0.1:{}", application_port);
// Launch the application as a background task
tokio::spawn(async move { application.run().await.expect("Failed to run the server") });
TestApp {
address,
// port: application_port,
port: application_port,
db_pool: connection_pool,
// email_server,
email_server,
// test_user: TestUser::generate(),
// api_client: client,
// email_client: configuration.email_client.client(),
}
}
/// Create a new database and run the migrations.
///
/// # Parameters
/// * `config`: The database configuration.
/// # Returns
/// The connection pool.
async fn configure_database(config: &DatabaseSettings) -> PgPool {
// Create database
let mut connection = PgConnection::connect_with(&config.without_db())

View file

@ -1,3 +1,4 @@
mod health_check;
mod helpers;
mod subscriptions;
mod subscriptions_confirm;

View file

@ -1,4 +1,48 @@
use crate::helpers::{spawn_app, TestApp};
use crate::helpers::spawn_app;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_with_a_link() {
//Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(email_request);
// The two links should be identical
assert_eq!(confirmation_links.html, confirmation_links.plain_text);
}
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_for_valid_data() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
// Mock asserts on drop
}
#[tokio::test]
async fn subscribe_returns_a_422_when_data_is_missing() {
@ -29,20 +73,42 @@ async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
let response = app.post_subscriptions(body.into()).await;
// Assert
assert_eq!(200, response.status().as_u16());
}
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
#[tokio::test]
async fn subscribe_persists_the_new_subscriber() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",)
.fetch_one(&app.db_pool)
.await
.expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "ursula_le_guin@gmail.com");
assert_eq!(saved.name, "le guin");
assert_eq!(saved.status, "pending_confirmation");
}
#[tokio::test]
@ -63,7 +129,7 @@ async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
assert_eq!(
400,
response.status().as_u16(),
"The API did not return a 200 OK when the payload was {}.",
"The API did not fail with 400 when the payload was {}.",
description
);
}

View file

@ -0,0 +1,41 @@
use crate::helpers::spawn_app;
use reqwest::Url;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
#[tokio::test]
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(email_request);
// Act
let response = reqwest::get(confirmation_links.html).await.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 200);
}
#[tokio::test]
async fn confirmations_without_token_are_rejected_with_a_400() {
// Arrange
let app = spawn_app().await;
// Act
let response = reqwest::get(&format!("{}/subscriptions/confirm", app.address))
.await
.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 400);
}