diff --git a/README.md b/README.md index f36d759..0e778ef 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,10 @@ sudo apt install pkg-config sudo apt install libssl-dev cargo install sqlx-cli --no-default-features --features rustls,postgres ``` + +## TODO + +- Register form on homepage +- Success message displayed to new subscriber who confirmed his account +- Worker to remove idempotency key from database +- List of subscribers (confirmed and unconfirmed) on admin dashboard diff --git a/configuration/base.yaml b/configuration/base.yaml index 7ec5134..9420ba9 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -1,14 +1,6 @@ application: port: 8000 database: - host: "127.0.0.1" - port: 5432 - username: "postgres" - password: "password" database_name: "newsletter" email_client: - base_url: "http://127.0.0.1" - sender_email: "sender@example.com" - authorization_token: "my-secret-token" timeout_milliseconds: 10000 -redis_uri: "redis://127.0.0.1:6379" diff --git a/configuration/local.yaml b/configuration/local.yaml index df4bde1..ae1127d 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -2,4 +2,13 @@ application: host: "127.0.0.1" base_url: "http://127.0.0.1:8000" database: + host: "127.0.0.1" + port: 5432 + username: "postgres" + password: "password" require_ssl: false +email_client: + base_url: "https://api.mailersend.com" + sender_email: "MS_PTrumQ@test-r6ke4n1mmzvgon12.mlsender.net" + authorization_token: "secret-token" +redis_uri: "redis://127.0.0.1:6379" diff --git a/src/routes.rs b/src/routes.rs index 772876f..71206e1 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2,6 +2,7 @@ mod admin; mod health_check; mod home; mod login; +mod register; mod subscriptions; mod subscriptions_confirm; @@ -9,5 +10,6 @@ pub use admin::*; pub use health_check::*; pub use home::*; pub use login::*; +pub use register::*; pub use subscriptions::*; pub use subscriptions_confirm::*; diff --git a/src/routes/home/home.html b/src/routes/home/home.html index cab76b9..bc14f35 100644 --- a/src/routes/home/home.html +++ b/src/routes/home/home.html @@ -7,6 +7,9 @@

Welcome to our newsletter!

-

Login

+
    +
  1. Admin login
  2. +
  3. Register
  4. +
diff --git a/src/routes/register.rs b/src/routes/register.rs new file mode 100644 index 0000000..1c9fb12 --- /dev/null +++ b/src/routes/register.rs @@ -0,0 +1,11 @@ +use axum::response::{Html, IntoResponse, Response}; +use axum_messages::Messages; +use std::fmt::Write; + +pub async fn register(messages: Messages) -> Response { + let mut error_html = String::new(); + for message in messages { + writeln!(error_html, "

{}

", message).unwrap(); + } + Html(format!(include_str!("register/register.html"), error_html)).into_response() +} diff --git a/src/routes/register/confirm.html b/src/routes/register/confirm.html new file mode 100644 index 0000000..48dc57a --- /dev/null +++ b/src/routes/register/confirm.html @@ -0,0 +1,11 @@ + + + + + + Account confirmed + + +

Your account has been confirmed. Welcome!

+ + diff --git a/src/routes/register/register.html b/src/routes/register/register.html new file mode 100644 index 0000000..82d576e --- /dev/null +++ b/src/routes/register/register.html @@ -0,0 +1,22 @@ + + + + + + Register + + +
+ + + + +
+ {} +

Back

+ + diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 3b9b40e..e821662 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -8,8 +8,9 @@ use axum::{ Form, Json, extract::State, http::StatusCode, - response::{IntoResponse, Response}, + response::{IntoResponse, Redirect, Response}, }; +use axum_messages::Messages; use chrono::Utc; use rand::{Rng, distr::Alphanumeric}; use serde::Deserialize; @@ -63,12 +64,16 @@ impl IntoResponse for SubscribeError { tracing::error!("{:?}", self); - let status = match self { - SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, - SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST, - }; - let message = "An internal server error occured."; - (status, Json(ErrorResponse { message })).into_response() + match self { + SubscribeError::UnexpectedError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + message: "An internal server error occured.", + }), + ) + .into_response(), + SubscribeError::ValidationError(_) => Redirect::to("/register").into_response(), + } } } @@ -81,6 +86,7 @@ impl IntoResponse for SubscribeError { ) )] pub async fn subscribe( + messages: Messages, State(AppState { connection_pool, email_client, @@ -89,11 +95,17 @@ pub async fn subscribe( }): State, Form(form): Form, ) -> Result { + let new_subscriber = match form.try_into() { + Ok(new_sub) => new_sub, + Err(e) => { + messages.error(&e); + return Err(SubscribeError::ValidationError(e)); + } + }; let mut transaction = connection_pool .begin() .await .context("Failed to acquire a Postgres connection from the pool.")?; - let new_subscriber = form.try_into().map_err(SubscribeError::ValidationError)?; let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber) .await .context("Failed to insert new subscriber in the database.")?; @@ -113,7 +125,8 @@ pub async fn subscribe( .commit() .await .context("Failed to commit the database transaction to store a new subscriber.")?; - Ok(StatusCode::OK.into_response()) + messages.success("A confirmation email has been sent."); + Ok(Redirect::to("/register").into_response()) } #[tracing::instrument( @@ -198,6 +211,7 @@ Click here to confirm your subscription.", pub struct SubscriptionFormData { name: String, email: String, + email_check: String, } impl TryFrom for NewSubscriber { @@ -205,6 +219,9 @@ impl TryFrom for NewSubscriber { fn try_from(value: SubscriptionFormData) -> Result { let name = SubscriberName::parse(value.name)?; + if value.email != value.email_check { + return Err("Email addresses don't match.".into()); + } let email = SubscriberEmail::parse(value.email)?; Ok(Self { name, email }) } diff --git a/src/routes/subscriptions_confirm.rs b/src/routes/subscriptions_confirm.rs index 4ef60b5..45bf90e 100644 --- a/src/routes/subscriptions_confirm.rs +++ b/src/routes/subscriptions_confirm.rs @@ -2,7 +2,7 @@ use crate::startup::AppState; use axum::{ extract::{Query, State}, http::StatusCode, - response::IntoResponse, + response::{Html, IntoResponse, Response}, }; use serde::Deserialize; use sqlx::PgPool; @@ -14,23 +14,23 @@ pub async fn confirm( connection_pool, .. }): State, Query(params): Query, -) -> impl IntoResponse { +) -> Response { let Ok(subscriber_id) = get_subscriber_id_from_token(&connection_pool, ¶ms.subscription_token).await else { - return StatusCode::INTERNAL_SERVER_ERROR; + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; if let Some(subscriber_id) = subscriber_id { if confirm_subscriber(&connection_pool, &subscriber_id) .await .is_err() { - StatusCode::INTERNAL_SERVER_ERROR + StatusCode::INTERNAL_SERVER_ERROR.into_response() } else { - StatusCode::OK + Html(include_str!("register/confirm.html")).into_response() } } else { - StatusCode::UNAUTHORIZED + StatusCode::UNAUTHORIZED.into_response() } } diff --git a/src/startup.rs b/src/startup.rs index 1ff574d..ba136e9 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -96,6 +96,7 @@ pub fn app( .layer(middleware::from_fn(require_auth)); Router::new() .route("/", get(home)) + .route("/register", get(register)) .route("/login", get(get_login).post(post_login)) .route("/health_check", get(health_check)) .route("/subscriptions", post(subscribe)) diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 1dffd3d..ee28006 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -177,6 +177,17 @@ impl TestApp { self.get_admin_dashboard().await.text().await.unwrap() } + pub async fn get_register_html(&self) -> String { + self.api_client + .get(format!("{}/register", &self.address)) + .send() + .await + .expect("Failed to execute request") + .text() + .await + .unwrap() + } + pub async fn get_change_password(&self) -> reqwest::Response { self.api_client .get(format!("{}/admin/password", &self.address)) diff --git a/tests/api/newsletters.rs b/tests/api/newsletters.rs index 89afee7..77d9aa6 100644 --- a/tests/api/newsletters.rs +++ b/tests/api/newsletters.rs @@ -199,7 +199,8 @@ async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { let email: String = SafeEmail().fake(); let body = serde_urlencoded::to_string(serde_json::json!({ "name": name, - "email": email + "email": email, + "email_check": email })) .unwrap(); diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index f82738e..1f2283f 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -1,11 +1,11 @@ -use crate::helpers::TestApp; +use crate::helpers::{TestApp, assert_is_redirect_to}; use wiremock::{ Mock, ResponseTemplate, matchers::{method, path}, }; #[tokio::test] -async fn subscribe_returns_a_200_for_valid_form_data() { +async fn subscribe_displays_a_confirmation_message_for_valid_form_data() { let app = TestApp::spawn().await; Mock::given(path("/v1/email")) @@ -14,10 +14,13 @@ async fn subscribe_returns_a_200_for_valid_form_data() { .mount(&app.email_server) .await; - let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; - let response = app.post_subscriptions(body.into()).await; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={0}&email_check={0}", email); + let response = app.post_subscriptions(body).await; - assert_eq!(200, response.status().as_u16()); + assert_is_redirect_to(&response, "/register"); + let page_html = app.get_register_html().await; + assert!(page_html.contains("A confirmation email has been sent")); } #[tokio::test] @@ -30,10 +33,13 @@ async fn subscribe_persists_the_new_subscriber() { .mount(&app.email_server) .await; - let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; - let response = app.post_subscriptions(body.into()).await; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={0}&email_check={0}", email); + let response = app.post_subscriptions(body).await; - assert_eq!(200, response.status().as_u16()); + assert_is_redirect_to(&response, "/register"); + let page_html = app.get_register_html().await; + assert!(page_html.contains("A confirmation email has been sent")); let saved = sqlx::query!("SELECT email, name, status FROM subscriptions") .fetch_one(&app.connection_pool) @@ -67,21 +73,32 @@ async fn subscribe_returns_a_422_when_data_is_missing() { } #[tokio::test] -async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { +async fn subscribe_shows_an_error_message_when_fields_are_present_but_invalid() { let app = TestApp::spawn().await; let test_cases = [ - ("name=&email=alphonse.paix%40outlook.com", "empty name"), - ("name=Alphonse&email=", "empty email"), - ("name=Alphonse&email=not-an-email", "invalid email"), + ("name=&email=alphonse.paix%40outlook.com", "an empty name"), + ("name=Alphonse&email=&email_check=", "an empty email"), + ( + "name=Alphonse&email=not-an-email&email_check=not-an_email", + "an invalid email", + ), + ( + "name=Alphonse&email=alphonse.paix@outlook.com&email_check=alphonse.paix@outlook.fr", + "two different email addresses", + ), ]; for (body, description) in test_cases { - let response = app.post_subscriptions(body.into()).await; + let response_text = app + .post_subscriptions(body.into()) + .await + .text() + .await + .unwrap(); - assert_eq!( - 400, - response.status().as_u16(), - "the API did not fail with 400 Bad Request when the payload had an {}.", + assert!( + !response_text.contains("Your account has been confirmed"), + "the API did not displayed an error message when the payload had an {}.", description ); } @@ -91,7 +108,8 @@ async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() { async fn subscribe_sends_a_confirmation_email_for_valid_data() { let app = TestApp::spawn().await; - let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={0}&email_check={0}", email); Mock::given(path("v1/email")) .and(method("POST")) @@ -100,14 +118,15 @@ async fn subscribe_sends_a_confirmation_email_for_valid_data() { .mount(&app.email_server) .await; - app.post_subscriptions(body.into()).await; + app.post_subscriptions(body).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"; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={0}&email_check={0}", email); Mock::given(path("v1/email")) .and(method("POST")) @@ -116,7 +135,7 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { .mount(&app.email_server) .await; - app.post_subscriptions(body.into()).await; + app.post_subscriptions(body).await; let email_request = &app.email_server.received_requests().await.unwrap()[0]; let confirmation_links = app.get_confirmation_links(email_request); @@ -127,14 +146,15 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { async fn subscribe_fails_if_there_is_a_fatal_database_error() { let app = TestApp::spawn().await; - let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={0}&email_check={0}", email); sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email") .execute(&app.connection_pool) .await .unwrap(); - let response = app.post_subscriptions(body.into()).await; + let response = app.post_subscriptions(body).await; assert_eq!(response.status().as_u16(), 500); } diff --git a/tests/api/subscriptions_confirm.rs b/tests/api/subscriptions_confirm.rs index 0a0c4cb..ccb3b45 100644 --- a/tests/api/subscriptions_confirm.rs +++ b/tests/api/subscriptions_confirm.rs @@ -15,10 +15,11 @@ async fn confirmation_links_without_token_are_rejected_with_a_400() { } #[tokio::test] -async fn the_link_returned_by_subscribe_returns_a_200_if_called() { +async fn clicking_on_the_link_shows_a_confiramtion_message() { let app = TestApp::spawn().await; - let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={email}&email_check={email}"); Mock::given(path("v1/email")) .and(method("POST")) @@ -27,19 +28,27 @@ async fn the_link_returned_by_subscribe_returns_a_200_if_called() { .mount(&app.email_server) .await; - app.post_subscriptions(body.into()).await; + app.post_subscriptions(body).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); + assert!( + response + .text() + .await + .unwrap() + .contains("Your account has been confirmed") + ); } #[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"; + let email = "alphonse.paix@outlook.com"; + let body = format!("name=Alphonse&email={email}&email_check={email}"); Mock::given(path("v1/email")) .and(method("POST")) @@ -48,7 +57,7 @@ async fn clicking_on_the_confirmation_link_confirms_a_subscriber() { .mount(&app.email_server) .await; - app.post_subscriptions(body.into()).await; + app.post_subscriptions(body).await; let email_request = &app.email_server.received_requests().await.unwrap()[0]; let confirmation_links = app.get_confirmation_links(email_request);