From 415d787260c1d8433fbe54500693c9110ae43098 Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Mon, 25 Aug 2025 17:46:03 +0200 Subject: [PATCH] Confirm subscription endpoint --- Cargo.lock | 11 +++ Cargo.toml | 2 + src/configuration.rs | 1 + src/email_client.rs | 6 +- src/routes.rs | 2 + src/routes/subscriptions.rs | 134 +++++++++++++++++++++++----- src/routes/subscriptions_confirm.rs | 83 +++++++++++++++++ src/startup.rs | 26 ++++-- tests/api/health_check.rs | 2 +- tests/api/helpers.rs | 53 +++++++++-- tests/api/main.rs | 1 + tests/api/subscriptions.rs | 69 +++++++++++++- tests/api/subscriptions_confirm.rs | 69 ++++++++++++++ 13 files changed, 420 insertions(+), 39 deletions(-) create mode 100644 src/routes/subscriptions_confirm.rs create mode 100644 tests/api/subscriptions_confirm.rs diff --git a/Cargo.lock b/Cargo.lock index fac245f..5d3f36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1217,6 +1217,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "litemap" version = "0.8.0" @@ -3373,9 +3382,11 @@ dependencies = [ "claims", "config", "fake", + "linkify", "once_cell", "quickcheck", "quickcheck_macros", + "rand 0.9.2", "reqwest", "secrecy", "serde", diff --git a/Cargo.toml b/Cargo.toml index a4e0cde..a756d20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ name = "zero2prod" axum = "0.8.4" chrono = { version = "0.4.41", default-features = false, features = ["clock"] } config = "0.15.14" +rand = { version = "0.9.2", features = ["std_rng"] } reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "json"] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } @@ -31,6 +32,7 @@ validator = { version = "0.20.0", features = ["derive"] } [dev-dependencies] claims = "0.8.0" fake = "4.4.0" +linkify = "0.10.0" once_cell = "1.21.3" quickcheck = "1.0.3" quickcheck_macros = "1.1.0" diff --git a/src/configuration.rs b/src/configuration.rs index a9c5cba..f1bbe26 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -67,6 +67,7 @@ pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, + pub base_url: String, } #[derive(Deserialize)] diff --git a/src/email_client.rs b/src/email_client.rs index 0285e85..0d46359 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -1,9 +1,7 @@ -use std::time::Duration; - +use crate::{configuration::EmailClientSettings, domain::SubscriberEmail}; use reqwest::Client; use secrecy::{ExposeSecret, SecretString}; - -use crate::{configuration::EmailClientSettings, domain::SubscriberEmail}; +use std::time::Duration; pub struct EmailClient { http_client: Client, diff --git a/src/routes.rs b/src/routes.rs index 90ffeed..d0ddba0 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,5 +1,7 @@ mod health_check; mod subscriptions; +mod subscriptions_confirm; pub use health_check::*; pub use subscriptions::*; +pub use subscriptions_confirm::*; diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index a0e5c12..354b795 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,63 +1,155 @@ -use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; +use crate::{ + domain::{NewSubscriber, SubscriberEmail, SubscriberName}, + email_client::EmailClient, + startup::AppState, +}; use axum::{Form, extract::State, http::StatusCode, response::IntoResponse}; use chrono::Utc; +use rand::{Rng, distr::Alphanumeric}; use serde::Deserialize; -use sqlx::PgPool; +use sqlx::{Executor, Postgres, Transaction}; use uuid::Uuid; +fn generate_subscription_token() -> String { + let mut rng = rand::rng(); + std::iter::repeat_with(|| rng.sample(Alphanumeric)) + .map(char::from) + .take(25) + .collect() +} + #[tracing::instrument( name = "Adding a new subscriber", - skip(connection, form), + skip(connection_pool, form, email_client), fields( subscriber_email = %form.email, subscriber_name = %form.name ) )] pub async fn subscribe( - State(connection): State, + State(AppState { + connection_pool, + email_client, + base_url, + }): State, Form(form): Form, ) -> impl IntoResponse { - let new_subscriber = match form.try_into() { - Ok(subscriber) => subscriber, - Err(_) => return StatusCode::BAD_REQUEST, + let Ok(mut transaction) = connection_pool.begin().await else { + return StatusCode::INTERNAL_SERVER_ERROR; }; - if insert_subscriber(&connection, &new_subscriber) + let Ok(new_subscriber) = form.try_into() else { + return StatusCode::BAD_REQUEST; + }; + let Ok(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber).await else { + return StatusCode::INTERNAL_SERVER_ERROR; + }; + let subscription_token = generate_subscription_token(); + if store_token(&mut transaction, &subscription_token, &subscriber_id) .await .is_err() { - StatusCode::INTERNAL_SERVER_ERROR - } else { - StatusCode::OK + return StatusCode::INTERNAL_SERVER_ERROR; } + if send_confirmation_email( + &email_client, + &new_subscriber, + &base_url, + &subscription_token, + ) + .await + .is_err() + { + return StatusCode::INTERNAL_SERVER_ERROR; + } + if transaction.commit().await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR; + } + StatusCode::OK } #[tracing::instrument( name = "Saving new subscriber details in the database", - skip(connection, new_subscriber) + skip(transaction, new_subscriber) )] pub async fn insert_subscriber( - connection: &PgPool, + transaction: &mut Transaction<'_, Postgres>, new_subscriber: &NewSubscriber, -) -> Result<(), sqlx::Error> { - sqlx::query!( +) -> Result { + let subscriber_id = Uuid::new_v4(); + let query = 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(), + subscriber_id, new_subscriber.email.as_ref(), new_subscriber.name.as_ref(), Utc::now() - ) - .execute(connection) - .await - .map_err(|e| { + ); + transaction.execute(query).await.map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + e + })?; + Ok(subscriber_id) +} + +#[tracing::instrument( + name = "Store subscription token in the database", + skip(transaction, subscription_token) +)] +async fn store_token( + transaction: &mut Transaction<'_, Postgres>, + subscription_token: &str, + subscriber_id: &Uuid, +) -> Result<(), sqlx::Error> { + let query = sqlx::query!( + r#" + INSERT INTO subscription_tokens (subscription_token, subscriber_id) + VALUES ($1, $2) + "#, + subscription_token, + subscriber_id, + ); + transaction.execute(query).await.map_err(|e| { tracing::error!("Failed to execute query: {:?}", e); e })?; Ok(()) } +#[tracing::instrument( + name = "Send a confirmation email to a new subscriber", + skip(email_client, new_subscriber, base_url, subscription_token) +)] +pub async fn send_confirmation_email( + email_client: &EmailClient, + new_subscriber: &NewSubscriber, + base_url: &str, + subscription_token: &str, +) -> Result<(), reqwest::Error> { + let confirmation_link = format!( + "{}/subscriptions/confirm?subscription_token={}", + base_url, subscription_token + ); + let html_content = format!( + "Welcome to our newsletter!
\ +Click here to confirm your subscription.", + confirmation_link + ); + let text_content = format!( + "Welcome to our newsletter!\nVisit {} to confirm your subscription.", + confirmation_link + ); + email_client + .send_email( + &new_subscriber.email, + "Welcome!", + &html_content, + &text_content, + ) + .await +} + #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct FormData { diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs new file mode 100644 index 0000000..eaec61d --- /dev/null +++ b/src/routes/subscriptions_confirm.rs @@ -0,0 +1,83 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::startup::AppState; + +#[tracing::instrument(name = "Confirming new subscriber", skip(params))] +pub async fn confirm( + State(AppState { + connection_pool, .. + }): State, + Query(params): Query, +) -> impl IntoResponse { + let Ok(subscriber_id) = + get_subscriber_id_from_token(&connection_pool, ¶ms.subscription_token).await + else { + return StatusCode::INTERNAL_SERVER_ERROR; + }; + if let Some(subscriber_id) = subscriber_id { + if confirm_subscriber(&connection_pool, &subscriber_id) + .await + .is_err() + { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + } + } else { + StatusCode::UNAUTHORIZED + } +} + +#[tracing::instrument( + name = "Mark subscriber as confirmed", + skip(connection_pool, subscriber_id) +)] +async fn confirm_subscriber( + connection_pool: &PgPool, + subscriber_id: &Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1", + subscriber_id + ) + .execute(connection_pool) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + e + })?; + Ok(()) +} + +#[tracing::instrument( + name = "Get subscriber_id from token", + skip(connection, subscription_token) +)] +async fn get_subscriber_id_from_token( + connection: &PgPool, + subscription_token: &str, +) -> Result, sqlx::Error> { + let saved = sqlx::query!( + "SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1", + subscription_token + ) + .fetch_optional(connection) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + e + })?; + Ok(saved.map(|r| r.subscriber_id)) +} + +#[derive(Debug, Deserialize)] +pub struct Params { + subscription_token: String, +} diff --git a/src/startup.rs b/src/startup.rs index 3909e41..cd526de 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -16,6 +16,13 @@ pub struct Application { router: Router, } +#[derive(Clone)] +pub struct AppState { + pub connection_pool: PgPool, + pub email_client: Arc, + pub base_url: String, +} + impl Application { pub async fn build(configuration: Settings) -> Result { let address = format!( @@ -26,7 +33,11 @@ impl Application { let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); let email_client = EmailClient::new(configuration.email_client); - let router = app(connection_pool, email_client); + let router = app( + connection_pool, + email_client, + configuration.application.base_url, + ); Ok(Self { listener, router }) } @@ -35,15 +46,21 @@ impl Application { axum::serve(self.listener, self.router).await } - pub fn address(&self) -> String { + pub fn local_addr(&self) -> String { self.listener.local_addr().unwrap().to_string() } } -pub fn app(connection_pool: PgPool, email_client: EmailClient) -> Router { +pub fn app(connection_pool: PgPool, email_client: EmailClient, base_url: String) -> Router { + let app_state = AppState { + connection_pool, + email_client: Arc::new(email_client), + base_url, + }; Router::new() .route("/health_check", get(health_check)) .route("/subscriptions", post(subscribe)) + .route("/subscriptions/confirm", get(confirm)) .layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request @@ -61,6 +78,5 @@ pub fn app(connection_pool: PgPool, email_client: EmailClient) -> Router { ) }), ) - .with_state(connection_pool) - .with_state(Arc::new(email_client)) + .with_state(app_state) } diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs index e9b70da..70dbd2a 100644 --- a/tests/api/health_check.rs +++ b/tests/api/health_check.rs @@ -6,7 +6,7 @@ async fn health_check_works() { let client = reqwest::Client::new(); let response = client - .get(format!("http://{}/health_check", app.address)) + .get(format!("{}/health_check", app.address)) .send() .await .unwrap(); diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index aadd17c..a2a881c 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,6 +1,8 @@ +use linkify::LinkFinder; use once_cell::sync::Lazy; use sqlx::{Connection, Executor, PgConnection, PgPool}; use uuid::Uuid; +use wiremock::MockServer; use zero2prod::{ configuration::{DatabaseSettings, get_configuration}, startup::Application, @@ -15,26 +17,67 @@ static TRACING: Lazy<()> = Lazy::new(|| { } }); +pub struct ConfirmationLinks { + pub html: reqwest::Url, + pub text: reqwest::Url, +} + pub struct TestApp { pub address: String, pub connection_pool: PgPool, + pub email_server: wiremock::MockServer, + pub port: u16, } impl TestApp { + pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + let get_link = |s: &str| { + let links: Vec<_> = LinkFinder::new() + .links(s) + .filter(|l| *l.kind() == linkify::LinkKind::Url) + .collect(); + assert_eq!(links.len(), 1); + let raw_link = links[0].as_str(); + let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap(); + 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["html"].as_str().unwrap()); + let text = get_link(body["text"].as_str().unwrap()); + ConfirmationLinks { html, text } + } + pub async fn spawn() -> Self { Lazy::force(&TRACING); - let mut configuration = get_configuration().expect("Failed to read configuration"); - configuration.database.database_name = Uuid::new_v4().to_string(); - configuration.application.port = 0; + let email_server = MockServer::start().await; + let configuration = { + let mut c = get_configuration().expect("Failed to read configuration"); + c.database.database_name = Uuid::new_v4().to_string(); + c.application.port = 0; + c.email_client.base_url = email_server.uri(); + c + }; let connection_pool = configure_database(&configuration.database).await; let application = Application::build(configuration) .await .expect("Failed to build application"); - let address = application.address(); + let local_addr = application.local_addr(); + let port = local_addr + .split(":") + .last() + .unwrap() + .parse::() + .unwrap(); + let address = format!("http://{}", application.local_addr()); let app = TestApp { address, connection_pool, + email_server, + port, }; tokio::spawn(application.run_until_stopped()); @@ -44,7 +87,7 @@ impl TestApp { pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { reqwest::Client::new() - .post(format!("http://{}/subscriptions", self.address)) + .post(format!("{}/subscriptions", self.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) .send() diff --git a/tests/api/main.rs b/tests/api/main.rs index 3b9c227..177847a 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -1,3 +1,4 @@ mod health_check; mod helpers; mod subscriptions; +mod subscriptions_confirm; diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index 1593b6a..ef11f8e 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -1,21 +1,48 @@ use crate::helpers::TestApp; +use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path}, +}; #[tokio::test] async fn subscribe_returns_a_200_for_valid_form_data() { let app = TestApp::spawn().await; - let body = "name=alphonse&email=alphonse.paix%40outlook.com"; + Mock::given(path("/v1/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + let response = app.post_subscriptions(body.into()).await; + + assert_eq!(200, response.status().as_u16()); +} + +#[tokio::test] +async fn subscribe_persists_the_new_subscriber() { + let app = TestApp::spawn().await; + + Mock::given(path("/v1/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&app.email_server) + .await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; let response = app.post_subscriptions(body.into()).await; assert_eq!(200, response.status().as_u16()); - let saved = sqlx::query!("SELECT email, name FROM subscriptions") + let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") .fetch_one(&app.connection_pool) .await .expect("Failed to fetch saved subscription"); assert_eq!(saved.email, "alphonse.paix@outlook.com"); - assert_eq!(saved.name, "alphonse"); + assert_eq!(saved.name, "Alphonse"); + assert_eq!(saved.status, "pending_confirmation"); } #[tokio::test] @@ -59,3 +86,39 @@ async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { ); } } + +#[tokio::test] +async fn subscribe_sends_a_confirmation_email_for_valid_data() { + let app = TestApp::spawn().await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + + Mock::given(path("v1/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + app.post_subscriptions(body.into()).await; +} + +#[tokio::test] +async fn subscribe_sends_a_confirmation_email_with_a_link() { + let app = TestApp::spawn().await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + + Mock::given(path("v1/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .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); + assert_eq!(confirmation_links.html, confirmation_links.text); +} diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs new file mode 100644 index 0000000..0a0c4cb --- /dev/null +++ b/tests/api/subscriptions_confirm.rs @@ -0,0 +1,69 @@ +use crate::helpers::TestApp; +use wiremock::{ + Mock, ResponseTemplate, + matchers::{method, path}, +}; + +#[tokio::test] +async fn confirmation_links_without_token_are_rejected_with_a_400() { + let app = TestApp::spawn().await; + + let response = reqwest::get(&format!("{}/subscriptions/confirm", &app.address)) + .await + .unwrap(); + assert_eq!(400, response.status().as_u16()); +} + +#[tokio::test] +async fn the_link_returned_by_subscribe_returns_a_200_if_called() { + let app = TestApp::spawn().await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + + Mock::given(path("v1/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .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); + let response = reqwest::get(confirmation_links.html).await.unwrap(); + assert_eq!(response.status().as_u16(), 200); +} + +#[tokio::test] +async fn clicking_on_the_confirmation_link_confirms_a_subscriber() { + let app = TestApp::spawn().await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + + Mock::given(path("v1/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .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); + reqwest::get(confirmation_links.html) + .await + .unwrap() + .error_for_status() + .unwrap(); + + let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") + .fetch_one(&app.connection_pool) + .await + .expect("Failed to fetch saved subscription"); + + assert_eq!(saved.email, "alphonse.paix@outlook.com"); + assert_eq!(saved.name, "Alphonse"); + assert_eq!(saved.status, "confirmed"); +}