diff --git a/configuration/local.yaml b/configuration/local.yaml index b5fad82..9ba41cb 100644 --- a/configuration/local.yaml +++ b/configuration/local.yaml @@ -1,6 +1,9 @@ application: host: "127.0.0.1" - base_url: "http://127.0.0.1" - hmac_secret: vPojv$zM3Rxt#RT0D*Tp + base_url: "http://127.0.0.1:8000" database: require_ssl: false +email_client: + base_url: "https://api.mailersend.com" + sender_email: "MS_PTrumQ@test-r6ke4n1mmzvgon12.mlsender.net" + authorization_token: "mlsn.9ea7aaeaa328b4d2eac74dc823deecf25e6bae1933bfe5c3d0304b1b3f2bc36c" diff --git a/src/authentication.rs b/src/authentication.rs index d1054c1..bb60701 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,9 +1,12 @@ -use crate::telemetry::spawn_blocking_with_tracing; +use crate::{ + routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing, +}; use anyhow::Context; use argon2::{ Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, password_hash::{SaltString, rand_core::OsRng}, }; +use axum::{extract::Request, middleware::Next, response::Response}; use secrecy::{ExposeSecret, SecretString}; use sqlx::PgPool; use uuid::Uuid; @@ -19,6 +22,8 @@ pub enum AuthError { UnexpectedError(#[from] anyhow::Error), #[error("Invalid credentials.")] InvalidCredentials(#[source] anyhow::Error), + #[error("Not authenticated.")] + NotAuthenticated, } #[tracing::instrument(name = "Change password", skip(password, connection_pool))] @@ -125,3 +130,34 @@ async fn get_stored_credentials( .map(|row| (row.user_id, SecretString::from(row.password_hash))); Ok(row) } + +pub async fn require_auth( + session: TypedSession, + mut request: Request, + next: Next, +) -> Result { + let user_id = session + .get_user_id() + .await + .map_err(|e| AdminError::UnexpectedError(e.into()))? + .ok_or(AdminError::NotAuthenticated)?; + let username = session + .get_username() + .await + .map_err(|e| AdminError::UnexpectedError(e.into()))? + .ok_or(AdminError::UnexpectedError(anyhow::anyhow!( + "Could not find username in session." + )))?; + + request + .extensions_mut() + .insert(AuthenticatedUser { user_id, username }); + + Ok(next.run(request).await) +} + +#[derive(Clone)] +pub struct AuthenticatedUser { + pub user_id: Uuid, + pub username: String, +} diff --git a/src/configuration.rs b/src/configuration.rs index 0b897a0..34e0bb7 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -69,7 +69,6 @@ pub struct ApplicationSettings { pub port: u16, pub host: String, pub base_url: String, - pub hmac_secret: SecretString, } #[derive(Deserialize)] diff --git a/src/routes.rs b/src/routes.rs index 671805f..772876f 100644 --- a/src/routes.rs +++ b/src/routes.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/admin.rs b/src/routes/admin.rs index 0d289ac..45afd10 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,29 +1,28 @@ -use crate::{ - authentication::{self, Credentials, validate_credentials}, - routes::error_chain_fmt, - session_state::TypedSession, - startup::AppState, -}; +pub mod change_password; +pub mod dashboard; +pub mod newsletters; + +use crate::{routes::error_chain_fmt, session_state::TypedSession}; use axum::{ - Extension, Form, Json, - extract::{Request, State}, - middleware::Next, - response::{Html, IntoResponse, Redirect, Response}, + Json, + response::{IntoResponse, Redirect, Response}, }; use axum_messages::Messages; +pub use change_password::*; +pub use dashboard::*; +pub use newsletters::*; use reqwest::StatusCode; -use secrecy::{ExposeSecret, SecretString}; -use std::fmt::Write; -use uuid::Uuid; #[derive(thiserror::Error)] pub enum AdminError { #[error("Something went wrong.")] UnexpectedError(#[from] anyhow::Error), - #[error("You must be logged in to access the admin dashboard.")] + #[error("Trying to access admin dashboard without authentication.")] NotAuthenticated, #[error("Updating password failed.")] ChangePassword, + #[error("Could not publish newsletter.")] + Publish, } impl std::fmt::Debug for AdminError { @@ -51,109 +50,14 @@ impl IntoResponse for AdminError { .into_response(), AdminError::NotAuthenticated => Redirect::to("/login").into_response(), AdminError::ChangePassword => Redirect::to("/admin/password").into_response(), + AdminError::Publish => Redirect::to("/admin/newsletters").into_response(), } } } -pub async fn require_auth( - session: TypedSession, - mut request: Request, - next: Next, -) -> Result { - let user_id = session - .get_user_id() - .await - .map_err(|e| AdminError::UnexpectedError(e.into()))? - .ok_or(AdminError::NotAuthenticated)?; - let username = session - .get_username() - .await - .map_err(|e| AdminError::UnexpectedError(e.into()))? - .ok_or(AdminError::UnexpectedError(anyhow::anyhow!( - "Could not find username in session." - )))?; - - request - .extensions_mut() - .insert(AuthenticatedUser { user_id, username }); - - Ok(next.run(request).await) -} - -#[derive(Clone)] -pub struct AuthenticatedUser { - user_id: Uuid, - username: String, -} - -pub async fn admin_dashboard( - Extension(AuthenticatedUser { username, .. }): Extension, -) -> Result { - Ok(Html(format!(include_str!("admin/dashboard.html"), username)).into_response()) -} - -#[derive(serde::Deserialize)] -pub struct PasswordFormData { - pub current_password: SecretString, - pub new_password: SecretString, - pub new_password_check: SecretString, -} - -pub async fn change_password_form(messages: Messages) -> Result { - let mut error_html = String::new(); - for message in messages { - writeln!(error_html, "

{}

", message).unwrap(); - } - Ok(Html(format!( - include_str!("admin/change_password_form.html"), - error_html - )) - .into_response()) -} - -pub async fn change_password( - Extension(AuthenticatedUser { user_id, username }): Extension, - State(AppState { - connection_pool, .. - }): State, - messages: Messages, - Form(form): Form, -) -> Result { - let credentials = Credentials { - username, - password: form.current_password, - }; - if form.new_password.expose_secret() != form.new_password_check.expose_secret() { - messages.error("You entered two different passwords - the field values must match."); - Err(AdminError::ChangePassword) - } else if validate_credentials(credentials, &connection_pool) - .await - .is_err() - { - messages.error("The current password is incorrect."); - Err(AdminError::ChangePassword) - } else if let Err(e) = verify_password(form.new_password.expose_secret()) { - messages.error(e); - Err(AdminError::ChangePassword) - } else { - authentication::change_password(user_id, form.new_password, &connection_pool) - .await - .map_err(|_| AdminError::ChangePassword)?; - messages.success("Your password has been changed."); - Ok(Redirect::to("/admin/password").into_response()) - } -} - #[tracing::instrument(name = "Logging out", skip(messages, session))] pub async fn logout(messages: Messages, session: TypedSession) -> Result { session.clear().await; messages.success("You have successfully logged out."); Ok(Redirect::to("/login").into_response()) } - -fn verify_password(password: &str) -> Result<(), String> { - if password.len() < 12 || password.len() > 128 { - return Err("The password must contain between 12 and 128 characters.".into()); - } - Ok(()) -} diff --git a/src/routes/admin/change_password.rs b/src/routes/admin/change_password.rs new file mode 100644 index 0000000..1b857cc --- /dev/null +++ b/src/routes/admin/change_password.rs @@ -0,0 +1,72 @@ +use crate::{ + authentication::{self, AuthenticatedUser, Credentials, validate_credentials}, + routes::AdminError, + startup::AppState, +}; +use axum::{ + Extension, Form, + extract::State, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_messages::Messages; +use secrecy::{ExposeSecret, SecretString}; +use std::fmt::Write; + +#[derive(serde::Deserialize)] +pub struct PasswordFormData { + pub current_password: SecretString, + pub new_password: SecretString, + pub new_password_check: SecretString, +} + +pub async fn change_password_form(messages: Messages) -> Result { + let mut error_html = String::new(); + for message in messages { + writeln!(error_html, "

{}

", message).unwrap(); + } + Ok(Html(format!( + include_str!("html/change_password_form.html"), + error_html + )) + .into_response()) +} + +pub async fn change_password( + Extension(AuthenticatedUser { user_id, username }): Extension, + State(AppState { + connection_pool, .. + }): State, + messages: Messages, + Form(form): Form, +) -> Result { + let credentials = Credentials { + username, + password: form.current_password, + }; + if form.new_password.expose_secret() != form.new_password_check.expose_secret() { + messages.error("You entered two different passwords - the field values must match."); + Err(AdminError::ChangePassword) + } else if validate_credentials(credentials, &connection_pool) + .await + .is_err() + { + messages.error("The current password is incorrect."); + Err(AdminError::ChangePassword) + } else if let Err(e) = verify_password(form.new_password.expose_secret()) { + messages.error(e); + Err(AdminError::ChangePassword) + } else { + authentication::change_password(user_id, form.new_password, &connection_pool) + .await + .map_err(|_| AdminError::ChangePassword)?; + messages.success("Your password has been changed."); + Ok(Redirect::to("/admin/password").into_response()) + } +} + +fn verify_password(password: &str) -> Result<(), String> { + if password.len() < 12 || password.len() > 128 { + return Err("The password must contain between 12 and 128 characters.".into()); + } + Ok(()) +} diff --git a/src/routes/admin/dashboard.rs b/src/routes/admin/dashboard.rs new file mode 100644 index 0000000..c6a0920 --- /dev/null +++ b/src/routes/admin/dashboard.rs @@ -0,0 +1,11 @@ +use crate::authentication::AuthenticatedUser; +use axum::{ + Extension, + response::{Html, IntoResponse, Response}, +}; + +pub async fn admin_dashboard( + Extension(AuthenticatedUser { username, .. }): Extension, +) -> Response { + Html(format!(include_str!("html/dashboard.html"), username)).into_response() +} diff --git a/src/routes/admin/change_password_form.html b/src/routes/admin/html/change_password_form.html similarity index 100% rename from src/routes/admin/change_password_form.html rename to src/routes/admin/html/change_password_form.html diff --git a/src/routes/admin/dashboard.html b/src/routes/admin/html/dashboard.html similarity index 88% rename from src/routes/admin/dashboard.html rename to src/routes/admin/html/dashboard.html index 466d2c5..75fa9a3 100644 --- a/src/routes/admin/dashboard.html +++ b/src/routes/admin/html/dashboard.html @@ -10,6 +10,7 @@

Available actions:

  1. Change password
  2. +
  3. Send a newsletter
  4. diff --git a/src/routes/admin/html/send_newsletter_form.html b/src/routes/admin/html/send_newsletter_form.html new file mode 100644 index 0000000..e421421 --- /dev/null +++ b/src/routes/admin/html/send_newsletter_form.html @@ -0,0 +1,18 @@ + + + + + + Send a newsletter + + + + + + + +
    + {} +

    Back

    + + diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs new file mode 100644 index 0000000..924d0a5 --- /dev/null +++ b/src/routes/admin/newsletters.rs @@ -0,0 +1,108 @@ +use crate::{domain::SubscriberEmail, routes::AdminError, startup::AppState}; +use anyhow::Context; +use axum::{ + Form, + extract::State, + response::{Html, IntoResponse, Redirect, Response}, +}; +use axum_messages::Messages; +use sqlx::PgPool; +use std::fmt::Write; + +#[derive(serde::Deserialize)] +pub struct BodyData { + title: String, + html: String, + text: String, +} + +pub async fn publish_form(messages: Messages) -> Response { + let mut error_html = String::new(); + for message in messages { + writeln!(error_html, "

    {}

    ", message).unwrap(); + } + Html(format!( + include_str!("html/send_newsletter_form.html"), + error_html + )) + .into_response() +} + +#[tracing::instrument( + name = "Publishing a newsletter", + skip(connection_pool, email_client, form) +)] +pub async fn publish( + State(AppState { + connection_pool, + email_client, + .. + }): State, + messages: Messages, + Form(form): Form, +) -> Result { + if let Err(e) = validate_form(&form) { + messages.error(e); + return Err(AdminError::Publish); + } + let subscribers = get_confirmed_subscribers(&connection_pool).await?; + for subscriber in subscribers { + match subscriber { + Ok(ConfirmedSubscriber { name, email }) => { + let title = format!("{}, we have news for you! {}", name, form.title); + email_client + .send_email(&email, &title, &form.html, &form.text) + .await + .with_context(|| { + format!("Failed to send newsletter issue to {}", email.as_ref()) + })?; + } + Err(e) => { + tracing::warn!( + "Skipping a confirmed subscriber. Their stored contact details are invalid: {}", + e + ) + } + } + } + messages.success(format!( + "The newsletter issue '{}' has been published!", + form.title, + )); + Ok(Redirect::to("/admin/newsletters").into_response()) +} + +fn validate_form(form: &BodyData) -> Result<(), &'static str> { + if form.title.is_empty() { + return Err("The title was empty"); + } + if form.html.is_empty() || form.text.is_empty() { + return Err("The content was empty."); + } + Ok(()) +} + +struct ConfirmedSubscriber { + name: String, + email: SubscriberEmail, +} + +#[tracing::instrument(name = "Get confirmed subscribers", skip(connection_pool))] +async fn get_confirmed_subscribers( + connection_pool: &PgPool, +) -> Result>, anyhow::Error> { + let rows = sqlx::query!("SELECT name, email FROM subscriptions WHERE status = 'confirmed'") + .fetch_all(connection_pool) + .await?; + let confirmed_subscribers = rows + .into_iter() + .map(|r| match SubscriberEmail::parse(r.email) { + Ok(email) => Ok(ConfirmedSubscriber { + name: r.name, + email, + }), + Err(e) => Err(anyhow::anyhow!(e)), + }) + .collect(); + Ok(confirmed_subscribers) +} diff --git a/src/routes/login.rs b/src/routes/login.rs index 0c6477f..05f7c5b 100644 --- a/src/routes/login.rs +++ b/src/routes/login.rs @@ -86,6 +86,7 @@ pub async fn post_login( messages.error(e.to_string()); e } + AuthError::NotAuthenticated => unreachable!(), }; Err(e) } diff --git a/src/routes/newsletters.rs b/src/routes/newsletters.rs deleted file mode 100644 index a1a5454..0000000 --- a/src/routes/newsletters.rs +++ /dev/null @@ -1,176 +0,0 @@ -use crate::{ - authentication::{AuthError, Credentials, validate_credentials}, - domain::SubscriberEmail, - routes::error_chain_fmt, - startup::AppState, -}; -use anyhow::Context; -use axum::{ - Json, - extract::State, - http::{HeaderMap, HeaderValue}, - response::{IntoResponse, Response}, -}; -use base64::Engine; -use reqwest::{StatusCode, header}; -use secrecy::SecretString; -use sqlx::PgPool; - -#[derive(thiserror::Error)] -pub enum PublishError { - #[error(transparent)] - UnexpectedError(#[from] anyhow::Error), - #[error("Authentication failed.")] - AuthError(#[source] 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 IntoResponse for PublishError { - fn into_response(self) -> Response { - #[derive(serde::Serialize)] - struct ErrorResponse<'a> { - message: &'a str, - } - - tracing::error!("{:?}", self); - - let mut authenticate_header_value = None; - let status = match self { - PublishError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, - PublishError::AuthError(_) => { - authenticate_header_value = - Some(HeaderValue::from_str(r#"Basic realm="publish""#).unwrap()); - StatusCode::UNAUTHORIZED - } - }; - - let message = "An internal server error occured."; - let mut response = (status, Json(ErrorResponse { message })).into_response(); - if let Some(header_value) = authenticate_header_value { - response - .headers_mut() - .insert(header::WWW_AUTHENTICATE, header_value); - } - - response - } -} - -#[derive(serde::Deserialize)] -pub struct BodyData { - title: String, - content: Content, -} - -#[derive(serde::Deserialize)] -pub struct Content { - html: String, - text: String, -} - -#[tracing::instrument( - name = "Publishing a newsletter", - skip(headers, connection_pool, email_client, body), - fields(username=tracing::field::Empty, user_id=tracing::field::Empty) -)] -pub async fn publish_newsletter( - headers: HeaderMap, - State(AppState { - connection_pool, - email_client, - .. - }): State, - body: Json, -) -> Result { - let credentials = basic_authentication(&headers).map_err(PublishError::AuthError)?; - tracing::Span::current().record("username", tracing::field::display(&credentials.username)); - let user_id = validate_credentials(credentials, &connection_pool) - .await - .map_err(|e| match e { - AuthError::UnexpectedError(_) => PublishError::UnexpectedError(e.into()), - AuthError::InvalidCredentials(_) => PublishError::AuthError(e.into()), - })?; - tracing::Span::current().record("user_id", tracing::field::display(&user_id)); - let subscribers = get_confirmed_subscribers(&connection_pool).await?; - for subscriber in subscribers { - match subscriber { - Ok(ConfirmedSubscriber { email, .. }) => { - email_client - .send_email(&email, &body.title, &body.content.html, &body.content.text) - .await - .with_context(|| { - format!("Failed to send newsletter issue to {}", email.as_ref()) - })?; - } - Err(e) => { - tracing::warn!( - "Skipping a confirmed subscriber. Their stored contact details are invalid: {}", - e - ) - } - } - } - Ok(StatusCode::OK.into_response()) -} - -fn basic_authentication(headers: &HeaderMap) -> Result { - 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_segment = header_value - .strip_prefix("Basic ") - .context("The authorization scheme was not 'Basic'.")?; - let decoded_bytes = base64::engine::general_purpose::STANDARD - .decode(base64encoded_segment) - .context("Failed to base64-decode 'Basic' credentials.")?; - let decoded_credentials = String::from_utf8(decoded_bytes) - .context("The decoded credential string is not valid UTF-8.")?; - - let mut credentials = decoded_credentials.splitn(2, ':'); - let username = credentials - .next() - .context("A username must be provided in 'Basic' auth.")? - .to_string(); - let password = credentials - .next() - .context("A password must be provided in 'Basic' auth.")? - .to_string(); - - Ok(Credentials { - username, - password: SecretString::from(password), - }) -} - -#[allow(dead_code)] -struct ConfirmedSubscriber { - name: String, - email: SubscriberEmail, -} - -#[tracing::instrument(name = "Get confirmed subscribers", skip(connection_pool))] -async fn get_confirmed_subscribers( - connection_pool: &PgPool, -) -> Result>, anyhow::Error> { - let rows = sqlx::query!("SELECT name, email FROM subscriptions WHERE status = 'confirmed'") - .fetch_all(connection_pool) - .await?; - let confirmed_subscribers = rows - .into_iter() - .map(|r| match SubscriberEmail::parse(r.email) { - Ok(email) => Ok(ConfirmedSubscriber { - name: r.name, - email, - }), - Err(e) => Err(anyhow::anyhow!(e)), - }) - .collect(); - Ok(confirmed_subscribers) -} diff --git a/src/startup.rs b/src/startup.rs index 3d362bb..0b6cbe0 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,4 +1,6 @@ -use crate::{configuration::Settings, email_client::EmailClient, routes::*}; +use crate::{ + authentication::require_auth, configuration::Settings, email_client::EmailClient, routes::*, +}; use axum::{ Router, extract::MatchedPath, @@ -7,7 +9,7 @@ use axum::{ routing::{get, post}, }; use axum_messages::MessagesManagerLayer; -use secrecy::{ExposeSecret, SecretString}; +use secrecy::ExposeSecret; use sqlx::{PgPool, postgres::PgPoolOptions}; use std::sync::Arc; use tokio::net::TcpListener; @@ -29,7 +31,6 @@ pub struct AppState { pub connection_pool: PgPool, pub email_client: Arc, pub base_url: String, - pub hmac_secret: SecretString, } impl Application { @@ -58,7 +59,6 @@ impl Application { connection_pool, email_client, configuration.application.base_url, - configuration.application.hmac_secret, redis_store, ); Ok(Self { listener, router }) @@ -78,18 +78,17 @@ pub fn app( connection_pool: PgPool, email_client: EmailClient, base_url: String, - hmac_secret: SecretString, redis_store: RedisStore, ) -> Router { let app_state = AppState { connection_pool, email_client: Arc::new(email_client), base_url, - hmac_secret, }; let admin_routes = Router::new() .route("/dashboard", get(admin_dashboard)) .route("/password", get(change_password_form).post(change_password)) + .route("/newsletters", get(publish_form).post(publish)) .route("/logout", post(logout)) .layer(middleware::from_fn(require_auth)); Router::new() @@ -98,7 +97,6 @@ pub fn app( .route("/health_check", get(health_check)) .route("/subscriptions", post(subscribe)) .route("/subscriptions/confirm", get(confirm)) - .route("/newsletters", post(publish_newsletter)) .nest("/admin", admin_routes) .layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 5c18a19..6a37f06 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -182,11 +182,25 @@ impl TestApp { .expect("Failed to execute request") } - pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response { - reqwest::Client::new() - .post(format!("{}/newsletters", self.address)) - .json(&body) - .basic_auth(&self.test_user.username, Some(&self.test_user.password)) + pub async fn get_newsletter_form(&self) -> reqwest::Response { + self.api_client + .get(format!("{}/admin/password", &self.address)) + .send() + .await + .expect("Failed to execute request") + } + + pub async fn get_newsletter_form_html(&self) -> String { + self.get_newsletter_form().await.text().await.unwrap() + } + + pub async fn post_newsletters(&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") diff --git a/tests/api/login.rs b/tests/api/login.rs index 626c028..60c3d0b 100644 --- a/tests/api/login.rs +++ b/tests/api/login.rs @@ -16,9 +16,6 @@ async fn an_error_flash_message_is_set_on_failure() { let login_page_html = app.get_login_html().await; assert!(login_page_html.contains("Authentication failed")); - - let login_page_html = app.get_login_html().await; - assert!(!login_page_html.contains("Authentication failed")); } #[tokio::test] diff --git a/tests/api/newsletters.rs b/tests/api/newsletters.rs index e145907..3787cfc 100644 --- a/tests/api/newsletters.rs +++ b/tests/api/newsletters.rs @@ -1,5 +1,4 @@ -use crate::helpers::{ConfirmationLinks, TestApp}; -use uuid::Uuid; +use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to}; use wiremock::{ Mock, ResponseTemplate, matchers::{any, method, path}, @@ -10,97 +9,43 @@ async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() { let app = TestApp::spawn().await; create_unconfirmed_subscriber(&app).await; + let login_body = serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password + }); + app.post_login(&login_body).await; + Mock::given(any()) .respond_with(ResponseTemplate::new(200)) .expect(0) .mount(&app.email_server) .await; - let newsletter_request_body = serde_json::json!({"title": "Newsletter title", "content": { "text": "Newsletter body as plain text", "html": "

    Newsletter body as HTML

    "}}); - let response = app.post_newsletters(newsletter_request_body).await; - - assert_eq!(response.status().as_u16(), 200); + let newsletter_request_body = serde_json::json!({ + "title": "Newsletter title", + "text": "Newsletter body as plain text", + "html": "

    Newsletter body as HTML

    " + }); + app.post_newsletters(&newsletter_request_body).await; } #[tokio::test] -async fn request_missing_authorization_are_rejected() { +async fn requests_without_authentication_are_redirected() { let app = TestApp::spawn().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&app.email_server) + .await; + let newsletter_request_body = serde_json::json!({ "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

    Newsletter body as HTML

    " - } + "text": "Newsletter body as plain text", + "html": "

    Newsletter body as HTML

    " }); - let response = reqwest::Client::new() - .post(format!("{}/newsletters", &app.address)) - .json(&newsletter_request_body) - .send() - .await - .expect("Failed to execute request"); - - assert_eq!(response.status().as_u16(), 401); - assert_eq!( - response.headers()["WWW-Authenticate"], - r#"Basic realm="publish""# - ); -} - -#[tokio::test] -async fn non_existing_user_is_rejected() { - let app = TestApp::spawn().await; - - let newsletter_request_body = serde_json::json!({ - "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

    Newsletter body as HTML

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

    Newsletter body as HTML

    " - } - }); - let username = app.test_user.username; - let password = Uuid::new_v4().to_string(); - let response = reqwest::Client::new() - .post(format!("{}/newsletters", &app.address)) - .json(&newsletter_request_body) - .basic_auth(username, Some(password)) - .send() - .await - .expect("Failed to execute request"); - - assert_eq!(response.status().as_u16(), 401); - assert_eq!( - response.headers()["WWW-Authenticate"], - r#"Basic realm="publish""# - ); + let response = app.post_newsletters(&newsletter_request_body).await; + assert_is_redirect_to(&response, "/login"); } #[tokio::test] @@ -108,56 +53,116 @@ async fn newsletters_are_delivered_to_confirmed_subscribers() { let app = TestApp::spawn().await; create_confirmed_subscriber(&app).await; + let login_body = serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password + }); + app.post_login(&login_body).await; + Mock::given(any()) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&app.email_server) .await; + let newsletter_title = "Newsletter title"; let newsletter_request_body = serde_json::json!({ - "title": "Newsletter title", - "content": { - "text": "Newsletter body as plain text", - "html": "

    Newsletter body as HTML

    " - } + "title": newsletter_title, + "text": "Newsletter body as plain text", + "html": "

    Newsletter body as HTML

    " }); - let response = app.post_newsletters(newsletter_request_body).await; - assert_eq!(response.status().as_u16(), 200); + let response = app.post_newsletters(&newsletter_request_body).await; + assert_is_redirect_to(&response, "/admin/newsletters"); + + let html_page = app.get_newsletter_form_html().await; + assert!(html_page.contains(&format!( + "The newsletter issue '{}' has been published", + newsletter_title + ))); } #[tokio::test] -async fn newsletters_returns_422_for_invalid_data() { +async fn form_shows_error_for_invalid_data() { let app = TestApp::spawn().await; + let login_body = serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password + }); + app.post_login(&login_body).await; + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&app.email_server) + .await; + let test_cases = [ ( serde_json::json!({ - "content": { - "text": "Newsletter body as plain text", - "html": "

    Newsletter body as HTML

    " - } - }), - "missing the title", + "title": "", + "text": "Newsletter body as plain text", + "html": "

    Newsletter body as HTML

    " + }), + "The title was empty", ), ( - serde_json::json!({ "title": "Newsletter" }), - "missing the title", + serde_json::json!({ "title": "Newsletter", "text": "", "html": "" }), + "The content was empty", ), ]; for (invalid_body, error_message) in test_cases { - let response = app.post_newsletters(invalid_body).await; - - assert_eq!( - response.status().as_u16(), - 422, - "The API did not fail with 422 Unprocessable Entity when the payload was {}.", - error_message - ); + app.post_newsletters(&invalid_body).await; + let html_page = app.get_newsletter_form_html().await; + assert!(html_page.contains(error_message)); } } +#[tokio::test] +async fn newsletter_creation_is_idempotent() { + let app = TestApp::spawn().await; + create_confirmed_subscriber(&app).await; + + let login_body = serde_json::json!({ + "username": app.test_user.username, + "password": app.test_user.password + }); + app.post_login(&login_body).await; + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + + let newsletter_title = "Newsletter title"; + let newsletter_request_body = serde_json::json!({ + "title": newsletter_title, + "text": "Newsletter body as plain text", + "html": "

    Newsletter body as HTML

    " + }); + + let response = app.post_newsletters(&newsletter_request_body).await; + assert_is_redirect_to(&response, "/admin/newsletters"); + + let html_page = app.get_newsletter_form_html().await; + assert!(html_page.contains(&format!( + "The newsletter issue '{}' has been published", + newsletter_title + ))); + + let response = app.post_newsletters(&newsletter_request_body).await; + assert_is_redirect_to(&response, "/admin/newsletters"); + + let html_page = app.get_newsletter_form_html().await; + assert!(html_page.contains(&format!( + "The newsletter issue '{}' has been published", + newsletter_title + ))); +} + async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks { let body = "name=Alphonse&email=alphonse.paix%40outlook.com";