Last part of chapter 10 - sessions, seed users and change password form

This commit is contained in:
Luca P 2022-03-12 19:03:55 +00:00
parent 2b6c2c5bc0
commit df6bdea82b
28 changed files with 1433 additions and 94 deletions

716
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -18,9 +18,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = "1.0.115"
config = { version = "0.11", default-features = false, features = ["yaml"] }
sqlx = { version = "0.5.5", default-features = false, features = [ "runtime-actix-rustls", "macros", "postgres", "uuid", "chrono", "migrate", "offline"] }
uuid = { version = "0.8.1", features = ["v4"] }
uuid = { version = "0.8.1", features = ["v4", "serde"] }
chrono = "0.4.15"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "cookies"] }
log = "0.4"
tracing = "0.1.19"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
@ -38,9 +38,10 @@ tracing-actix-web = "0.5"
secrecy = { version = "0.8", features = ["serde"] }
urlencoding = "2"
htmlescape = "0.3"
hmac = { version = "0.12", features = ["std"] }
sha2 = "0.10"
hex = "0.4"
actix-web-flash-messages = { version = "0.3", features = ["cookies"] }
actix-session = { git = "https://github.com/actix/actix-extras", branch = "master", features = ["redis-rs-tls-session"] }
serde_json = "1"
actix-web-lab = "0.15"
[dev-dependencies]
once_cell = "1.7.2"

View file

@ -13,4 +13,5 @@ email_client:
base_url: "localhost"
sender_email: "test@gmail.com"
authorization_token: "my-secret-token"
timeout_milliseconds: 10000
timeout_milliseconds: 10000
redis_uri: "redis://127.0.0.1:6379"

View file

@ -0,0 +1,6 @@
INSERT INTO users (user_id, username, password_hash)
VALUES (
'ddf8994f-d522-4659-8d02-c1d479057be6',
'admin',
'$argon2id$v=19$m=15000,t=2,p=1$OEx/rcq+3ts//WUDzGNl2g$Am8UFBA4w5NJEmAtquGvBmAlu92q/VQcaoL5AyJPfc8'
);

20
scripts/init_redis.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -x
set -eo pipefail
# if a redis container is running, print instructions to kill it and exit
RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}')
if [[ -n $RUNNING_CONTAINER ]]; then
echo >&2 "there is a redis container already running, kill it with"
echo >&2 " docker kill ${RUNNING_CONTAINER}"
exit 1
fi
# Launch Redis using Docker
docker run \
-p "6379:6379" \
-d \
--name "redis_$(date '+%s')" \
redis:6
>&2 echo "Redis is ready to go!"

View file

@ -0,0 +1,48 @@
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::error::InternalError;
use actix_web::{FromRequest, HttpMessage};
use actix_web_lab::middleware::Next;
use std::ops::Deref;
use uuid::Uuid;
#[derive(Copy, Clone, Debug)]
pub struct UserId(Uuid);
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl Deref for UserId {
type Target = Uuid;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub async fn reject_anonymous_users(
mut req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
let session = {
let (http_request, payload) = req.parts_mut();
TypedSession::from_request(http_request, payload).await
}?;
match session.get_user_id().map_err(e500)? {
Some(user_id) => {
req.extensions_mut().insert(UserId(user_id));
next.call(req).await
}
None => {
let response = see_other("/login");
let e = anyhow::anyhow!("The user has not logged in");
Err(InternalError::from_response(e, response).into())
}
}
}

View file

@ -0,0 +1,5 @@
mod middleware;
mod password;
pub use middleware::reject_anonymous_users;
pub use middleware::UserId;
pub use password::{change_password, validate_credentials, AuthError, Credentials};

View file

@ -1,10 +1,10 @@
use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use argon2::password_hash::SaltString;
use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
use secrecy::{ExposeSecret, Secret};
use sqlx::PgPool;
use crate::telemetry::spawn_blocking_with_tracing;
#[derive(thiserror::Error, Debug)]
pub enum AuthError {
#[error("Invalid credentials.")]
@ -88,3 +88,39 @@ fn verify_password_hash(
.context("Invalid password.")
.map_err(AuthError::InvalidCredentials)
}
#[tracing::instrument(name = "Change password", skip(password, pool))]
pub async fn change_password(
user_id: uuid::Uuid,
password: Secret<String>,
pool: &PgPool,
) -> Result<(), anyhow::Error> {
let password_hash = spawn_blocking_with_tracing(move || compute_password_hash(password))
.await?
.context("Failed to hash password")?;
sqlx::query!(
r#"
UPDATE users
SET password_hash = $1
WHERE user_id = $2
"#,
password_hash.expose_secret(),
user_id
)
.execute(pool)
.await
.context("Failed to change user's password in the database.")?;
Ok(())
}
fn compute_password_hash(password: Secret<String>) -> Result<Secret<String>, anyhow::Error> {
let salt = SaltString::generate(&mut rand::thread_rng());
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(15000, 2, 1, None).unwrap(),
)
.hash_password(password.expose_secret().as_bytes(), &salt)?
.to_string();
Ok(Secret::new(password_hash))
}

View file

@ -10,6 +10,7 @@ pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
pub email_client: EmailClientSettings,
pub redis_uri: Secret<String>,
}
#[derive(serde::Deserialize, Clone)]

View file

@ -3,5 +3,7 @@ pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod session_state;
pub mod startup;
pub mod telemetry;
pub mod utils;

View file

@ -3,7 +3,7 @@ use zero2prod::startup::Application;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
#[tokio::main]
async fn main() -> std::io::Result<()> {
async fn main() -> anyhow::Result<()> {
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber);

View file

@ -0,0 +1,60 @@
use crate::session_state::TypedSession;
use crate::utils::e500;
use actix_web::http::header::LOCATION;
use actix_web::{http::header::ContentType, web, HttpResponse};
use anyhow::Context;
use sqlx::PgPool;
use uuid::Uuid;
pub async fn admin_dashboard(
session: TypedSession,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, actix_web::Error> {
let username = if let Some(user_id) = session.get_user_id().map_err(e500)? {
get_username(user_id, &pool).await.map_err(e500)?
} else {
return Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish());
};
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>Admin dashboard</title>
</head>
<body>
<p>Welcome {username}!</p>
<p>Available actions:</p>
<ol>
<li><a href="/admin/password">Change password</a></li>
<li>
<a href="javascript:document.logoutForm.submit()">Logout</a>
<form name="logoutForm" action="/admin/logout" method="post" hidden>
<input hidden type="submit" value="Logout">
</form>
</li>
</ol>
</body>
</html>"#,
)))
}
#[tracing::instrument(name = "Get username", skip(pool))]
pub async fn get_username(user_id: Uuid, pool: &PgPool) -> Result<String, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT username
FROM users
WHERE user_id = $1
"#,
user_id,
)
.fetch_one(pool)
.await
.context("Failed to perform a query to retrieve a username.")?;
Ok(row.username)
}

View file

@ -0,0 +1,14 @@
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
use actix_web::HttpResponse;
use actix_web_flash_messages::FlashMessage;
pub async fn log_out(session: TypedSession) -> Result<HttpResponse, actix_web::Error> {
if session.get_user_id().map_err(e500)?.is_none() {
Ok(see_other("/login"))
} else {
session.log_out();
FlashMessage::info("You have successfully logged out.").send();
Ok(see_other("/login"))
}
}

7
src/routes/admin/mod.rs Normal file
View file

@ -0,0 +1,7 @@
mod dashboard;
mod logout;
mod password;
pub use dashboard::admin_dashboard;
pub use logout::log_out;
pub use password::*;

View file

@ -0,0 +1,61 @@
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
use actix_web_flash_messages::IncomingFlashMessages;
use std::fmt::Write;
pub async fn change_password_form(
session: TypedSession,
flash_messages: IncomingFlashMessages,
) -> Result<HttpResponse, actix_web::Error> {
if session.get_user_id().map_err(e500)?.is_none() {
return Ok(see_other("/login"));
};
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>Change Password</title>
</head>
<body>
{msg_html}
<form action="/admin/password" method="post">
<label>Current password
<input
type="password"
placeholder="Enter current password"
name="current_password"
>
</label>
<br>
<label>New password
<input
type="password"
placeholder="Enter new password"
name="new_password"
>
</label>
<br>
<label>Confirm new password
<input
type="password"
placeholder="Type the new password again"
name="new_password_check"
>
</label>
<br>
<button type="submit">Change password</button>
</form>
<p><a href="/admin/dashboard">&lt;- Back</a></p>
</body>
</html>"#,
)))
}

View file

@ -0,0 +1,4 @@
mod get;
pub use get::change_password_form;
mod post;
pub use post::change_password;

View file

@ -0,0 +1,48 @@
use crate::authentication::{validate_credentials, AuthError, Credentials, UserId};
use crate::routes::admin::dashboard::get_username;
use crate::utils::{e500, see_other};
use actix_web::{web, HttpResponse};
use actix_web_flash_messages::FlashMessage;
use secrecy::{ExposeSecret, Secret};
use sqlx::PgPool;
#[derive(serde::Deserialize)]
pub struct FormData {
current_password: Secret<String>,
new_password: Secret<String>,
new_password_check: Secret<String>,
}
pub async fn change_password(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
user_id: web::ReqData<UserId>,
) -> Result<HttpResponse, actix_web::Error> {
let user_id = user_id.into_inner();
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
FlashMessage::error(
"You entered two different new passwords - the field values must match.",
)
.send();
return Ok(see_other("/admin/password"));
}
let username = get_username(*user_id, &pool).await.map_err(e500)?;
let credentials = Credentials {
username,
password: form.0.current_password,
};
if let Err(e) = validate_credentials(credentials, &pool).await {
return match e {
AuthError::InvalidCredentials(_) => {
FlashMessage::error("The current password is incorrect.").send();
Ok(see_other("/admin/password"))
}
AuthError::UnexpectedError(_) => Err(e500(e)),
};
}
crate::authentication::change_password(*user_id, form.0.new_password, &pool)
.await
.map_err(e500)?;
FlashMessage::error("Your password has been changed.").send();
Ok(see_other("/admin/password"))
}

View file

@ -1,48 +1,12 @@
use crate::startup::HmacSecret;
use actix_web::{http::header::ContentType, web, HttpResponse};
use hmac::{Hmac, Mac};
use secrecy::ExposeSecret;
use actix_web::{http::header::ContentType, HttpResponse};
use actix_web_flash_messages::{IncomingFlashMessages};
use std::fmt::Write;
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: String,
tag: String,
}
impl QueryParams {
fn verify(self, secret: &HmacSecret) -> Result<String, anyhow::Error> {
let tag = hex::decode(self.tag)?;
let query_string = format!("error={}", urlencoding::Encoded::new(&self.error));
let mut mac =
Hmac::<sha2::Sha256>::new_from_slice(secret.0.expose_secret().as_bytes()).unwrap();
mac.update(query_string.as_bytes());
mac.verify_slice(&tag)?;
Ok(self.error)
pub async fn login_form(flash_messages: IncomingFlashMessages) -> HttpResponse {
let mut error_html = String::new();
for m in flash_messages.iter() {
writeln!(error_html, "<p><i>{}</i></p>", m.content()).unwrap();
}
}
pub async fn login_form(
query: Option<web::Query<QueryParams>>,
secret: web::Data<HmacSecret>,
) -> HttpResponse {
let error_html = match query {
None => "".into(),
Some(query) => match query.0.verify(&secret) {
Ok(error) => {
format!("<p><i>{}</i></p>", htmlescape::encode_minimal(&error))
}
Err(e) => {
tracing::warn!(
error.message = %e,
error.cause_chain = ?e,
"Failed to verify query parameters using the HMAC tag"
);
"".into()
}
},
};
HttpResponse::Ok()
.content_type(ContentType::html())
.body(format!(

View file

@ -1,13 +1,13 @@
use crate::authentication::AuthError;
use crate::authentication::{validate_credentials, Credentials};
use crate::routes::error_chain_fmt;
use crate::startup::HmacSecret;
use crate::session_state::TypedSession;
use actix_web::error::InternalError;
use actix_web::http::header::LOCATION;
use actix_web::web;
use actix_web::HttpResponse;
use hmac::{Hmac, Mac};
use secrecy::{ExposeSecret, Secret};
use actix_web_flash_messages::FlashMessage;
use secrecy::Secret;
use sqlx::PgPool;
#[derive(serde::Deserialize)]
@ -17,14 +17,14 @@ pub struct FormData {
}
#[tracing::instrument(
skip(form, pool, secret),
skip(form, pool, session),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
// We are now injecting `PgPool` to retrieve stored credentials from the database
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
secret: web::Data<HmacSecret>,
session: TypedSession,
) -> Result<HttpResponse, InternalError<LoginError>> {
let credentials = Credentials {
username: form.0.username,
@ -34,8 +34,12 @@ pub async fn login(
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
session.renew();
session
.insert_user_id(user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.insert_header((LOCATION, "/admin/dashboard"))
.finish())
}
Err(e) => {
@ -43,22 +47,19 @@ pub async fn login(
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
};
let query_string = format!("error={}", urlencoding::Encoded::new(e.to_string()));
let hmac_tag = {
let mut mac =
Hmac::<sha2::Sha256>::new_from_slice(secret.0.expose_secret().as_bytes())
.unwrap();
mac.update(query_string.as_bytes());
mac.finalize().into_bytes()
};
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, format!("/login?{query_string}&tag={hmac_tag:x}")))
.finish();
Err(InternalError::from_response(e, response))
Err(login_redirect(e))
}
}
}
fn login_redirect(e: LoginError) -> InternalError<LoginError> {
FlashMessage::error(e.to_string()).send();
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish();
InternalError::from_response(e, response)
}
#[derive(thiserror::Error)]
pub enum LoginError {
#[error("Authentication failed")]

View file

@ -1,3 +1,4 @@
mod admin;
mod health_check;
mod home;
mod login;
@ -5,6 +6,7 @@ mod newsletters;
mod subscriptions;
mod subscriptions_confirm;
pub use admin::*;
pub use health_check::*;
pub use home::*;
pub use login::*;

37
src/session_state.rs Normal file
View file

@ -0,0 +1,37 @@
use actix_session::Session;
use actix_session::SessionExt;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest};
use std::future::{ready, Ready};
use uuid::Uuid;
pub struct TypedSession(Session);
impl TypedSession {
const USER_ID_KEY: &'static str = "user_id";
pub fn renew(&self) {
self.0.renew();
}
pub fn insert_user_id(&self, user_id: Uuid) -> Result<(), serde_json::Error> {
self.0.insert(Self::USER_ID_KEY, user_id)
}
pub fn get_user_id(&self) -> Result<Option<Uuid>, serde_json::Error> {
self.0.get(Self::USER_ID_KEY)
}
pub fn log_out(self) {
self.0.purge()
}
}
impl FromRequest for TypedSession {
type Error = <Session as FromRequest>::Error;
type Future = Ready<Result<TypedSession, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
ready(Ok(TypedSession(req.get_session())))
}
}

View file

@ -1,12 +1,20 @@
use crate::authentication::reject_anonymous_users;
use crate::configuration::{DatabaseSettings, Settings};
use crate::email_client::EmailClient;
use crate::routes::{
confirm, health_check, home, login, login_form, publish_newsletter, subscribe,
admin_dashboard, change_password, change_password_form, confirm, health_check, home, log_out,
login, login_form, publish_newsletter, subscribe,
};
use actix_session::storage::RedisSessionStore;
use actix_session::SessionMiddleware;
use actix_web::cookie::Key;
use actix_web::dev::Server;
use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
use secrecy::Secret;
use actix_web_flash_messages::storage::CookieMessageStore;
use actix_web_flash_messages::FlashMessagesFramework;
use actix_web_lab::middleware::from_fn;
use secrecy::{ExposeSecret, Secret};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::net::TcpListener;
@ -18,7 +26,7 @@ pub struct Application {
}
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
pub async fn build(configuration: Settings) -> Result<Self, anyhow::Error> {
let connection_pool = get_connection_pool(&configuration.database)
.await
.expect("Failed to connect to Postgres.");
@ -47,7 +55,9 @@ impl Application {
email_client,
configuration.application.base_url,
configuration.application.hmac_secret,
)?;
configuration.redis_uri,
)
.await?;
Ok(Self { port, server })
}
@ -70,20 +80,38 @@ pub async fn get_connection_pool(configuration: &DatabaseSettings) -> Result<PgP
pub struct ApplicationBaseUrl(pub String);
fn run(
async fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
base_url: String,
hmac_secret: Secret<String>,
) -> Result<Server, std::io::Error> {
redis_uri: Secret<String>,
) -> Result<Server, anyhow::Error> {
let db_pool = Data::new(db_pool);
let email_client = Data::new(email_client);
let base_url = Data::new(ApplicationBaseUrl(base_url));
let secret_key = Key::from(hmac_secret.expose_secret().as_bytes());
let message_store = CookieMessageStore::builder(secret_key.clone()).build();
let message_framework = FlashMessagesFramework::builder(message_store).build();
let redis_store = RedisSessionStore::new(redis_uri.expose_secret()).await?;
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(
redis_store.clone(),
secret_key.clone(),
))
.wrap(TracingLogger::default())
.route("/", web::get().to(home))
.service(
web::scope("/admin")
.wrap(from_fn(reject_anonymous_users))
.route("/dashboard", web::get().to(admin_dashboard))
.route("/password", web::get().to(change_password_form))
.route("/password", web::post().to(change_password))
.route("/logout", web::post().to(log_out)),
)
.route("/login", web::get().to(login_form))
.route("/login", web::post().to(login))
.route("/health_check", web::get().to(health_check))

15
src/utils.rs Normal file
View file

@ -0,0 +1,15 @@
use actix_web::http::header::LOCATION;
use actix_web::HttpResponse;
// Return an opaque 500 while preserving the error root's cause for logging.
pub fn e500<T>(e: T) -> actix_web::Error
where
T: std::fmt::Debug + std::fmt::Display + 'static,
{
actix_web::error::ErrorInternalServerError(e)
}
pub fn see_other(location: &str) -> HttpResponse {
HttpResponse::SeeOther()
.insert_header((LOCATION, location))
.finish()
}

View file

@ -0,0 +1,43 @@
use crate::helpers::{assert_is_redirect_to, spawn_app};
#[tokio::test]
async fn you_must_be_logged_in_to_access_the_admin_dashboard() {
// Arrange
let app = spawn_app().await;
// Act
let response = app.get_admin_dashboard().await;
// Assert
assert_is_redirect_to(&response, "/login");
}
#[tokio::test]
async fn logout_clears_session_state() {
// Arrange
let app = spawn_app().await;
// Act - Part 1 - Login
let login_body = serde_json::json!({
"username": &app.test_user.username,
"password": &app.test_user.password
});
let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard");
// Act - Part 2 - Follow the redirect
let html_page = app.get_admin_dashboard_html().await;
assert!(html_page.contains(&format!("Welcome {}", app.test_user.username)));
// Act - Part 3 - Logout
let response = app.post_logout().await;
assert_is_redirect_to(&response, "/login");
// Act - Part 4 - Follow the redirect
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>You have successfully logged out.</i></p>"#));
// Act - Part 5 - Attempt to load admin panel
let response = app.get_admin_dashboard().await;
assert_is_redirect_to(&response, "/login");
}

View file

@ -0,0 +1,141 @@
use crate::helpers::{assert_is_redirect_to, spawn_app};
use uuid::Uuid;
#[tokio::test]
async fn you_must_be_logged_in_to_see_the_change_password_form() {
// Arrange
let app = spawn_app().await;
// Act
let response = app.get_change_password().await;
// Assert
assert_is_redirect_to(&response, "/login");
}
#[tokio::test]
async fn you_must_be_logged_in_to_change_your_password() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
// Act
let response = app
.post_change_password(&serde_json::json!({
"current_password": Uuid::new_v4().to_string(),
"new_password": &new_password,
"new_password_check": &new_password,
}))
.await;
// Assert
assert_is_redirect_to(&response, "/login");
}
#[tokio::test]
async fn new_password_fields_must_match() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
let another_new_password = Uuid::new_v4().to_string();
// Act - Part 1 - Login
app.post_login(&serde_json::json!({
"username": &app.test_user.username,
"password": &app.test_user.password
}))
.await;
// Act - Part 2 - Try to change password
let response = app
.post_change_password(&serde_json::json!({
"current_password": &app.test_user.password,
"new_password": &new_password,
"new_password_check": &another_new_password,
}))
.await;
assert_is_redirect_to(&response, "/admin/password");
// Act - Part 3 - Follow the redirect
let html_page = app.get_change_password_html().await;
assert!(html_page.contains(
"<p><i>You entered two different new passwords - \
the field values must match.</i></p>"
));
}
#[tokio::test]
async fn current_password_must_be_valid() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
let wrong_password = Uuid::new_v4().to_string();
// Act - Part 1 - Login
app.post_login(&serde_json::json!({
"username": &app.test_user.username,
"password": &app.test_user.password
}))
.await;
// Act - Part 2 - Try to change password
let response = app
.post_change_password(&serde_json::json!({
"current_password": &wrong_password,
"new_password": &new_password,
"new_password_check": &new_password,
}))
.await;
// Assert
assert_is_redirect_to(&response, "/admin/password");
// Act - Part 3 - Follow the redirect
let html_page = app.get_change_password_html().await;
assert!(html_page.contains("<p><i>The current password is incorrect.</i></p>"));
}
#[tokio::test]
async fn changing_password_works() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
// Act - Part 1 - Login
let login_body = serde_json::json!({
"username": &app.test_user.username,
"password": &app.test_user.password
});
let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard");
// Act - Part 2 - Change password
let response = app
.post_change_password(&serde_json::json!({
"current_password": &app.test_user.password,
"new_password": &new_password,
"new_password_check": &new_password,
}))
.await;
assert_is_redirect_to(&response, "/admin/password");
// Act - Part 3 - Follow the redirect
let html_page = app.get_change_password_html().await;
assert!(html_page.contains("<p><i>Your password has been changed.</i></p>"));
// Act - Part 4 - Logout
let response = app.post_logout().await;
assert_is_redirect_to(&response, "/login");
// Act - Part 5 - Follow the redirect
let html_page = app.get_login_html().await;
assert!(html_page.contains("<p><i>You have successfully logged out.</i></p>"));
// Act - Part 6 - Login using the new password
let login_body = serde_json::json!({
"username": &app.test_user.username,
"password": &new_password
});
let response = app.post_login(&login_body).await;
assert_is_redirect_to(&response, "/admin/dashboard");
}

View file

@ -27,6 +27,7 @@ pub struct TestApp {
pub db_pool: PgPool,
pub email_server: MockServer,
pub test_user: TestUser,
pub api_client: reqwest::Client,
}
/// Confirmation links embedded in the request to the email API.
@ -37,7 +38,7 @@ pub struct ConfirmationLinks {
impl TestApp {
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
reqwest::Client::new()
self.api_client
.post(&format!("{}/subscriptions", &self.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
@ -47,7 +48,7 @@ impl TestApp {
}
pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
reqwest::Client::new()
self.api_client
.post(&format!("{}/newsletters", &self.address))
.basic_auth(&self.test_user.username, Some(&self.test_user.password))
.json(&body)
@ -56,6 +57,73 @@ impl TestApp {
.expect("Failed to execute request.")
}
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(&format!("{}/login", &self.address))
.form(body)
.send()
.await
.expect("Failed to execute request.")
}
pub async fn get_login_html(&self) -> String {
self.api_client
.get(&format!("{}/login", &self.address))
.send()
.await
.expect("Failed to execute request.")
.text()
.await
.unwrap()
}
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client
.get(&format!("{}/admin/dashboard", &self.address))
.send()
.await
.expect("Failed to execute request.")
}
pub async fn get_admin_dashboard_html(&self) -> String {
self.get_admin_dashboard().await.text().await.unwrap()
}
pub async fn get_change_password(&self) -> reqwest::Response {
self.api_client
.get(&format!("{}/admin/password", &self.address))
.send()
.await
.expect("Failed to execute request.")
}
pub async fn get_change_password_html(&self) -> String {
self.get_change_password().await.text().await.unwrap()
}
pub async fn post_logout(&self) -> reqwest::Response {
self.api_client
.post(&format!("{}/admin/logout", &self.address))
.send()
.await
.expect("Failed to execute request.")
}
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(&format!("{}/admin/password", &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();
@ -109,6 +177,12 @@ pub async fn spawn_app() -> TestApp {
let application_port = application.port();
let _ = tokio::spawn(application.run_until_stopped());
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();
let test_app = TestApp {
address: format!("http://localhost:{}", application_port),
port: application_port,
@ -117,6 +191,7 @@ pub async fn spawn_app() -> TestApp {
.expect("Failed to connect to the database"),
email_server,
test_user: TestUser::generate(),
api_client: client,
};
test_app.test_user.store(&test_app.db_pool).await;
@ -184,3 +259,8 @@ impl TestUser {
.expect("Failed to store test user.");
}
}
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
assert_eq!(response.status().as_u16(), 303);
assert_eq!(response.headers().get("Location").unwrap(), location);
}

25
tests/api/login.rs Normal file
View file

@ -0,0 +1,25 @@
use crate::helpers::{assert_is_redirect_to, spawn_app};
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
let app = spawn_app().await;
// Act
let login_body = serde_json::json!({
"username": "random-username",
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
assert_is_redirect_to(&response, "/login");
// Act - Part 2 - Follow the redirect
let html_page = app.get_login_html().await;
assert!(html_page.contains("<p><i>Authentication failed</i></p>"));
// Act - Part 3 - Reload the login page
let html_page = app.get_login_html().await;
assert!(!html_page.contains("<p><i>Authentication failed</i></p>"));
}

View file

@ -1,5 +1,8 @@
mod admin_dashboard;
mod change_password;
mod health_check;
mod helpers;
mod login;
mod newsletter;
mod subscriptions;
mod subscriptions_confirm;