diff --git a/src/authentication.rs b/src/authentication.rs index 6d20c97..0d7b751 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,5 +1,3 @@ -use std::fmt::Display; - use crate::telemetry::spawn_blocking_with_tracing; use anyhow::Context; use argon2::{ @@ -8,6 +6,7 @@ use argon2::{ }; use secrecy::{ExposeSecret, SecretString}; use sqlx::PgPool; +use std::fmt::Display; use uuid::Uuid; pub struct Credentials { diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 7026d39..a00fbe7 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -6,7 +6,7 @@ mod posts; mod subscribers; use crate::{ - authentication::{AuthenticatedUser, Role}, + authentication::AuthenticatedUser, routes::{AppError, error_chain_fmt}, session_state::TypedSession, templates::{HtmlTemplate, MessageTemplate}, @@ -81,11 +81,10 @@ pub async fn require_admin( request: Request, next: Next, ) -> Result { - if let Role::Admin = session - .get_role() + if session + .has_admin_permissions() .await .context("Error retrieving user role in session.")? - .ok_or(anyhow::anyhow!("Could not find user role in session."))? { Ok(next.run(request).await) } else { diff --git a/src/routes/users.rs b/src/routes/users.rs index 876fa4e..dbf10e7 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -62,7 +62,10 @@ impl TryFrom for NewUser { anyhow::bail!("Password mismatch."); } - let role = value.admin.map(|_| Role::Admin).unwrap_or(Role::Writer); + let role = match value.admin { + Some(true) => Role::Admin, + _ => Role::Writer, + }; let password_hash = crate::authentication::compute_pasword_hash(value.password) .context("Failed to hash password.")?; Ok(Self { diff --git a/src/session_state.rs b/src/session_state.rs index e482c6c..a725abe 100644 --- a/src/session_state.rs +++ b/src/session_state.rs @@ -42,6 +42,15 @@ impl TypedSession { self.0.get(Self::ROLE_KEY).await } + pub async fn has_admin_permissions(&self) -> Result { + let role = self.0.get(Self::ROLE_KEY).await?; + if let Some(Role::Admin) = role { + Ok(true) + } else { + Ok(false) + } + } + pub async fn clear(&self) { self.0.clear().await; } diff --git a/templates/dashboard/comments/list.html b/templates/dashboard/comments/list.html index 554cb71..7e98275 100644 --- a/templates/dashboard/comments/list.html +++ b/templates/dashboard/comments/list.html @@ -16,7 +16,7 @@ -
+
{% block comments %} {% if comments.is_empty() %}
@@ -29,11 +29,11 @@ d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
-

No data to display

-

The request did not return any data.

+

No comments to display

+

Content may have shifted due to recent updates or list is empty.

{% else %} -
+
{% for comment in comments %} {% include "dashboard/comments/card.html" %} {% endfor %} diff --git a/templates/dashboard/posts/list.html b/templates/dashboard/posts/list.html index d7dd9c1..c0081ba 100644 --- a/templates/dashboard/posts/list.html +++ b/templates/dashboard/posts/list.html @@ -16,7 +16,7 @@
-
+
{% block posts %} {% if posts.is_empty() %}
@@ -29,11 +29,11 @@ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
-

No data to display

-

The request did not return any data.

+

No posts to display

+

Content may have shifted due to recent updates or list is empty.

{% else %} -
+
{% for post in posts %} {% include "dashboard/posts/card.html" %} {% endfor %} diff --git a/templates/dashboard/subscribers/list.html b/templates/dashboard/subscribers/list.html index 7b50fcf..921add8 100644 --- a/templates/dashboard/subscribers/list.html +++ b/templates/dashboard/subscribers/list.html @@ -19,10 +19,10 @@
-
+
{% block subs %} {% if subscribers.is_empty() %} -
+
-

No data available

+

No subscribers to display

Content may have shifted due to recent updates or list is empty.

{% else %} -
+
{% for subscriber in subscribers %} {% include "dashboard/subscribers/card.html" %} {% endfor %} diff --git a/templates/dashboard/users/list.html b/templates/dashboard/users/list.html index 8407513..b9b4df5 100644 --- a/templates/dashboard/users/list.html +++ b/templates/dashboard/users/list.html @@ -28,8 +28,8 @@ d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
-

No users found

-

No users in the system.

+

No users to display

+

Content may have shifted due to recent updates or list is empty.

{% else %}
diff --git a/tests/api/admin_dashboard.rs b/tests/api/admin_dashboard.rs index 72bf9da..c10e165 100644 --- a/tests/api/admin_dashboard.rs +++ b/tests/api/admin_dashboard.rs @@ -40,7 +40,7 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) { app.admin_login().await; let response = app.get_admin_dashboard_html().await; - assert!(response.contains("No data available")); + assert!(response.contains("No subscribers to display")); app.create_confirmed_subscriber().await; let subscriber = sqlx::query!("SELECT id, email FROM subscriptions") @@ -53,10 +53,90 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) { app.delete_subscriber(subscriber.id).await; let response = app.get_admin_dashboard_html().await; - assert!(response.contains("No data available")); + assert!(response.contains("No subscribers to display")); assert!(!response.contains(&subscriber.email)); } +#[sqlx::test] +async fn posts_are_visible_on_the_dashboard(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + + let response = app.get_admin_dashboard_html().await; + assert!(response.contains("No posts to display")); + + let response = app.post_create_post(&fake_post_body()).await; + assert!( + response + .text() + .await + .unwrap() + .contains("Your new post has been published") + ); + + let (post_id, post_title) = { + let record = sqlx::query!("SELECT post_id, title FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + (record.post_id, record.title) + }; + + let html = app.get_admin_dashboard_html().await; + assert!(html.contains(&post_title)); + + app.delete_post(post_id).await; + let response = app.get_admin_dashboard_html().await; + assert!(response.contains("No posts to display")); +} + +#[sqlx::test] +async fn comments_are_visible_on_the_dashboard(connection_pool: PgPool) { + let app = TestApp::spawn(connection_pool).await; + app.admin_login().await; + + let response = app.get_admin_dashboard_html().await; + assert!(response.contains("No comments to display")); + + app.post_create_post(&fake_post_body()).await; + + let (post_id, post_title) = { + let record = sqlx::query!("SELECT post_id, title FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + (record.post_id, record.title) + }; + + let author = "author"; + let content = "comment"; + let comment_body = serde_json::json!({ + "author": author, + "content": content, + "idempotency_key": "key" + }); + app.post_comment(&post_id, &comment_body).await; + + let response = app.get_admin_dashboard_html().await; + assert!(response.contains(author)); + assert!(response.contains(content)); + + let html = app.get_admin_dashboard_html().await; + assert!(html.contains(&post_title)); + + let comment_id = { + let record = sqlx::query!("SELECT comment_id FROM comments") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.comment_id + }; + + app.delete_comment(comment_id).await; + let response = app.get_admin_dashboard_html().await; + assert!(response.contains("No comments to display")); +} + #[sqlx::test] async fn dashboard_shows_correct_stats(connection_pool: PgPool) { let app = TestApp::spawn(connection_pool).await; diff --git a/tests/api/helpers.rs b/tests/api/helpers.rs index ea36a1a..1629181 100644 --- a/tests/api/helpers.rs +++ b/tests/api/helpers.rs @@ -130,6 +130,26 @@ impl TestApp { app } + pub async fn create_user( + &self, + username: &str, + password: &str, + admin: bool, + ) -> reqwest::Response { + let body = serde_json::json!({ + "username": username, + "password": password, + "password_check": password, + "admin": admin, + }); + self.api_client + .post(format!("{}/admin/users", self.address)) + .form(&body) + .send() + .await + .unwrap() + } + pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks { let email: String = SafeEmail().fake(); let body = format!("email={email}"); @@ -166,7 +186,7 @@ impl TestApp { .unwrap(); } - pub async fn delete_subscriber(&self, subscriber_id: Uuid) { + pub async fn delete_subscriber(&self, subscriber_id: Uuid) -> reqwest::Response { self.api_client .delete(format!( "{}/admin/subscribers/{}", @@ -174,7 +194,15 @@ impl TestApp { )) .send() .await - .expect("Could not delete subscriber"); + .expect("Could not delete subscriber") + } + + pub async fn delete_user(&self, user_id: Uuid) -> reqwest::Response { + self.api_client + .delete(format!("{}/admin/users/{}", self.address, user_id)) + .send() + .await + .expect("Could not delete user") } pub async fn dispatch_all_pending_emails(&self) { @@ -371,12 +399,20 @@ impl TestApp { .expect("Failed to execute request") } - pub async fn delete_post(&self, post_id: Uuid) { + pub async fn delete_post(&self, post_id: Uuid) -> reqwest::Response { self.api_client .delete(format!("{}/admin/posts/{}", self.address, post_id)) .send() .await - .expect("Could not delete post"); + .expect("Could not delete post") + } + + pub async fn delete_comment(&self, comment_id: Uuid) -> reqwest::Response { + self.api_client + .delete(format!("{}/admin/comments/{}", self.address, comment_id)) + .send() + .await + .expect("Could not delete comment") } pub async fn post_unsubscribe(&self, body: &Body) -> reqwest::Response @@ -418,8 +454,7 @@ pub fn fake_post_body() -> serde_json::Value { serde_json::json!({ "title": "Post title", "content": "Post content", - "idempotency_key": Uuid::new_v4().to_string(), - + "idempotency_key": Uuid::new_v4().to_string() }) } diff --git a/tests/api/main.rs b/tests/api/main.rs index e7fb4d9..954d9d0 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -10,3 +10,4 @@ mod subscriptions; mod subscriptions_confirm; mod unsubscribe; mod unsubscribe_confirm; +mod users; diff --git a/tests/api/users.rs b/tests/api/users.rs new file mode 100644 index 0000000..bdd5c57 --- /dev/null +++ b/tests/api/users.rs @@ -0,0 +1,346 @@ +use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email}; +use sqlx::PgPool; +use wiremock::ResponseTemplate; +use zero2prod::authentication::Role; + +#[sqlx::test] +async fn admin_can_create_user(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 record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username) + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_some()); + + let html = app.get_admin_dashboard_html().await; + assert!(html.contains(username)); +} + +#[sqlx::test] +async fn admin_can_create_admin_user(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, true).await; + + let record = sqlx::query!( + r#" + SELECT role as "role: Role" + FROM users WHERE username = $1 + "#, + username + ) + .fetch_one(&app.connection_pool) + .await + .unwrap(); + matches!(record.role, Role::Admin); + + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + + let html = app.get_admin_dashboard_html().await; + assert!(html.contains("Administration")); +} + +#[sqlx::test] +async fn admin_users_can_create_posts(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, true).await; + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + app.create_confirmed_subscriber().await; + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + app.post_create_post(&fake_post_body()).await; + app.dispatch_all_pending_emails().await; +} + +#[sqlx::test] +async fn admin_users_can_send_emails(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, true).await; + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + app.create_confirmed_subscriber().await; + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&app.email_server) + .await; + app.post_newsletters(&fake_newsletter_body()).await; + app.dispatch_all_pending_emails().await; +} + +#[sqlx::test] +async fn admin_users_can_create_users(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, true).await; + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + + let username = "other_user"; + app.create_user(username, password, true).await; + let html = app.get_admin_dashboard_html().await; + assert!(html.contains(username)); +} + +#[sqlx::test] +async fn admin_users_can_delete_contents(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, true).await; + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + + app.create_confirmed_subscriber().await; + let (subscriber_id, email) = { + let record = sqlx::query!("SELECT id, email FROM subscriptions") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + (record.id, record.email) + }; + let response = app.delete_subscriber(subscriber_id).await; + let text = response.text().await.unwrap(); + assert!(text.contains(&email)); + assert!(text.contains("has been deleted")); + + app.create_user("other_user", password, true).await; + let user_id = { + let record = sqlx::query!("SELECT user_id FROM users") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.user_id + }; + let response = app.delete_user(user_id).await; + let text = response.text().await.unwrap(); + assert!(text.contains("The user has been deleted")); + + app.post_create_post(&fake_post_body()).await; + let post_id = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + + let comment_body = serde_json::json!({ + "author": "author", + "content": "comment", + "idempotency_key": "key", + }); + app.post_comment(&post_id, &comment_body).await; + let comment_id = { + let record = sqlx::query!("SELECT comment_id FROM comments") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.comment_id + }; + let response = app.delete_comment(comment_id).await; + assert!( + response + .text() + .await + .unwrap() + .contains("The comment has been deleted") + ); + + let response = app.delete_post(post_id).await; + let text = response.text().await.unwrap(); + assert!(text.contains("The post has been deleted")); +} + +#[sqlx::test] +async fn admin_functions_are_hidden_for_non_admin_users(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 record = sqlx::query!( + r#" + SELECT role as "role: Role" + FROM users WHERE username = $1 + "#, + username + ) + .fetch_one(&app.connection_pool) + .await + .unwrap(); + matches!(record.role, Role::Writer); + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + let response = app.post_login(&login_body).await; + assert!(!response.text().await.unwrap().contains("Administration")); +} + +#[sqlx::test] +async fn writers_can_publish_posts_and_send_emails(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; + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + + app.create_confirmed_subscriber().await; + when_sending_an_email() + .respond_with(ResponseTemplate::new(200)) + .expect(2) + .mount(&app.email_server) + .await; + app.post_create_post(&fake_post_body()).await; + app.post_newsletters(&fake_newsletter_body()).await; + app.dispatch_all_pending_emails().await; +} + +#[sqlx::test] +async fn writers_cannot_perform_admin_functions(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; + app.post_create_post(&fake_post_body()).await; + let post_id = { + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.post_id + }; + app.create_confirmed_subscriber().await; + let subscriber_id = { + let record = sqlx::query!("SELECT id FROM subscriptions") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.id + }; + let comment_body = serde_json::json!({ + "author": "author", + "content": "comment", + "idempotency_key": "key", + }); + app.post_comment(&post_id, &comment_body).await; + let comment_id = { + let record = sqlx::query!("SELECT comment_id FROM comments") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.comment_id + }; + + app.logout().await; + let login_body = serde_json::json!({ + "username": username, + "password": password + }); + app.post_login(&login_body).await; + + let response = app.delete_subscriber(subscriber_id).await; + let html = response.text().await.unwrap(); + assert!(html.contains("requires administrator privileges")); + let record = sqlx::query!("SELECT id FROM subscriptions") + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_some()); + + let response = app.delete_comment(comment_id).await; + let html = response.text().await.unwrap(); + assert!(html.contains("requires administrator privileges")); + let record = sqlx::query!("SELECT comment_id FROM comments") + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_some()); + + let response = app.delete_post(post_id).await; + let html = response.text().await.unwrap(); + assert!(html.contains("requires administrator privileges")); + let record = sqlx::query!("SELECT post_id FROM posts") + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_some()); + + let user_id = { + let record = sqlx::query!("SELECT user_id FROM users") + .fetch_one(&app.connection_pool) + .await + .unwrap(); + record.user_id + }; + let response = app.delete_user(user_id).await; + let html = response.text().await.unwrap(); + assert!(html.contains("requires administrator privileges")); + + let record = sqlx::query_scalar!("SELECT username FROM users WHERE user_id = $1", user_id) + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_some()); + + let username = "friend"; + let password = "123456789abc"; + let response = app.create_user(username, password, false).await; + let html = response.text().await.unwrap(); + assert!(html.contains("requires administrator privileges")); + let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username) + .fetch_optional(&app.connection_pool) + .await + .unwrap(); + assert!(record.is_none()); +}