Confirm subscription endpoint

This commit is contained in:
Alphonse Paix
2025-08-25 17:46:03 +02:00
parent 73ff7c04fe
commit d1cf1f6c4f
14 changed files with 421 additions and 39 deletions

11
Cargo.lock generated
View File

@@ -1217,6 +1217,15 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linkify"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.0" version = "0.8.0"
@@ -3373,9 +3382,11 @@ dependencies = [
"claims", "claims",
"config", "config",
"fake", "fake",
"linkify",
"once_cell", "once_cell",
"quickcheck", "quickcheck",
"quickcheck_macros", "quickcheck_macros",
"rand 0.9.2",
"reqwest", "reqwest",
"secrecy", "secrecy",
"serde", "serde",

View File

@@ -14,6 +14,7 @@ name = "zero2prod"
axum = "0.8.4" axum = "0.8.4"
chrono = { version = "0.4.41", default-features = false, features = ["clock"] } chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
config = "0.15.14" config = "0.15.14"
rand = { version = "0.9.2", features = ["std_rng"] }
reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "json"] }
secrecy = { version = "0.10.3", features = ["serde"] } secrecy = { version = "0.10.3", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
@@ -31,6 +32,7 @@ validator = { version = "0.20.0", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
claims = "0.8.0" claims = "0.8.0"
fake = "4.4.0" fake = "4.4.0"
linkify = "0.10.0"
once_cell = "1.21.3" once_cell = "1.21.3"
quickcheck = "1.0.3" quickcheck = "1.0.3"
quickcheck_macros = "1.1.0" quickcheck_macros = "1.1.0"

View File

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

View File

@@ -67,6 +67,7 @@ pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")] #[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16, pub port: u16,
pub host: String, pub host: String,
pub base_url: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View File

@@ -1,9 +1,7 @@
use std::time::Duration; use crate::{configuration::EmailClientSettings, domain::SubscriberEmail};
use reqwest::Client; use reqwest::Client;
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::time::Duration;
use crate::{configuration::EmailClientSettings, domain::SubscriberEmail};
pub struct EmailClient { pub struct EmailClient {
http_client: Client, http_client: Client,

View File

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

View File

@@ -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 axum::{Form, extract::State, http::StatusCode, response::IntoResponse};
use chrono::Utc; use chrono::Utc;
use rand::{Rng, distr::Alphanumeric};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::{Executor, Postgres, Transaction};
use uuid::Uuid; 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( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(connection, form), skip(connection_pool, form, email_client),
fields( fields(
subscriber_email = %form.email, subscriber_email = %form.email,
subscriber_name = %form.name subscriber_name = %form.name
) )
)] )]
pub async fn subscribe( pub async fn subscribe(
State(connection): State<PgPool>, State(AppState {
connection_pool,
email_client,
base_url,
}): State<AppState>,
Form(form): Form<FormData>, Form(form): Form<FormData>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let new_subscriber = match form.try_into() { let Ok(mut transaction) = connection_pool.begin().await else {
Ok(subscriber) => subscriber, return StatusCode::INTERNAL_SERVER_ERROR;
Err(_) => return StatusCode::BAD_REQUEST,
}; };
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 .await
.is_err() .is_err()
{ {
StatusCode::INTERNAL_SERVER_ERROR return StatusCode::INTERNAL_SERVER_ERROR;
} else {
StatusCode::OK
} }
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( #[tracing::instrument(
name = "Saving new subscriber details in the database", name = "Saving new subscriber details in the database",
skip(connection, new_subscriber) skip(transaction, new_subscriber)
)] )]
pub async fn insert_subscriber( pub async fn insert_subscriber(
connection: &PgPool, transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber, new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> { ) -> Result<Uuid, sqlx::Error> {
sqlx::query!( let subscriber_id = Uuid::new_v4();
let query = sqlx::query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status) 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.email.as_ref(),
new_subscriber.name.as_ref(), new_subscriber.name.as_ref(),
Utc::now() Utc::now()
) );
.execute(connection) transaction.execute(query).await.map_err(|e| {
.await tracing::error!("Failed to execute query: {:?}", e);
.map_err(|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); tracing::error!("Failed to execute query: {:?}", e);
e e
})?; })?;
Ok(()) 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!<br />\
Click <a href=\"{}\">here</a> 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)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct FormData { pub struct FormData {

View File

@@ -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<AppState>,
Query(params): Query<Params>,
) -> impl IntoResponse {
let Ok(subscriber_id) =
get_subscriber_id_from_token(&connection_pool, &params.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<Option<Uuid>, 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,
}

View File

@@ -16,6 +16,13 @@ pub struct Application {
router: Router, router: Router,
} }
#[derive(Clone)]
pub struct AppState {
pub connection_pool: PgPool,
pub email_client: Arc<EmailClient>,
pub base_url: String,
}
impl Application { impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> { pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
let address = format!( let address = format!(
@@ -26,7 +33,11 @@ impl Application {
let connection_pool = let connection_pool =
PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::new(configuration.email_client); 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 }) Ok(Self { listener, router })
} }
@@ -35,15 +46,21 @@ impl Application {
axum::serve(self.listener, self.router).await 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() 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() Router::new()
.route("/health_check", get(health_check)) .route("/health_check", get(health_check))
.route("/subscriptions", post(subscribe)) .route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm))
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
let matched_path = 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(app_state)
.with_state(Arc::new(email_client))
} }

View File

@@ -6,7 +6,7 @@ async fn health_check_works() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let response = client let response = client
.get(format!("http://{}/health_check", app.address)) .get(format!("{}/health_check", app.address))
.send() .send()
.await .await
.unwrap(); .unwrap();

View File

@@ -1,6 +1,8 @@
use linkify::LinkFinder;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool}; use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid; use uuid::Uuid;
use wiremock::MockServer;
use zero2prod::{ use zero2prod::{
configuration::{DatabaseSettings, get_configuration}, configuration::{DatabaseSettings, get_configuration},
startup::Application, 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 struct TestApp {
pub address: String, pub address: String,
pub connection_pool: PgPool, pub connection_pool: PgPool,
pub email_server: wiremock::MockServer,
pub port: u16,
} }
impl TestApp { 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 { pub async fn spawn() -> Self {
Lazy::force(&TRACING); Lazy::force(&TRACING);
let mut configuration = get_configuration().expect("Failed to read configuration"); let email_server = MockServer::start().await;
configuration.database.database_name = Uuid::new_v4().to_string(); let configuration = {
configuration.application.port = 0; 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 connection_pool = configure_database(&configuration.database).await;
let application = Application::build(configuration) let application = Application::build(configuration)
.await .await
.expect("Failed to build application"); .expect("Failed to build application");
let address = application.address(); let local_addr = application.local_addr();
let port = local_addr
.split(":")
.last()
.unwrap()
.parse::<u16>()
.unwrap();
let address = format!("http://{}", application.local_addr());
let app = TestApp { let app = TestApp {
address, address,
connection_pool, connection_pool,
email_server,
port,
}; };
tokio::spawn(application.run_until_stopped()); tokio::spawn(application.run_until_stopped());
@@ -44,7 +87,7 @@ impl TestApp {
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response { pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
reqwest::Client::new() reqwest::Client::new()
.post(format!("http://{}/subscriptions", self.address)) .post(format!("{}/subscriptions", self.address))
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.body(body) .body(body)
.send() .send()

View File

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

View File

@@ -1,21 +1,48 @@
use crate::helpers::TestApp; use crate::helpers::TestApp;
use wiremock::{
Mock, ResponseTemplate,
matchers::{method, path},
};
#[tokio::test] #[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() { async fn subscribe_returns_a_200_for_valid_form_data() {
let app = TestApp::spawn().await; 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; let response = app.post_subscriptions(body.into()).await;
assert_eq!(200, response.status().as_u16()); 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) .fetch_one(&app.connection_pool)
.await .await
.expect("Failed to fetch saved subscription"); .expect("Failed to fetch saved subscription");
assert_eq!(saved.email, "alphonse.paix@outlook.com"); 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] #[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);
}

View File

@@ -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");
}