From 525e9bb4157c824d97681ee1366cea18d0233f4a Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Sun, 10 Nov 2024 13:53:18 +0100 Subject: [PATCH 1/3] controllers/user/update: Move `regenerate_token_and_send()` to a dedicated module --- src/controllers/user.rs | 2 ++ src/controllers/user/resend.rs | 54 ++++++++++++++++++++++++++++++++++ src/controllers/user/update.rs | 44 +-------------------------- src/router.rs | 2 +- 4 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 src/controllers/user/resend.rs diff --git a/src/controllers/user.rs b/src/controllers/user.rs index dcc502dea63..4226f0f3a0e 100644 --- a/src/controllers/user.rs +++ b/src/controllers/user.rs @@ -1,6 +1,8 @@ pub mod me; pub mod other; +mod resend; pub mod session; pub mod update; +pub use resend::regenerate_token_and_send; pub use update::update_user; diff --git a/src/controllers/user/resend.rs b/src/controllers/user/resend.rs new file mode 100644 index 00000000000..3fbafb6aa39 --- /dev/null +++ b/src/controllers/user/resend.rs @@ -0,0 +1,54 @@ +use super::update::UserConfirmEmail; +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::helpers::ok_true; +use crate::models::Email; +use crate::tasks::spawn_blocking; +use crate::util::errors::bad_request; +use crate::util::errors::AppResult; +use axum::extract::Path; +use axum::response::Response; +use crates_io_database::schema::emails; +use diesel::dsl::sql; +use diesel::prelude::*; +use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; +use http::request::Parts; + +/// Handles `PUT /user/:user_id/resend` route +pub async fn regenerate_token_and_send( + state: AppState, + Path(param_user_id): Path, + req: Parts, +) -> AppResult { + let mut conn = state.db_write().await?; + let auth = AuthCheck::default().check(&req, &mut conn).await?; + spawn_blocking(move || { + let conn: &mut AsyncConnectionWrapper<_> = &mut conn.into(); + + let user = auth.user(); + + // need to check if current user matches user to be updated + if user.id != param_user_id { + return Err(bad_request("current user does not match requested user")); + } + + conn.transaction(|conn| -> AppResult<_> { + let email: Email = diesel::update(Email::belonging_to(user)) + .set(emails::token.eq(sql("DEFAULT"))) + .get_result(conn) + .optional()? + .ok_or_else(|| bad_request("Email could not be found"))?; + + let email1 = UserConfirmEmail { + user_name: &user.gh_login, + domain: &state.emails.domain, + token: email.token, + }; + + state.emails.send(&email.email, email1).map_err(Into::into) + })?; + + ok_true() + }) + .await +} diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs index ca607eecfc0..947fadef552 100644 --- a/src/controllers/user/update.rs +++ b/src/controllers/user/update.rs @@ -1,7 +1,7 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::controllers::helpers::ok_true; -use crate::models::{Email, NewEmail}; +use crate::models::NewEmail; use crate::schema::{emails, users}; use crate::tasks::spawn_blocking; use crate::util::diesel::prelude::*; @@ -9,7 +9,6 @@ use crate::util::errors::{bad_request, server_error, AppResult}; use axum::extract::Path; use axum::response::Response; use axum::Json; -use diesel::dsl::sql; use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; use http::request::Parts; use lettre::Address; @@ -114,47 +113,6 @@ pub async fn update_user( .await } -/// Handles `PUT /user/:user_id/resend` route -pub async fn regenerate_token_and_send( - state: AppState, - Path(param_user_id): Path, - req: Parts, -) -> AppResult { - let mut conn = state.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; - spawn_blocking(move || { - use diesel::RunQueryDsl; - - let conn: &mut AsyncConnectionWrapper<_> = &mut conn.into(); - - let user = auth.user(); - - // need to check if current user matches user to be updated - if user.id != param_user_id { - return Err(bad_request("current user does not match requested user")); - } - - conn.transaction(|conn| -> AppResult<_> { - let email: Email = diesel::update(Email::belonging_to(user)) - .set(emails::token.eq(sql("DEFAULT"))) - .get_result(conn) - .optional()? - .ok_or_else(|| bad_request("Email could not be found"))?; - - let email1 = UserConfirmEmail { - user_name: &user.gh_login, - domain: &state.emails.domain, - token: email.token, - }; - - state.emails.send(&email.email, email1).map_err(Into::into) - })?; - - ok_true() - }) - .await -} - pub struct UserConfirmEmail<'a> { pub user_name: &'a str, pub domain: &'a str, diff --git a/src/router.rs b/src/router.rs index b74cce82a69..ca484287c81 100644 --- a/src/router.rs +++ b/src/router.rs @@ -133,7 +133,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { ) .route( "/api/v1/users/:user_id/resend", - put(user::update::regenerate_token_and_send), + put(user::regenerate_token_and_send), ) .route( "/api/v1/site_metadata", From bb7d089e121add05577a20191be4733079545e8e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Sun, 10 Nov 2024 14:14:47 +0100 Subject: [PATCH 2/3] insta: Redact email confirmation tokens --- src/tests/util/test_app.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index a0e31281b4b..508c0caf9b1 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -189,6 +189,9 @@ impl TestApp { static DATE_TIME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z").unwrap()); + static EMAIL_CONFIRM_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"/confirm/\w+").unwrap()); + static INVITE_TOKEN_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"/accept-invite/\w+").unwrap()); @@ -199,6 +202,7 @@ impl TestApp { .map(|email| { let email = EMAIL_HEADER_REGEX.replace_all(&email, ""); let email = DATE_TIME_REGEX.replace_all(&email, "[0000-00-00T00:00:00Z]"); + let email = EMAIL_CONFIRM_REGEX.replace_all(&email, "/confirm/[confirm-token]"); let email = INVITE_TOKEN_REGEX.replace_all(&email, "/accept-invite/[invite-token]"); email.to_string() }) From 7bf0db5e66ddef1ca57773dad95ff0ad32dd31cd Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Sun, 10 Nov 2024 14:15:24 +0100 Subject: [PATCH 3/3] controllers/user/resend: Add basic test suite --- src/controllers/user/resend.rs | 44 +++++++++++++++++++ ...rs__user__resend__tests__happy_path-2.snap | 15 +++++++ 2 files changed, 59 insertions(+) create mode 100644 src/controllers/user/snapshots/crates_io__controllers__user__resend__tests__happy_path-2.snap diff --git a/src/controllers/user/resend.rs b/src/controllers/user/resend.rs index 3fbafb6aa39..05529073aaf 100644 --- a/src/controllers/user/resend.rs +++ b/src/controllers/user/resend.rs @@ -52,3 +52,47 @@ pub async fn regenerate_token_and_send( }) .await } + +#[cfg(test)] +mod tests { + use crate::tests::util::{RequestHelper, TestApp}; + use http::StatusCode; + use insta::assert_snapshot; + + #[tokio::test(flavor = "multi_thread")] + async fn test_no_auth() { + let (app, anon, user) = TestApp::init().with_user(); + + let url = format!("/api/v1/users/{}/resend", user.as_model().id); + let response = anon.put::<()>(&url, "").await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + + assert_eq!(app.emails().len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_wrong_user() { + let (app, _anon, user) = TestApp::init().with_user(); + let user2 = app.db_new_user("bar"); + + let url = format!("/api/v1/users/{}/resend", user2.as_model().id); + let response = user.put::<()>(&url, "").await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#); + + assert_eq!(app.emails().len(), 0); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_happy_path() { + let (app, _anon, user) = TestApp::init().with_user(); + + let url = format!("/api/v1/users/{}/resend", user.as_model().id); + let response = user.put::<()>(&url, "").await; + assert_eq!(response.status(), StatusCode::OK); + assert_snapshot!(response.text(), @r###"{"ok":true}"###); + + assert_snapshot!(app.emails_snapshot()); + } +} diff --git a/src/controllers/user/snapshots/crates_io__controllers__user__resend__tests__happy_path-2.snap b/src/controllers/user/snapshots/crates_io__controllers__user__resend__tests__happy_path-2.snap new file mode 100644 index 00000000000..9bd3f655e9c --- /dev/null +++ b/src/controllers/user/snapshots/crates_io__controllers__user__resend__tests__happy_path-2.snap @@ -0,0 +1,15 @@ +--- +source: src/controllers/user/resend.rs +expression: app.emails_snapshot() +snapshot_kind: text +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Please confirm your email address +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + +Hello foo! Welcome to crates.io. Please click the +link below to verify your email address. Thank you! + +https://crates.io/confirm/[confirm-token]