diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 3966f5b..06f53e3 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -6,11 +6,11 @@ on: # this specific branch (a.k.a. book chapter). push: branches: - - main + - root-chapter-10-part3 pull_request: types: [ opened, synchronize, reopened ] branches: - - main + - root-chapter-10-part3 env: CARGO_TERM_COLOR: always diff --git a/sqlx-data.json b/sqlx-data.json index b369239..78208ec 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1,5 +1,38 @@ { "db": "PostgreSQL", + "2880480077b654e38b63f423ab40680697a500ffe1af1d1b39108910594b581b": { + "query": "\n UPDATE users\n SET password_hash = $1\n WHERE user_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + } + }, + "33b11051e779866db9aeb86d28a59db07a94323ffdc59a5a2c1da694ebe9a65f": { + "query": "\n SELECT username\n FROM users\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + } + }, "51c9c995452d3359e3da7e2f2ff8a6e68690f740a36d2a32ec7c40b08931ebdb": { "query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at, status)\n VALUES ($1, $2, $3, $4, 'pending_confirmation')\n ", "describe": { @@ -28,6 +61,24 @@ "nullable": [] } }, + "7b57e2776a245ba1602f638121550485e2219a6ccaaa62b5ec3e4683e33a3b5f": { + "query": "\n SELECT email\n FROM subscriptions\n WHERE status = 'confirmed'\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + } + }, "a71a1932b894572106460ca2e34a63dc0cb8c1ba7a70547add1cddbb68133c2b": { "query": "UPDATE subscriptions SET status = 'confirmed' WHERE id = $1", "describe": { @@ -40,6 +91,32 @@ "nullable": [] } }, + "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58": { + "query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + } + }, "ad120337ee606be7b8d87238e2bb765d0da8ee61b1a3bc142414c4305ec5e17f": { "query": "SELECT subscriber_id FROM subscription_tokens WHERE subscription_token = $1", "describe": { diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs index 857ea51..276ce09 100644 --- a/src/routes/admin/mod.rs +++ b/src/routes/admin/mod.rs @@ -1,7 +1,9 @@ mod dashboard; mod logout; +mod newsletter; mod password; pub use dashboard::admin_dashboard; pub use logout::log_out; +pub use newsletter::*; pub use password::*; diff --git a/src/routes/admin/newsletter/get.rs b/src/routes/admin/newsletter/get.rs new file mode 100644 index 0000000..0213592 --- /dev/null +++ b/src/routes/admin/newsletter/get.rs @@ -0,0 +1,58 @@ +use actix_web::http::header::ContentType; +use actix_web::HttpResponse; +use actix_web_flash_messages::IncomingFlashMessages; +use std::fmt::Write; + +pub async fn publish_newsletter_form( + flash_messages: IncomingFlashMessages, +) -> Result { + let mut msg_html = String::new(); + for m in flash_messages.iter() { + writeln!(msg_html, "

{}

", m.content()).unwrap(); + } + + Ok(HttpResponse::Ok() + .content_type(ContentType::html()) + .body(format!( + r#" + + + + Publish Newsletter Issue + + + {msg_html} +
+ +
+ +
+ +
+ +
+

<- Back

+ +"#, + ))) +} diff --git a/src/routes/admin/newsletter/mod.rs b/src/routes/admin/newsletter/mod.rs new file mode 100644 index 0000000..146f7bc --- /dev/null +++ b/src/routes/admin/newsletter/mod.rs @@ -0,0 +1,5 @@ +mod get; +mod post; + +pub use get::publish_newsletter_form; +pub use post::publish_newsletter; diff --git a/src/routes/admin/newsletter/post.rs b/src/routes/admin/newsletter/post.rs new file mode 100644 index 0000000..eb766eb --- /dev/null +++ b/src/routes/admin/newsletter/post.rs @@ -0,0 +1,83 @@ +use crate::authentication::UserId; +use crate::domain::SubscriberEmail; +use crate::email_client::EmailClient; +use crate::utils::{e500, see_other}; +use actix_web::web::ReqData; +use actix_web::{web, HttpResponse}; +use actix_web_flash_messages::FlashMessage; +use anyhow::Context; +use sqlx::PgPool; + +#[derive(serde::Deserialize)] +pub struct FormData { + title: String, + text_content: String, + html_content: String, +} + +#[tracing::instrument( + name = "Publish a newsletter issue", + skip(form, pool, email_client, user_id), + fields(user_id=%*user_id) +)] +pub async fn publish_newsletter( + form: web::Form, + user_id: ReqData, + pool: web::Data, + email_client: web::Data, +) -> Result { + let subscribers = get_confirmed_subscribers(&pool).await.map_err(e500)?; + for subscriber in subscribers { + match subscriber { + Ok(subscriber) => { + email_client + .send_email( + &subscriber.email, + &form.title, + &form.html_content, + &form.text_content, + ) + .await + .with_context(|| { + format!("Failed to send newsletter issue to {}", subscriber.email) + }) + .map_err(e500)?; + } + Err(error) => { + tracing::warn!( + error.cause_chain = ?error, + error.message = %error, + "Skipping a confirmed subscriber. Their stored contact details are invalid", + ); + } + } + } + FlashMessage::info("The newsletter issue has been published!").send(); + Ok(see_other("/admin/newsletters")) +} + +struct ConfirmedSubscriber { + email: SubscriberEmail, +} + +#[tracing::instrument(name = "Get confirmed subscribers", skip(pool))] +async fn get_confirmed_subscribers( + pool: &PgPool, +) -> Result>, anyhow::Error> { + let confirmed_subscribers = sqlx::query!( + r#" + SELECT email + FROM subscriptions + WHERE status = 'confirmed' + "#, + ) + .fetch_all(pool) + .await? + .into_iter() + .map(|r| match SubscriberEmail::parse(r.email) { + Ok(email) => Ok(ConfirmedSubscriber { email }), + Err(error) => Err(anyhow::anyhow!(error)), + }) + .collect(); + Ok(confirmed_subscribers) +} diff --git a/src/routes/login/get.rs b/src/routes/login/get.rs index 218cbcf..527eeb4 100644 --- a/src/routes/login/get.rs +++ b/src/routes/login/get.rs @@ -1,5 +1,5 @@ use actix_web::{http::header::ContentType, HttpResponse}; -use actix_web_flash_messages::{IncomingFlashMessages}; +use actix_web_flash_messages::IncomingFlashMessages; use std::fmt::Write; pub async fn login_form(flash_messages: IncomingFlashMessages) -> HttpResponse { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 671805f..772876f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,7 +2,6 @@ mod admin; mod health_check; mod home; mod login; -mod newsletters; mod subscriptions; mod subscriptions_confirm; @@ -10,6 +9,5 @@ pub use admin::*; pub use health_check::*; pub use home::*; pub use login::*; -pub use newsletters::*; pub use subscriptions::*; pub use subscriptions_confirm::*; diff --git a/src/routes/newsletters.rs b/src/routes/newsletters.rs deleted file mode 100644 index 8964bb7..0000000 --- a/src/routes/newsletters.rs +++ /dev/null @@ -1,163 +0,0 @@ -use crate::authentication::{validate_credentials, Credentials}; -use crate::email_client::EmailClient; -use crate::routes::error_chain_fmt; -use crate::{authentication::AuthError, domain::SubscriberEmail}; -use actix_web::http::{ - header::{HeaderMap, HeaderValue}, - StatusCode, -}; -use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; -use anyhow::Context; -use secrecy::Secret; -use sqlx::PgPool; - -#[derive(serde::Deserialize)] -pub struct BodyData { - title: String, - content: Content, -} - -#[derive(serde::Deserialize)] -pub struct Content { - html: String, - text: String, -} - -pub fn basic_authentication(headers: &HeaderMap) -> Result { - // The header value, if present, must be a valid UTF8 string - let header_value = headers - .get("Authorization") - .context("The 'Authorization' header was missing")? - .to_str() - .context("The 'Authorization' header was not a valid UTF8 string.")?; - let base64encoded_credentials = header_value - .strip_prefix("Basic ") - .context("The authorization scheme was not 'Basic'.")?; - let decoded_credentials = base64::decode_config(base64encoded_credentials, base64::STANDARD) - .context("Failed to base64-decode 'Basic' credentials.")?; - let decoded_credentials = String::from_utf8(decoded_credentials) - .context("The decoded credential string is valid UTF8.")?; - - let mut credentials = decoded_credentials.splitn(2, ':'); - let username = credentials - .next() - .ok_or_else(|| anyhow::anyhow!("A username must be provided in 'Basic' auth."))? - .to_string(); - let password = credentials - .next() - .ok_or_else(|| anyhow::anyhow!("A password must be provided in 'Basic' auth."))? - .to_string(); - - Ok(Credentials { - username, - password: Secret::new(password), - }) -} - -#[derive(thiserror::Error)] -pub enum PublishError { - #[error("Authentication failed.")] - AuthError(#[source] anyhow::Error), - #[error(transparent)] - UnexpectedError(#[from] anyhow::Error), -} - -impl std::fmt::Debug for PublishError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - error_chain_fmt(self, f) - } -} - -impl ResponseError for PublishError { - fn error_response(&self) -> HttpResponse { - match self { - PublishError::UnexpectedError(_) => { - HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR) - } - PublishError::AuthError(_) => { - let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED); - let header_value = HeaderValue::from_str(r#"Basic realm="publish""#).unwrap(); - response - .headers_mut() - .insert(actix_web::http::header::WWW_AUTHENTICATE, header_value); - response - } - } - } -} - -#[tracing::instrument( -name = "Publish a newsletter issue", - skip(body, pool, email_client, request), - fields(username=tracing::field::Empty, user_id=tracing::field::Empty) -)] -pub async fn publish_newsletter( - body: web::Json, - pool: web::Data, - email_client: web::Data, - request: HttpRequest, -) -> Result { - let credentials = basic_authentication(request.headers()).map_err(PublishError::AuthError)?; - tracing::Span::current().record("username", &tracing::field::display(&credentials.username)); - let user_id = validate_credentials(credentials, &pool) - .await - .map_err(|e| match e { - AuthError::InvalidCredentials(_) => PublishError::AuthError(e.into()), - AuthError::UnexpectedError(_) => PublishError::UnexpectedError(e.into()), - })?; - - tracing::Span::current().record("user_id", &tracing::field::display(&user_id)); - - let subscribers = get_confirmed_subscribers(&pool).await?; - for subscriber in subscribers { - match subscriber { - Ok(subscriber) => { - email_client - .send_email( - &subscriber.email, - &body.title, - &body.content.html, - &body.content.text, - ) - .await - .with_context(|| { - format!("Failed to send newsletter issue to {}", subscriber.email) - })?; - } - Err(error) => { - tracing::warn!( - error.cause_chain = ?error, - "Skipping a confirmed subscriber. \ - Their stored contact details are invalid", - ); - } - } - } - Ok(HttpResponse::Ok().finish()) -} - -struct ConfirmedSubscriber { - email: SubscriberEmail, -} - -#[tracing::instrument(name = "Get confirmed subscribers", skip(pool))] -async fn get_confirmed_subscribers( - pool: &PgPool, -) -> Result>, anyhow::Error> { - let confirmed_subscribers = sqlx::query!( - r#" - SELECT email - FROM subscriptions - WHERE status = 'confirmed' - "#, - ) - .fetch_all(pool) - .await? - .into_iter() - .map(|r| match SubscriberEmail::parse(r.email) { - Ok(email) => Ok(ConfirmedSubscriber { email }), - Err(error) => Err(anyhow::anyhow!(error)), - }) - .collect(); - Ok(confirmed_subscribers) -} diff --git a/src/startup.rs b/src/startup.rs index 8669a17..25b1951 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -3,7 +3,7 @@ use crate::configuration::{DatabaseSettings, Settings}; use crate::email_client::EmailClient; use crate::routes::{ admin_dashboard, change_password, change_password_form, confirm, health_check, home, log_out, - login, login_form, publish_newsletter, subscribe, + login, login_form, publish_newsletter, publish_newsletter_form, subscribe, }; use actix_session::storage::RedisSessionStore; use actix_session::SessionMiddleware; @@ -108,6 +108,8 @@ async fn run( web::scope("/admin") .wrap(from_fn(reject_anonymous_users)) .route("/dashboard", web::get().to(admin_dashboard)) + .route("/newsletters", web::get().to(publish_newsletter_form)) + .route("/newsletters", web::post().to(publish_newsletter)) .route("/password", web::get().to(change_password_form)) .route("/password", web::post().to(change_password)) .route("/logout", web::post().to(log_out)), diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 09643ba..2c2dc0d 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -47,16 +47,6 @@ impl TestApp { .expect("Failed to execute request.") } - pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response { - self.api_client - .post(&format!("{}/newsletters", &self.address)) - .basic_auth(&self.test_user.username, Some(&self.test_user.password)) - .json(&body) - .send() - .await - .expect("Failed to execute request.") - } - pub async fn post_login(&self, body: &Body) -> reqwest::Response where Body: serde::Serialize, @@ -124,6 +114,30 @@ impl TestApp { .expect("Failed to execute request.") } + pub async fn get_publish_newsletter(&self) -> reqwest::Response { + self.api_client + .get(&format!("{}/admin/newsletters", &self.address)) + .send() + .await + .expect("Failed to execute request.") + } + + pub async fn get_publish_newsletter_html(&self) -> String { + self.get_publish_newsletter().await.text().await.unwrap() + } + + pub async fn post_publish_newsletter(&self, body: &Body) -> reqwest::Response + where + Body: serde::Serialize, + { + self.api_client + .post(&format!("{}/admin/newsletters", &self.address)) + .form(body) + .send() + .await + .expect("Failed to execute request.") + } + /// Extract the confirmation links embedded in the request to the email API. pub fn get_confirmation_links(&self, email_request: &wiremock::Request) -> ConfirmationLinks { let body: serde_json::Value = serde_json::from_slice(&email_request.body).unwrap(); @@ -236,6 +250,14 @@ impl TestUser { } } + pub async fn login(&self, app: &TestApp) { + app.post_login(&serde_json::json!({ + "username": &self.username, + "password": &self.password + })) + .await; + } + async fn store(&self, pool: &PgPool) { let salt = SaltString::generate(&mut rand::thread_rng()); // Match production parameters diff --git a/tests/api/newsletter.rs b/tests/api/newsletter.rs index ddcd2be..735e1ae 100644 --- a/tests/api/newsletter.rs +++ b/tests/api/newsletter.rs @@ -1,5 +1,4 @@ -use crate::helpers::{spawn_app, ConfirmationLinks, TestApp}; -use uuid::Uuid; +use crate::helpers::{assert_is_redirect_to, spawn_app, ConfirmationLinks, TestApp}; use wiremock::matchers::{any, method, path}; use wiremock::{Mock, ResponseTemplate}; @@ -42,6 +41,7 @@ async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { // Arrange let app = spawn_app().await; create_unconfirmed_subscriber(&app).await; + app.test_user.login(&app).await; Mock::given(any()) .respond_with(ResponseTemplate::new(200)) @@ -49,18 +49,18 @@ async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { .mount(&app.email_server) .await; - // Act + // Act - Part 1 - Submit newsletter form let newsletter_request_body = serde_json::json!({ "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

Newsletter body as HTML

", - } + "text_content": "Newsletter body as plain text", + "html_content": "

Newsletter body as HTML

", }); - let response = app.post_newsletters(newsletter_request_body).await; + let response = app.post_publish_newsletter(&newsletter_request_body).await; + assert_is_redirect_to(&response, "/admin/newsletters"); - // Assert - assert_eq!(response.status().as_u16(), 200); + // Act - Part 2 - Follow the redirect + let html_page = app.get_publish_newsletter_html().await; + assert!(html_page.contains("

The newsletter issue has been published!

")); // Mock verifies on Drop that we haven't sent the newsletter email } @@ -69,6 +69,7 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() { // Arrange let app = spawn_app().await; create_confirmed_subscriber(&app).await; + app.test_user.login(&app).await; Mock::given(path("/email")) .and(method("POST")) @@ -77,140 +78,46 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() { .mount(&app.email_server) .await; - // Act + // Act - Part 1 - Submit newsletter form let newsletter_request_body = serde_json::json!({ "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

Newsletter body as HTML

", - } + "text_content": "Newsletter body as plain text", + "html_content": "

Newsletter body as HTML

", }); - let response = app.post_newsletters(newsletter_request_body).await; + let response = app.post_publish_newsletter(&newsletter_request_body).await; + assert_is_redirect_to(&response, "/admin/newsletters"); - // Assert - assert_eq!(response.status().as_u16(), 200); + // Act - Part 2 - Follow the redirect + let html_page = app.get_publish_newsletter_html().await; + assert!(html_page.contains("

The newsletter issue has been published!

")); // Mock verifies on Drop that we have sent the newsletter email } #[tokio::test] -async fn newsletters_returns_400_for_invalid_data() { +async fn you_must_be_logged_in_to_see_the_newsletter_form() { // Arrange let app = spawn_app().await; - let test_cases = vec![ - ( - serde_json::json!({ - "content": { - "text": "Newsletter body as plain text", - "html": "

Newsletter body as HTML

", - } - }), - "missing title", - ), - ( - serde_json::json!({ - "title": "Newsletter!" - }), - "missing content", - ), - ]; - for (invalid_body, error_message) in test_cases { - let response = app.post_newsletters(invalid_body).await; + // Act + let response = app.get_publish_newsletter().await; - // Assert - assert_eq!( - 400, - response.status().as_u16(), - // Additional customised error message on test failure - "The API did not fail with 400 Bad Request when the payload was {}.", - error_message - ); - } + // Assert + assert_is_redirect_to(&response, "/login"); } #[tokio::test] -async fn requests_missing_authorization_are_rejected() { +async fn you_must_be_logged_in_to_publish_a_newsletter() { // Arrange let app = spawn_app().await; - let response = reqwest::Client::new() - .post(&format!("{}/newsletters", &app.address)) - .json(&serde_json::json!({ - "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

Newsletter body as HTML

", - } - })) - .send() - .await - .expect("Failed to execute request."); + // Act + let newsletter_request_body = serde_json::json!({ + "title": "Newsletter title", + "text_content": "Newsletter body as plain text", + "html_content": "

Newsletter body as HTML

", + }); + let response = app.post_publish_newsletter(&newsletter_request_body).await; // Assert - assert_eq!(401, response.status().as_u16()); - assert_eq!( - r#"Basic realm="publish""#, - response.headers()["WWW-Authenticate"] - ); -} - -#[tokio::test] -async fn non_existing_user_is_rejected() { - // Arrange - let app = spawn_app().await; - // Random credentials - let username = Uuid::new_v4().to_string(); - let password = Uuid::new_v4().to_string(); - - let response = reqwest::Client::new() - .post(&format!("{}/newsletters", &app.address)) - .basic_auth(username, Some(password)) - .json(&serde_json::json!({ - "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

Newsletter body as HTML

", - } - })) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!(401, response.status().as_u16()); - assert_eq!( - r#"Basic realm="publish""#, - response.headers()["WWW-Authenticate"] - ); -} - -#[tokio::test] -async fn invalid_password_is_rejected() { - // Arrange - let app = spawn_app().await; - let username = &app.test_user.username; - // Random password - let password = Uuid::new_v4().to_string(); - assert_ne!(app.test_user.password, password); - - let response = reqwest::Client::new() - .post(&format!("{}/newsletters", &app.address)) - .basic_auth(username, Some(password)) - .json(&serde_json::json!({ - "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

Newsletter body as HTML

", - } - })) - .send() - .await - .expect("Failed to execute request."); - - // Assert - assert_eq!(401, response.status().as_u16()); - assert_eq!( - r#"Basic realm="publish""#, - response.headers()["WWW-Authenticate"] - ); + assert_is_redirect_to(&response, "/login"); }