Solve reader exercise at the end of chapter 10

This commit is contained in:
Luca P 2022-03-13 14:53:56 +00:00
parent df6bdea82b
commit fc36707be8
12 changed files with 296 additions and 305 deletions

View file

@ -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

View file

@ -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": {

View file

@ -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::*;

View file

@ -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<HttpResponse, actix_web::Error> {
let mut msg_html = String::new();
for m in flash_messages.iter() {
writeln!(msg_html, "<p><i>{}</i></p>", m.content()).unwrap();
}
Ok(HttpResponse::Ok()
.content_type(ContentType::html())
.body(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Publish Newsletter Issue</title>
</head>
<body>
{msg_html}
<form action="/admin/newsletters" method="post">
<label>Title:<br>
<input
type="text"
placeholder="Enter the issue title"
name="title"
>
</label>
<br>
<label>Plain text content:<br>
<textarea
placeholder="Enter the content in plain text"
name="text_content"
rows="20"
cols="50"
></textarea>
</label>
<br>
<label>HTML content:<br>
<textarea
placeholder="Enter the content in HTML format"
name="html_content"
rows="20"
cols="50"
></textarea>
</label>
<br>
<button type="submit">Publish</button>
</form>
<p><a href="/admin/dashboard">&lt;- Back</a></p>
</body>
</html>"#,
)))
}

View file

@ -0,0 +1,5 @@
mod get;
mod post;
pub use get::publish_newsletter_form;
pub use post::publish_newsletter;

View file

@ -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<FormData>,
user_id: ReqData<UserId>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
) -> Result<HttpResponse, actix_web::Error> {
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<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, 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)
}

View file

@ -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 {

View file

@ -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::*;

View file

@ -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<Credentials, anyhow::Error> {
// 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<BodyData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
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<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, 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)
}

View file

@ -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)),

View file

@ -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<Body>(&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<Body>(&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

View file

@ -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": "<p>Newsletter body as HTML</p>",
}
"text_content": "Newsletter body as plain text",
"html_content": "<p>Newsletter body as HTML</p>",
});
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("<p><i>The newsletter issue has been published!</i></p>"));
// 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": "<p>Newsletter body as HTML</p>",
}
"text_content": "Newsletter body as plain text",
"html_content": "<p>Newsletter body as HTML</p>",
});
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("<p><i>The newsletter issue has been published!</i></p>"));
// 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": "<p>Newsletter body as HTML</p>",
}
}),
"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": "<p>Newsletter body as HTML</p>",
}
}))
.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": "<p>Newsletter body as HTML</p>",
});
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": "<p>Newsletter body as HTML</p>",
}
}))
.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": "<p>Newsletter body as HTML</p>",
}
}))
.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");
}