diff --git a/configuration/local.yaml b/configuration/local.yaml index 6c18707..d7f541e 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -1,5 +1,5 @@ application: host: 127.0.0.1 - url: "http://127.0.0.1" + base_url: "http://127.0.0.1" database: require_ssl: false diff --git a/src/configuration.rs b/src/configuration.rs index ae71c6d..fb46c8d 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -15,7 +15,7 @@ pub struct ApplicationSettings { #[serde(deserialize_with = "deserialize_number_from_string")] pub port: u16, pub host: String, - pub url: String, + pub base_url: String, } #[derive(serde::Deserialize, Clone)] 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 2b5ecaf..218cee6 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -1,6 +1,6 @@ use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; use crate::email_client::EmailClient; -use crate::startup::ApplicationUrl; +use crate::startup::ApplicationBaseUrl; use actix_web::{web, HttpResponse}; use chrono::Utc; use sqlx::PgPool; @@ -25,7 +25,7 @@ impl TryInto for FormData { #[tracing::instrument( name = "Adding a new subscriber", - skip(form, pool, email_client, application_url), + skip(form, pool, email_client, base_url), fields( email = %form.email, name = %form.name @@ -35,7 +35,7 @@ pub async fn subscribe( form: web::Form, pool: web::Data, email_client: web::Data, - application_url: web::Data, + base_url: web::Data, ) -> Result { let new_subscriber = form .0 @@ -45,20 +45,20 @@ pub async fn subscribe( .await .map_err(|_| HttpResponse::InternalServerError().finish())?; // We are swallowing the error for the time being. - let _ = send_confirmation_email(&email_client, new_subscriber, &application_url.0).await; + let _ = send_confirmation_email(&email_client, new_subscriber, &base_url.0).await; Ok(HttpResponse::Ok().finish()) } #[tracing::instrument( name = "Send a confirmation email to a new subscriber", - skip(email_client, new_subscriber, application_url) + skip(email_client, new_subscriber, base_url) )] pub async fn send_confirmation_email( email_client: &EmailClient, new_subscriber: NewSubscriber, - application_url: &str, + base_url: &str, ) -> Result<(), reqwest::Error> { - let confirmation_link = format!("{}/subscriptions/confirm", application_url); + let confirmation_link = format!("{}/subscriptions/confirm?subscription_token=mytoken", base_url); let plain_body = format!( "Welcome to our newsletter!\nVisit {} to confirm your subscription.", confirmation_link diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs new file mode 100644 index 0000000..fa07244 --- /dev/null +++ b/src/routes/subscriptions_confirm.rs @@ -0,0 +1,14 @@ +use actix_web::{HttpResponse, web}; + +#[derive(serde::Deserialize)] +pub struct Parameters { + subscription_token: String +} + +#[tracing::instrument( + name = "Confirm a pending subscriber", + skip(_parameters) +)] +pub async fn confirm(_parameters: web::Query) -> HttpResponse { + HttpResponse::Ok().finish() +} diff --git a/src/startup.rs b/src/startup.rs index d7b745c..6450303 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,6 +1,6 @@ use crate::configuration::{DatabaseSettings, Settings}; use crate::email_client::EmailClient; -use crate::routes::{health_check, subscribe}; +use crate::routes::{health_check, subscribe, confirm}; use actix_web::dev::Server; use actix_web::web::Data; use actix_web::{web, App, HttpServer}; @@ -40,7 +40,7 @@ impl Application { listener, connection_pool, email_client, - configuration.application.url, + configuration.application.base_url, )?; Ok(Self { port, server }) @@ -62,13 +62,13 @@ pub async fn get_connection_pool(configuration: &DatabaseSettings) -> Result Result { let db_pool = Data::new(db_pool); let email_client = Data::new(email_client); @@ -77,9 +77,10 @@ fn run( .wrap(TracingLogger) .route("/health_check", web::get().to(health_check)) .route("/subscriptions", web::post().to(subscribe)) + .route("/subscriptions/confirm", web::get().to(confirm)) .app_data(db_pool.clone()) .app_data(email_client.clone()) - .data(ApplicationUrl(application_url.clone())) + .data(ApplicationBaseUrl(base_url.clone())) }) .listen(listener)? .run(); diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index fba61d3..1870c57 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -16,6 +16,7 @@ lazy_static::lazy_static! { pub struct TestApp { pub address: String, + pub port: u16, pub db_pool: PgPool, pub email_server: MockServer, } @@ -57,11 +58,12 @@ pub async fn spawn_app() -> TestApp { let application = Application::build(configuration.clone()) .await .expect("Failed to build application."); - let address = format!("http://localhost:{}", application.port()); + let application_port = application.port(); let _ = tokio::spawn(application.run_until_stopped()); TestApp { - address, + address: format!("http://localhost:{}", application_port), + port: application_port, db_pool: get_connection_pool(&configuration.database) .await .expect("Failed to connect to the database"), 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 0053195..24e0796 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -1,5 +1,4 @@ use crate::helpers::spawn_app; -use reqwest::Url; use wiremock::matchers::{method, path}; use wiremock::{Mock, ResponseTemplate}; @@ -91,53 +90,6 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { assert_eq!(html_link, text_link); } -#[actix_rt::test] -async fn the_link_returned_by_subscribe_sets_a_subscriber_as_confirmed() { - // 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 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); - links[0].as_str().to_owned() - }; - let confirmation_link = Url::parse(&get_link(&body["HtmlBody"].as_str().unwrap())).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"); - // Let's rewrite the URL to include the port - let confirmation_link = format!("{}{}", app.address, confirmation_link.path()); - - // Act - let response = reqwest::get(&confirmation_link) - .await - .unwrap() - .error_for_status() - .unwrap(); - - // Assert - let saved = sqlx::query!(r#"SELECT status FROM subscriptions WHERE name = 'le guin' AND email = 'ursula_le_guin@gmail.com'"#,) - .fetch_one(&app.db_pool) - .await - .expect("Failed to fetch saved subscription."); - - assert_eq!(saved.status, "confirmed"); -} - #[actix_rt::test] async fn subscribe_returns_a_400_when_data_is_missing() { // Arrange diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs new file mode 100644 index 0000000..4b2a01b --- /dev/null +++ b/tests/api/subscriptions_confirm.rs @@ -0,0 +1,58 @@ +use crate::helpers::spawn_app; +use reqwest::Url; +use wiremock::{ResponseTemplate, Mock}; +use wiremock::matchers::{path, method}; + +#[actix_rt::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); +} + +#[actix_rt::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 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); + links[0].as_str().to_owned() + }; + let mut confirmation_link = Url::parse(&get_link(&body["HtmlBody"].as_str().unwrap())).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(app.port)).unwrap(); + + // Act + let response = reqwest::get(confirmation_link) + .await + .unwrap(); + + // Assert + assert_eq!(response.status().as_u16(), 200); +} +