diff --git a/src/routes.rs b/src/routes.rs index 66ed5ca..fa65136 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -28,6 +28,7 @@ pub use subscriptions::*; pub use subscriptions_confirm::*; pub use unsubscribe::*; pub use users::*; +use validator::ValidationErrors; use crate::{ authentication::AuthError, @@ -209,3 +210,20 @@ where } } } + +pub fn join_error_messages(e: ValidationErrors) -> String { + let error_messages: Vec<_> = e + .field_errors() + .iter() + .flat_map(|(field, errors)| { + errors.iter().map(move |error| { + error + .message + .as_ref() + .map(|msg| msg.to_string()) + .unwrap_or(format!("Invalid field: {}", field)) + }) + }) + .collect(); + error_messages.join("\n") +} diff --git a/src/routes/posts.rs b/src/routes/posts.rs index bf89ce8..e21ae82 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -1,5 +1,5 @@ use crate::authentication::AuthenticatedUser; -use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page}; +use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages}; use crate::session_state::TypedSession; use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate}; use crate::{ @@ -19,6 +19,7 @@ use axum::{ }; use sqlx::PgPool; use uuid::Uuid; +use validator::Validate; pub const POSTS_PER_PAGE: i64 = 3; @@ -119,9 +120,11 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()), Some(record) if record.author_id == user_id => { + if let Err(e) = form.validate().map_err(join_error_messages) { + let template = HtmlTemplate(MessageTemplate::error(e)); + return Ok(template.into_response()); + } sqlx::query!( " UPDATE posts diff --git a/src/routes/users.rs b/src/routes/users.rs index 7365390..adc8045 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1,5 +1,5 @@ use crate::authentication::AuthenticatedUser; -use crate::routes::verify_password; +use crate::routes::{join_error_messages, verify_password}; use crate::session_state::TypedSession; use crate::templates::{MessageTemplate, UserEditTemplate}; use crate::{ @@ -64,21 +64,8 @@ pub async fn update_user( }): Extension, Form(form): Form, ) -> Result { - if let Err(e) = form.validate() { - let error_messages: Vec<_> = e - .field_errors() - .iter() - .flat_map(|(field, errors)| { - errors.iter().map(move |error| { - error - .message - .as_ref() - .map(|msg| msg.to_string()) - .unwrap_or(format!("Invalid field: {}", field)) - }) - }) - .collect(); - let template = HtmlTemplate(MessageTemplate::error(error_messages.join("\n"))); + if let Err(e) = form.validate().map_err(join_error_messages) { + let template = HtmlTemplate(MessageTemplate::error(e)); return Ok(template.into_response()); } if form.user_id != session_user_id { diff --git a/templates/posts/page.html b/templates/posts/page.html index 5f53514..806b723 100644 --- a/templates/posts/page.html +++ b/templates/posts/page.html @@ -1,111 +1,107 @@ {% extends "base.html" %} -{% block title %}Edit: {{ post.title }}{% endblock %} +{% block title %}{{ post.title }}{% endblock %} {% block content %} -
-
-
-

{{ post.title }}

-
-
-
-
- +
+
+

{{ post.title }}

+
+
+
+
+ + + +
+ {{ post.author }} +
+
+ - + +
- {{ post.author }} -
-
- - - -
+ {% if session_username.as_deref() == Some(post.author) %} +
+ +
+ {% endif %}
- {% if session_username.as_deref() == Some(post.author) %} -
- +
+ {% if session_username.as_deref() == Some(post.author) %} + - {% endif %} -
- - - {% if session_username.as_deref() == Some(post.author) %} - -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tests/api/comments.rs b/tests/api/comments.rs index 4353a9a..b4d7fb7 100644 --- a/tests/api/comments.rs +++ b/tests/api/comments.rs @@ -21,7 +21,7 @@ async fn visitor_can_leave_a_comment(connection_pool: PgPool) { "idempotency_key": "key", }); app.post_comment(&post_id, &comment_body).await; - let post = app.get_post_html(post_id).await; + let post = app.get_post_html(&post_id).await; assert!(post.contains(comment_author)); assert!(post.contains(comment_content)); } @@ -44,7 +44,7 @@ async fn visitor_can_comment_anonymously(connection_pool: PgPool) { "idempotency_key": "key", }); app.post_comment(&post_id, &comment_body).await; - let post = app.get_post_html(post_id).await; + let post = app.get_post_html(&post_id).await; assert!(post.contains("Anonymous")); assert!(post.contains(comment_content)); } diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index 59483d5..220fc1d 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -289,6 +289,18 @@ impl TestApp { self.get_admin_dashboard().await.text().await.unwrap() } + pub async fn edit_post(&self, body: &Body, post_id: &Uuid) -> reqwest::Response + where + Body: serde::Serialize, + { + self.api_client + .put(format!("{}/posts/{}", self.address, post_id)) + .form(body) + .send() + .await + .expect("Failed to execute request") + } + pub async fn get_posts(&self) -> reqwest::Response { self.api_client .get(format!("{}/posts", &self.address)) @@ -301,7 +313,7 @@ impl TestApp { self.get_posts().await.text().await.unwrap() } - pub async fn get_post(&self, post_id: Uuid) -> reqwest::Response { + pub async fn get_post(&self, post_id: &Uuid) -> reqwest::Response { self.api_client .get(format!("{}/posts/{}", &self.address, post_id)) .send() @@ -309,7 +321,7 @@ impl TestApp { .expect("Failed to execute request") } - pub async fn get_post_html(&self, post_id: Uuid) -> String { + pub async fn get_post_html(&self, post_id: &Uuid) -> String { self.get_post(post_id).await.text().await.unwrap() } diff --git a/tests/api/posts.rs b/tests/api/posts.rs index 50b9865..b998fc2 100644 --- a/tests/api/posts.rs +++ b/tests/api/posts.rs @@ -144,7 +144,7 @@ async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) { .fetch_one(&app.connection_pool) .await .unwrap(); - let html = app.get_post_html(post.post_id).await; + let html = app.get_post_html(&post.post_id).await; assert!(html.contains(&title)); } @@ -171,7 +171,7 @@ async fn visitor_can_read_a_blog_post(connection_pool: PgPool) { .fetch_one(&app.connection_pool) .await .unwrap(); - let html = app.get_post_html(post.post_id).await; + let html = app.get_post_html(&post.post_id).await; assert!(html.contains(&title)); } @@ -197,7 +197,7 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) { app.delete_post(post.post_id).await; - let html = app.get_post_html(post.post_id).await; + let html = app.get_post_html(&post.post_id).await; assert!(html.contains("Not Found")); } @@ -234,3 +234,109 @@ async fn clicking_the_notification_link_marks_the_email_as_opened(connection_poo .opened ); } + +#[sqlx::test] +async fn only_post_author_can_access_the_edit_form(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + let username = "alphonse"; + let password = "123456789abc"; + app.create_user(username, password, false).await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + app.post_create_post(&fake_post_body()).await; + let post_id = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap() + .post_id; + let html = app.get_post_html(&post_id).await; + assert!(html.contains("Edit")); + + app.logout().await; + app.admin_login().await; + let html = app.get_post_html(&post_id).await; + assert!(!html.contains("Edit")); +} + +#[sqlx::test] +async fn only_post_author_can_edit_post(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + let username = "alphonse"; + let password = "123456789abc"; + app.create_user(username, password, false).await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + app.post_create_post(&fake_post_body()).await; + let post_id = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap() + .post_id; + + let new_title = "Stunning new title"; + let new_content = "Astonishing content"; + let edit_body = serde_json::json!({ + "title": new_title, + "content": new_content, + }); + let response = app.edit_post(&edit_body, &post_id).await; + let text = response.text().await.unwrap(); + assert!(text.contains("Your changes have been saved")); + let text = app.get_post_html(&post_id).await; + assert!(text.contains(new_title)); + assert!(text.contains(new_content)); + + app.logout().await; + app.admin_login().await; + let response = app.edit_post(&edit_body, &post_id).await; + let text = response.text().await.unwrap(); + assert!(text.contains("You are not authorized.")); +} + +#[sqlx::test] +async fn invalid_fields_are_rejected(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + app.post_create_post(&fake_post_body()).await; + let post_id = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap() + .post_id; + + let test_cases = [ + ( + serde_json::json!({ + "title": "", + "content": "content" + }), + "Title must be at least one character", + "title was empty", + ), + ( + serde_json::json!({ + "title": "Title", + "content": "" + }), + "Content must be at least one character", + "content was empty", + ), + ]; + for (invalid_body, expected_error_message, explaination) in test_cases { + let response = app.edit_post(&invalid_body, &post_id).await; + let text = response.text().await.unwrap(); + assert!( + text.contains(expected_error_message), + "The API did not reject the changes when the {}", + explaination + ); + } +}