From aa15de3d36ff10fa2ffb40ce02ef846b32a7a611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 09:37:02 +0100 Subject: [PATCH 01/11] add table for team invites --- database/migrations/0013_team_invites.sql | 13 ++++ ...tables.sql => 0014_create_hypertables.sql} | 0 ...ests_stats.sql => 0015_requests_stats.sql} | 0 ...on_stats.sql => 0016_connection_stats.sql} | 0 ...ssion_stats.sql => 0017_session_stats.sql} | 0 database/src/tables/mod.rs | 1 + database/src/tables/team_invites/mod.rs | 3 + database/src/tables/team_invites/select.rs | 56 ++++++++++++++ .../src/tables/team_invites/table_struct.rs | 32 ++++++++ database/src/tables/team_invites/update.rs | 73 +++++++++++++++++++ 10 files changed, 178 insertions(+) create mode 100644 database/migrations/0013_team_invites.sql rename database/migrations/{0013_create_hypertables.sql => 0014_create_hypertables.sql} (100%) rename database/migrations/{0014_requests_stats.sql => 0015_requests_stats.sql} (100%) rename database/migrations/{0015_connection_stats.sql => 0016_connection_stats.sql} (100%) rename database/migrations/{0016_session_stats.sql => 0017_session_stats.sql} (100%) create mode 100644 database/src/tables/team_invites/mod.rs create mode 100644 database/src/tables/team_invites/select.rs create mode 100644 database/src/tables/team_invites/table_struct.rs create mode 100644 database/src/tables/team_invites/update.rs diff --git a/database/migrations/0013_team_invites.sql b/database/migrations/0013_team_invites.sql new file mode 100644 index 00000000..460f4131 --- /dev/null +++ b/database/migrations/0013_team_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE team_invites( + invite_id SERIAL PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES team(team_id), + user_email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ +); + +CREATE INDEX team_invites_user_email_idx ON team_invites(user_email); +CREATE INDEX team_invites_team_id_idx ON team_invites(team_id); +-- This will ensure that a user can only be invited to a team once +CREATE UNIQUE INDEX team_invites_team_id_user_email_idx ON team_invites(team_id, user_email); diff --git a/database/migrations/0013_create_hypertables.sql b/database/migrations/0014_create_hypertables.sql similarity index 100% rename from database/migrations/0013_create_hypertables.sql rename to database/migrations/0014_create_hypertables.sql diff --git a/database/migrations/0014_requests_stats.sql b/database/migrations/0015_requests_stats.sql similarity index 100% rename from database/migrations/0014_requests_stats.sql rename to database/migrations/0015_requests_stats.sql diff --git a/database/migrations/0015_connection_stats.sql b/database/migrations/0016_connection_stats.sql similarity index 100% rename from database/migrations/0015_connection_stats.sql rename to database/migrations/0016_connection_stats.sql diff --git a/database/migrations/0016_session_stats.sql b/database/migrations/0017_session_stats.sql similarity index 100% rename from database/migrations/0016_session_stats.sql rename to database/migrations/0017_session_stats.sql diff --git a/database/src/tables/mod.rs b/database/src/tables/mod.rs index ea8e84f6..9c5ec739 100644 --- a/database/src/tables/mod.rs +++ b/database/src/tables/mod.rs @@ -9,6 +9,7 @@ pub mod registered_app; pub mod session_public_keys; pub mod sessions; pub mod team; +pub mod team_invites; pub mod test_utils; pub mod user_app_privileges; pub mod utils; diff --git a/database/src/tables/team_invites/mod.rs b/database/src/tables/team_invites/mod.rs new file mode 100644 index 00000000..4b2d4aa3 --- /dev/null +++ b/database/src/tables/team_invites/mod.rs @@ -0,0 +1,3 @@ +pub mod select; +pub mod table_struct; +pub mod update; diff --git a/database/src/tables/team_invites/select.rs b/database/src/tables/team_invites/select.rs new file mode 100644 index 00000000..5c9b82d7 --- /dev/null +++ b/database/src/tables/team_invites/select.rs @@ -0,0 +1,56 @@ +use super::table_struct::TeamInvite; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::grafana_users::table_struct::GRAFANA_USERS_TABLE_NAME; +use crate::tables::team_invites::table_struct::TEAM_INVITES_TABLE_NAME; +use sqlx::query_as; + +impl Db { + pub async fn get_invites_by_team_id( + &self, + team_id: &String, + active_invites: bool, + ) -> Result, DbError> { + let query = if active_invites { + format!( + "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 AND accepted_at IS NULL AND canceled_at IS NULL" + ) + } else { + format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1") + }; + let typed_query = query_as::<_, TeamInvite>(&query); + + return typed_query + .bind(&team_id) + .fetch_all(&self.connection_pool) + .await + .map_err(|e| e.into()); + } + + pub async fn get_invites_by_user_id( + &self, + user_id: &String, + active_invites: bool, + ) -> Result, DbError> { + let query = if active_invites { + format!( + "SELECT ti.* FROM {TEAM_INVITES_TABLE_NAME} ti + JOIN {GRAFANA_USERS_TABLE_NAME} gu ON ti.user_email = gu.email + WHERE gu.user_id = $1 AND ti.accepted_at IS NULL AND ti.cancelled_at IS NULL" + ) + } else { + format!( + "SELECT ti.* FROM {TEAM_INVITES_TABLE_NAME} ti + JOIN {GRAFANA_USERS_TABLE_NAME} gu ON ti.user_email = gu.email + WHERE gu.user_id = $1" + ) + }; + let typed_query = query_as::<_, TeamInvite>(&query); + + return typed_query + .bind(&user_id) + .fetch_all(&self.connection_pool) + .await + .map_err(|e| e.into()); + } +} diff --git a/database/src/tables/team_invites/table_struct.rs b/database/src/tables/team_invites/table_struct.rs new file mode 100644 index 00000000..d4caf5ca --- /dev/null +++ b/database/src/tables/team_invites/table_struct.rs @@ -0,0 +1,32 @@ +use sqlx::{ + postgres::PgRow, + types::chrono::{DateTime, Utc}, + FromRow, Row, +}; + +pub const TEAM_INVITES_TABLE_NAME: &str = "team_invites"; +pub const TEAM_INVITES_KEYS: &str = + "invite_id, team_id, user_email, created_at, accepted_at, canceled_at"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TeamInvite { + pub invite_id: String, + pub team_id: String, + pub user_email: String, + pub created_at: DateTime, + pub accepted_at: Option>, + pub canceled_at: Option>, +} + +impl FromRow<'_, PgRow> for TeamInvite { + fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result { + Ok(TeamInvite { + invite_id: row.get("invite_id"), + team_id: row.get("team_id"), + user_email: row.get("user_email"), + created_at: row.get("created_at"), + accepted_at: row.get("accepted_at"), + canceled_at: row.get("canceled_at"), + }) + } +} diff --git a/database/src/tables/team_invites/update.rs b/database/src/tables/team_invites/update.rs new file mode 100644 index 00000000..e93e5281 --- /dev/null +++ b/database/src/tables/team_invites/update.rs @@ -0,0 +1,73 @@ +use super::table_struct::{TEAM_INVITES_KEYS, TEAM_INVITES_TABLE_NAME}; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::utils::get_current_datetime; +use sqlx::query; + +impl Db { + pub async fn create_new_team_invite( + &self, + team_id: &String, + user_email: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "INSERT INTO {TEAM_INVITES_TABLE_NAME} ({TEAM_INVITES_KEYS}) VALUES (DEFAULT, $1, $2, $3, NULL, NULL)" + ); + + let query_result = query(&query_body) + .bind(&team_id) + .bind(&user_email) + .bind(&get_current_datetime()) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } + + pub async fn accept_team_invite( + &self, + team_id: &String, + user_email: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "UPDATE {TEAM_INVITES_TABLE_NAME} SET accepted_at = $1 WHERE team_id = $2 AND user_email = $3" + ); + + let query_result = query(&query_body) + .bind(&get_current_datetime()) + .bind(&team_id) + .bind(&user_email) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } + + pub async fn cancel_team_invite( + &self, + team_id: &String, + user_email: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "UPDATE {TEAM_INVITES_TABLE_NAME} SET canceled_at = $1 WHERE team_id = $2 AND user_email = $3" + ); + + let query_result = query(&query_body) + .bind(&get_current_datetime()) + .bind(&team_id) + .bind(&user_email) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } +} From 727a4e1cb8a8794dff8687cd54d5671d2af8bec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 09:57:39 +0100 Subject: [PATCH 02/11] add endpoint to invite user to the team --- server/src/http/cloud/add_user_to_team.rs | 444 ---------------- server/src/http/cloud/invite_user_to_team.rs | 481 ++++++++++++++++++ server/src/http/cloud/mod.rs | 2 +- server/src/routes/cloud_router.rs | 8 +- server/src/structs/cloud/api_cloud_errors.rs | 2 + .../src/structs/cloud/cloud_http_endpoints.rs | 6 +- server/src/test_utils.rs | 2 +- 7 files changed, 492 insertions(+), 453 deletions(-) delete mode 100644 server/src/http/cloud/add_user_to_team.rs create mode 100644 server/src/http/cloud/invite_user_to_team.rs diff --git a/server/src/http/cloud/add_user_to_team.rs b/server/src/http/cloud/add_user_to_team.rs deleted file mode 100644 index 30b7fb9f..00000000 --- a/server/src/http/cloud/add_user_to_team.rs +++ /dev/null @@ -1,444 +0,0 @@ -use crate::{ - middlewares::auth_middleware::UserId, - statics::USERS_AMOUNT_LIMIT_PER_TEAM, - structs::cloud::api_cloud_errors::CloudApiErrors, - utils::{custom_validate_uuid, validate_request}, -}; -use axum::{extract::State, http::StatusCode, Extension, Json}; -use database::db::Db; -use garde::Validate; -use log::error; -use serde::{Deserialize, Serialize}; -use std::{collections::HashSet, sync::Arc}; -use ts_rs::TS; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -pub struct HttpAddUserToTeamRequest { - #[garde(custom(custom_validate_uuid))] - pub team_id: String, - #[garde(email)] - pub user_email: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct HttpAddUserToTeamResponse {} - -pub async fn add_user_to_team( - State(db): State>>, - Extension(user_id): Extension, - Json(request): Json, -) -> Result, (StatusCode, String)> { - // Db connection has already been checked in the middleware - let db = db.as_ref().ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::CloudFeatureDisabled.to_string(), - ))?; - - // Validate request - validate_request(&request, &())?; - - // Get team data and perform checks - match db.get_team_by_team_id(None, &request.team_id).await { - Ok(Some(team)) => { - // Check if user is a admin of this team - if team.team_admin_id != user_id { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::InsufficientPermissions.to_string(), - )); - } - - // Check if team has at least one registered app - match db.get_registered_apps_by_team_id(&request.team_id).await { - Ok(apps) => { - if apps.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::TeamHasNoRegisteredApps.to_string(), - )); - } - } - Err(err) => { - error!("Failed to get registered apps by team id: {:?}", err); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - } - - // Check if limit of users in the team has been reached - match db.get_privileges_by_team_id(&request.team_id).await { - Ok(privileges) => { - let users = privileges - .iter() - .map(|privilege| privilege.user_id.clone()) - .collect::>(); - - if users.len() >= USERS_AMOUNT_LIMIT_PER_TEAM { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::MaximumUsersPerTeamReached.to_string(), - )); - } - } - Err(err) => { - error!("Failed to get privileges by team id: {:?}", err); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - } - - // Get user data and perform checks - let user = match db.get_user_by_email(&request.user_email).await { - Ok(Some(user)) => user, - Ok(None) => { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::UserDoesNotExist.to_string(), - )); - } - Err(err) => { - error!("Failed to get user by email: {:?}", err); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - }; - - // Check if user already belongs to the team - match db - .get_teams_and_apps_membership_by_user_id(&user.user_id) - .await - { - Ok(teams) => { - // This won't check if user has permissions to all apps in the team - if teams.iter().any(|(team_id, _)| team_id == &request.team_id) { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string(), - )); - } - } - Err(err) => { - error!( - "Failed to get teams and apps membership by user id: {:?}", - err - ); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - } - - // Add user to the team - match db - .add_user_to_the_team(&user.user_id, &request.team_id) - .await - { - Ok(_) => { - return Ok(Json(HttpAddUserToTeamResponse {})); - } - Err(err) => { - error!("Failed to add user to the team: {:?}", err); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - } - } - Ok(None) => { - return Err(( - StatusCode::BAD_REQUEST, - CloudApiErrors::TeamDoesNotExist.to_string(), - )); - } - Err(err) => { - error!("Failed to get team: {:?}", err); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::DatabaseError.to_string(), - )); - } - } -} - -#[cfg(feature = "cloud_db_tests")] -#[cfg(test)] -mod tests { - use crate::{ - env::JWT_SECRET, - http::cloud::{ - add_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, - register_new_app::HttpRegisterNewAppRequest, - }, - statics::USERS_AMOUNT_LIMIT_PER_TEAM, - structs::cloud::{ - api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, - }, - test_utils::test_utils::{ - add_test_app, add_test_team, add_user_to_test_team, convert_response, create_test_app, - generate_valid_name, register_and_login_random_user, - }, - }; - use axum::{ - body::Body, - extract::ConnectInfo, - http::{Method, Request}, - }; - use std::net::SocketAddr; - use tower::ServiceExt; - - #[tokio::test] - async fn test_add_user_to_team() { - let test_app = create_test_app(false).await; - - let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - - // Register new team - let team_name = generate_valid_name(); - let team_id = add_test_team(&team_name, &auth_token, &test_app, false) - .await - .unwrap(); - - // Register app under the team - let app_name = generate_valid_name(); - let request = HttpRegisterNewAppRequest { - team_id: team_id.clone(), - app_name: app_name.clone(), - whitelisted_domains: vec![], - ack_public_keys: vec![], - }; - - // unwrap err as it should have failed - let _ = add_test_app(&request, &auth_token, &test_app) - .await - .unwrap(); - - // Register new user - let (_test_user_auth_token, test_user_email, _test_user_password) = - register_and_login_random_user(&test_app).await; - - // Add user to the team - let request = HttpAddUserToTeamRequest { - team_id: team_id.clone(), - user_email: test_user_email.clone(), - }; - - let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); - let json = serde_json::to_string(&request).unwrap(); - let auth = auth_token.encode(JWT_SECRET()).unwrap(); - - let req = Request::builder() - .method(Method::POST) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {auth}")) - .uri(format!( - "/cloud/private{}", - HttpCloudEndpoint::AddUserToTeam.to_string() - )) - .extension(ip.clone()) - .body(Body::from(json)) - .unwrap(); - - // Send request - let response = test_app.clone().oneshot(req).await.unwrap(); - // Validate response - convert_response::(response) - .await - .unwrap(); - - // Try to add user to the team again, should fail as user is already in the team - let request = HttpAddUserToTeamRequest { - team_id: team_id.clone(), - user_email: test_user_email.clone(), - }; - - let json = serde_json::to_string(&request).unwrap(); - let auth = auth_token.encode(JWT_SECRET()).unwrap(); - - let req = Request::builder() - .method(Method::POST) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {auth}")) - .uri(format!( - "/cloud/private{}", - HttpCloudEndpoint::AddUserToTeam.to_string() - )) - .extension(ip) - .body(Body::from(json)) - .unwrap(); - - // Send request - let response = test_app.clone().oneshot(req).await.unwrap(); - // Validate response - let err = convert_response::(response) - .await - .unwrap_err(); - - assert_eq!( - err.to_string(), - CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string() - ); - } - - #[tokio::test] - async fn test_add_user_to_team_team_not_found() { - let test_app = create_test_app(false).await; - - let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - - // Team does not exist, use random uuid - let resp = add_user_to_test_team( - &uuid7::uuid7().to_string(), - &"test_user_email@gmail.com".to_string(), - &auth_token, - &test_app, - ) - .await - .unwrap_err(); - - assert_eq!( - resp.to_string(), - CloudApiErrors::TeamDoesNotExist.to_string() - ); - } - - #[tokio::test] - async fn test_add_user_to_team_no_registered_apps() { - let test_app = create_test_app(false).await; - - let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - - // Register new team - let team_name = generate_valid_name(); - let team_id = add_test_team(&team_name, &auth_token, &test_app, false) - .await - .unwrap(); - - // Team does not exist, use random uuid - let resp = add_user_to_test_team( - &team_id.to_string(), - &"test_user_email@gmail.com".to_string(), - &auth_token, - &test_app, - ) - .await - .unwrap_err(); - - assert_eq!( - resp.to_string(), - CloudApiErrors::TeamHasNoRegisteredApps.to_string() - ); - } - - #[tokio::test] - async fn test_add_user_to_team_user_limit_reached() { - let test_app = create_test_app(false).await; - - let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - - // Register new team - let team_name = generate_valid_name(); - let team_id = add_test_team(&team_name, &auth_token, &test_app, false) - .await - .unwrap(); - - // Register app under the team - let app_name = generate_valid_name(); - let request = HttpRegisterNewAppRequest { - team_id: team_id.clone(), - app_name: app_name.clone(), - whitelisted_domains: vec![], - ack_public_keys: vec![], - }; - - let _ = add_test_app(&request, &auth_token, &test_app) - .await - .unwrap(); - - // Add [USERS_AMOUNT_LIMIT_PER_TEAM] users to the team - for _ in 1..USERS_AMOUNT_LIMIT_PER_TEAM { - let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; - - // Add user to the team - add_user_to_test_team( - &team_id.to_string(), - &test_user_email.to_string(), - &auth_token, - &test_app, - ) - .await - .unwrap(); - } - - // Try to add another user to the team, should fail as user limit has been reached - let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; - - // Add user to the team - let resp = add_user_to_test_team( - &team_id.to_string(), - &test_user_email.to_string(), - &auth_token, - &test_app, - ) - .await - .unwrap_err(); - - assert_eq!( - resp.to_string(), - CloudApiErrors::MaximumUsersPerTeamReached.to_string() - ); - } - - #[tokio::test] - async fn test_add_user_to_team_user_does_not_exist() { - let test_app = create_test_app(false).await; - - let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - - // Register new team - let team_name = generate_valid_name(); - let team_id = add_test_team(&team_name, &auth_token, &test_app, false) - .await - .unwrap(); - - // Register app under the team - let app_name = generate_valid_name(); - let request = HttpRegisterNewAppRequest { - team_id: team_id.clone(), - app_name: app_name.clone(), - whitelisted_domains: vec![], - ack_public_keys: vec![], - }; - - let _ = add_test_app(&request, &auth_token, &test_app) - .await - .unwrap(); - - // Try to add non-existing user to the team, should fail as user limit has been reached - - // Add user to the team - let resp = add_user_to_test_team( - &team_id.to_string(), - &"non-existing-user@gmail.com".to_string(), - &auth_token, - &test_app, - ) - .await - .unwrap_err(); - - assert_eq!( - resp.to_string(), - CloudApiErrors::UserDoesNotExist.to_string() - ); - } -} diff --git a/server/src/http/cloud/invite_user_to_team.rs b/server/src/http/cloud/invite_user_to_team.rs new file mode 100644 index 00000000..7ca7b13b --- /dev/null +++ b/server/src/http/cloud/invite_user_to_team.rs @@ -0,0 +1,481 @@ +use crate::{ + middlewares::auth_middleware::UserId, + statics::USERS_AMOUNT_LIMIT_PER_TEAM, + structs::cloud::api_cloud_errors::CloudApiErrors, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, sync::Arc}; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpAddUserToTeamRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, + #[garde(email)] + pub user_email: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpAddUserToTeamResponse {} + +pub async fn invite_user_to_team( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_string(), + ))?; + + // Validate request + validate_request(&request, &())?; + + // Get team data and perform checks + match db.get_team_by_team_id(None, &request.team_id).await { + Ok(Some(team)) => { + // Check if user is a admin of this team + if team.team_admin_id != user_id { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InsufficientPermissions.to_string(), + )); + } + + // Check if team has at least one registered app + match db.get_registered_apps_by_team_id(&request.team_id).await { + Ok(apps) => { + if apps.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::TeamHasNoRegisteredApps.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get registered apps by team id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Get user data and perform checks + let user = match db.get_user_by_email(&request.user_email).await { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get user by email: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Check if user already belongs to the team + match db + .get_teams_and_apps_membership_by_user_id(&user.user_id) + .await + { + Ok(teams) => { + // This won't check if user has permissions to all apps in the team + if teams.iter().any(|(team_id, _)| team_id == &request.team_id) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string(), + )); + } + } + Err(err) => { + error!( + "Failed to get teams and apps membership by user id: {:?}", + err + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Check if limit of users in the team has been reached + match db.get_privileges_by_team_id(&request.team_id).await { + Ok(privileges) => { + let users = privileges + .iter() + .map(|privilege| privilege.user_id.clone()) + .collect::>(); + + if users.len() >= USERS_AMOUNT_LIMIT_PER_TEAM { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::MaximumUsersPerTeamReached.to_string(), + )); + } + + // Check the amount of invites to the team, team can only have one invite per user at a time + // Limit amount of possible invites to the team to the USERS_AMOUNT_LIMIT_PER_TEAM - amount of users in the team + // Get active invites + match db.get_invites_by_team_id(&request.team_id, true).await { + Ok(invites) => { + let invites = invites + .iter() + .map(|invite| invite.user_email.clone()) + .collect::>(); + + // Additional check if user has already been invited to the team + if invites.contains(&request.user_email) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserAlreadyInvitedToTheTeam.to_string(), + )); + } + + // Check if invites limit has been reached + if invites.len() >= USERS_AMOUNT_LIMIT_PER_TEAM - users.len() { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::MaximumInvitesPerTeamReached.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get invites by team id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + Err(err) => { + error!("Failed to get privileges by team id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Add invite to the team + match db + .create_new_team_invite(&request.team_id, &request.user_email) + .await + { + Ok(_) => { + return Ok(Json(HttpAddUserToTeamResponse {})); + } + Err(err) => { + error!("Failed to invite user to the team: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::TeamDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get team: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} + +// TODO fix this in a moment + +// #[cfg(feature = "cloud_db_tests")] +// #[cfg(test)] +// mod tests { +// use crate::{ +// env::JWT_SECRET, +// http::cloud::{ +// invite_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, +// register_new_app::HttpRegisterNewAppRequest, +// }, +// statics::USERS_AMOUNT_LIMIT_PER_TEAM, +// structs::cloud::{ +// api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, +// }, +// test_utils::test_utils::{ +// add_test_app, add_test_team, add_user_to_test_team, convert_response, create_test_app, +// generate_valid_name, register_and_login_random_user, +// }, +// }; +// use axum::{ +// body::Body, +// extract::ConnectInfo, +// http::{Method, Request}, +// }; +// use std::net::SocketAddr; +// use tower::ServiceExt; + +// #[tokio::test] +// async fn test_add_user_to_team() { +// let test_app = create_test_app(false).await; + +// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + +// // Register new team +// let team_name = generate_valid_name(); +// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) +// .await +// .unwrap(); + +// // Register app under the team +// let app_name = generate_valid_name(); +// let request = HttpRegisterNewAppRequest { +// team_id: team_id.clone(), +// app_name: app_name.clone(), +// whitelisted_domains: vec![], +// ack_public_keys: vec![], +// }; + +// // unwrap err as it should have failed +// let _ = add_test_app(&request, &auth_token, &test_app) +// .await +// .unwrap(); + +// // Register new user +// let (_test_user_auth_token, test_user_email, _test_user_password) = +// register_and_login_random_user(&test_app).await; + +// // Add user to the team +// let request = HttpAddUserToTeamRequest { +// team_id: team_id.clone(), +// user_email: test_user_email.clone(), +// }; + +// let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); +// let json = serde_json::to_string(&request).unwrap(); +// let auth = auth_token.encode(JWT_SECRET()).unwrap(); + +// let req = Request::builder() +// .method(Method::POST) +// .header("content-type", "application/json") +// .header("authorization", format!("Bearer {auth}")) +// .uri(format!( +// "/cloud/private{}", +// HttpCloudEndpoint::AddUserToTeam.to_string() +// )) +// .extension(ip.clone()) +// .body(Body::from(json)) +// .unwrap(); + +// // Send request +// let response = test_app.clone().oneshot(req).await.unwrap(); +// // Validate response +// convert_response::(response) +// .await +// .unwrap(); + +// // Try to add user to the team again, should fail as user is already in the team +// let request = HttpAddUserToTeamRequest { +// team_id: team_id.clone(), +// user_email: test_user_email.clone(), +// }; + +// let json = serde_json::to_string(&request).unwrap(); +// let auth = auth_token.encode(JWT_SECRET()).unwrap(); + +// let req = Request::builder() +// .method(Method::POST) +// .header("content-type", "application/json") +// .header("authorization", format!("Bearer {auth}")) +// .uri(format!( +// "/cloud/private{}", +// HttpCloudEndpoint::AddUserToTeam.to_string() +// )) +// .extension(ip) +// .body(Body::from(json)) +// .unwrap(); + +// // Send request +// let response = test_app.clone().oneshot(req).await.unwrap(); +// // Validate response +// let err = convert_response::(response) +// .await +// .unwrap_err(); + +// assert_eq!( +// err.to_string(), +// CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string() +// ); +// } + +// #[tokio::test] +// async fn test_add_user_to_team_team_not_found() { +// let test_app = create_test_app(false).await; + +// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + +// // Team does not exist, use random uuid +// let resp = add_user_to_test_team( +// &uuid7::uuid7().to_string(), +// &"test_user_email@gmail.com".to_string(), +// &auth_token, +// &test_app, +// ) +// .await +// .unwrap_err(); + +// assert_eq!( +// resp.to_string(), +// CloudApiErrors::TeamDoesNotExist.to_string() +// ); +// } + +// #[tokio::test] +// async fn test_add_user_to_team_no_registered_apps() { +// let test_app = create_test_app(false).await; + +// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + +// // Register new team +// let team_name = generate_valid_name(); +// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) +// .await +// .unwrap(); + +// // Team does not exist, use random uuid +// let resp = add_user_to_test_team( +// &team_id.to_string(), +// &"test_user_email@gmail.com".to_string(), +// &auth_token, +// &test_app, +// ) +// .await +// .unwrap_err(); + +// assert_eq!( +// resp.to_string(), +// CloudApiErrors::TeamHasNoRegisteredApps.to_string() +// ); +// } + +// #[tokio::test] +// async fn test_add_user_to_team_user_limit_reached() { +// let test_app = create_test_app(false).await; + +// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + +// // Register new team +// let team_name = generate_valid_name(); +// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) +// .await +// .unwrap(); + +// // Register app under the team +// let app_name = generate_valid_name(); +// let request = HttpRegisterNewAppRequest { +// team_id: team_id.clone(), +// app_name: app_name.clone(), +// whitelisted_domains: vec![], +// ack_public_keys: vec![], +// }; + +// let _ = add_test_app(&request, &auth_token, &test_app) +// .await +// .unwrap(); + +// // Add [USERS_AMOUNT_LIMIT_PER_TEAM] users to the team +// for _ in 1..USERS_AMOUNT_LIMIT_PER_TEAM { +// let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; + +// // Add user to the team +// add_user_to_test_team( +// &team_id.to_string(), +// &test_user_email.to_string(), +// &auth_token, +// &test_app, +// ) +// .await +// .unwrap(); +// } + +// // Try to add another user to the team, should fail as user limit has been reached +// let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; + +// // Add user to the team +// let resp = add_user_to_test_team( +// &team_id.to_string(), +// &test_user_email.to_string(), +// &auth_token, +// &test_app, +// ) +// .await +// .unwrap_err(); + +// assert_eq!( +// resp.to_string(), +// CloudApiErrors::MaximumUsersPerTeamReached.to_string() +// ); +// } + +// #[tokio::test] +// async fn test_add_user_to_team_user_does_not_exist() { +// let test_app = create_test_app(false).await; + +// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + +// // Register new team +// let team_name = generate_valid_name(); +// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) +// .await +// .unwrap(); + +// // Register app under the team +// let app_name = generate_valid_name(); +// let request = HttpRegisterNewAppRequest { +// team_id: team_id.clone(), +// app_name: app_name.clone(), +// whitelisted_domains: vec![], +// ack_public_keys: vec![], +// }; + +// let _ = add_test_app(&request, &auth_token, &test_app) +// .await +// .unwrap(); + +// // Try to add non-existing user to the team, should fail as user limit has been reached + +// // Add user to the team +// let resp = add_user_to_test_team( +// &team_id.to_string(), +// &"non-existing-user@gmail.com".to_string(), +// &auth_token, +// &test_app, +// ) +// .await +// .unwrap_err(); + +// assert_eq!( +// resp.to_string(), +// CloudApiErrors::UserDoesNotExist.to_string() +// ); +// } +// } diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index 10714ee8..f57fd226 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -1,6 +1,6 @@ -pub mod add_user_to_team; pub mod events; pub mod get_user_joined_teams; +pub mod invite_user_to_team; pub mod login_with_google; pub mod login_with_password; pub mod register_new_app; diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 9b28afe3..248e9d8b 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -1,7 +1,7 @@ use crate::{ http::cloud::{ - add_user_to_team::add_user_to_team, events::events, - get_user_joined_teams::get_user_joined_teams, login_with_google::login_with_google, + events::events, get_user_joined_teams::get_user_joined_teams, + invite_user_to_team::invite_user_to_team, login_with_google::login_with_google, login_with_password::login_with_password, register_new_app::register_new_app, register_new_team::register_new_team, register_with_password::register_with_password, remove_user_from_team::remove_user_from_team, @@ -59,8 +59,8 @@ pub fn private_router(state: ServerState) -> Router { post(register_new_team), ) .route( - &HttpCloudEndpoint::AddUserToTeam.to_string(), - post(add_user_to_team), + &HttpCloudEndpoint::InviteUserToTeam.to_string(), + post(invite_user_to_team), ) .route( &HttpCloudEndpoint::RemoveUserFromTeam.to_string(), diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 1c7283ee..5b5a447d 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -26,4 +26,6 @@ pub enum CloudApiErrors { InvalidName, UnauthorizedOriginError, AppDoesNotExist, + UserAlreadyInvitedToTheTeam, + MaximumInvitesPerTeamReached, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index 5048d605..57baac83 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -14,14 +14,14 @@ pub enum HttpCloudEndpoint { LoginWithGoogle, #[serde(rename = "/register_new_team")] RegisterNewTeam, - #[serde(rename = "/add_user_to_team")] - AddUserToTeam, #[serde(rename = "/remove_user_from_team")] RemoveUserFromTeam, #[serde(rename = "/get_user_joined_teams")] GetUserJoinedTeams, #[serde(rename = "/events")] Events, + #[serde(rename = "/invite_user_to_team")] + InviteUserToTeam, } impl HttpCloudEndpoint { @@ -32,10 +32,10 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::LoginWithPassword => "/login_with_password".to_string(), HttpCloudEndpoint::LoginWithGoogle => "/login_with_google".to_string(), HttpCloudEndpoint::RegisterNewTeam => "/register_new_team".to_string(), - HttpCloudEndpoint::AddUserToTeam => "/add_user_to_team".to_string(), HttpCloudEndpoint::RemoveUserFromTeam => "/remove_user_from_team".to_string(), HttpCloudEndpoint::GetUserJoinedTeams => "/get_user_joined_teams".to_string(), HttpCloudEndpoint::Events => "/events".to_string(), + HttpCloudEndpoint::InviteUserToTeam => "/invite_user_to_team".to_string(), } } } diff --git a/server/src/test_utils.rs b/server/src/test_utils.rs index 8745563b..bad4c99a 100644 --- a/server/src/test_utils.rs +++ b/server/src/test_utils.rs @@ -4,8 +4,8 @@ pub mod test_utils { auth::AuthToken, env::{JWT_PUBLIC_KEY, JWT_SECRET}, http::cloud::{ - add_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, get_user_joined_teams::HttpGetUserJoinedTeamsResponse, + invite_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, login_with_password::{HttpLoginRequest, HttpLoginResponse}, register_new_app::{HttpRegisterNewAppRequest, HttpRegisterNewAppResponse}, register_new_team::{HttpRegisterNewTeamRequest, HttpRegisterNewTeamResponse}, From ebb496e647c4731ebfe7f4d9125d30713d67246f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 12:22:15 +0100 Subject: [PATCH 03/11] accept invite endpoint + tests fixes --- database/migrations/0011_events_index.sql | 2 +- database/migrations/0013_team_invites.sql | 2 +- database/src/tables/grafana_users/select.rs | 7 +- database/src/tables/grafana_users/update.rs | 2 +- database/src/tables/team_invites/select.rs | 19 +- .../src/tables/team_invites/table_struct.rs | 8 +- database/src/tables/team_invites/update.rs | 7 +- .../src/tables/user_app_privileges/update.rs | 13 +- server/bindings/CloudApiErrors.ts | 2 +- .../bindings/HttpAcceptTeamInviteRequest.ts | 3 + .../bindings/HttpAcceptTeamInviteResponse.ts | 3 + server/bindings/HttpCloudEndpoint.ts | 2 +- .../bindings/HttpInviteUserToTeamRequest.ts | 3 + .../bindings/HttpInviteUserToTeamResponse.ts | 3 + server/bindings/HttpLoginWithGoogleRequest.ts | 3 + server/bindings/LoginWithGoogleResponse.ts | 3 + server/src/http/cloud/accept_team_invite.rs | 319 ++++++++++ .../src/http/cloud/get_user_joined_teams.rs | 24 +- server/src/http/cloud/invite_user_to_team.rs | 554 +++++++++--------- server/src/http/cloud/mod.rs | 1 + .../src/http/cloud/remove_user_from_team.rs | 14 +- server/src/routes/cloud_router.rs | 13 +- server/src/structs/cloud/api_cloud_errors.rs | 1 + .../src/structs/cloud/cloud_http_endpoints.rs | 3 + server/src/test_utils.rs | 61 +- 25 files changed, 742 insertions(+), 330 deletions(-) create mode 100644 server/bindings/HttpAcceptTeamInviteRequest.ts create mode 100644 server/bindings/HttpAcceptTeamInviteResponse.ts create mode 100644 server/bindings/HttpInviteUserToTeamRequest.ts create mode 100644 server/bindings/HttpInviteUserToTeamResponse.ts create mode 100644 server/bindings/HttpLoginWithGoogleRequest.ts create mode 100644 server/bindings/LoginWithGoogleResponse.ts create mode 100644 server/src/http/cloud/accept_team_invite.rs diff --git a/database/migrations/0011_events_index.sql b/database/migrations/0011_events_index.sql index d0d6c3c3..3668152e 100644 --- a/database/migrations/0011_events_index.sql +++ b/database/migrations/0011_events_index.sql @@ -1,5 +1,5 @@ CREATE TABLE events( - event_id SERIAL PRIMARY KEY, + event_id BIGSERIAL PRIMARY KEY, app_id TEXT NOT NULL, event_type event_type_enum NOT NULL, creation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/database/migrations/0013_team_invites.sql b/database/migrations/0013_team_invites.sql index 460f4131..8f692b5e 100644 --- a/database/migrations/0013_team_invites.sql +++ b/database/migrations/0013_team_invites.sql @@ -1,5 +1,5 @@ CREATE TABLE team_invites( - invite_id SERIAL PRIMARY KEY, + invite_id BIGSERIAL PRIMARY KEY, team_id TEXT NOT NULL REFERENCES team(team_id), user_email TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, diff --git a/database/src/tables/grafana_users/select.rs b/database/src/tables/grafana_users/select.rs index 004dd597..ecfc5548 100644 --- a/database/src/tables/grafana_users/select.rs +++ b/database/src/tables/grafana_users/select.rs @@ -5,13 +5,16 @@ use crate::tables::grafana_users::table_struct::GRAFANA_USERS_TABLE_NAME; use sqlx::query_as; impl Db { - pub async fn get_user_by_user_id(&self, user_id: &String) -> Result { + pub async fn get_user_by_user_id( + &self, + user_id: &String, + ) -> Result, DbError> { let query = format!("SELECT * FROM {GRAFANA_USERS_TABLE_NAME} WHERE user_id = $1"); let typed_query = query_as::<_, GrafanaUser>(&query); return typed_query .bind(&user_id) - .fetch_one(&self.connection_pool) + .fetch_optional(&self.connection_pool) .await .map_err(|e| e.into()); } diff --git a/database/src/tables/grafana_users/update.rs b/database/src/tables/grafana_users/update.rs index b5cef05a..dded9d7f 100644 --- a/database/src/tables/grafana_users/update.rs +++ b/database/src/tables/grafana_users/update.rs @@ -79,6 +79,6 @@ mod tests { db.add_new_user(&user).await.unwrap(); let user_result = db.get_user_by_user_id(&user.user_id).await.unwrap(); - assert_eq!(user_result, user); + assert_eq!(user_result, Some(user)); } } diff --git a/database/src/tables/team_invites/select.rs b/database/src/tables/team_invites/select.rs index 5c9b82d7..ae64d629 100644 --- a/database/src/tables/team_invites/select.rs +++ b/database/src/tables/team_invites/select.rs @@ -1,7 +1,6 @@ use super::table_struct::TeamInvite; use crate::db::Db; use crate::structs::db_error::DbError; -use crate::tables::grafana_users::table_struct::GRAFANA_USERS_TABLE_NAME; use crate::tables::team_invites::table_struct::TEAM_INVITES_TABLE_NAME; use sqlx::query_as; @@ -13,7 +12,7 @@ impl Db { ) -> Result, DbError> { let query = if active_invites { format!( - "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 AND accepted_at IS NULL AND canceled_at IS NULL" + "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 AND accepted_at IS NULL AND cancelled_at IS NULL" ) } else { format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1") @@ -27,28 +26,22 @@ impl Db { .map_err(|e| e.into()); } - pub async fn get_invites_by_user_id( + pub async fn get_invites_by_user_email( &self, - user_id: &String, + user_email: &String, active_invites: bool, ) -> Result, DbError> { let query = if active_invites { format!( - "SELECT ti.* FROM {TEAM_INVITES_TABLE_NAME} ti - JOIN {GRAFANA_USERS_TABLE_NAME} gu ON ti.user_email = gu.email - WHERE gu.user_id = $1 AND ti.accepted_at IS NULL AND ti.cancelled_at IS NULL" + "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1 AND accepted_at IS NULL AND cancelled_at IS NULL" ) } else { - format!( - "SELECT ti.* FROM {TEAM_INVITES_TABLE_NAME} ti - JOIN {GRAFANA_USERS_TABLE_NAME} gu ON ti.user_email = gu.email - WHERE gu.user_id = $1" - ) + format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1") }; let typed_query = query_as::<_, TeamInvite>(&query); return typed_query - .bind(&user_id) + .bind(&user_email) .fetch_all(&self.connection_pool) .await .map_err(|e| e.into()); diff --git a/database/src/tables/team_invites/table_struct.rs b/database/src/tables/team_invites/table_struct.rs index d4caf5ca..a05d6c59 100644 --- a/database/src/tables/team_invites/table_struct.rs +++ b/database/src/tables/team_invites/table_struct.rs @@ -6,16 +6,16 @@ use sqlx::{ pub const TEAM_INVITES_TABLE_NAME: &str = "team_invites"; pub const TEAM_INVITES_KEYS: &str = - "invite_id, team_id, user_email, created_at, accepted_at, canceled_at"; + "invite_id, team_id, user_email, created_at, accepted_at, cancelled_at"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct TeamInvite { - pub invite_id: String, + pub invite_id: i64, pub team_id: String, pub user_email: String, pub created_at: DateTime, pub accepted_at: Option>, - pub canceled_at: Option>, + pub cancelled_at: Option>, } impl FromRow<'_, PgRow> for TeamInvite { @@ -26,7 +26,7 @@ impl FromRow<'_, PgRow> for TeamInvite { user_email: row.get("user_email"), created_at: row.get("created_at"), accepted_at: row.get("accepted_at"), - canceled_at: row.get("canceled_at"), + cancelled_at: row.get("cancelled_at"), }) } } diff --git a/database/src/tables/team_invites/update.rs b/database/src/tables/team_invites/update.rs index e93e5281..2aab9ad3 100644 --- a/database/src/tables/team_invites/update.rs +++ b/database/src/tables/team_invites/update.rs @@ -29,18 +29,19 @@ impl Db { pub async fn accept_team_invite( &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, team_id: &String, user_email: &String, ) -> Result<(), DbError> { let query_body = format!( - "UPDATE {TEAM_INVITES_TABLE_NAME} SET accepted_at = $1 WHERE team_id = $2 AND user_email = $3" + "UPDATE {TEAM_INVITES_TABLE_NAME} SET accepted_at = $1 WHERE team_id = $2 AND user_email = $3 AND accepted_at IS NULL AND cancelled_at IS NULL" ); let query_result = query(&query_body) .bind(&get_current_datetime()) .bind(&team_id) .bind(&user_email) - .execute(&self.connection_pool) + .execute(&mut **tx) .await; match query_result { @@ -55,7 +56,7 @@ impl Db { user_email: &String, ) -> Result<(), DbError> { let query_body = format!( - "UPDATE {TEAM_INVITES_TABLE_NAME} SET canceled_at = $1 WHERE team_id = $2 AND user_email = $3" + "UPDATE {TEAM_INVITES_TABLE_NAME} SET cancelled_at = $1 WHERE team_id = $2 AND user_email = $3 AND accepted_at IS NULL AND cancelled_at IS NULL" ); let query_result = query(&query_body) diff --git a/database/src/tables/user_app_privileges/update.rs b/database/src/tables/user_app_privileges/update.rs index c2e87459..ff69e769 100644 --- a/database/src/tables/user_app_privileges/update.rs +++ b/database/src/tables/user_app_privileges/update.rs @@ -57,6 +57,7 @@ impl Db { pub async fn add_user_to_the_team( &self, + tx: &mut Transaction<'_, sqlx::Postgres>, user_id: &String, team_id: &String, ) -> Result<(), DbError> { @@ -98,9 +99,7 @@ impl Db { values_str ); - sqlx::query(&insert_query) - .execute(&self.connection_pool) - .await?; + sqlx::query(&insert_query).execute(&mut **tx).await?; Ok(()) } @@ -294,9 +293,11 @@ mod tests { db.register_new_app(&app).await.unwrap(); } - db.add_user_to_the_team(&user.user_id, &team_id) + let mut tx = db.connection_pool.begin().await.unwrap(); + db.add_user_to_the_team(&mut tx, &user.user_id, &team_id) .await .unwrap(); + tx.commit().await.unwrap(); let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); assert!(get_by_user_id.len() == 8); @@ -331,9 +332,11 @@ mod tests { } // Add user to the new team - db.add_user_to_the_team(&user.user_id, &team_id) + let mut tx = db.connection_pool.begin().await.unwrap(); + db.add_user_to_the_team(&mut tx, &user.user_id, &team_id) .await .unwrap(); + tx.commit().await.unwrap(); let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); assert!(get_by_user_id.len() == 10); diff --git a/server/bindings/CloudApiErrors.ts b/server/bindings/CloudApiErrors.ts index 8dad0957..8723ed52 100644 --- a/server/bindings/CloudApiErrors.ts +++ b/server/bindings/CloudApiErrors.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist"; \ No newline at end of file +export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound"; \ No newline at end of file diff --git a/server/bindings/HttpAcceptTeamInviteRequest.ts b/server/bindings/HttpAcceptTeamInviteRequest.ts new file mode 100644 index 00000000..00337cfc --- /dev/null +++ b/server/bindings/HttpAcceptTeamInviteRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpAcceptTeamInviteRequest { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpAcceptTeamInviteResponse.ts b/server/bindings/HttpAcceptTeamInviteResponse.ts new file mode 100644 index 00000000..0bdf4a41 --- /dev/null +++ b/server/bindings/HttpAcceptTeamInviteResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HttpAcceptTeamInviteResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpCloudEndpoint.ts b/server/bindings/HttpCloudEndpoint.ts index c1de5623..4fd89a8e 100644 --- a/server/bindings/HttpCloudEndpoint.ts +++ b/server/bindings/HttpCloudEndpoint.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/register_new_team" | "/add_user_to_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite"; \ No newline at end of file diff --git a/server/bindings/HttpInviteUserToTeamRequest.ts b/server/bindings/HttpInviteUserToTeamRequest.ts new file mode 100644 index 00000000..a11a6be0 --- /dev/null +++ b/server/bindings/HttpInviteUserToTeamRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpInviteUserToTeamRequest { teamId: string, userEmail: string, } \ No newline at end of file diff --git a/server/bindings/HttpInviteUserToTeamResponse.ts b/server/bindings/HttpInviteUserToTeamResponse.ts new file mode 100644 index 00000000..2b278664 --- /dev/null +++ b/server/bindings/HttpInviteUserToTeamResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HttpInviteUserToTeamResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpLoginWithGoogleRequest.ts b/server/bindings/HttpLoginWithGoogleRequest.ts new file mode 100644 index 00000000..3a3391be --- /dev/null +++ b/server/bindings/HttpLoginWithGoogleRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpLoginWithGoogleRequest { oauthToken: string, email: string, enforceIp: boolean, } \ No newline at end of file diff --git a/server/bindings/LoginWithGoogleResponse.ts b/server/bindings/LoginWithGoogleResponse.ts new file mode 100644 index 00000000..718e6d34 --- /dev/null +++ b/server/bindings/LoginWithGoogleResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface LoginWithGoogleResponse { userId: string, authToken: string, refreshToken: string, } \ No newline at end of file diff --git a/server/src/http/cloud/accept_team_invite.rs b/server/src/http/cloud/accept_team_invite.rs new file mode 100644 index 00000000..67dbae42 --- /dev/null +++ b/server/src/http/cloud/accept_team_invite.rs @@ -0,0 +1,319 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::api_cloud_errors::CloudApiErrors, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpAcceptTeamInviteRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpAcceptTeamInviteResponse {} + +pub async fn accept_team_invite( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_string(), + ))?; + + // Validate request + validate_request(&request, &())?; + + // Get user data and perform checks + let user = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get user by user_id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Just in case check if user already belongs to the team + match db + .get_teams_and_apps_membership_by_user_id(&user.user_id) + .await + { + Ok(teams) => { + if teams.iter().any(|(team_id, _)| team_id == &request.team_id) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string(), + )); + } + } + Err(err) => { + error!( + "Failed to get teams and apps membership by user id: {:?}", + err + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Check if user was invited to the team + match db.get_invites_by_user_email(&user.email, true).await { + Ok(invites) => { + if !invites + .iter() + .any(|invite| invite.team_id == request.team_id) + { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InviteNotFound.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get invites by user id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Accept invite + let mut tx = match db.connection_pool.begin().await { + Ok(tx) => tx, + Err(err) => { + error!("Failed to start transaction: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Accept invite + if let Err(err) = db + .accept_team_invite(&mut tx, &request.team_id, &user.email) + .await + { + error!("Failed to accept team invite: {:?}", err); + if let Err(err) = tx.rollback().await { + error!("Failed to rollback transaction: {:?}", err); + } + + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + // Add user to the team + if let Err(err) = db + .add_user_to_the_team(&mut tx, &user_id, &request.team_id) + .await + { + error!("Failed to add user to the team: {:?}", err); + println!("Failed to add user to the team: {:?}", err); + if let Err(err) = tx.rollback().await { + error!("Failed to rollback transaction: {:?}", err); + } + + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + // Commit transaction + if let Err(err) = tx.commit().await { + error!("Failed to commit transaction: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + Ok(Json(HttpAcceptTeamInviteResponse {})) +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::accept_invite_to_test_team; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::{ + api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, + }, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_add_user_to_team() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Register new user + let (test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Invite user to the team + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + + // Accept invite to the team + let request = HttpAcceptTeamInviteRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = test_user_auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::AcceptTeamInvite.to_string() + )) + .extension(ip.clone()) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response) + .await + .unwrap(); + + // Try to add user to the team again, should fail as user is already in the team + let request = HttpAcceptTeamInviteRequest { + team_id: team_id.clone(), + }; + + let json = serde_json::to_string(&request).unwrap(); + let auth = test_user_auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::AcceptTeamInvite.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let err = convert_response::(response) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string() + ); + } + + #[tokio::test] + async fn test_accept_no_invite_found() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Register new user + let (test_user_auth_token, _test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Accept invite to the team + let err = accept_invite_to_test_team(&team_id, &test_user_auth_token, &test_app) + .await + .unwrap_err(); + + assert_eq!(err.to_string(), CloudApiErrors::InviteNotFound.to_string()); + } +} diff --git a/server/src/http/cloud/get_user_joined_teams.rs b/server/src/http/cloud/get_user_joined_teams.rs index e7737638..414441e5 100644 --- a/server/src/http/cloud/get_user_joined_teams.rs +++ b/server/src/http/cloud/get_user_joined_teams.rs @@ -163,18 +163,30 @@ mod tests { // Add user to first team let before_first_join = get_current_datetime(); - add_user_to_test_team(&team_ids[0], &app_user_email, &auth_token, &test_app) - .await - .unwrap(); + add_user_to_test_team( + &team_ids[0], + &app_user_email, + &auth_token, + &app_user_auth_token, + &test_app, + ) + .await + .unwrap(); let after_first_join = get_current_datetime(); // Wait for 1 second tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // Add user to second team - add_user_to_test_team(&team_ids[1], &app_user_email, &auth_token, &test_app) - .await - .unwrap(); + add_user_to_test_team( + &team_ids[1], + &app_user_email, + &auth_token, + &app_user_auth_token, + &test_app, + ) + .await + .unwrap(); let after_second_join = get_current_datetime(); // Get user joined teams diff --git a/server/src/http/cloud/invite_user_to_team.rs b/server/src/http/cloud/invite_user_to_team.rs index 7ca7b13b..71e367cd 100644 --- a/server/src/http/cloud/invite_user_to_team.rs +++ b/server/src/http/cloud/invite_user_to_team.rs @@ -15,7 +15,7 @@ use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] #[ts(export)] #[serde(rename_all = "camelCase")] -pub struct HttpAddUserToTeamRequest { +pub struct HttpInviteUserToTeamRequest { #[garde(custom(custom_validate_uuid))] pub team_id: String, #[garde(email)] @@ -24,13 +24,13 @@ pub struct HttpAddUserToTeamRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct HttpAddUserToTeamResponse {} +pub struct HttpInviteUserToTeamResponse {} pub async fn invite_user_to_team( State(db): State>>, Extension(user_id): Extension, - Json(request): Json, -) -> Result, (StatusCode, String)> { + Json(request): Json, +) -> Result, (StatusCode, String)> { // Db connection has already been checked in the middleware let db = db.as_ref().ok_or(( StatusCode::INTERNAL_SERVER_ERROR, @@ -179,7 +179,7 @@ pub async fn invite_user_to_team( .await { Ok(_) => { - return Ok(Json(HttpAddUserToTeamResponse {})); + return Ok(Json(HttpInviteUserToTeamResponse {})); } Err(err) => { error!("Failed to invite user to the team: {:?}", err); @@ -206,276 +206,274 @@ pub async fn invite_user_to_team( } } -// TODO fix this in a moment - -// #[cfg(feature = "cloud_db_tests")] -// #[cfg(test)] -// mod tests { -// use crate::{ -// env::JWT_SECRET, -// http::cloud::{ -// invite_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, -// register_new_app::HttpRegisterNewAppRequest, -// }, -// statics::USERS_AMOUNT_LIMIT_PER_TEAM, -// structs::cloud::{ -// api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, -// }, -// test_utils::test_utils::{ -// add_test_app, add_test_team, add_user_to_test_team, convert_response, create_test_app, -// generate_valid_name, register_and_login_random_user, -// }, -// }; -// use axum::{ -// body::Body, -// extract::ConnectInfo, -// http::{Method, Request}, -// }; -// use std::net::SocketAddr; -// use tower::ServiceExt; - -// #[tokio::test] -// async fn test_add_user_to_team() { -// let test_app = create_test_app(false).await; - -// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - -// // Register new team -// let team_name = generate_valid_name(); -// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) -// .await -// .unwrap(); - -// // Register app under the team -// let app_name = generate_valid_name(); -// let request = HttpRegisterNewAppRequest { -// team_id: team_id.clone(), -// app_name: app_name.clone(), -// whitelisted_domains: vec![], -// ack_public_keys: vec![], -// }; - -// // unwrap err as it should have failed -// let _ = add_test_app(&request, &auth_token, &test_app) -// .await -// .unwrap(); - -// // Register new user -// let (_test_user_auth_token, test_user_email, _test_user_password) = -// register_and_login_random_user(&test_app).await; - -// // Add user to the team -// let request = HttpAddUserToTeamRequest { -// team_id: team_id.clone(), -// user_email: test_user_email.clone(), -// }; - -// let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); -// let json = serde_json::to_string(&request).unwrap(); -// let auth = auth_token.encode(JWT_SECRET()).unwrap(); - -// let req = Request::builder() -// .method(Method::POST) -// .header("content-type", "application/json") -// .header("authorization", format!("Bearer {auth}")) -// .uri(format!( -// "/cloud/private{}", -// HttpCloudEndpoint::AddUserToTeam.to_string() -// )) -// .extension(ip.clone()) -// .body(Body::from(json)) -// .unwrap(); - -// // Send request -// let response = test_app.clone().oneshot(req).await.unwrap(); -// // Validate response -// convert_response::(response) -// .await -// .unwrap(); - -// // Try to add user to the team again, should fail as user is already in the team -// let request = HttpAddUserToTeamRequest { -// team_id: team_id.clone(), -// user_email: test_user_email.clone(), -// }; - -// let json = serde_json::to_string(&request).unwrap(); -// let auth = auth_token.encode(JWT_SECRET()).unwrap(); - -// let req = Request::builder() -// .method(Method::POST) -// .header("content-type", "application/json") -// .header("authorization", format!("Bearer {auth}")) -// .uri(format!( -// "/cloud/private{}", -// HttpCloudEndpoint::AddUserToTeam.to_string() -// )) -// .extension(ip) -// .body(Body::from(json)) -// .unwrap(); - -// // Send request -// let response = test_app.clone().oneshot(req).await.unwrap(); -// // Validate response -// let err = convert_response::(response) -// .await -// .unwrap_err(); - -// assert_eq!( -// err.to_string(), -// CloudApiErrors::UserAlreadyBelongsToTheTeam.to_string() -// ); -// } - -// #[tokio::test] -// async fn test_add_user_to_team_team_not_found() { -// let test_app = create_test_app(false).await; - -// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - -// // Team does not exist, use random uuid -// let resp = add_user_to_test_team( -// &uuid7::uuid7().to_string(), -// &"test_user_email@gmail.com".to_string(), -// &auth_token, -// &test_app, -// ) -// .await -// .unwrap_err(); - -// assert_eq!( -// resp.to_string(), -// CloudApiErrors::TeamDoesNotExist.to_string() -// ); -// } - -// #[tokio::test] -// async fn test_add_user_to_team_no_registered_apps() { -// let test_app = create_test_app(false).await; - -// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - -// // Register new team -// let team_name = generate_valid_name(); -// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) -// .await -// .unwrap(); - -// // Team does not exist, use random uuid -// let resp = add_user_to_test_team( -// &team_id.to_string(), -// &"test_user_email@gmail.com".to_string(), -// &auth_token, -// &test_app, -// ) -// .await -// .unwrap_err(); - -// assert_eq!( -// resp.to_string(), -// CloudApiErrors::TeamHasNoRegisteredApps.to_string() -// ); -// } - -// #[tokio::test] -// async fn test_add_user_to_team_user_limit_reached() { -// let test_app = create_test_app(false).await; - -// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - -// // Register new team -// let team_name = generate_valid_name(); -// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) -// .await -// .unwrap(); - -// // Register app under the team -// let app_name = generate_valid_name(); -// let request = HttpRegisterNewAppRequest { -// team_id: team_id.clone(), -// app_name: app_name.clone(), -// whitelisted_domains: vec![], -// ack_public_keys: vec![], -// }; - -// let _ = add_test_app(&request, &auth_token, &test_app) -// .await -// .unwrap(); - -// // Add [USERS_AMOUNT_LIMIT_PER_TEAM] users to the team -// for _ in 1..USERS_AMOUNT_LIMIT_PER_TEAM { -// let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; - -// // Add user to the team -// add_user_to_test_team( -// &team_id.to_string(), -// &test_user_email.to_string(), -// &auth_token, -// &test_app, -// ) -// .await -// .unwrap(); -// } - -// // Try to add another user to the team, should fail as user limit has been reached -// let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; - -// // Add user to the team -// let resp = add_user_to_test_team( -// &team_id.to_string(), -// &test_user_email.to_string(), -// &auth_token, -// &test_app, -// ) -// .await -// .unwrap_err(); - -// assert_eq!( -// resp.to_string(), -// CloudApiErrors::MaximumUsersPerTeamReached.to_string() -// ); -// } - -// #[tokio::test] -// async fn test_add_user_to_team_user_does_not_exist() { -// let test_app = create_test_app(false).await; - -// let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; - -// // Register new team -// let team_name = generate_valid_name(); -// let team_id = add_test_team(&team_name, &auth_token, &test_app, false) -// .await -// .unwrap(); - -// // Register app under the team -// let app_name = generate_valid_name(); -// let request = HttpRegisterNewAppRequest { -// team_id: team_id.clone(), -// app_name: app_name.clone(), -// whitelisted_domains: vec![], -// ack_public_keys: vec![], -// }; - -// let _ = add_test_app(&request, &auth_token, &test_app) -// .await -// .unwrap(); - -// // Try to add non-existing user to the team, should fail as user limit has been reached - -// // Add user to the team -// let resp = add_user_to_test_team( -// &team_id.to_string(), -// &"non-existing-user@gmail.com".to_string(), -// &auth_token, -// &test_app, -// ) -// .await -// .unwrap_err(); - -// assert_eq!( -// resp.to_string(), -// CloudApiErrors::UserDoesNotExist.to_string() -// ); -// } -// } +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use crate::{ + env::JWT_SECRET, + http::cloud::{ + invite_user_to_team::{HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse}, + register_new_app::HttpRegisterNewAppRequest, + }, + statics::USERS_AMOUNT_LIMIT_PER_TEAM, + structs::cloud::{ + api_cloud_errors::CloudApiErrors, cloud_http_endpoints::HttpCloudEndpoint, + }, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_invite_user_to_team() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Invite user to the team + let request = HttpInviteUserToTeamRequest { + team_id: team_id.clone(), + user_email: test_user_email.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::InviteUserToTeam.to_string() + )) + .extension(ip.clone()) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response) + .await + .unwrap(); + + // Try to invite user to the team again, should fail as user is already invited to the team + let request = HttpInviteUserToTeamRequest { + team_id: team_id.clone(), + user_email: test_user_email.clone(), + }; + + let json = serde_json::to_string(&request).unwrap(); + let auth = auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::InviteUserToTeam.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let err = convert_response::(response) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + CloudApiErrors::UserAlreadyInvitedToTheTeam.to_string() + ); + } + + #[tokio::test] + async fn test_invite_user_to_team_team_not_found() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Team does not exist, use random uuid + let resp = invite_user_to_test_team( + &uuid7::uuid7().to_string(), + &"test_user_email@gmail.com".to_string(), + &auth_token, + &test_app, + ) + .await + .unwrap_err(); + + assert_eq!( + resp.to_string(), + CloudApiErrors::TeamDoesNotExist.to_string() + ); + } + + #[tokio::test] + async fn test_add_user_to_team_no_registered_apps() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Team does not have any apps + let resp = invite_user_to_test_team( + &team_id.to_string(), + &"test_user_email@gmail.com".to_string(), + &auth_token, + &test_app, + ) + .await + .unwrap_err(); + + assert_eq!( + resp.to_string(), + CloudApiErrors::TeamHasNoRegisteredApps.to_string() + ); + } + + #[tokio::test] + async fn test_invite_user_to_team_user_limit_reached() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Add [USERS_AMOUNT_LIMIT_PER_TEAM] users to the team + for _ in 1..USERS_AMOUNT_LIMIT_PER_TEAM { + let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; + + // Add user to the team + invite_user_to_test_team( + &team_id.to_string(), + &test_user_email.to_string(), + &auth_token, + &test_app, + ) + .await + .unwrap(); + } + + // Try to add another user to the team, should fail as user limit has been reached + let (_, test_user_email, _) = register_and_login_random_user(&test_app).await; + + // Add user to the team + let resp = invite_user_to_test_team( + &team_id.to_string(), + &test_user_email.to_string(), + &auth_token, + &test_app, + ) + .await + .unwrap_err(); + + assert_eq!( + resp.to_string(), + CloudApiErrors::MaximumInvitesPerTeamReached.to_string() + ); + } + + #[tokio::test] + async fn test_add_user_to_team_user_does_not_exist() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Try to add non-existing user to the team, should fail as user limit has been reached + + // Add user to the team + let resp = invite_user_to_test_team( + &team_id.to_string(), + &"non-existing-user@gmail.com".to_string(), + &auth_token, + &test_app, + ) + .await + .unwrap_err(); + + assert_eq!( + resp.to_string(), + CloudApiErrors::UserDoesNotExist.to_string() + ); + } +} diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index f57fd226..94cbd35c 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -1,3 +1,4 @@ +pub mod accept_team_invite; pub mod events; pub mod get_user_joined_teams; pub mod invite_user_to_team; diff --git a/server/src/http/cloud/remove_user_from_team.rs b/server/src/http/cloud/remove_user_from_team.rs index b3b63078..fae4f962 100644 --- a/server/src/http/cloud/remove_user_from_team.rs +++ b/server/src/http/cloud/remove_user_from_team.rs @@ -181,13 +181,19 @@ mod tests { .unwrap(); // Register new user - let (_test_user_auth_token, test_user_email, _test_user_password) = + let (test_user_auth_token, test_user_email, _test_user_password) = register_and_login_random_user(&test_app).await; // Add user to the team - add_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) - .await - .unwrap(); + add_user_to_test_team( + &team_id, + &test_user_email, + &auth_token, + &test_user_auth_token, + &test_app, + ) + .await + .unwrap(); // Remove user from the team let request = HttpRemoveUserFromTeamRequest { diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 248e9d8b..224808fa 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -1,9 +1,10 @@ use crate::{ http::cloud::{ - events::events, get_user_joined_teams::get_user_joined_teams, - invite_user_to_team::invite_user_to_team, login_with_google::login_with_google, - login_with_password::login_with_password, register_new_app::register_new_app, - register_new_team::register_new_team, register_with_password::register_with_password, + accept_team_invite::accept_team_invite, events::events, + get_user_joined_teams::get_user_joined_teams, invite_user_to_team::invite_user_to_team, + login_with_google::login_with_google, login_with_password::login_with_password, + register_new_app::register_new_app, register_new_team::register_new_team, + register_with_password::register_with_password, remove_user_from_team::remove_user_from_team, }, middlewares::auth_middleware::access_auth_middleware, @@ -62,6 +63,10 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::InviteUserToTeam.to_string(), post(invite_user_to_team), ) + .route( + &HttpCloudEndpoint::AcceptTeamInvite.to_string(), + post(accept_team_invite), + ) .route( &HttpCloudEndpoint::RemoveUserFromTeam.to_string(), post(remove_user_from_team), diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 5b5a447d..6c9ac618 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -28,4 +28,5 @@ pub enum CloudApiErrors { AppDoesNotExist, UserAlreadyInvitedToTheTeam, MaximumInvitesPerTeamReached, + InviteNotFound, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index 57baac83..d77c6995 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -22,6 +22,8 @@ pub enum HttpCloudEndpoint { Events, #[serde(rename = "/invite_user_to_team")] InviteUserToTeam, + #[serde(rename = "/accept_team_invite")] + AcceptTeamInvite, } impl HttpCloudEndpoint { @@ -36,6 +38,7 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::GetUserJoinedTeams => "/get_user_joined_teams".to_string(), HttpCloudEndpoint::Events => "/events".to_string(), HttpCloudEndpoint::InviteUserToTeam => "/invite_user_to_team".to_string(), + HttpCloudEndpoint::AcceptTeamInvite => "/accept_team_invite".to_string(), } } } diff --git a/server/src/test_utils.rs b/server/src/test_utils.rs index bad4c99a..905f40f6 100644 --- a/server/src/test_utils.rs +++ b/server/src/test_utils.rs @@ -4,8 +4,9 @@ pub mod test_utils { auth::AuthToken, env::{JWT_PUBLIC_KEY, JWT_SECRET}, http::cloud::{ + accept_team_invite::{HttpAcceptTeamInviteRequest, HttpAcceptTeamInviteResponse}, get_user_joined_teams::HttpGetUserJoinedTeamsResponse, - invite_user_to_team::{HttpAddUserToTeamRequest, HttpAddUserToTeamResponse}, + invite_user_to_team::{HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse}, login_with_password::{HttpLoginRequest, HttpLoginResponse}, register_new_app::{HttpRegisterNewAppRequest, HttpRegisterNewAppResponse}, register_new_team::{HttpRegisterNewTeamRequest, HttpRegisterNewTeamResponse}, @@ -225,14 +226,14 @@ pub mod test_utils { .map(|response| Ok(response.app_id))? } - pub async fn add_user_to_test_team( + pub async fn invite_user_to_test_team( team_id: &String, user_email: &String, admin_token: &AuthToken, app: &Router, ) -> anyhow::Result<()> { - // Add user to test team - let request = HttpAddUserToTeamRequest { + // Invite user to test team + let request = HttpInviteUserToTeamRequest { team_id: team_id.clone(), user_email: user_email.clone(), }; @@ -247,7 +248,41 @@ pub mod test_utils { .header("authorization", format!("Bearer {auth}")) .uri(format!( "/cloud/private{}", - HttpCloudEndpoint::AddUserToTeam.to_string() + HttpCloudEndpoint::InviteUserToTeam.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response) + .await + .map(|_| Ok(()))? + } + + pub async fn accept_invite_to_test_team( + team_id: &String, + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result<()> { + // Invite user to test team + let request = HttpAcceptTeamInviteRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = user_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::POST) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::AcceptTeamInvite.to_string() )) .extension(ip) .body(Body::from(json)) @@ -256,11 +291,25 @@ pub mod test_utils { // Send request let response = app.clone().oneshot(req).await.unwrap(); // Validate response - convert_response::(response) + convert_response::(response) .await .map(|_| Ok(()))? } + pub async fn add_user_to_test_team( + team_id: &String, + user_email: &String, + admin_token: &AuthToken, + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result<()> { + // Add user to test team + match invite_user_to_test_team(team_id, user_email, admin_token, app).await { + Ok(_) => accept_invite_to_test_team(team_id, user_token, app).await, + Err(e) => bail!("Failed to invite user to the team: {}", e), + } + } + pub async fn remove_user_from_test_team( team_id: &String, user_email: &String, From 1b91d27328f675f4faac6679ed526a63f4d1ee9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 12:28:31 +0100 Subject: [PATCH 04/11] remove constraint --- database/migrations/0013_team_invites.sql | 2 -- 1 file changed, 2 deletions(-) diff --git a/database/migrations/0013_team_invites.sql b/database/migrations/0013_team_invites.sql index 8f692b5e..2832dace 100644 --- a/database/migrations/0013_team_invites.sql +++ b/database/migrations/0013_team_invites.sql @@ -9,5 +9,3 @@ CREATE TABLE team_invites( CREATE INDEX team_invites_user_email_idx ON team_invites(user_email); CREATE INDEX team_invites_team_id_idx ON team_invites(team_id); --- This will ensure that a user can only be invited to a team once -CREATE UNIQUE INDEX team_invites_team_id_user_email_idx ON team_invites(team_id, user_email); From 7190aafc9d40a26c8f49d998d2c089699f98897d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 13:00:53 +0100 Subject: [PATCH 05/11] remove leftover --- server/src/http/cloud/accept_team_invite.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/http/cloud/accept_team_invite.rs b/server/src/http/cloud/accept_team_invite.rs index 67dbae42..d92616a9 100644 --- a/server/src/http/cloud/accept_team_invite.rs +++ b/server/src/http/cloud/accept_team_invite.rs @@ -136,7 +136,6 @@ pub async fn accept_team_invite( .await { error!("Failed to add user to the team: {:?}", err); - println!("Failed to add user to the team: {:?}", err); if let Err(err) = tx.rollback().await { error!("Failed to rollback transaction: {:?}", err); } From 61fa09af686f3c4d959a18b0de27b520f661d628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 13:05:13 +0100 Subject: [PATCH 06/11] add check for team type --- server/src/http/cloud/invite_user_to_team.rs | 8 ++++++++ server/src/structs/cloud/api_cloud_errors.rs | 1 + 2 files changed, 9 insertions(+) diff --git a/server/src/http/cloud/invite_user_to_team.rs b/server/src/http/cloud/invite_user_to_team.rs index 71e367cd..7371247f 100644 --- a/server/src/http/cloud/invite_user_to_team.rs +++ b/server/src/http/cloud/invite_user_to_team.rs @@ -51,6 +51,14 @@ pub async fn invite_user_to_team( )); } + // Check team type + if team.personal { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::CantInviteToPersonalTeam.to_string(), + )); + } + // Check if team has at least one registered app match db.get_registered_apps_by_team_id(&request.team_id).await { Ok(apps) => { diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 6c9ac618..5008801f 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -29,4 +29,5 @@ pub enum CloudApiErrors { UserAlreadyInvitedToTheTeam, MaximumInvitesPerTeamReached, InviteNotFound, + CantInviteToPersonalTeam, } From 50cc1ff84fa654f00f7fbb8140803d56286f5fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 13:51:49 +0100 Subject: [PATCH 07/11] get team users invites endpoint --- database/src/tables/team_invites/select.rs | 8 +- .../src/http/cloud/get_team_user_invites.rs | 247 ++++++++++++++++++ server/src/http/cloud/invite_user_to_team.rs | 2 +- server/src/http/cloud/mod.rs | 1 + server/src/routes/cloud_router.rs | 12 +- server/src/structs/cloud/api_cloud_errors.rs | 2 +- .../src/structs/cloud/cloud_http_endpoints.rs | 3 + server/src/structs/cloud/mod.rs | 1 + server/src/structs/cloud/team_invite.rs | 20 ++ server/src/test_utils.rs | 37 ++- 10 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 server/src/http/cloud/get_team_user_invites.rs create mode 100644 server/src/structs/cloud/team_invite.rs diff --git a/database/src/tables/team_invites/select.rs b/database/src/tables/team_invites/select.rs index ae64d629..c95a567b 100644 --- a/database/src/tables/team_invites/select.rs +++ b/database/src/tables/team_invites/select.rs @@ -12,10 +12,10 @@ impl Db { ) -> Result, DbError> { let query = if active_invites { format!( - "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 AND accepted_at IS NULL AND cancelled_at IS NULL" + "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 AND accepted_at IS NULL AND cancelled_at IS NULL ORDER BY created_at DESC" ) } else { - format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1") + format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 ORDER BY created_at DESC") }; let typed_query = query_as::<_, TeamInvite>(&query); @@ -33,10 +33,10 @@ impl Db { ) -> Result, DbError> { let query = if active_invites { format!( - "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1 AND accepted_at IS NULL AND cancelled_at IS NULL" + "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1 AND accepted_at IS NULL AND cancelled_at IS NULL ORDER BY created_at DESC" ) } else { - format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1") + format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1 ORDER BY created_at DESC") }; let typed_query = query_as::<_, TeamInvite>(&query); diff --git a/server/src/http/cloud/get_team_user_invites.rs b/server/src/http/cloud/get_team_user_invites.rs new file mode 100644 index 00000000..5a945f04 --- /dev/null +++ b/server/src/http/cloud/get_team_user_invites.rs @@ -0,0 +1,247 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::{api_cloud_errors::CloudApiErrors, team_invite::TeamInvite}, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpGetTeamUserInvitesRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpGetTeamUserInvitesResponse { + pub team_invites: Vec, +} + +pub async fn get_team_user_invites( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_string(), + ))?; + + // Validate request + validate_request(&request, &())?; + + // Get team data and perform checks + match db.get_team_by_team_id(None, &request.team_id).await { + Ok(Some(team)) => { + // Check if user is a admin of this team + if team.team_admin_id != user_id { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InsufficientPermissions.to_string(), + )); + } + + // Check team type + if team.personal { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::ActionForbiddenForPersonalTeam.to_string(), + )); + } + + // Get active invites for the team + match db.get_invites_by_team_id(&request.team_id, true).await { + Ok(invites) => { + let team_invites: Vec = invites + .into_iter() + .map(|invite| TeamInvite::from(invite)) + .collect(); + + Ok(Json(HttpGetTeamUserInvitesResponse { team_invites })) + } + Err(err) => { + error!("Failed to get team invites: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::TeamDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get team: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::get_test_team_user_invites; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_team_user_invites() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + let mut users_emails: Vec = vec![]; + // Invite a few users to the team + for _ in 0..3 { + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + users_emails.push(test_user_email.clone()); + + // Create invite + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + } + + // Get team invites + let request = HttpGetTeamUserInvitesRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetTeamUserInvites.to_string() + )) + .extension(ip.clone()) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let res = convert_response::(response) + .await + .unwrap(); + + assert_eq!(res.team_invites.len(), 3); + assert_eq!(res.team_invites[0].user_email, users_emails[2]); + assert_eq!(res.team_invites[1].user_email, users_emails[1]); + assert_eq!(res.team_invites[2].user_email, users_emails[0]); + + // Invite another user to the team + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Create invite + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 4); + assert_eq!(resp.team_invites[0].user_email, test_user_email); + assert_eq!(resp.team_invites[1].user_email, users_emails[2]); + assert_eq!(resp.team_invites[2].user_email, users_emails[1]); + assert_eq!(resp.team_invites[3].user_email, users_emails[0]); + } + + #[tokio::test] + async fn test_no_invites() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 0); + } +} diff --git a/server/src/http/cloud/invite_user_to_team.rs b/server/src/http/cloud/invite_user_to_team.rs index 7371247f..3fc62b96 100644 --- a/server/src/http/cloud/invite_user_to_team.rs +++ b/server/src/http/cloud/invite_user_to_team.rs @@ -55,7 +55,7 @@ pub async fn invite_user_to_team( if team.personal { return Err(( StatusCode::BAD_REQUEST, - CloudApiErrors::CantInviteToPersonalTeam.to_string(), + CloudApiErrors::ActionForbiddenForPersonalTeam.to_string(), )); } diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index 94cbd35c..74b55704 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -1,5 +1,6 @@ pub mod accept_team_invite; pub mod events; +pub mod get_team_user_invites; pub mod get_user_joined_teams; pub mod invite_user_to_team; pub mod login_with_google; diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 224808fa..a378b60a 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -1,10 +1,10 @@ use crate::{ http::cloud::{ accept_team_invite::accept_team_invite, events::events, - get_user_joined_teams::get_user_joined_teams, invite_user_to_team::invite_user_to_team, - login_with_google::login_with_google, login_with_password::login_with_password, - register_new_app::register_new_app, register_new_team::register_new_team, - register_with_password::register_with_password, + get_team_user_invites::get_team_user_invites, get_user_joined_teams::get_user_joined_teams, + invite_user_to_team::invite_user_to_team, login_with_google::login_with_google, + login_with_password::login_with_password, register_new_app::register_new_app, + register_new_team::register_new_team, register_with_password::register_with_password, remove_user_from_team::remove_user_from_team, }, middlewares::auth_middleware::access_auth_middleware, @@ -75,5 +75,9 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::GetUserJoinedTeams.to_string(), get(get_user_joined_teams), ) + .route( + &HttpCloudEndpoint::GetTeamUserInvites.to_string(), + get(get_team_user_invites), + ) .with_state(state) } diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 5008801f..8f13ba05 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -29,5 +29,5 @@ pub enum CloudApiErrors { UserAlreadyInvitedToTheTeam, MaximumInvitesPerTeamReached, InviteNotFound, - CantInviteToPersonalTeam, + ActionForbiddenForPersonalTeam, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index d77c6995..cf3d0b3e 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -24,6 +24,8 @@ pub enum HttpCloudEndpoint { InviteUserToTeam, #[serde(rename = "/accept_team_invite")] AcceptTeamInvite, + #[serde(rename = "/get_team_user_invites")] + GetTeamUserInvites, } impl HttpCloudEndpoint { @@ -39,6 +41,7 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::Events => "/events".to_string(), HttpCloudEndpoint::InviteUserToTeam => "/invite_user_to_team".to_string(), HttpCloudEndpoint::AcceptTeamInvite => "/accept_team_invite".to_string(), + HttpCloudEndpoint::GetTeamUserInvites => "/get_team_user_invites".to_string(), } } } diff --git a/server/src/structs/cloud/mod.rs b/server/src/structs/cloud/mod.rs index 610711d7..fc119b04 100644 --- a/server/src/structs/cloud/mod.rs +++ b/server/src/structs/cloud/mod.rs @@ -4,4 +4,5 @@ pub mod cloud_events; pub mod cloud_http_endpoints; pub mod device_metadata; pub mod joined_team; +pub mod team_invite; pub mod user_privilege; diff --git a/server/src/structs/cloud/team_invite.rs b/server/src/structs/cloud/team_invite.rs new file mode 100644 index 00000000..22d8f727 --- /dev/null +++ b/server/src/structs/cloud/team_invite.rs @@ -0,0 +1,20 @@ +use database::tables::team_invites::table_struct::TeamInvite as DbTeamInvite; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct TeamInvite { + pub user_email: String, + pub created_at: String, +} + +impl From for TeamInvite { + fn from(db_team_invite: DbTeamInvite) -> Self { + Self { + user_email: db_team_invite.user_email, + created_at: db_team_invite.created_at.to_string(), + } + } +} diff --git a/server/src/test_utils.rs b/server/src/test_utils.rs index 905f40f6..e3a4080d 100644 --- a/server/src/test_utils.rs +++ b/server/src/test_utils.rs @@ -5,6 +5,9 @@ pub mod test_utils { env::{JWT_PUBLIC_KEY, JWT_SECRET}, http::cloud::{ accept_team_invite::{HttpAcceptTeamInviteRequest, HttpAcceptTeamInviteResponse}, + get_team_user_invites::{ + HttpGetTeamUserInvitesRequest, HttpGetTeamUserInvitesResponse, + }, get_user_joined_teams::HttpGetUserJoinedTeamsResponse, invite_user_to_team::{HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse}, login_with_password::{HttpLoginRequest, HttpLoginResponse}, @@ -17,7 +20,7 @@ pub mod test_utils { }, routes::router::get_router, statics::NAME_REGEX, - structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + structs::cloud::{cloud_http_endpoints::HttpCloudEndpoint, team_invite::TeamInvite}, }; use anyhow::bail; use axum::{ @@ -310,6 +313,38 @@ pub mod test_utils { } } + pub async fn get_test_team_user_invites( + team_id: &String, + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result { + // Get team invites for users + let request = HttpGetTeamUserInvitesRequest { + team_id: team_id.clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = user_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetTeamUserInvites.to_string() + )) + .extension(ip) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response).await + } + pub async fn remove_user_from_test_team( team_id: &String, user_email: &String, From fd0171baa6fb674ee16636935513baadb10bb4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Fri, 22 Mar 2024 15:45:07 +0100 Subject: [PATCH 08/11] add get user invites to teams endpoint --- database/src/tables/team_invites/select.rs | 46 ++-- .../src/tables/team_invites/table_struct.rs | 5 + server/bindings/CloudApiErrors.ts | 2 +- server/bindings/HttpCloudEndpoint.ts | 2 +- .../bindings/HttpGetTeamUserInvitesRequest.ts | 3 + .../HttpGetTeamUserInvitesResponse.ts | 4 + .../HttpGetUserTeamInvitesResponse.ts | 4 + server/bindings/TeamInvite.ts | 3 + .../src/http/cloud/get_user_team_invites.rs | 223 ++++++++++++++++++ server/src/http/cloud/mod.rs | 1 + server/src/routes/cloud_router.rs | 11 +- .../src/structs/cloud/cloud_http_endpoints.rs | 3 + server/src/structs/cloud/team_invite.rs | 4 + server/src/test_utils.rs | 28 +++ 14 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 server/bindings/HttpGetTeamUserInvitesRequest.ts create mode 100644 server/bindings/HttpGetTeamUserInvitesResponse.ts create mode 100644 server/bindings/HttpGetUserTeamInvitesResponse.ts create mode 100644 server/bindings/TeamInvite.ts create mode 100644 server/src/http/cloud/get_user_team_invites.rs diff --git a/database/src/tables/team_invites/select.rs b/database/src/tables/team_invites/select.rs index c95a567b..bcfbe83d 100644 --- a/database/src/tables/team_invites/select.rs +++ b/database/src/tables/team_invites/select.rs @@ -2,7 +2,6 @@ use super::table_struct::TeamInvite; use crate::db::Db; use crate::structs::db_error::DbError; use crate::tables::team_invites::table_struct::TEAM_INVITES_TABLE_NAME; -use sqlx::query_as; impl Db { pub async fn get_invites_by_team_id( @@ -10,17 +9,25 @@ impl Db { team_id: &String, active_invites: bool, ) -> Result, DbError> { - let query = if active_invites { - format!( - "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 AND accepted_at IS NULL AND cancelled_at IS NULL ORDER BY created_at DESC" - ) + let additional_filter = if active_invites { + "AND ti.accepted_at IS NULL AND ti.cancelled_at IS NULL" } else { - format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE team_id = $1 ORDER BY created_at DESC") + "" }; - let typed_query = query_as::<_, TeamInvite>(&query); + + let query = format!( + "SELECT ti.invite_id, ti.team_id, ti.user_email, ti.created_at, + ti.accepted_at, ti.cancelled_at, t.team_name, gu.email AS admin_email + FROM {TEAM_INVITES_TABLE_NAME} ti + INNER JOIN team t ON ti.team_id = t.team_id + INNER JOIN grafana_users gu ON t.team_admin_id = gu.user_id + WHERE ti.team_id = $1 {additional_filter} + ORDER BY ti.created_at DESC", + ); + + let typed_query = sqlx::query_as::<_, TeamInvite>(&query).bind(team_id); return typed_query - .bind(&team_id) .fetch_all(&self.connection_pool) .await .map_err(|e| e.into()); @@ -31,17 +38,26 @@ impl Db { user_email: &String, active_invites: bool, ) -> Result, DbError> { - let query = if active_invites { - format!( - "SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1 AND accepted_at IS NULL AND cancelled_at IS NULL ORDER BY created_at DESC" - ) + let additional_filter = if active_invites { + "AND ti.accepted_at IS NULL AND ti.cancelled_at IS NULL" } else { - format!("SELECT * FROM {TEAM_INVITES_TABLE_NAME} WHERE user_email = $1 ORDER BY created_at DESC") + "" }; - let typed_query = query_as::<_, TeamInvite>(&query); + + let query = format!( + "SELECT ti.invite_id, ti.team_id, ti.user_email, ti.created_at, \ + ti.accepted_at, ti.cancelled_at, t.team_name, gu.email AS admin_email \ + FROM {TEAM_INVITES_TABLE_NAME} ti \ + INNER JOIN team t ON ti.team_id = t.team_id \ + INNER JOIN grafana_users gu ON t.team_admin_id = gu.user_id \ + WHERE ti.user_email = $1 {additional_filter} \ + ORDER BY ti.created_at DESC", + TEAM_INVITES_TABLE_NAME = TEAM_INVITES_TABLE_NAME + ); + + let typed_query = sqlx::query_as::<_, TeamInvite>(&query).bind(user_email); return typed_query - .bind(&user_email) .fetch_all(&self.connection_pool) .await .map_err(|e| e.into()); diff --git a/database/src/tables/team_invites/table_struct.rs b/database/src/tables/team_invites/table_struct.rs index a05d6c59..5f961295 100644 --- a/database/src/tables/team_invites/table_struct.rs +++ b/database/src/tables/team_invites/table_struct.rs @@ -16,6 +16,9 @@ pub struct TeamInvite { pub created_at: DateTime, pub accepted_at: Option>, pub cancelled_at: Option>, + // Not present in the table, queried from the team table + pub team_name: String, + pub admin_email: String, } impl FromRow<'_, PgRow> for TeamInvite { @@ -27,6 +30,8 @@ impl FromRow<'_, PgRow> for TeamInvite { created_at: row.get("created_at"), accepted_at: row.get("accepted_at"), cancelled_at: row.get("cancelled_at"), + team_name: row.get("team_name"), + admin_email: row.get("admin_email"), }) } } diff --git a/server/bindings/CloudApiErrors.ts b/server/bindings/CloudApiErrors.ts index 8723ed52..b0d415ab 100644 --- a/server/bindings/CloudApiErrors.ts +++ b/server/bindings/CloudApiErrors.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound"; \ No newline at end of file +export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam"; \ No newline at end of file diff --git a/server/bindings/HttpCloudEndpoint.ts b/server/bindings/HttpCloudEndpoint.ts index 4fd89a8e..45bbb210 100644 --- a/server/bindings/HttpCloudEndpoint.ts +++ b/server/bindings/HttpCloudEndpoint.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites"; \ No newline at end of file diff --git a/server/bindings/HttpGetTeamUserInvitesRequest.ts b/server/bindings/HttpGetTeamUserInvitesRequest.ts new file mode 100644 index 00000000..4b641992 --- /dev/null +++ b/server/bindings/HttpGetTeamUserInvitesRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpGetTeamUserInvitesRequest { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpGetTeamUserInvitesResponse.ts b/server/bindings/HttpGetTeamUserInvitesResponse.ts new file mode 100644 index 00000000..b6865457 --- /dev/null +++ b/server/bindings/HttpGetTeamUserInvitesResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TeamInvite } from "./TeamInvite"; + +export interface HttpGetTeamUserInvitesResponse { team_invites: Array, } \ No newline at end of file diff --git a/server/bindings/HttpGetUserTeamInvitesResponse.ts b/server/bindings/HttpGetUserTeamInvitesResponse.ts new file mode 100644 index 00000000..f4ff08dc --- /dev/null +++ b/server/bindings/HttpGetUserTeamInvitesResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TeamInvite } from "./TeamInvite"; + +export interface HttpGetUserTeamInvitesResponse { team_invites: Array, } \ No newline at end of file diff --git a/server/bindings/TeamInvite.ts b/server/bindings/TeamInvite.ts new file mode 100644 index 00000000..ae35f807 --- /dev/null +++ b/server/bindings/TeamInvite.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface TeamInvite { creatorEmail: string, teamName: string, userEmail: string, createdAt: string, } \ No newline at end of file diff --git a/server/src/http/cloud/get_user_team_invites.rs b/server/src/http/cloud/get_user_team_invites.rs new file mode 100644 index 00000000..7463d248 --- /dev/null +++ b/server/src/http/cloud/get_user_team_invites.rs @@ -0,0 +1,223 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::{api_cloud_errors::CloudApiErrors, team_invite::TeamInvite}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpGetUserTeamInvitesResponse { + pub team_invites: Vec, +} + +pub async fn get_user_team_invites( + State(db): State>>, + Extension(user_id): Extension, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_string(), + ))?; + + // Get user data and perform checks + let user = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get user by user_id: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Get active invites for the team + match db.get_invites_by_user_email(&user.email, true).await { + Ok(invites) => { + let team_invites: Vec = invites + .into_iter() + .map(|invite| TeamInvite::from(invite)) + .collect(); + + Ok(Json(HttpGetUserTeamInvitesResponse { team_invites })) + } + Err(err) => { + error!("Failed to get user team invites: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use crate::auth::AuthToken; + use crate::http::cloud::get_user_team_invites::HttpGetUserTeamInvitesResponse; + use crate::test_utils::test_utils::get_test_user_team_invites; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_user_team_invites() { + let test_app = create_test_app(false).await; + + // Register 3 teams + let mut team_ids: Vec = vec![]; + let mut admin_auth_tokens: Vec = vec![]; + + for _ in 0..3 { + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + admin_auth_tokens.push(auth_token.clone()); + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + team_ids.push(team_id.clone()); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + } + + // Register new user + let (test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + // Create invites for first two teams + invite_user_to_test_team( + &team_ids[0], + &test_user_email, + &admin_auth_tokens[0], + &test_app, + ) + .await + .unwrap(); + + invite_user_to_test_team( + &team_ids[1], + &test_user_email, + &admin_auth_tokens[1], + &test_app, + ) + .await + .unwrap(); + + // Get user team invites + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let auth = test_user_auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetUserTeamInvites.to_string() + )) + .extension(ip.clone()) + .body(Body::empty()) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + let res = convert_response::(response) + .await + .unwrap(); + + assert_eq!(res.team_invites.len(), 2); + + // Invite user to the last team + invite_user_to_test_team( + &team_ids[2], + &test_user_email, + &admin_auth_tokens[2], + &test_app, + ) + .await + .unwrap(); + + // Get user team invites + let invites = get_test_user_team_invites(&test_user_auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(invites.team_invites.len(), 3); + } + + #[tokio::test] + async fn test_no_invites() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_user_team_invites(&auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 0); + } +} diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index 74b55704..9d6303ef 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -2,6 +2,7 @@ pub mod accept_team_invite; pub mod events; pub mod get_team_user_invites; pub mod get_user_joined_teams; +pub mod get_user_team_invites; pub mod invite_user_to_team; pub mod login_with_google; pub mod login_with_password; diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index a378b60a..897c6e79 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -2,9 +2,10 @@ use crate::{ http::cloud::{ accept_team_invite::accept_team_invite, events::events, get_team_user_invites::get_team_user_invites, get_user_joined_teams::get_user_joined_teams, - invite_user_to_team::invite_user_to_team, login_with_google::login_with_google, - login_with_password::login_with_password, register_new_app::register_new_app, - register_new_team::register_new_team, register_with_password::register_with_password, + get_user_team_invites::get_user_team_invites, invite_user_to_team::invite_user_to_team, + login_with_google::login_with_google, login_with_password::login_with_password, + register_new_app::register_new_app, register_new_team::register_new_team, + register_with_password::register_with_password, remove_user_from_team::remove_user_from_team, }, middlewares::auth_middleware::access_auth_middleware, @@ -79,5 +80,9 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::GetTeamUserInvites.to_string(), get(get_team_user_invites), ) + .route( + &HttpCloudEndpoint::GetUserTeamInvites.to_string(), + get(get_user_team_invites), + ) .with_state(state) } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index cf3d0b3e..2bc6305c 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -26,6 +26,8 @@ pub enum HttpCloudEndpoint { AcceptTeamInvite, #[serde(rename = "/get_team_user_invites")] GetTeamUserInvites, + #[serde(rename = "/get_user_team_invites")] + GetUserTeamInvites, } impl HttpCloudEndpoint { @@ -42,6 +44,7 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::InviteUserToTeam => "/invite_user_to_team".to_string(), HttpCloudEndpoint::AcceptTeamInvite => "/accept_team_invite".to_string(), HttpCloudEndpoint::GetTeamUserInvites => "/get_team_user_invites".to_string(), + HttpCloudEndpoint::GetUserTeamInvites => "/get_user_team_invites".to_string(), } } } diff --git a/server/src/structs/cloud/team_invite.rs b/server/src/structs/cloud/team_invite.rs index 22d8f727..fbc84441 100644 --- a/server/src/structs/cloud/team_invite.rs +++ b/server/src/structs/cloud/team_invite.rs @@ -6,6 +6,8 @@ use ts_rs::TS; #[ts(export)] #[serde(rename_all = "camelCase")] pub struct TeamInvite { + pub creator_email: String, + pub team_name: String, pub user_email: String, pub created_at: String, } @@ -13,6 +15,8 @@ pub struct TeamInvite { impl From for TeamInvite { fn from(db_team_invite: DbTeamInvite) -> Self { Self { + creator_email: db_team_invite.admin_email, + team_name: db_team_invite.team_name, user_email: db_team_invite.user_email, created_at: db_team_invite.created_at.to_string(), } diff --git a/server/src/test_utils.rs b/server/src/test_utils.rs index e3a4080d..1c67b0e3 100644 --- a/server/src/test_utils.rs +++ b/server/src/test_utils.rs @@ -9,6 +9,7 @@ pub mod test_utils { HttpGetTeamUserInvitesRequest, HttpGetTeamUserInvitesResponse, }, get_user_joined_teams::HttpGetUserJoinedTeamsResponse, + get_user_team_invites::HttpGetUserTeamInvitesResponse, invite_user_to_team::{HttpInviteUserToTeamRequest, HttpInviteUserToTeamResponse}, login_with_password::{HttpLoginRequest, HttpLoginResponse}, register_new_app::{HttpRegisterNewAppRequest, HttpRegisterNewAppResponse}, @@ -345,6 +346,33 @@ pub mod test_utils { convert_response::(response).await } + pub async fn get_test_user_team_invites( + user_token: &AuthToken, + app: &Router, + ) -> anyhow::Result { + // Get use team invites + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let auth = user_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::GetUserTeamInvites.to_string() + )) + .extension(ip) + .body(Body::empty()) + .unwrap(); + + // Send request + let response = app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response).await + } + pub async fn remove_user_from_test_team( team_id: &String, user_email: &String, From d41b74bbbc2f05a223e7388bbf76902290080551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Mon, 25 Mar 2024 08:59:16 +0100 Subject: [PATCH 09/11] bump --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f1340cad..951aa19a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,14 +19,14 @@ log = "0.4.20" jsonwebtoken = "9.2.0" pwhash = "1.0.0" futures = "0.3.30" -axum = { version = "0.7.4", features = ["ws", "macros"] } +axum = { version = "0.7.5", features = ["ws", "macros"] } tower = { version = "0.4.13", features = [ "util", "timeout", "load-shed", "limit", ] } -tower-http = { version = "0.5.1", features = ["cors", "trace"] } +tower-http = { version = "0.5.2", features = ["cors", "trace"] } reqwest = {version = "0.11.24", features = ["json"]} tokio = { version = "1.35.1", features = ["full"] } async-trait = "0.1.77" From 7453f7fb0494956767e1d1dbfbc14d085ccced6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Mon, 25 Mar 2024 09:07:17 +0100 Subject: [PATCH 10/11] bump --- .github/workflows/connect-test-local.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/connect-test-local.yml b/.github/workflows/connect-test-local.yml index 0dbb25df..dcc7ad92 100644 --- a/.github/workflows/connect-test-local.yml +++ b/.github/workflows/connect-test-local.yml @@ -37,6 +37,8 @@ jobs: - uses: actions-rs/toolchain@v1 with: toolchain: stable + default: true + override: true - name: install packages if: steps.cache-node-modules.outputs.cache-hit != 'true' run: | From 3512917a4257d723e4cc76f4b6a4673a9d8ac24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CGiems=E2=80=9D?= <“hubert.wabia@gmail.com”> Date: Mon, 25 Mar 2024 09:57:59 +0100 Subject: [PATCH 11/11] fix missing camel case --- server/bindings/CloudApiErrors.ts | 2 +- .../HttpCancelTeamUserInviteRequest.ts | 3 + .../HttpCancelTeamUserInviteResponse.ts | 3 + server/bindings/HttpCloudEndpoint.ts | 2 +- .../HttpGetTeamUserInvitesResponse.ts | 2 +- .../HttpGetUserJoinedTeamsResponse.ts | 2 +- .../HttpGetUserTeamInvitesResponse.ts | 2 +- server/bindings/HttpLoginResponse.ts | 2 +- server/bindings/HttpRegisterNewAppResponse.ts | 2 +- .../bindings/HttpRegisterNewTeamResponse.ts | 2 +- .../HttpRegisterWithPasswordResponse.ts | 2 +- server/src/http/cloud/cancel_team_invite.rs | 268 ++++++++++++++++++ .../src/http/cloud/get_team_user_invites.rs | 1 + .../src/http/cloud/get_user_joined_teams.rs | 1 + .../src/http/cloud/get_user_team_invites.rs | 1 + server/src/http/cloud/login_with_password.rs | 1 + server/src/http/cloud/mod.rs | 1 + server/src/http/cloud/register_new_app.rs | 1 + server/src/http/cloud/register_new_team.rs | 1 + .../src/http/cloud/register_with_password.rs | 1 + server/src/routes/cloud_router.rs | 16 +- server/src/structs/cloud/api_cloud_errors.rs | 1 + .../src/structs/cloud/cloud_http_endpoints.rs | 3 + 23 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 server/bindings/HttpCancelTeamUserInviteRequest.ts create mode 100644 server/bindings/HttpCancelTeamUserInviteResponse.ts create mode 100644 server/src/http/cloud/cancel_team_invite.rs diff --git a/server/bindings/CloudApiErrors.ts b/server/bindings/CloudApiErrors.ts index b0d415ab..f7946575 100644 --- a/server/bindings/CloudApiErrors.ts +++ b/server/bindings/CloudApiErrors.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam"; \ No newline at end of file +export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist"; \ No newline at end of file diff --git a/server/bindings/HttpCancelTeamUserInviteRequest.ts b/server/bindings/HttpCancelTeamUserInviteRequest.ts new file mode 100644 index 00000000..be2e71b3 --- /dev/null +++ b/server/bindings/HttpCancelTeamUserInviteRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpCancelTeamUserInviteRequest { teamId: string, userEmail: string, } \ No newline at end of file diff --git a/server/bindings/HttpCancelTeamUserInviteResponse.ts b/server/bindings/HttpCancelTeamUserInviteResponse.ts new file mode 100644 index 00000000..d59fda24 --- /dev/null +++ b/server/bindings/HttpCancelTeamUserInviteResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HttpCancelTeamUserInviteResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpCloudEndpoint.ts b/server/bindings/HttpCloudEndpoint.ts index 45bbb210..f28f2a20 100644 --- a/server/bindings/HttpCloudEndpoint.ts +++ b/server/bindings/HttpCloudEndpoint.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_invite"; \ No newline at end of file diff --git a/server/bindings/HttpGetTeamUserInvitesResponse.ts b/server/bindings/HttpGetTeamUserInvitesResponse.ts index b6865457..8110bfe9 100644 --- a/server/bindings/HttpGetTeamUserInvitesResponse.ts +++ b/server/bindings/HttpGetTeamUserInvitesResponse.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { TeamInvite } from "./TeamInvite"; -export interface HttpGetTeamUserInvitesResponse { team_invites: Array, } \ No newline at end of file +export interface HttpGetTeamUserInvitesResponse { teamInvites: Array, } \ No newline at end of file diff --git a/server/bindings/HttpGetUserJoinedTeamsResponse.ts b/server/bindings/HttpGetUserJoinedTeamsResponse.ts index 65783cf5..7b54bbea 100644 --- a/server/bindings/HttpGetUserJoinedTeamsResponse.ts +++ b/server/bindings/HttpGetUserJoinedTeamsResponse.ts @@ -3,4 +3,4 @@ import type { AppInfo } from "./AppInfo"; import type { JoinedTeam } from "./JoinedTeam"; import type { UserPrivilege } from "./UserPrivilege"; -export interface HttpGetUserJoinedTeamsResponse { teams: Record, teams_apps: Record>, user_privileges: Record>, } \ No newline at end of file +export interface HttpGetUserJoinedTeamsResponse { teams: Record, teamsApps: Record>, userPrivileges: Record>, } \ No newline at end of file diff --git a/server/bindings/HttpGetUserTeamInvitesResponse.ts b/server/bindings/HttpGetUserTeamInvitesResponse.ts index f4ff08dc..a996333f 100644 --- a/server/bindings/HttpGetUserTeamInvitesResponse.ts +++ b/server/bindings/HttpGetUserTeamInvitesResponse.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { TeamInvite } from "./TeamInvite"; -export interface HttpGetUserTeamInvitesResponse { team_invites: Array, } \ No newline at end of file +export interface HttpGetUserTeamInvitesResponse { teamInvites: Array, } \ No newline at end of file diff --git a/server/bindings/HttpLoginResponse.ts b/server/bindings/HttpLoginResponse.ts index ecf5d957..222f4049 100644 --- a/server/bindings/HttpLoginResponse.ts +++ b/server/bindings/HttpLoginResponse.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface HttpLoginResponse { user_id: string, auth_token: string, refresh_token: string, } \ No newline at end of file +export interface HttpLoginResponse { userId: string, authToken: string, refreshToken: string, } \ No newline at end of file diff --git a/server/bindings/HttpRegisterNewAppResponse.ts b/server/bindings/HttpRegisterNewAppResponse.ts index 4491fc30..79bfb3d7 100644 --- a/server/bindings/HttpRegisterNewAppResponse.ts +++ b/server/bindings/HttpRegisterNewAppResponse.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface HttpRegisterNewAppResponse { app_id: string, } \ No newline at end of file +export interface HttpRegisterNewAppResponse { appId: string, } \ No newline at end of file diff --git a/server/bindings/HttpRegisterNewTeamResponse.ts b/server/bindings/HttpRegisterNewTeamResponse.ts index d56c4b22..c73cfb55 100644 --- a/server/bindings/HttpRegisterNewTeamResponse.ts +++ b/server/bindings/HttpRegisterNewTeamResponse.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface HttpRegisterNewTeamResponse { team_id: string, } \ No newline at end of file +export interface HttpRegisterNewTeamResponse { teamId: string, } \ No newline at end of file diff --git a/server/bindings/HttpRegisterWithPasswordResponse.ts b/server/bindings/HttpRegisterWithPasswordResponse.ts index 6092e7ff..ac9570c3 100644 --- a/server/bindings/HttpRegisterWithPasswordResponse.ts +++ b/server/bindings/HttpRegisterWithPasswordResponse.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface HttpRegisterWithPasswordResponse { user_id: string, } \ No newline at end of file +export interface HttpRegisterWithPasswordResponse { userId: string, } \ No newline at end of file diff --git a/server/src/http/cloud/cancel_team_invite.rs b/server/src/http/cloud/cancel_team_invite.rs new file mode 100644 index 00000000..a44f2a6b --- /dev/null +++ b/server/src/http/cloud/cancel_team_invite.rs @@ -0,0 +1,268 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::cloud::{api_cloud_errors::CloudApiErrors, team_invite::TeamInvite}, + utils::{custom_validate_uuid, validate_request}, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS, Validate)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpCancelTeamUserInviteRequest { + #[garde(custom(custom_validate_uuid))] + pub team_id: String, + #[garde(email)] + pub user_email: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct HttpCancelTeamUserInviteResponse {} + +pub async fn cancel_team_user_invite( + State(db): State>>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Db connection has already been checked in the middleware + let db = db.as_ref().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::CloudFeatureDisabled.to_string(), + ))?; + + // Validate request + validate_request(&request, &())?; + + // Get team data and perform checks + match db.get_team_by_team_id(None, &request.team_id).await { + Ok(Some(team)) => { + // Check if user is a admin of this team + if team.team_admin_id != user_id { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InsufficientPermissions.to_string(), + )); + } + + // Check team type + if team.personal { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::ActionForbiddenForPersonalTeam.to_string(), + )); + } + + // Check if invite exists + match db.get_invites_by_team_id(&request.team_id, true).await { + Ok(invites) => { + let team_invites: Vec = invites + .into_iter() + .map(|invite| TeamInvite::from(invite)) + .collect(); + + // Find the invite + let invite = team_invites + .iter() + .find(|invite| invite.user_email == request.user_email); + + if let None = invite { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InviteDoesNotExist.to_string(), + )); + } + } + Err(err) => { + error!("Failed to get team invites: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Cancel invite + // Cancel the invite + match db + .cancel_team_invite(&request.team_id, &request.user_email) + .await + { + Ok(_) => { + return Ok(Json(HttpCancelTeamUserInviteResponse {})); + } + Err(err) => { + error!("Failed to cancel invite: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + } + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::TeamDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get team: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::get_test_team_user_invites; + use crate::{ + env::JWT_SECRET, + http::cloud::register_new_app::HttpRegisterNewAppRequest, + structs::cloud::cloud_http_endpoints::HttpCloudEndpoint, + test_utils::test_utils::{ + add_test_app, add_test_team, convert_response, create_test_app, generate_valid_name, + invite_user_to_test_team, register_and_login_random_user, + }, + }; + use axum::{ + body::Body, + extract::ConnectInfo, + http::{Method, Request}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + #[tokio::test] + async fn test_cancel_team_user_invite() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + let mut users_emails: Vec = vec![]; + // Invite a few users to the team + for _ in 0..3 { + // Register new user + let (_test_user_auth_token, test_user_email, _test_user_password) = + register_and_login_random_user(&test_app).await; + + users_emails.push(test_user_email.clone()); + + // Create invite + invite_user_to_test_team(&team_id, &test_user_email, &auth_token, &test_app) + .await + .unwrap(); + } + + // Check if team invite exists + let team_invites = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(team_invites.team_invites.len(), 3); + assert_eq!(team_invites.team_invites[0].user_email, users_emails[2]); + assert_eq!(team_invites.team_invites[1].user_email, users_emails[1]); + assert_eq!(team_invites.team_invites[2].user_email, users_emails[0]); + + // Cancel team invite for the first user + let request = HttpCancelTeamUserInviteRequest { + team_id: team_id.clone(), + user_email: users_emails[0].clone(), + }; + + let ip: ConnectInfo = ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))); + let json = serde_json::to_string(&request).unwrap(); + let auth = auth_token.encode(JWT_SECRET()).unwrap(); + + let req = Request::builder() + .method(Method::GET) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {auth}")) + .uri(format!( + "/cloud/private{}", + HttpCloudEndpoint::CancelTeamInvite.to_string() + )) + .extension(ip.clone()) + .body(Body::from(json)) + .unwrap(); + + // Send request + let response = test_app.clone().oneshot(req).await.unwrap(); + // Validate response + convert_response::(response) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 2); + assert_eq!(resp.team_invites[0].user_email, users_emails[2]); + assert_eq!(resp.team_invites[1].user_email, users_emails[1]); + } + + #[tokio::test] + async fn test_no_invites() { + let test_app = create_test_app(false).await; + + let (auth_token, _email, _password) = register_and_login_random_user(&test_app).await; + + // Register new team + let team_name = generate_valid_name(); + let team_id = add_test_team(&team_name, &auth_token, &test_app, false) + .await + .unwrap(); + + // Register app under the team + let app_name = generate_valid_name(); + let request = HttpRegisterNewAppRequest { + team_id: team_id.clone(), + app_name: app_name.clone(), + whitelisted_domains: vec![], + ack_public_keys: vec![], + }; + + // unwrap err as it should have failed + let _ = add_test_app(&request, &auth_token, &test_app) + .await + .unwrap(); + + // Get team invites + let resp = get_test_team_user_invites(&team_id, &auth_token, &test_app) + .await + .unwrap(); + + assert_eq!(resp.team_invites.len(), 0); + } +} diff --git a/server/src/http/cloud/get_team_user_invites.rs b/server/src/http/cloud/get_team_user_invites.rs index 5a945f04..38ccc23d 100644 --- a/server/src/http/cloud/get_team_user_invites.rs +++ b/server/src/http/cloud/get_team_user_invites.rs @@ -21,6 +21,7 @@ pub struct HttpGetTeamUserInvitesRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpGetTeamUserInvitesResponse { pub team_invites: Vec, } diff --git a/server/src/http/cloud/get_user_joined_teams.rs b/server/src/http/cloud/get_user_joined_teams.rs index 414441e5..058768c8 100644 --- a/server/src/http/cloud/get_user_joined_teams.rs +++ b/server/src/http/cloud/get_user_joined_teams.rs @@ -17,6 +17,7 @@ use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpGetUserJoinedTeamsResponse { pub teams: HashMap, pub teams_apps: HashMap>, diff --git a/server/src/http/cloud/get_user_team_invites.rs b/server/src/http/cloud/get_user_team_invites.rs index 7463d248..8f0dad12 100644 --- a/server/src/http/cloud/get_user_team_invites.rs +++ b/server/src/http/cloud/get_user_team_invites.rs @@ -11,6 +11,7 @@ use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpGetUserTeamInvitesResponse { pub team_invites: Vec, } diff --git a/server/src/http/cloud/login_with_password.rs b/server/src/http/cloud/login_with_password.rs index 11da3b95..b99e62d2 100644 --- a/server/src/http/cloud/login_with_password.rs +++ b/server/src/http/cloud/login_with_password.rs @@ -31,6 +31,7 @@ pub struct HttpLoginRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpLoginResponse { pub user_id: String, pub auth_token: String, diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index 9d6303ef..eacae1f5 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -1,4 +1,5 @@ pub mod accept_team_invite; +pub mod cancel_team_invite; pub mod events; pub mod get_team_user_invites; pub mod get_user_joined_teams; diff --git a/server/src/http/cloud/register_new_app.rs b/server/src/http/cloud/register_new_app.rs index f88012a9..5b188775 100644 --- a/server/src/http/cloud/register_new_app.rs +++ b/server/src/http/cloud/register_new_app.rs @@ -31,6 +31,7 @@ pub struct HttpRegisterNewAppRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpRegisterNewAppResponse { pub app_id: String, } diff --git a/server/src/http/cloud/register_new_team.rs b/server/src/http/cloud/register_new_team.rs index 63a7c4a7..643ac682 100644 --- a/server/src/http/cloud/register_new_team.rs +++ b/server/src/http/cloud/register_new_team.rs @@ -28,6 +28,7 @@ pub struct HttpRegisterNewTeamRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpRegisterNewTeamResponse { pub team_id: String, } diff --git a/server/src/http/cloud/register_with_password.rs b/server/src/http/cloud/register_with_password.rs index 57abb26a..4a38cc15 100644 --- a/server/src/http/cloud/register_with_password.rs +++ b/server/src/http/cloud/register_with_password.rs @@ -28,6 +28,7 @@ pub struct HttpRegisterWithPasswordRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] +#[serde(rename_all = "camelCase")] pub struct HttpRegisterWithPasswordResponse { pub user_id: String, } diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 897c6e79..2f79326b 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -1,11 +1,11 @@ use crate::{ http::cloud::{ - accept_team_invite::accept_team_invite, events::events, - get_team_user_invites::get_team_user_invites, get_user_joined_teams::get_user_joined_teams, - get_user_team_invites::get_user_team_invites, invite_user_to_team::invite_user_to_team, - login_with_google::login_with_google, login_with_password::login_with_password, - register_new_app::register_new_app, register_new_team::register_new_team, - register_with_password::register_with_password, + accept_team_invite::accept_team_invite, cancel_team_invite::cancel_team_user_invite, + events::events, get_team_user_invites::get_team_user_invites, + get_user_joined_teams::get_user_joined_teams, get_user_team_invites::get_user_team_invites, + invite_user_to_team::invite_user_to_team, login_with_google::login_with_google, + login_with_password::login_with_password, register_new_app::register_new_app, + register_new_team::register_new_team, register_with_password::register_with_password, remove_user_from_team::remove_user_from_team, }, middlewares::auth_middleware::access_auth_middleware, @@ -84,5 +84,9 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::GetUserTeamInvites.to_string(), get(get_user_team_invites), ) + .route( + &HttpCloudEndpoint::CancelTeamInvite.to_string(), + get(cancel_team_user_invite), + ) .with_state(state) } diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 8f13ba05..c3ba6038 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -30,4 +30,5 @@ pub enum CloudApiErrors { MaximumInvitesPerTeamReached, InviteNotFound, ActionForbiddenForPersonalTeam, + InviteDoesNotExist, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index 2bc6305c..ebbac3ae 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -28,6 +28,8 @@ pub enum HttpCloudEndpoint { GetTeamUserInvites, #[serde(rename = "/get_user_team_invites")] GetUserTeamInvites, + #[serde(rename = "/cancel_team_invite")] + CancelTeamInvite, } impl HttpCloudEndpoint { @@ -45,6 +47,7 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::AcceptTeamInvite => "/accept_team_invite".to_string(), HttpCloudEndpoint::GetTeamUserInvites => "/get_team_user_invites".to_string(), HttpCloudEndpoint::GetUserTeamInvites => "/get_user_team_invites".to_string(), + HttpCloudEndpoint::CancelTeamInvite => "/cancel_team_invite".to_string(), } } }