diff --git a/.sqlx/query-348365fd1e76ebfcfa065d1ea7e22cd7cbcd2e981f75fce75f29f1c4fbfc3df5.json b/.sqlx/query-348365fd1e76ebfcfa065d1ea7e22cd7cbcd2e981f75fce75f29f1c4fbfc3df5.json new file mode 100644 index 00000000..2c416956 --- /dev/null +++ b/.sqlx/query-348365fd1e76ebfcfa065d1ea7e22cd7cbcd2e981f75fce75f29f1c4fbfc3df5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_ SET locked = $1 WHERE id = $2;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "348365fd1e76ebfcfa065d1ea7e22cd7cbcd2e981f75fce75f29f1c4fbfc3df5" +} diff --git a/.sqlx/query-3e246c54d31804140272a6fc2e3c241c17b086ff219d6084684a0f2b7c31eeed.json b/.sqlx/query-3e246c54d31804140272a6fc2e3c241c17b086ff219d6084684a0f2b7c31eeed.json new file mode 100644 index 00000000..ea9625d4 --- /dev/null +++ b/.sqlx/query-3e246c54d31804140272a6fc2e3c241c17b086ff219d6084684a0f2b7c31eeed.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_ SET receiveNotifications = $1 WHERE id = $2;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "3e246c54d31804140272a6fc2e3c241c17b086ff219d6084684a0f2b7c31eeed" +} diff --git a/.sqlx/query-868bbdcb65f0ee862f221b7e3d1a4f4dbc4d818315d1713a110c4ad7acd09e3e.json b/.sqlx/query-868bbdcb65f0ee862f221b7e3d1a4f4dbc4d818315d1713a110c4ad7acd09e3e.json new file mode 100644 index 00000000..c47a9b57 --- /dev/null +++ b/.sqlx/query-868bbdcb65f0ee862f221b7e3d1a4f4dbc4d818315d1713a110c4ad7acd09e3e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_ SET password = $1, salt = $2 WHERE id = $3;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "868bbdcb65f0ee862f221b7e3d1a4f4dbc4d818315d1713a110c4ad7acd09e3e" +} diff --git a/.sqlx/query-fd2f782d28612d969aa20eb35ea8da4bfdba6f059dbd45b510122210807ac5b6.json b/.sqlx/query-fd2f782d28612d969aa20eb35ea8da4bfdba6f059dbd45b510122210807ac5b6.json new file mode 100644 index 00000000..175e32f6 --- /dev/null +++ b/.sqlx/query-fd2f782d28612d969aa20eb35ea8da4bfdba6f059dbd45b510122210807ac5b6.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_ SET name = $1, email = $2, role = $3, function = $4, areaId = $5 WHERE id = $6;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "role", + "kind": { + "Enum": [ + "staff", + "areamanager", + "admin" + ] + } + } + }, + { + "Custom": { + "name": "function", + "kind": { + "Enum": [ + "posten", + "fuehrungsassistent", + "wachhabender" + ] + } + } + }, + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "fd2f782d28612d969aa20eb35ea8da4bfdba6f059dbd45b510122210807ac5b6" +} diff --git a/Cargo.lock b/Cargo.lock index 638aaf11..e3b20067 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,18 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dummy" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ee4e39146145f7dd28e6c85ffdce489d93c0d9c88121063b8aacabbd9858d2" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "either" version = "1.13.0" @@ -1347,6 +1359,7 @@ checksum = "aef603df4ba9adbca6a332db7da6f614f21eafefbaf8e087844e452fdec152d0" dependencies = [ "chrono", "deunicode", + "dummy", "rand", ] @@ -1549,6 +1562,8 @@ checksum = "f4bd1d7843e437a4caf1d6a9112ba1ee9635b09d909af22aa4e6ec01fe971e22" dependencies = [ "compact_str", "garde_derive", + "once_cell", + "regex", "smallvec", ] @@ -1560,6 +1575,7 @@ checksum = "a0636cbdc03994db48fc89a0ce7765bd68d08bd8a7a68cb9a36bcde96790f413" dependencies = [ "proc-macro2", "quote", + "regex", "syn 2.0.96", ] diff --git a/web/Cargo.toml b/web/Cargo.toml index f1f728d4..b4e4f492 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -30,7 +30,7 @@ brass-macros = { path = "../macros" } brass-config = { path = "../config" } actix-http = "3.9.0" rinja = "0.3.5" -garde = { version = "0.21.0", features = ["derive"] } +garde = { version = "0.21.0", features = ["derive", "email"] } maud = "0.26.0" [build-dependencies] @@ -40,5 +40,5 @@ change-detection = "1.2.0" [dev-dependencies] insta = { version = "1.41.1", features = ["yaml", "filters"] } -fake = { version = "3.0.1", features = ["chrono"]} +fake = { version = "3.0.1", features = ["chrono", "derive"]} regex = "1.11.1" diff --git a/web/src/endpoints/user/mod.rs b/web/src/endpoints/user/mod.rs index ca42cbbc..cd21573b 100644 --- a/web/src/endpoints/user/mod.rs +++ b/web/src/endpoints/user/mod.rs @@ -123,20 +123,7 @@ async fn handle_password_change_request( let (hash, salt) = generate_salt_and_hash_plain_password(password).unwrap(); - User::update( - pool, - user_id, - None, - None, - Some(&hash), - Some(&salt), - None, - None, - None, - None, - None, - ) - .await?; + User::update_password(pool, user_id, &hash, &salt).await?; if let Some(token) = token { token.delete(pool).await?; diff --git a/web/src/endpoints/user/post_edit.rs b/web/src/endpoints/user/post_edit.rs index 532ea344..a80b5651 100644 --- a/web/src/endpoints/user/post_edit.rs +++ b/web/src/endpoints/user/post_edit.rs @@ -1,24 +1,16 @@ use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; -use brass_macros::db_test; -use serde::{Deserialize, Serialize}; +use garde::Validate; +use serde::Deserialize; use sqlx::PgPool; use crate::{ endpoints::IdPath, - models::{Function, Role, User}, + models::{Area, Role, User, UserChangeset}, utils::ApplicationError, }; -#[cfg(test)] -use crate::utils::test_helper::{test_post, DbTestContext, RequestConfig, StatusCode}; -#[cfg(test)] -use fake::{ - faker::{internet::raw::SafeEmail, name::raw::Name}, - locales::EN, - Fake, -}; - -#[derive(Deserialize, Serialize)] +#[derive(Deserialize)] +#[cfg_attr(test, derive(serde::Serialize))] pub struct EditUserForm { email: String, name: String, @@ -50,146 +42,100 @@ pub async fn post_edit( return Err(ApplicationError::Unauthorized); } - let mut changed = false; - - let email = if user_in_db.email != form.email { - changed = true; - Some(form.email.as_str()) - } else { - None - }; - - let name = if user_in_db.name != form.name { - changed = true; - Some(form.name.as_str()) - } else { - None - }; - - let role = if user_in_db.role as u8 != form.role { - if let Ok(r) = Role::try_from(form.role) { - changed = true; - Some(r) - } else { - None - } - } else { - None - }; - - let function = if user_in_db.function as u8 != form.function { - if let Ok(f) = Function::try_from(form.function) { - changed = true; - Some(f) - } else { - None - } - } else { - None - }; - - let area = if user.role == Role::Admin - && form.area.is_some() - && user_in_db.area_id != form.area.unwrap() - { - changed = true; - Some(form.area.unwrap()) - } else { - None - }; - - if changed { - User::update( - pool.get_ref(), - path.id, - email, - name, - None, - None, - role, - function, - area, - None, - None, - ) - .await?; + let area_id = form.area.unwrap_or(user_in_db.area_id); + if Area::read_by_id(pool.get_ref(), area_id).await?.is_none() { + return Err(ApplicationError::Unauthorized); } + let changeset = UserChangeset { + name: form.name.clone(), + email: form.email.clone(), + role: form.role.try_into()?, + function: form.function.try_into()?, + area_id, + }; + + if let Err(e) = changeset.validate() { + return Ok(HttpResponse::BadRequest().body(e.to_string())); + }; + + User::update(pool.get_ref(), user_in_db.id, changeset).await?; + Ok(HttpResponse::Found() .insert_header((LOCATION, "/users")) .insert_header(("HX-LOCATION", "/users")) .finish()) } -#[db_test] -async fn works_when_user_is_admin(context: &DbTestContext) { - User::create( - &context.db_pool, - &Name(EN).fake::(), - &SafeEmail(EN).fake::(), - Role::Staff, - Function::Posten, - 1, - ) - .await - .unwrap(); - - crate::models::Area::create(&context.db_pool, "Süd") - .await - .unwrap(); - - let app = context.app().await; - let config = RequestConfig { - uri: "/users/edit/1".to_string(), - role: Role::Admin, - function: crate::models::Function::Posten, - user_area: 1, +#[cfg(test)] +mod tests { + use super::*; + use crate::{models::*, utils::test_helper::*}; + use brass_macros::db_test; + use fake::{ + faker::{internet::en::SafeEmail, name::en::Name}, + Fake, Faker, }; - let new_name: String = Name(EN).fake(); - let new_mail: String = SafeEmail(EN).fake(); + #[db_test] + async fn works_when_user_is_admin(context: &DbTestContext) { + User::create(&context.db_pool, Faker.fake()).await.unwrap(); - let form = EditUserForm { - name: new_name.clone(), - email: new_mail.clone(), - role: Role::AreaManager as u8, - function: Function::Fuehrungsassistent as u8, - area: Some(2), - }; + Area::create(&context.db_pool, "Süd").await.unwrap(); - let response = test_post(&context.db_pool, app, &config, form).await; - assert_eq!(StatusCode::FOUND, response.status()); + let app = context.app().await; + let config = RequestConfig { + uri: "/users/edit/1".to_string(), + role: Role::Admin, + function: Function::Posten, + user_area: 1, + }; - let updated_user = User::read_by_id(&context.db_pool, 1) - .await - .unwrap() - .unwrap(); + let new_name: String = Name().fake(); + let new_mail: String = SafeEmail().fake(); - assert_eq!(new_name, updated_user.name); - assert_eq!(new_mail, updated_user.email); - assert_eq!(Role::AreaManager, updated_user.role); - assert_eq!(Function::Fuehrungsassistent, updated_user.function); - assert_eq!(2, updated_user.area_id); -} - -#[db_test] -async fn cant_edit_oneself(context: &DbTestContext) { - let app = context.app().await; - let config = RequestConfig { - uri: "/users/edit/1".to_string(), - role: Role::Admin, - function: crate::models::Function::Posten, - user_area: 1, - }; - - let form = EditUserForm { - name: "".to_string(), - email: "".to_string(), - role: Role::AreaManager as u8, - function: Function::Fuehrungsassistent as u8, - area: Some(1), - }; - - let response = test_post(&context.db_pool, app, &config, form).await; - assert_eq!(StatusCode::BAD_REQUEST, response.status()); + let form = EditUserForm { + name: new_name.clone(), + email: new_mail.clone(), + role: Role::AreaManager as u8, + function: Function::Fuehrungsassistent as u8, + area: Some(2), + }; + + let response = test_post(&context.db_pool, app, &config, form).await; + assert_eq!(StatusCode::FOUND, response.status()); + + let updated_user = User::read_by_id(&context.db_pool, 1) + .await + .unwrap() + .unwrap(); + + assert_eq!(new_name, updated_user.name); + assert_eq!(new_mail, updated_user.email); + assert_eq!(Role::AreaManager, updated_user.role); + assert_eq!(Function::Fuehrungsassistent, updated_user.function); + assert_eq!(2, updated_user.area_id); + } + + #[db_test] + async fn cant_edit_oneself(context: &DbTestContext) { + let app = context.app().await; + let config = RequestConfig { + uri: "/users/edit/1".to_string(), + role: Role::Admin, + function: Function::Posten, + user_area: 1, + }; + + let form = EditUserForm { + name: "".to_string(), + email: "".to_string(), + role: Role::AreaManager as u8, + function: Function::Fuehrungsassistent as u8, + area: Some(1), + }; + + let response = test_post(&context.db_pool, app, &config, form).await; + assert_eq!(StatusCode::BAD_REQUEST, response.status()); + } } diff --git a/web/src/endpoints/user/post_new.rs b/web/src/endpoints/user/post_new.rs index 9f7398c5..bfae1226 100644 --- a/web/src/endpoints/user/post_new.rs +++ b/web/src/endpoints/user/post_new.rs @@ -1,10 +1,11 @@ use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; +use garde::Validate; use serde::Deserialize; use sqlx::PgPool; use crate::{ mail::Mailer, - models::{Function, Registration, Role, User}, + models::{Function, Registration, Role, User, UserChangeset}, utils::ApplicationError, }; @@ -37,13 +38,21 @@ pub async fn post_new( let role = Role::try_from(form.role)?; let function = Function::try_from(form.function)?; - let id = User::create( - pool.get_ref(), - &form.name, - &form.email, + let changeset = UserChangeset { + name: form.name.clone(), + email: form.email.clone(), role, function, - area_id, + area_id + }; + + if let Err(e) = changeset.validate() { + return Ok(HttpResponse::BadRequest().body(e.to_string())); + }; + + let id = User::create( + pool.get_ref(), + changeset ) .await?; diff --git a/web/src/endpoints/user/post_toggle.rs b/web/src/endpoints/user/post_toggle.rs index 0036b512..d0262f75 100644 --- a/web/src/endpoints/user/post_toggle.rs +++ b/web/src/endpoints/user/post_toggle.rs @@ -25,28 +25,19 @@ pub async fn post( } let user = if user.id != path.id { - User::read_by_id(pool.get_ref(), path.id).await.unwrap().unwrap() + User::read_by_id(pool.get_ref(), path.id) + .await + .unwrap() + .unwrap() } else { user.into_inner() }; match query.field.as_str() { "locked" => { - User::update( - pool.get_ref(), - user.id, - None, - None, - None, - None, - None, - None, - None, - None, - Some(!user.locked), - ) - .await - .unwrap(); + User::update_locked(pool.get_ref(), user.id, !user.locked) + .await + .unwrap(); if !user.locked { return HttpResponse::Ok().body(format!( @@ -79,18 +70,10 @@ pub async fn post( } } "receiveNotifications" => { - User::update( + User::update_receive_notifications( pool.get_ref(), user.id, - None, - None, - None, - None, - None, - None, - None, - Some(!user.receive_notifications), - None, + !user.receive_notifications, ) .await .unwrap(); diff --git a/web/src/models/mod.rs b/web/src/models/mod.rs index 13e730f8..46c81ecf 100644 --- a/web/src/models/mod.rs +++ b/web/src/models/mod.rs @@ -12,6 +12,7 @@ mod password_reset; mod registration; mod role; mod user; +mod user_changeset; mod vehicle; mod vehicle_assignement; @@ -33,6 +34,7 @@ pub use password_reset::{NoneToken, PasswordReset, Token}; pub use registration::Registration; pub use role::Role; pub use user::User; +pub use user_changeset::UserChangeset; pub use vehicle::Vehicle; pub use vehicle_assignement::VehicleAssignement; diff --git a/web/src/models/user.rs b/web/src/models/user.rs index 69f63357..ad46e148 100644 --- a/web/src/models/user.rs +++ b/web/src/models/user.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; -use super::{Area, Function, Result, Role}; +use super::{Area, Function, Result, Role, UserChangeset}; #[derive(Clone, Debug)] pub struct User { @@ -20,28 +20,22 @@ pub struct User { } impl User { - pub async fn create( - pool: &PgPool, - name: &str, - email: &str, - role: Role, - function: Function, - area_id: i32, - ) -> Result { + pub async fn create(pool: &PgPool, changeset: UserChangeset) -> Result { sqlx::query!( r#" INSERT INTO user_ (name, email, role, function, areaId) VALUES ($1, $2, $3, $4, $5) RETURNING id; "#, - name, - email, - role as Role, - function as Function, - area_id + changeset.name, + changeset.email, + changeset.role as Role, + changeset.function as Function, + changeset.area_id ) .fetch_one(pool) - .await.map(|r| r.id) + .await + .map(|r| r.id) } pub async fn create_with_password( @@ -54,8 +48,6 @@ impl User { function: Function, area_id: i32, ) -> Result { - - sqlx::query!( r#" INSERT INTO user_ (name, email, password, salt, role, function, areaId) @@ -71,7 +63,8 @@ impl User { area_id ) .fetch_one(pool) - .await.map(|r| r.id) + .await + .map(|r| r.id) } pub async fn read_by_id(pool: &PgPool, id: i32) -> Result> { @@ -286,72 +279,55 @@ impl User { Ok(result) } - pub async fn update( + pub async fn update(pool: &PgPool, id: i32, changeset: UserChangeset) -> Result<()> { + sqlx::query!( + "UPDATE user_ SET name = $1, email = $2, role = $3, function = $4, areaId = $5 WHERE id = $6;", + changeset.name, + changeset.email, + changeset.role as Role, + changeset.function as Function, + changeset.area_id, + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn update_password(pool: &PgPool, id: i32, password: &str, salt: &str) -> Result<()> { + sqlx::query!( + "UPDATE user_ SET password = $1, salt = $2 WHERE id = $3;", + password, + salt, + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn update_locked(pool: &PgPool, id: i32, locked: bool) -> Result<()> { + sqlx::query!("UPDATE user_ SET locked = $1 WHERE id = $2;", locked, id) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn update_receive_notifications( pool: &PgPool, id: i32, - email: Option<&str>, - name: Option<&str>, - password: Option<&str>, - salt: Option<&str>, - role: Option, - function: Option, - area_id: Option, - receive_notifications: Option, - locked: Option, + receive_notifications: bool, ) -> Result<()> { - let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET "); - let mut separated = query_builder.separated(", "); - - if let Some(email) = email { - separated.push("email = "); - separated.push_bind_unseparated(email); - } - - if let Some(name) = name { - separated.push("name = "); - separated.push_bind_unseparated(name); - } - - if let Some(password) = password { - separated.push("password = "); - separated.push_bind_unseparated(password); - } - - if let Some(salt) = salt { - separated.push("salt = "); - separated.push_bind_unseparated(salt); - } - - if let Some(role) = role { - separated.push("role = "); - separated.push_bind_unseparated(role as Role); - } - - if let Some(function) = function { - separated.push("function = "); - separated.push_bind_unseparated(function as Function); - } - - if let Some(area_id) = area_id { - separated.push("areaId = "); - separated.push_bind_unseparated(area_id); - } - - if let Some(receive_notifications) = receive_notifications { - separated.push("receiveNotifications = "); - separated.push_bind_unseparated(receive_notifications); - } - - if let Some(locked) = locked { - separated.push("locked = "); - separated.push_bind_unseparated(locked); - } - - query_builder.push(" WHERE id = "); - query_builder.push_bind(id); - query_builder.push(";"); - - query_builder.build().execute(pool).await?; + sqlx::query!( + "UPDATE user_ SET receiveNotifications = $1 WHERE id = $2;", + receive_notifications, + id + ) + .execute(pool) + .await?; Ok(()) } diff --git a/web/src/models/user_changeset.rs b/web/src/models/user_changeset.rs new file mode 100644 index 00000000..fe45261e --- /dev/null +++ b/web/src/models/user_changeset.rs @@ -0,0 +1,24 @@ +#[cfg(test)] +use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy}; + +use garde::Validate; + +use super::{Function, Role}; + +#[derive(Validate)] +#[cfg_attr(test, derive(Dummy))] +#[garde(allow_unvalidated)] +pub struct UserChangeset { + #[cfg_attr(test, dummy(faker = "Name()"))] + pub name: String, + #[garde(email)] + #[cfg_attr(test, dummy(faker = "SafeEmail()"))] + pub email: String, + #[cfg_attr(test, dummy(expr = "Role::Staff"))] + pub role: Role, + #[cfg_attr(test, dummy(expr = "Function::Posten"))] + pub function: Function, + /// check before: must exist and user can create other user for this area + #[cfg_attr(test, dummy(expr = "1"))] + pub area_id: i32, +}