Flash messages using axum-messages

This commit is contained in:
Alphonse Paix
2025-08-30 01:15:54 +02:00
parent 3ae50830f4
commit de1fc4a825
23 changed files with 819 additions and 45 deletions

View File

@@ -1,3 +1,7 @@
use argon2::{
Argon2, PasswordHasher,
password_hash::{SaltString, rand_core::OsRng},
};
use linkify::LinkFinder;
use once_cell::sync::Lazy;
use sqlx::{Connection, Executor, PgConnection, PgPool};
@@ -22,34 +26,49 @@ pub struct ConfirmationLinks {
pub text: reqwest::Url,
}
pub struct TestUser {
pub user_id: Uuid,
pub username: String,
pub password: String,
}
impl TestUser {
pub fn generate() -> Self {
Self {
user_id: Uuid::new_v4(),
username: Uuid::new_v4().to_string(),
password: Uuid::new_v4().to_string(),
}
}
pub async fn store(&self, connection_pool: &PgPool) {
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(self.password.as_bytes(), &salt)
.unwrap()
.to_string();
sqlx::query!(
"INSERT INTO users (user_id, username, password_hash) VALUES ($1, $2, $3)",
self.user_id,
self.username,
password_hash
)
.execute(connection_pool)
.await
.expect("Failed to create test user");
}
}
pub struct TestApp {
pub address: String,
pub connection_pool: PgPool,
pub email_server: wiremock::MockServer,
pub port: u16,
pub test_user: TestUser,
pub api_client: reqwest::Client,
}
impl TestApp {
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
let get_link = |s: &str| {
let links: Vec<_> = LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
let raw_link = links[0].as_str();
let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap();
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
confirmation_link.set_port(Some(self.port)).unwrap();
confirmation_link
};
let html = get_link(body["html"].as_str().unwrap());
let text = get_link(body["text"].as_str().unwrap());
ConfirmationLinks { html, text }
}
pub async fn spawn() -> Self {
Lazy::force(&TRACING);
@@ -73,11 +92,20 @@ impl TestApp {
.parse::<u16>()
.unwrap();
let address = format!("http://{}", application.local_addr());
let test_user = TestUser::generate();
test_user.store(&connection_pool).await;
let api_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();
let app = TestApp {
address,
connection_pool,
email_server,
port,
test_user,
api_client,
};
tokio::spawn(application.run_until_stopped());
@@ -85,8 +113,39 @@ impl TestApp {
app
}
pub fn get_confirmation_links(&self, request: &wiremock::Request) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
let get_link = |s: &str| {
let links: Vec<_> = LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
let raw_link = links[0].as_str();
let mut confirmation_link = reqwest::Url::parse(raw_link).unwrap();
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
confirmation_link.set_port(Some(self.port)).unwrap();
confirmation_link
};
let html = get_link(body["html"].as_str().unwrap());
let text = get_link(body["text"].as_str().unwrap());
ConfirmationLinks { html, text }
}
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 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)
@@ -99,6 +158,19 @@ impl TestApp {
reqwest::Client::new()
.post(format!("{}/newsletters", self.address))
.json(&body)
.basic_auth(&self.test_user.username, Some(&self.test_user.password))
.send()
.await
.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")
@@ -124,3 +196,8 @@ async fn configure_database(config: &DatabaseSettings) -> PgPool {
connection_pool
}
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);
}

22
tests/api/login.rs Normal file
View File

@@ -0,0 +1,22 @@
use crate::helpers::{TestApp, assert_is_redirect_to};
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
let app = TestApp::spawn().await;
let login_body = serde_json::json!({
"username": "user",
"password": "password"
});
let response = app.post_login(&login_body).await;
assert_eq!(response.status().as_u16(), 303);
assert_is_redirect_to(&response, "/login");
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"));
}

View File

@@ -1,5 +1,6 @@
mod health_check;
mod helpers;
mod login;
mod newsletters;
mod subscriptions;
mod subscriptions_confirm;

View File

@@ -1,4 +1,5 @@
use crate::helpers::{ConfirmationLinks, TestApp};
use uuid::Uuid;
use wiremock::{
Mock, ResponseTemplate,
matchers::{any, method, path},
@@ -21,6 +22,87 @@ async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
assert_eq!(response.status().as_u16(), 200);
}
#[tokio::test]
async fn request_missing_authorization_are_rejected() {
let app = TestApp::spawn().await;
let newsletter_request_body = serde_json::json!({
"title": "Newsletter title",
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as HTML</p>"
}
});
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": "<p>Newsletter body as HTML</p>"
}
});
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": "<p>Newsletter body as HTML</p>"
}
});
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""#
);
}
#[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() {
let app = TestApp::spawn().await;