From e8a7a552433c2238ea634b0da8f92ef4c6e07338 Mon Sep 17 00:00:00 2001 From: fan-tastic-z Date: Sat, 2 Nov 2024 21:01:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=83=E7=AB=A0=E4=BB=A3?= =?UTF-8?q?=E7=A0=81(=E4=B8=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 10 + Cargo.toml | 1 + configuration/local.yaml | 1 + ...1102065508_add_status_to_subscriptions.sql | 5 + ..._make_status_not_null_in_subscriptions.sql | 18 ++ ...70203_create_subscription_tokens_table.sql | 6 + src/configuration.rs | 1 + src/routes/mod.rs | 2 + src/routes/subscriptions.rs | 94 ++++++++- src/routes/subscriptions_confirm.rs | 78 +++++++ src/startup.rs | 5 +- tests/api/health_check.rs | 4 +- tests/api/helpers.rs | 50 ++++- tests/api/subscriptions.rs | 196 ++++++++++++++++-- 14 files changed, 442 insertions(+), 29 deletions(-) create mode 100644 migrations/20241102065508_add_status_to_subscriptions.sql create mode 100644 migrations/20241102065900_make_status_not_null_in_subscriptions.sql create mode 100644 migrations/20241102070203_create_subscription_tokens_table.sql create mode 100644 src/routes/subscriptions_confirm.rs diff --git a/Cargo.lock b/Cargo.lock index bdcc599..3feaf89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1159,6 +1159,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 = "linux-raw-sys" version = "0.4.14" @@ -3265,6 +3274,7 @@ dependencies = [ "claims", "config", "fake", + "linkify", "mime", "once_cell", "quickcheck", diff --git a/Cargo.toml b/Cargo.toml index 7673a35..019f99e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ validator = "0.18.1" [dev-dependencies] claims = "0.7.1" fake = "3.0.0" +linkify = "0.10.0" mime = "0.3.17" once_cell = "1.20.2" quickcheck = "1.0.3" diff --git a/configuration/local.yaml b/configuration/local.yaml index 4b25dd2..ba726c6 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -1,5 +1,6 @@ application: host: 127.0.0.1 + base_url: "http://127.0.0.1" database: require_ssl: false diff --git a/migrations/20241102065508_add_status_to_subscriptions.sql b/migrations/20241102065508_add_status_to_subscriptions.sql new file mode 100644 index 0000000..94ac64a --- /dev/null +++ b/migrations/20241102065508_add_status_to_subscriptions.sql @@ -0,0 +1,5 @@ +-- Add migration script here +ALTER TABLE + subscriptions +ADD + COLUMN status TEXT NULL; diff --git a/migrations/20241102065900_make_status_not_null_in_subscriptions.sql b/migrations/20241102065900_make_status_not_null_in_subscriptions.sql new file mode 100644 index 0000000..34db05a --- /dev/null +++ b/migrations/20241102065900_make_status_not_null_in_subscriptions.sql @@ -0,0 +1,18 @@ +-- Add migration script here +BEGIN; + +UPDATE + subscriptions +SET + status = 'confirmed' +WHERE + status IS NULL; + +ALTER TABLE + subscriptions +ALTER COLUMN + status +SET + NOT NULL; + +COMMIT; diff --git a/migrations/20241102070203_create_subscription_tokens_table.sql b/migrations/20241102070203_create_subscription_tokens_table.sql new file mode 100644 index 0000000..3bb2337 --- /dev/null +++ b/migrations/20241102070203_create_subscription_tokens_table.sql @@ -0,0 +1,6 @@ +-- Add migration script here +CREATE TABLE subscription_tokens( + subscription_token TEXT NOT NULL, + subscriber_id uuid NOT NULL REFERENCES subscriptions (id), + PRIMARY KEY (subscription_token) +) diff --git a/src/configuration.rs b/src/configuration.rs index 83fae2a..d1bea46 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -87,6 +87,7 @@ pub struct ApplicationSettings { pub host: String, #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, + pub base_url: String, } impl ApplicationSettings { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 90ffeed..d0ddba0 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.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 bac51c5..8c55c8f 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -5,12 +5,14 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; use crate::{ domain::{NewSubscriber, SubscriberEmail, SubscriberName}, + email_client::EmailClient, startup::AppState, }; @@ -29,23 +31,69 @@ pub async fn subscribe( Ok(subscriber) => subscriber, Err(_) => return Err(StatusCode::BAD_REQUEST), }; - match insert_subscriber(&state.db_pool, &new_subscriber).await { - Ok(_) => Ok((StatusCode::OK).into_response()), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + let subscriber_id = match insert_subscriber(&state.db_pool, &new_subscriber).await { + Ok(subscriber_id) => subscriber_id, + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + let subscription_token = generate_subscription_token(); + if store_token(&state.db_pool, subscriber_id, &subscription_token) + .await + .is_err() + { + return Err(StatusCode::INTERNAL_SERVER_ERROR); } + + if send_confirm_email( + &state.email_client, + new_subscriber, + &state.base_url, + &subscription_token, + ) + .await + .is_err() + { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + Ok((StatusCode::OK).into_response()) +} + +pub async fn send_confirm_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 plain_body = format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription", + confirmation_link + ); + let html_body = &format!( + "Welcome to our newsletter!
\ + Click here to confirm your subscription", + confirmation_link + ); + email_client + .send_email(new_subscriber.email, "Welcome!", html_body, &plain_body) + .await } pub async fn insert_subscriber( pool: &PgPool, new_subscriber: &NewSubscriber, -) -> Result<(), sqlx::Error> { +) -> Result { + let subscriber_id = Uuid::new_v4(); sqlx::query( r#" - INSERT INTO subscriptions (id, email, name, subscribed_at) - VALUES ($1, $2, $3, $4) + INSERT INTO subscriptions (id, email, name, subscribed_at, status) + VALUES ($1, $2, $3, $4, 'pending_confirmation') "#, ) - .bind(Uuid::new_v4()) + .bind(subscriber_id) .bind(new_subscriber.email.as_ref()) .bind(new_subscriber.name.as_ref()) .bind(Utc::now()) @@ -55,7 +103,7 @@ pub async fn insert_subscriber( tracing::error!("Failed to execute query: {:?}", e); e })?; - Ok(()) + Ok(subscriber_id) } impl TryFrom for NewSubscriber { @@ -67,3 +115,33 @@ impl TryFrom for NewSubscriber { Ok(NewSubscriber { email, name }) } } + +fn generate_subscription_token() -> String { + let mut rng = thread_rng(); + std::iter::repeat_with(|| rng.sample(Alphanumeric)) + .map(char::from) + .take(25) + .collect() +} + +pub async fn store_token( + pool: &PgPool, + subscriber_id: Uuid, + subscription_token: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO subscription_tokens (subscription_token,subscriber_id) + VALUES ($1, $2) + "#, + ) + .bind(subscription_token) + .bind(subscriber_id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + e + })?; + Ok(()) +} diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs new file mode 100644 index 0000000..3177074 --- /dev/null +++ b/src/routes/subscriptions_confirm.rs @@ -0,0 +1,78 @@ +use axum::{ + debug_handler, + extract::{Query, State}, + response::{IntoResponse, Response}, +}; +use reqwest::StatusCode; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::startup::AppState; + +#[derive(Deserialize)] +pub struct Parameters { + subscription_token: String, +} + +#[debug_handler] +pub async fn confirm( + State(state): State, + Query(params): Query, +) -> Result { + let id = match get_subscriber_id_from_token(&state.db_pool, ¶ms.subscription_token).await { + Ok(id) => id, + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + dbg!(id); + match id { + Some(subscriber_id) => { + if confirm_subscriber(&state.db_pool, subscriber_id) + .await + .is_err() + { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok((StatusCode::OK).into_response()) + } + None => Err(StatusCode::UNAUTHORIZED), + } +} + +pub async fn confirm_subscriber(pool: &PgPool, subscriber_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE subscriptions SET status = 'confirmed' WHERE id = $1 + "#, + ) + .bind(subscriber_id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + e + })?; + Ok(()) +} + +#[derive(sqlx::FromRow, Debug, PartialEq, Eq)] +pub struct SubscriberId(Uuid); + +async fn get_subscriber_id_from_token( + pool: &PgPool, + subscription_token: &str, +) -> Result, sqlx::Error> { + let result: Option = sqlx::query_as( + r#" + SELECT subscriber_id FROM subscription_tokens WHERE subscription_token=$1 + "#, + ) + .bind(subscription_token) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + e + })?; + Ok(result.map(|r| r.0)) +} diff --git a/src/startup.rs b/src/startup.rs index 4b243b3..aec1c14 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -11,13 +11,14 @@ use tower_http::trace::TraceLayer; use crate::{ configuration::{DatabaseSettings, Settings}, email_client::EmailClient, - routes::{health, subscribe}, + routes::{confirm, health, subscribe}, }; #[derive(Clone)] pub struct AppState { pub db_pool: Arc>, pub email_client: Arc, + pub base_url: String, } impl AppState { @@ -42,6 +43,7 @@ impl AppState { Self { db_pool, email_client, + base_url: configuration.application.base_url.clone(), } } } @@ -50,6 +52,7 @@ pub fn app(state: AppState) -> Router { Router::new() .route("/health", get(health)) .route("/subscriptions", post(subscribe)) + .route("/subscriptions/confirm", get(confirm)) .with_state(state) .layer(TraceLayer::new_for_http()) } diff --git a/tests/api/health_check.rs b/tests/api/health_check.rs index ad06cd7..b10f778 100644 --- a/tests/api/health_check.rs +++ b/tests/api/health_check.rs @@ -10,8 +10,8 @@ use crate::helpers::spawn_app; #[tokio::test] async fn health_check_works() { - let state = spawn_app().await; - let app = app(state); + let test_app = spawn_app().await; + let app = app(test_app.app_state); let response = app .oneshot( diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 745d222..6e1d913 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -1,5 +1,7 @@ use once_cell::sync::Lazy; +use reqwest::Url; use uuid::Uuid; +use wiremock::MockServer; use zero2prod::{ configuration::get_configuration, startup::{configuration_database, AppState}, @@ -10,10 +12,54 @@ static TRACING: Lazy<()> = Lazy::new(|| { init_tracing(); }); -pub async fn spawn_app() -> AppState { +pub struct TestApp { + pub app_state: AppState, + pub email_server: MockServer, +} + +impl TestApp { + pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + 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 confirmation_link = Url::parse(&raw_link).unwrap(); + assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1"); + 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 { Lazy::force(&TRACING); + + let email_server = MockServer::start().await; + let mut configuration = get_configuration().expect("Failed to read configuration."); configuration.database.database_name = Uuid::new_v4().to_string(); + configuration.email_client.base_url = email_server.uri(); configuration_database(&configuration.database).await; - AppState::build(&configuration).await + let app_state = AppState::build(&configuration).await; + TestApp { + app_state, + email_server, + } +} + +pub struct ConfirmationLinks { + pub html: reqwest::Url, + pub plain_text: reqwest::Url, +} + +pub fn path_and_query(link: reqwest::Url) -> String { + let url_path = link.path(); + let query = link.query().unwrap(); + format!("{}?{}", url_path, query) } diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index f71dc90..aedc002 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -4,9 +4,20 @@ use axum::{ Router, }; use tower::ServiceExt; +use wiremock::{ + matchers::{method, path}, + Mock, ResponseTemplate, +}; use zero2prod::startup::app; -use crate::helpers::spawn_app; +use crate::helpers::{path_and_query, spawn_app}; + +#[derive(sqlx::FromRow, Debug, PartialEq, Eq)] +struct Subscription { + name: String, + email: String, + status: String, +} pub async fn post_subscriptions(app: Router, body: &str) -> http::Response { app.oneshot( @@ -26,31 +37,49 @@ pub async fn post_subscriptions(app: Router, body: &str) -> http::Response #[tokio::test] async fn subscribe_returns_a_200_for_valid_form_data() { - let state = spawn_app().await; - let app = app(state.clone()); + let test_app = spawn_app().await; + let app = app(test_app.app_state.clone()); let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&test_app.email_server) + .await; + let response = post_subscriptions(app, body).await; - assert!(response.status().is_success()); + assert_eq!(200, response.status().as_u16()); +} - #[derive(sqlx::FromRow, Debug, PartialEq, Eq)] - struct Subscription { - name: String, - email: String, - } - let saved: Subscription = sqlx::query_as("SELECT email, name FROM subscriptions") - .fetch_one(state.db_pool.as_ref()) +#[tokio::test] +async fn subscribe_persists_the_new_subscriber() { + let test_app = spawn_app().await; + let app = app(test_app.app_state.clone()); + let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&test_app.email_server) + .await; + + post_subscriptions(app, body).await; + let saved: Subscription = sqlx::query_as("SELECT email, name, status FROM subscriptions") + .fetch_one(test_app.app_state.db_pool.as_ref()) .await .expect("Failed to fetch saved subscription."); - assert_eq!(saved.email, "fantastic.fun.zf@gmail.com"); assert_eq!(saved.name, "fan-tastic.z"); + assert_eq!(saved.status, "pending_confirmation"); } #[tokio::test] async fn subscribe_returns_a_422_when_data_is_missing() { - let state = spawn_app().await; - let app = app(state); + let test_app = spawn_app().await; + let app = app(test_app.app_state); let test_cases = vec![ ("name=fan-tastic.z", "missing the email"), ("email=fantastic.fun.zf@gmail.com", "missing the name"), @@ -70,8 +99,8 @@ async fn subscribe_returns_a_422_when_data_is_missing() { #[tokio::test] async fn subscribe_returns_a_400_when_fields_are_present_but_empty() { - let state = spawn_app().await; - let app = app(state); + let test_app = spawn_app().await; + let app = app(test_app.app_state); let test_cases = vec![ ("name=&email=fantastic.fun.zf@gmail.com", "empty name"), @@ -91,3 +120,138 @@ async fn subscribe_returns_a_400_when_fields_are_present_but_empty() { ); } } + +#[tokio::test] +async fn subscribe_sends_a_confirmation_email_for_valid_data() { + let test_app = spawn_app().await; + let app = app(test_app.app_state); + let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&test_app.email_server) + .await; + let response = post_subscriptions(app, body).await; + + assert_eq!(200, response.status().as_u16()); +} + +#[tokio::test] +async fn subscribe_sends_a_confirmation_email_with_a_link() { + let test_app = spawn_app().await; + let app = app(test_app.app_state); + let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&test_app.email_server) + .await; + post_subscriptions(app, body).await; + + let email_request = &test_app.email_server.received_requests().await.unwrap()[0]; + let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); + + 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); + links[0].as_str().to_owned() + }; + let html_link = get_link(body["HtmlBody"].as_str().unwrap()); + let text_link = get_link(body["TextBody"].as_str().unwrap()); + assert_eq!(html_link, text_link); +} + +#[tokio::test] +async fn confirmations_without_token_are_rejected_with_a_400() { + let test_app = spawn_app().await; + let app = app(test_app.app_state); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/subscriptions/confirm") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 400); +} + +#[tokio::test] +async fn the_link_returned_by_subscribe_returns_a_200_if_called() { + let test_app = spawn_app().await; + let app_state = &test_app.app_state; + let app = app(app_state.clone()); + let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&test_app.email_server) + .await; + post_subscriptions(app.clone(), body).await; + let email_request = &test_app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = test_app.get_confirmation_links(email_request); + + let path_and_query = path_and_query(confirmation_links.html); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(path_and_query) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status().as_u16(), 200); +} + +#[tokio::test] +async fn clicking_on_the_confirmation_link_confirms_a_subscriber() { + let test_app = spawn_app().await; + let app_state = &test_app.app_state; + let app = app(app_state.clone()); + let body = "name=fan-tastic.z&email=fantastic.fun.zf@gmail.com"; + + Mock::given(path("/email")) + .and(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&test_app.email_server) + .await; + post_subscriptions(app.clone(), body).await; + let email_request = &test_app.email_server.received_requests().await.unwrap()[0]; + let confirmation_links = test_app.get_confirmation_links(email_request); + let path_and_query = path_and_query(confirmation_links.plain_text); + + app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri(path_and_query) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let saved: Subscription = sqlx::query_as("SELECT email, name, status FROM subscriptions") + .fetch_one(test_app.app_state.db_pool.as_ref()) + .await + .expect("Failed to fetch saved subscription."); + assert_eq!(saved.email, "fantastic.fun.zf@gmail.com"); + assert_eq!(saved.name, "fan-tastic.z"); + assert_eq!(saved.status, "confirmed"); +}