diff --git a/Cargo.lock b/Cargo.lock index c0b7170e..030e2fc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,7 +93,7 @@ dependencies = [ "actix-utils", "ahash 0.8.3", "base64 0.21.2", - "bitflags 2.3.3", + "bitflags 2.4.0", "brotli", "bytes", "bytestring", @@ -597,9 +597,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.3" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" [[package]] name = "bitvec" @@ -3597,7 +3597,7 @@ version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" dependencies = [ - "bitflags 2.3.3", + "bitflags 2.4.0", "errno", "libc", "linux-raw-sys 0.4.3", diff --git a/src/auth/validate.rs b/src/auth/validate.rs index b2599e3c..e37d1415 100644 --- a/src/auth/validate.rs +++ b/src/auth/validate.rs @@ -166,11 +166,12 @@ pub async fn check_is_moderator_from_headers<'a, 'b, E>( executor: E, redis: &RedisPool, session_queue: &AuthQueue, + required_scopes: Option<&[Scopes]>, ) -> Result where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { - let user = get_user_from_headers(req, executor, redis, session_queue, None) + let user = get_user_from_headers(req, executor, redis, session_queue, required_scopes) .await? .1; diff --git a/src/models/pats.rs b/src/models/pats.rs index 130adfe5..44b9ee9c 100644 --- a/src/models/pats.rs +++ b/src/models/pats.rs @@ -104,7 +104,7 @@ bitflags::bitflags! { const ORGANIZATION_DELETE = 1 << 38; const ALL = 0b111111111111111111111111111111111111111; - const NOT_RESTRICTED = 0b1111111100000011111111111111100111; + const NOT_RESTRICTED = 0b1111111110000000111111111111111111100111; const NONE = 0b0; } } @@ -112,7 +112,7 @@ bitflags::bitflags! { impl Scopes { // these scopes cannot be specified in a personal access token pub fn restricted(&self) -> bool { - self.contains( + self.intersects( Scopes::PAT_CREATE | Scopes::PAT_READ | Scopes::PAT_WRITE @@ -159,3 +159,27 @@ impl PersonalAccessToken { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn pat_sanity() { + assert_eq!(Scopes::NONE, Scopes::empty()); + + // Ensure PATs add up and match + // (Such as NOT_RESTRICTED lining up with is_restricted()) + let mut calculated_not_restricted = Scopes::NONE; + let mut calculated_all = Scopes::NONE; + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !scope.restricted() { + calculated_not_restricted |= scope; + } + calculated_all |= scope; + } + assert_eq!(Scopes::ALL | Scopes::NOT_RESTRICTED, calculated_all); + assert_eq!(Scopes::NOT_RESTRICTED, calculated_not_restricted); + } +} diff --git a/src/routes/v2/collections.rs b/src/routes/v2/collections.rs index b76b09ad..56b658c1 100644 --- a/src/routes/v2/collections.rs +++ b/src/routes/v2/collections.rs @@ -212,16 +212,15 @@ pub async fn collection_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_WRITE]), ) - .await - .map(|x| x.1) - .ok(); + .await? + .1; new_collection .validate() @@ -232,7 +231,7 @@ pub async fn collection_edit( let result = database::models::Collection::get(id, &**pool, &redis).await?; if let Some(collection_item) = result { - if !is_authorized_collection(&collection_item, &user_option).await? { + if collection_item.user_id != user.id.into() && !user.role.is_mod() { return Ok(HttpResponse::Unauthorized().body("")); } @@ -269,27 +268,25 @@ pub async fn collection_edit( } if let Some(status) = &new_collection.status { - if let Some(user) = user_option { - if !(user.role.is_mod() - || collection_item.status.is_approved() && status.can_be_requested()) - { - return Err(ApiError::CustomAuthentication( - "You don't have permission to set this status!".to_string(), - )); - } - - sqlx::query!( - " - UPDATE collections - SET status = $1 - WHERE (id = $2) - ", - status.to_string(), - id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; + if !(user.role.is_mod() + || collection_item.status.is_approved() && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); } + + sqlx::query!( + " + UPDATE collections + SET status = $1 + WHERE (id = $2) + ", + status.to_string(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; } if let Some(new_project_ids) = &new_collection.new_projects { @@ -356,16 +353,15 @@ pub async fn collection_icon_edit( ) -> Result { if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { let cdn_url = dotenvy::var("CDN_URL")?; - let user_option = get_user_from_headers( + let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_WRITE]), ) - .await - .map(|x| x.1) - .ok(); + .await? + .1; let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); @@ -375,7 +371,7 @@ pub async fn collection_icon_edit( ApiError::InvalidInput("The specified collection does not exist!".to_string()) })?; - if !is_authorized_collection(&collection_item, &user_option).await? { + if collection_item.user_id != user.id.into() && !user.role.is_mod() { return Ok(HttpResponse::Unauthorized().body("")); } @@ -439,16 +435,16 @@ pub async fn delete_collection_icon( file_host: web::Data>, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_WRITE]), ) - .await - .map(|x| x.1) - .ok(); + .await? + .1; + let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); let collection_item = database::models::Collection::get(id, &**pool, &redis) @@ -456,7 +452,7 @@ pub async fn delete_collection_icon( .ok_or_else(|| { ApiError::InvalidInput("The specified collection does not exist!".to_string()) })?; - if !is_authorized_collection(&collection_item, &user_option).await? { + if collection_item.user_id != user.id.into() && !user.role.is_mod() { return Ok(HttpResponse::Unauthorized().body("")); } @@ -497,16 +493,15 @@ pub async fn collection_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_DELETE]), ) - .await - .map(|x| x.1) - .ok(); + .await? + .1; let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); @@ -515,7 +510,7 @@ pub async fn collection_delete( .ok_or_else(|| { ApiError::InvalidInput("The specified collection does not exist!".to_string()) })?; - if !is_authorized_collection(&collection, &user_option).await? { + if collection.user_id != user.id.into() && !user.role.is_mod() { return Ok(HttpResponse::Unauthorized().body("")); } let mut transaction = pool.begin().await?; diff --git a/src/routes/v2/moderation.rs b/src/routes/v2/moderation.rs index f1d56dd1..ebebf654 100644 --- a/src/routes/v2/moderation.rs +++ b/src/routes/v2/moderation.rs @@ -1,9 +1,9 @@ use super::ApiError; -use crate::auth::check_is_moderator_from_headers; use crate::database; use crate::database::redis::RedisPool; use crate::models::projects::ProjectStatus; use crate::queue::session::AuthQueue; +use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; use actix_web::{get, web, HttpRequest, HttpResponse}; use serde::Deserialize; use sqlx::PgPool; @@ -30,7 +30,14 @@ pub async fn get_projects( count: web::Query, session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await?; use futures::stream::TryStreamExt; diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs index fe0235d3..754d1a1e 100644 --- a/src/routes/v2/organizations.rs +++ b/src/routes/v2/organizations.rs @@ -40,16 +40,14 @@ pub fn config(cfg: &mut web::ServiceConfig) { #[derive(Deserialize, Validate)] pub struct NewOrganization { - #[validate(length(min = 3, max = 256))] - pub description: String, #[validate( length(min = 3, max = 64), regex = "crate::util::validate::RE_URL_SAFE" )] // Title of the organization, also used as slug pub title: String, - #[serde(default = "crate::models::teams::ProjectPermissions::default")] - pub default_project_permissions: ProjectPermissions, + #[validate(length(min = 3, max = 256))] + pub description: String, } #[post("organization")] @@ -290,7 +288,6 @@ pub struct OrganizationEdit { )] // Title of the organization, also used as slug pub title: Option, - pub default_project_permissions: Option, } #[patch("{id}")] @@ -508,7 +505,7 @@ pub async fn organization_projects_get( &**pool, &redis, &session_queue, - Some(&[Scopes::ORGANIZATION_READ]), + Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]), ) .await .map(|x| x.1) @@ -520,7 +517,7 @@ pub async fn organization_projects_get( let project_ids = sqlx::query!( " SELECT m.id FROM organizations o - LEFT JOIN mods m ON m.id = o.id + INNER JOIN mods m ON m.organization_id = o.id WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL) ", possible_organization_id.map(|x| x as i64), diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index f336ef5f..c0eba9c3 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -405,7 +405,7 @@ pub async fn report_edit( let report = crate::database::models::report_item::Report::get(id, &**pool).await?; if let Some(report) = report { - if !user.role.is_mod() && report.user_id != Some(user.id.into()) { + if !user.role.is_mod() && report.reporter != user.id.into() { return Ok(HttpResponse::NotFound().body("")); } @@ -496,7 +496,14 @@ pub async fn report_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_DELETE]), + ) + .await?; let mut transaction = pool.begin().await?; diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index d16cc314..866ee436 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -453,7 +453,6 @@ pub async fn add_team_member( let organization_permissions = OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) .unwrap_or_default(); - println!("{:?}", organization_permissions); if !organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) { return Err(ApiError::CustomAuthentication( "You don't have permission to invite users to this organization".to_string(), diff --git a/src/routes/v2/threads.rs b/src/routes/v2/threads.rs index 2aa68617..79930a0b 100644 --- a/src/routes/v2/threads.rs +++ b/src/routes/v2/threads.rs @@ -512,7 +512,14 @@ pub async fn moderation_inbox( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await?; let ids = sqlx::query!( " @@ -540,7 +547,14 @@ pub async fn thread_read( redis: web::Data, session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await?; let id = info.into_inner().0; let mut transaction = pool.begin().await?; diff --git a/tests/common/actix.rs b/tests/common/actix.rs index 35bf6483..03935e50 100644 --- a/tests/common/actix.rs +++ b/tests/common/actix.rs @@ -1,6 +1,7 @@ use actix_web::test::TestRequest; use bytes::{Bytes, BytesMut}; +// Multipart functionality (actix-test does not innately support multipart) #[derive(Debug, Clone)] pub struct MultipartSegment { pub name: String, @@ -10,6 +11,7 @@ pub struct MultipartSegment { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub enum MultipartSegmentData { Text(String), Binary(Vec), diff --git a/tests/common/database.rs b/tests/common/database.rs index 43cee67f..2be236cd 100644 --- a/tests/common/database.rs +++ b/tests/common/database.rs @@ -1,13 +1,56 @@ +#![allow(dead_code)] + use labrinth::database::redis::RedisPool; use sqlx::{postgres::PgPoolOptions, Executor, PgPool}; use std::time::Duration; use url::Url; -pub const ADMIN_USER_ID: i64 = 1; -pub const MOD_USER_ID: i64 = 2; -pub const USER_USER_ID: i64 = 3; -pub const FRIEND_USER_ID: i64 = 4; -pub const ENEMY_USER_ID: i64 = 5; +// The dummy test database adds a fair bit of 'dummy' data to test with. +// These constants are used to refer to that data, and are described here. + +// The user IDs are as follows: +pub const ADMIN_USER_ID: &str = "1"; +pub const MOD_USER_ID: &str = "2"; +pub const USER_USER_ID: &str = "3"; // This is the 'main' user ID, and is used for most tests. +pub const FRIEND_USER_ID: &str = "4"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) +pub const ENEMY_USER_ID: &str = "5"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) + +pub const ADMIN_USER_ID_PARSED: i64 = 1; +pub const MOD_USER_ID_PARSED: i64 = 2; +pub const USER_USER_ID_PARSED: i64 = 3; +pub const FRIEND_USER_ID_PARSED: i64 = 4; +pub const ENEMY_USER_ID_PARSED: i64 = 5; + +// These are full-scoped PATs- as if the user was logged in (including illegal scopes). +pub const ADMIN_USER_PAT : &str = "mrp_patadmin"; +pub const MOD_USER_PAT : &str = "mrp_patmoderator"; +pub const USER_USER_PAT : &str = "mrp_patuser"; +pub const FRIEND_USER_PAT : &str = "mrp_patfriend"; +pub const ENEMY_USER_PAT : &str = "mrp_patenemy"; + +// There are two test projects. They are both created by user 3 (USER_USER_ID). +// They differ only in that 'ALPHA' is a public, approved project, and 'BETA' is a private, project in queue. +// The same goes for their corresponding versions- one listed, one draft. +pub const PROJECT_ALPHA_TEAM_ID : &str = "1c"; +pub const PROJECT_BETA_TEAM_ID : &str = "1d"; + +pub const PROJECT_ALPHA_PROJECT_ID : &str = "G8"; +pub const PROJECT_BETA_PROJECT_ID : &str = "G9"; + +pub const PROJECT_ALPHA_PROJECT_SLUG : &str = "testslug"; +pub const PROJECT_BETA_PROJECT_SLUG : &str = "testslug2"; + +pub const PROJECT_ALPHA_VERSION_ID : &str = "Hk"; +pub const PROJECT_BETA_VERSION_ID : &str = "Hl"; + +// These are threads created alongside the projects. +pub const PROJECT_ALPHA_THREAD_ID : &str = "U"; +pub const PROJECT_BETA_THREAD_ID : &str = "V"; + +// These are the hashes of the files attached to their versions: they do not reflect a 'real' hash of data. +// This can be used for /version_file/ type endpoints which get a project's data from its hash. +pub const PROJECT_ALPHA_THREAD_FILE_HASH : &str = "000000000"; +pub const PROJECT_BETA_THREAD_FILE_HASH : &str = "111111111"; pub struct TemporaryDatabase { pub pool: PgPool, @@ -16,6 +59,13 @@ pub struct TemporaryDatabase { } impl TemporaryDatabase { + // Creates a temporary database like sqlx::test does + // 1. Logs into the main database + // 2. Creates a new randomly generated database + // 3. Runs migrations on the new database + // 4. (Optionally, by using create_with_dummy) adds dummy data to the database + // If a db is created with create_with_dummy, it must be cleaned up with cleanup. + // This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise. pub async fn create() -> Self { let temp_database_name = generate_random_database_name(); println!("Creating temporary database: {}", &temp_database_name); @@ -68,6 +118,9 @@ impl TemporaryDatabase { db } + // Deletes the temporary database + // If a temporary db is created, it must be cleaned up with cleanup. + // This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise. pub async fn cleanup(mut self) { let database_url = dotenvy::var("DATABASE_URL").expect("No database URL"); self.pool.close().await; @@ -95,20 +148,6 @@ impl TemporaryDatabase { .expect("Database deletion failed"); } - /* - Adds the following dummy data to the database: - - 5 users (admin, mod, user, friend, enemy) - - Admin and mod have special powers, the others do not - - User is our mock user. Friend and enemy can be used to simulate a collaborator to user to be given permnissions on a project, - whereas enemy might be banned or otherwise not given permission. (These are arbitrary and dependent on the test) - - PATs for each of the five users, with full privileges (for testing purposes). - - 'mrp_patadmin' for admin, etc - - 1 game version (1.20.1) - - 1 dummy project called 'testslug' (and testslug2) with the following properties: - - several categories, tags, etc - - This is a test function, so it panics on error. - */ pub async fn add_dummy_data(&self) { let pool = &self.pool.clone(); pool.execute(include_str!("../files/dummy_data.sql")) diff --git a/tests/common/environment.rs b/tests/common/environment.rs new file mode 100644 index 00000000..b01dbb64 --- /dev/null +++ b/tests/common/environment.rs @@ -0,0 +1,191 @@ +#![allow(dead_code)] + +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, + App, +}; +use chrono::Utc; +use super::database::{TemporaryDatabase, USER_USER_ID_PARSED}; +use labrinth::{ + database::{self, models::generate_pat_id}, + models::pats::Scopes, +}; +use crate::common::setup; + +// A complete test environment, with a test actix app and a database. +// Must be called in an #[actix_rt::test] context. It also simulates a +// temporary sqlx db like #[sqlx::test] would. +// Use .call(req) on it directly to make a test call as if test::call_service(req) were being used. +pub struct TestEnvironment { + test_app: Box, + pub db: TemporaryDatabase, +} + +impl TestEnvironment { + pub async fn new() -> Self { + let db = TemporaryDatabase::create_with_dummy().await; + let labrinth_config = setup(&db).await; + let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); + let test_app = test::init_service(app).await; + Self { test_app: Box::new(test_app), db } + } + pub async fn cleanup(self) { + self.db.cleanup().await; + } + + pub async fn call(&self, req: actix_http::Request) -> ServiceResponse { + self.test_app.call(req).await.unwrap() + } +} + + +trait LocalService { + fn call(&self, req: actix_http::Request) -> std::pin::Pin>>>; +} +impl LocalService for S +where + S: actix_web::dev::Service, + S::Future: 'static, +{ + fn call(&self, req: actix_http::Request) -> std::pin::Pin>>> { + Box::pin(self.call(req)) + } +} + +// A reusable test type that works for any scope test testing an endpoint that: +// - returns a known 'expected_failure_code' if the scope is not present (defaults to 401) +// - returns a 200-299 if the scope is present +// - returns failure and success JSON bodies for requests that are 200 (for performing non-simple follow-up tests on) +// This uses a builder format, so you can chain methods to set the parameters to non-defaults (most will probably be not need to be set). +pub struct ScopeTest<'a> +{ + test_env: &'a TestEnvironment, + // Scopes expected to fail on this test. By default, this is all scopes except the success scopes. + // (To ensure we have isolated the scope we are testing) + failure_scopes: Option, + // User ID to use for the PATs. By default, this is the USER_USER_ID_PARSED constant. + user_id: i64, + // The code that is expected to be returned if the scope is not present. By default, this is 401 (Unauthorized) + expected_failure_code: u16, +} + +impl<'a> ScopeTest<'a> +{ + pub fn new(test_env: &'a TestEnvironment) -> Self { + Self { + test_env, + failure_scopes: None, + user_id: USER_USER_ID_PARSED, + expected_failure_code: 401, + } + } + + // Set non-standard failure scopes + // If not set, it will be set to all scopes except the success scopes + // (eg: if a combination of scopes is needed, but you want to make sure that the endpoint does not work with all-but-one of them) + pub fn with_failure_scopes(mut self, scopes: Scopes) -> Self { + self.failure_scopes = Some(scopes); + self + } + + // Set the user ID to use + // (eg: a moderator, or friend) + pub fn with_user_id(mut self, user_id: i64) -> Self { + self.user_id = user_id; + self + } + + // If a non-401 code is expected. + // (eg: a 404 for a hidden resource, or 200 for a resource with hidden values deeper in) + pub fn with_failure_code(mut self, code: u16) -> Self { + self.expected_failure_code = code; + self + } + + // Call the endpoint generated by req_gen twice, once with a PAT with the failure scopes, and once with the success scopes. + // success_scopes : the scopes that we are testing that should succeed + // returns a tuple of (failure_body, success_body) + // Should return a String error if on unexpected status code, allowing unwrapping in tests. + pub async fn test(&self, req_gen: T, success_scopes: Scopes) -> Result<(serde_json::Value, serde_json::Value), String> + where T: Fn() -> TestRequest + { + + // First, create a PAT with failure scopes + let failure_scopes = self.failure_scopes.unwrap_or(Scopes::ALL ^ success_scopes); + let access_token_all_others = create_test_pat(failure_scopes, self.user_id, &self.test_env.db).await; + + // Create a PAT with the success scopes + let access_token = create_test_pat(success_scopes, self.user_id, &self.test_env.db).await; + + // Perform test twice, once with each PAT + // the first time, we expect a 401 (or known failure code) + let req = req_gen() + .append_header(("Authorization", access_token_all_others.as_str())) + .to_request(); + let resp = self.test_env.test_app.call(req).await.unwrap(); + + if resp.status().as_u16() != self.expected_failure_code { + return Err(format!( + "Expected failure code {}, got {}", + self.expected_failure_code, + resp.status().as_u16() + )); + } + + let failure_body = if resp.status() == 200 + && resp.headers().contains_key("Content-Type") + && resp.headers().get("Content-Type").unwrap() == "application/json" + { + test::read_body_json(resp).await + } else { + serde_json::Value::Null + }; + + // The second time, we expect a success code + let req = req_gen() + .append_header(("Authorization", access_token.as_str())) + .to_request(); + let resp = self.test_env.test_app.call(req).await.unwrap(); + + if !(resp.status().is_success() || resp.status().is_redirection()) { + return Err(format!( + "Expected success code, got {}", + resp.status().as_u16() + )); + } + + let success_body = if resp.status() == 200 + && resp.headers().contains_key("Content-Type") + && resp.headers().get("Content-Type").unwrap() == "application/json" + { + test::read_body_json(resp).await + } else { + serde_json::Value::Null + }; + Ok((failure_body, success_body)) + + } + +} + +// Creates a PAT with the given scopes, and returns the access token +// Interfacing with the db directly, rather than using a ourte, +// allows us to test with scopes that are not allowed to be created by PATs +async fn create_test_pat(scopes: Scopes, user_id: i64, db: &TemporaryDatabase) -> String { + let mut transaction = db.pool.begin().await.unwrap(); + let id = generate_pat_id(&mut transaction).await.unwrap(); + let pat = database::models::pat_item::PersonalAccessToken { + id, + name: format!("test_pat_{}", scopes.bits()), + access_token: format!("mrp_{}", id.0), + scopes, + user_id: database::models::ids::UserId(user_id), + created: Utc::now(), + expires: Utc::now() + chrono::Duration::days(1), + last_used: None, + }; + pat.insert(&mut transaction).await.unwrap(); + transaction.commit().await.unwrap(); + pat.access_token +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 186f8f46..122849ea 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -10,7 +10,10 @@ use self::database::TemporaryDatabase; pub mod actix; pub mod database; +pub mod environment; +// Testing equivalent to 'setup' function, producing a LabrinthConfig +// If making a test, you should probably use environment::TestEnvironment::new() (which calls this) pub async fn setup(db: &TemporaryDatabase) -> LabrinthConfig { println!("Setting up labrinth config"); @@ -38,6 +41,9 @@ pub async fn setup(db: &TemporaryDatabase) -> LabrinthConfig { } // This is so that env vars not used immediately don't panic at runtime +// Currently, these are the same as main.rs ones. +// TODO: go through after all tests are created and remove any that are not used +// Low priority as .env file should include all of these anyway fn check_test_vars() -> bool { let mut failed = false; diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 91ec7ad0..be118513 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -1,13 +1,8 @@ -ALTER TABLE threads DISABLE TRIGGER ALL; -ALTER TABLE pats DISABLE TRIGGER ALL; -ALTER TABLE loaders_project_types DISABLE TRIGGER ALL; -ALTER TABLE team_members DISABLE TRIGGER ALL; -ALTER TABLE versions DISABLE TRIGGER ALL; -ALTER TABLE loaders_versions DISABLE TRIGGER ALL; -ALTER TABLE game_versions_versions DISABLE TRIGGER ALL; -ALTER TABLE files DISABLE TRIGGER ALL; -ALTER TABLE hashes DISABLE TRIGGER ALL; +-- Dummy test data for use in tests. +-- IDs are listed as integers, followed by their equivalent base 62 representation. +-- Inserts 5 dummy users for testing, with slight differences +-- 'Friend' and 'enemy' function like 'user', but we can use them to simulate 'other' users that may or may not be able to access certain things -- IDs 1-5, 1-5 INSERT INTO users (id, username, name, email, role) VALUES (1, 'admin', 'Administrator Test', 'admin@modrinth.com', 'admin'); INSERT INTO users (id, username, name, email, role) VALUES (2, 'moderator', 'Moderator Test', 'moderator@modrinth.com', 'moderator'); @@ -15,6 +10,8 @@ INSERT INTO users (id, username, name, email, role) VALUES (3, 'user', 'User Tes INSERT INTO users (id, username, name, email, role) VALUES (4, 'friend', 'Friend Test', 'friend@modrinth.com', 'developer'); INSERT INTO users (id, username, name, email, role) VALUES (5, 'enemy', 'Enemy Test', 'enemy@modrinth.com', 'developer'); +-- Full PATs for each user, with different scopes +-- These are not legal PATs, as they contain all scopes- they mimic permissions of a logged in user -- IDs: 50-54, o p q r s INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (50, 1, 'admin-pat', 'mrp_patadmin', B'11111111111111111111111111111111111'::BIGINT, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (51, 2, 'moderator-pat', 'mrp_patmoderator', B'11111111111111111111111111111111111'::BIGINT, '2030-08-18 15:48:58.435729+00'); @@ -22,6 +19,7 @@ INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', B'11111111111111111111111111111111111'::BIGINT, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', B'11111111111111111111111111111111111'::BIGINT, '2030-08-18 15:48:58.435729+00'); +-- Sample game versions, loaders, categories INSERT INTO game_versions (id, version, type, created) VALUES (20000, '1.20.1', 'release', timezone('utc', now())); @@ -43,12 +41,12 @@ INSERT INTO teams (id) VALUES (100); -- ID: 100, 1c INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering) VALUES (200, 100, 3, 'Owner', B'1111111111'::BIGINT, true, 100.0, 0); -- ID: 1000, G8 --- Approved, viewable +-- This project is approved, viewable INSERT INTO mods (id, team_id, title, description, body, published, downloads, status, requested_status, client_side, server_side, license, slug, project_type, monetization_status) VALUES (1000, 100, 'Test Mod', 'Test mod description', 'Test mod body', timezone('utc', now()), 0, 'approved', 'approved', 1, 2, 'MIT', 'testslug', 1, 'monetized'); -- ID: 1100, Hk --- Listed, viewable +-- This version is listed, viewable INSERT INTO versions ( id, mod_id, author_id, name, version_number, changelog, date_published, downloads, version_type, featured, status) VALUES (1100, 1000, 3, 'v1', 'v1.2.1', 'No changes', timezone('utc', now()), 0,'released', true, 'listed'); @@ -60,6 +58,7 @@ INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type) VALUES (800, 1100, 'http://www.url.to/myfile.jar', 'myfile.jar', true, 1, 'required-resource-pack'); INSERT INTO hashes (file_id, algorithm, hash) VALUES (800, 'sha1', '000000000'); +-- ID: 30, U INSERT INTO threads (id, thread_type, mod_id, report_id) VALUES (30, 'project', 1000, null); ------------------------------------------------------------ @@ -67,12 +66,12 @@ INSERT INTO teams (id) VALUES (101); -- ID: 101, 1d INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering) VALUES (201, 101, 3, 'Owner', B'1111111111'::BIGINT, true, 100.0, 0); -- ID: 1001, G9 --- Processing, and therefore not viewable +-- This project is processing, and therefore not publically viewable INSERT INTO mods (id, team_id, title, description, body, published, downloads, status, requested_status, client_side, server_side, license, slug, project_type, monetization_status) VALUES (1001, 101, 'Test Mod 2', 'Test mod description 2', 'Test mod body 2', timezone('utc', now()), 0, 'processing', 'approved', 1, 2, 'MIT', 'testslug2', 1, 'monetized'); -- ID: 1101, Hl --- Draft, and therefore not viewable +-- This version is a draft, and therefore not publically viewable INSERT INTO versions ( id, mod_id, author_id, name, version_number, changelog, date_published, downloads, version_type, featured, status) VALUES (1101, 1001, 3, 'v1.0', 'v1.2.1', 'No changes', timezone('utc', now()), 0,'released', true, 'draft'); @@ -84,4 +83,5 @@ INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type) VALUES (801, 1101, 'http://www.url.to/myfile2.jar', 'myfile2.jar', true, 1, 'required-resource-pack'); INSERT INTO hashes (file_id, algorithm, hash) VALUES (801, 'sha1', '111111111'); -INSERT INTO threads (id, thread_type, mod_id, report_id) VALUES (31, 'project', 1001, null); \ No newline at end of file +-- ID: 31, V +INSERT INTO threads (id, thread_type, mod_id, report_id, show_in_mod_inbox) VALUES (31, 'project', 1001, null, true); \ No newline at end of file diff --git a/tests/pats.rs b/tests/pats.rs index 9f45a2c7..c5f37ab4 100644 --- a/tests/pats.rs +++ b/tests/pats.rs @@ -1,1220 +1,292 @@ -use actix_web::{ - dev::ServiceResponse, - test::{self, TestRequest}, - App, -}; -use bytes::Bytes; +use actix_web::test; use chrono::{Duration, Utc}; -use common::{actix::AppendsMultipart, database::TemporaryDatabase}; -use labrinth::{ - database::{self, models::generate_pat_id}, - models::pats::Scopes, -}; +use common::database::*; +use labrinth::models::pats::Scopes; use serde_json::json; -use crate::common::{ - database::{ADMIN_USER_ID, ENEMY_USER_ID, FRIEND_USER_ID, MOD_USER_ID, USER_USER_ID}, - setup, -}; +use crate::common::environment::TestEnvironment; // importing common module. mod common; -// For each scope, we (using test_scope): -// - create a PAT with a given set of scopes for a function -// - create a PAT with all other scopes for a function -// - test the function with the PAT with the given scopes -// - test the function with the PAT with all other scopes - -// Test for users, emails, and payout scopes (not user auth scope or notifs) +// Full pat test: +// - create a PAT and ensure it can be used for the scope +// - ensure access token is not returned for any PAT in GET +// - ensure PAT can be patched to change scopes +// - ensure PAT can be patched to change expiry +// - ensure expired PATs cannot be used +// - ensure PATs can be deleted #[actix_rt::test] -async fn test_user_scopes() { - // Test setup and dummy data - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; - - // User reading - println!("Testing user reading..."); - let read_user = Scopes::USER_READ; - let request_generator = || test::TestRequest::get().uri("/v2/user"); - let (_, read_user) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_user), - read_user, - USER_USER_ID, - 401, - ) - .await; - assert!(read_user["email"].as_str().is_none()); // email should not be present - assert!(read_user["payout_data"].as_object().is_none()); // payout should not be present - - // Email reading - println!("Testing email reading..."); - let read_email = Scopes::USER_READ | Scopes::USER_READ_EMAIL; - let request_generator = || test::TestRequest::get().uri("/v2/user"); - let (_, read_email_test) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_email), - read_email, - USER_USER_ID, - 401, - ) - .await; - assert_eq!(read_email_test["email"], json!("user@modrinth.com")); // email should be present - - // Payout reading - println!("Testing payout reading..."); - let read_payout = Scopes::USER_READ | Scopes::PAYOUTS_READ; - let request_generator = || test::TestRequest::get().uri("/v2/user"); - let (_, read_payout_test) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_payout), - read_payout, - USER_USER_ID, - 401, - ) - .await; - assert!(read_payout_test["payout_data"].as_object().is_some()); // payout should be present - - // User writing - // We use the Admin PAT for this test, on the 'user' user - println!("Testing user writing..."); - let write_user = Scopes::USER_WRITE; - let request_generator = || { - test::TestRequest::patch() - .uri("/v2/user/user") - .set_json(json!( { - // Do not include 'username', as to not change the rest of the tests - "name": "NewName", - "bio": "New bio", - "location": "New location", - "role": "admin", - "badges": 5, - // Do not include payout info, different scope - })) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_user), - write_user, - ADMIN_USER_ID, - 401, - ) - .await; - - // User payout info writing - println!("Testing user payout info writing..."); - let failure_write_user_payout = all_scopes_except(Scopes::PAYOUTS_WRITE); // Failure case should include USER_WRITE - let write_user_payout = Scopes::USER_WRITE | Scopes::PAYOUTS_WRITE; - let request_generator = || { - test::TestRequest::patch() - .uri("/v2/user/user") - .set_json(json!( { - "payout_data": { - "payout_wallet": "paypal", - "payout_wallet_type": "email", - "payout_address": "test@modrinth.com" - } - })) - }; - test_scope( - &test_app, - &db, - request_generator, - failure_write_user_payout, - write_user_payout, - USER_USER_ID, - 401, - ) - .await; - - // User deletion - // (The failure is first, and this is the last test for this test function, we can delete it and use the same PAT for both tests) - println!("Testing user deletion..."); - let delete_user = Scopes::USER_DELETE; - let request_generator = || test::TestRequest::delete().uri("/v2/user/enemy"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(delete_user), - delete_user, - ENEMY_USER_ID, - 401, - ) - .await; +pub async fn pat_full_test() { + let test_env = TestEnvironment::new().await; - // Cleanup test db - db.cleanup().await; -} - -// Notifications -#[actix_rt::test] -pub async fn test_notifications_scopes() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; - - // We will invite user 'friend' to project team, and use that as a notification - // Get notifications + // Create a PAT for a full test let req = test::TestRequest::post() - .uri("/v2/team/1c/members") - .append_header(("Authorization", "mrp_patuser")) - .set_json(json!( { - "user_id": "4" // friend + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "name": "test_pat_scopes Test", + "expires": Utc::now() + Duration::days(1), })) .to_request(); - let resp = test::call_service(&test_app, req).await; - assert_eq!(resp.status(), 204); - - // Notification get - println!("Testing getting notifications..."); - let read_notifications = Scopes::NOTIFICATION_READ; - let request_generator = || test::TestRequest::get().uri("/v2/user/4/notifications"); - let (_, notifications) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_notifications), - read_notifications, - FRIEND_USER_ID, - 401, - ) - .await; - let notification_id = notifications.as_array().unwrap()[0]["id"].as_str().unwrap(); - - let request_generator = || { - test::TestRequest::get().uri(&format!( - "/v2/notifications?ids=[{uri}]", - uri = urlencoding::encode(&format!("\"{notification_id}\"")) - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_notifications), - read_notifications, - FRIEND_USER_ID, - 401, - ) - .await; - - let request_generator = - || test::TestRequest::get().uri(&format!("/v2/notification/{notification_id}")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_notifications), - read_notifications, - FRIEND_USER_ID, - 401, - ) - .await; - - // Notification mark as read - println!("Testing marking notifications as read..."); - let write_notifications = Scopes::NOTIFICATION_WRITE; - let request_generator = || { - test::TestRequest::patch().uri(&format!( - "/v2/notifications?ids=[{uri}]", - uri = urlencoding::encode(&format!("\"{notification_id}\"")) - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_notifications), - write_notifications, - FRIEND_USER_ID, - 401, - ) - .await; - let request_generator = - || test::TestRequest::patch().uri(&format!("/v2/notification/{notification_id}")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_notifications), - write_notifications, - FRIEND_USER_ID, - 401, - ) - .await; - - // Notification delete - println!("Testing deleting notifications..."); - let request_generator = - || test::TestRequest::delete().uri(&format!("/v2/notification/{notification_id}")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_notifications), - write_notifications, - FRIEND_USER_ID, - 401, - ) - .await; + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 200); + let success: serde_json::Value = test::read_body_json(resp).await; + let id = success["id"].as_str().unwrap(); + + // Has access token and correct scopes + assert!(success["access_token"].as_str().is_some()); + assert_eq!( + success["scopes"].as_u64().unwrap(), + Scopes::COLLECTION_CREATE.bits() + ); + let access_token = success["access_token"].as_str().unwrap(); - // Mass notification delete - // We invite mod, get the notification ID, and do mass delete using that - let req = test::TestRequest::post() - .uri("/v2/team/1c/members") - .append_header(("Authorization", "mrp_patuser")) - .set_json(json!( { - "user_id": "2" // mod - })) + // Get PAT again + let req = test::TestRequest::get() + .append_header(("Authorization", USER_USER_PAT)) + .uri(&"/v2/pat".to_string()) .to_request(); - let resp = test::call_service(&test_app, req).await; - assert_eq!(resp.status(), 204); - let read_notifications = Scopes::NOTIFICATION_READ; - let request_generator = || test::TestRequest::get().uri("/v2/user/2/notifications"); - let (_, notifications) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_notifications), - read_notifications, - MOD_USER_ID, - 401, - ) - .await; - let notification_id = notifications.as_array().unwrap()[0]["id"].as_str().unwrap(); - - let request_generator = || { - test::TestRequest::delete().uri(&format!( - "/v2/notifications?ids=[{uri}]", - uri = urlencoding::encode(&format!("\"{notification_id}\"")) - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_notifications), - write_notifications, - MOD_USER_ID, - 401, - ) - .await; - - // Cleanup test db - db.cleanup().await; -} - -// Project version creation scopes -#[actix_rt::test] -pub async fn test_project_version_create_scopes() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; - - // Create project - println!("Testing creating project..."); - let create_project = Scopes::PROJECT_CREATE; - let json_data = json!( - { - "title": "Test_Add_Project project", - "slug": "demo", - "description": "Example description.", - "body": "Example body.", - "client_side": "required", - "server_side": "optional", - "initial_versions": [{ - "file_parts": ["basic-mod.jar"], - "version_number": "1.2.3", - "version_title": "start", - "dependencies": [], - "game_versions": ["1.20.1"] , - "release_channel": "release", - "loaders": ["fabric"], - "featured": true - }], - "categories": [], - "license_id": "MIT" + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 200); + let success: serde_json::Value = test::read_body_json(resp).await; + + // Ensure access token is NOT returned for any PATs + for pat in success.as_array().unwrap() { + assert!(pat["access_token"].as_str().is_none()); + } + + // Create mock test for using PAT + let mock_pat_test = |token: &str| { + let token = token.to_string(); + async { + let req = test::TestRequest::post() + .uri(&"/v2/collection".to_string()) + .append_header(("Authorization", token)) + .set_json(json!({ + "title": "Test Collection 1", + "description": "Test Collection Description" + })) + .to_request(); + let resp = test_env.call(req).await; + resp.status().as_u16() } - ); - let json_segment = common::actix::MultipartSegment { - name: "data".to_string(), - filename: None, - content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), - }; - let file_segment = common::actix::MultipartSegment { - name: "basic-mod.jar".to_string(), - filename: Some("basic-mod.jar".to_string()), - content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( - include_bytes!("../tests/files/basic-mod.jar").to_vec(), - ), - }; - - let request_generator = || { - test::TestRequest::post() - .uri(&format!("/v2/project")) - .set_multipart(vec![json_segment.clone(), file_segment.clone()]) - }; - let (_, project) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(create_project), - create_project, - USER_USER_ID, - 401, - ) - .await; - let project_id = project["id"].as_str().unwrap(); - - // Add version to project - println!("Testing adding version to project..."); - let create_version = Scopes::VERSION_CREATE; - let json_data = json!( - { - "project_id": project_id, - "file_parts": ["basic-mod-different.jar"], - "version_number": "1.2.3.4", - "version_title": "start", - "dependencies": [], - "game_versions": ["1.20.1"] , - "release_channel": "release", - "loaders": ["fabric"], - "featured": true - } - ); - let json_segment = common::actix::MultipartSegment { - name: "data".to_string(), - filename: None, - content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), }; - let file_segment = common::actix::MultipartSegment { - name: "basic-mod-different.jar".to_string(), - filename: Some("basic-mod.jar".to_string()), - content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( - include_bytes!("../tests/files/basic-mod-different.jar").to_vec(), - ), - }; - - let request_generator = || { - test::TestRequest::post() - .uri(&format!("/v2/version")) - .set_multipart(vec![json_segment.clone(), file_segment.clone()]) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(create_version), - create_version, - USER_USER_ID, - 401, - ) - .await; - - // Cleanup test db - db.cleanup().await; -} -// Project management scopes -#[actix_rt::test] -pub async fn test_project_version_reads_scopes() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; - - // Project reading - // Uses 404 as the expected failure code (or 200 and an empty list for mass reads) - let read_project = Scopes::PROJECT_READ; - let request_generator = || test::TestRequest::get().uri("/v2/project/G9"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 404, - ) - .await; - - let request_generator = || test::TestRequest::get().uri("/v2/project/G9/dependencies"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 404, - ) - .await; - - let request_generator = || { - test::TestRequest::get().uri(&format!( - "/v2/projects?ids=[{uri}]", - uri = urlencoding::encode(&format!("\"{}\"", "G9")) - )) - }; - let (failure, success) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 200, - ) - .await; - assert!(failure.as_array().unwrap().is_empty()); - assert!(!success.as_array().unwrap().is_empty()); + assert_eq!(mock_pat_test(access_token).await, 200); - // Team project reading - let request_generator = || test::TestRequest::get().uri("/v2/project/G9/members"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 404, - ) - .await; - - // Get team members - // In this case, as these are public endpoints, logging in only is relevant to showing permissions - // So for our test project (with 1 user, 'user') we will check the permissions before and after having the scope. - let request_generator = || test::TestRequest::get().uri("/v2/team/1c/members"); - let (failure, success) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 200, - ) - .await; - assert!(!failure.as_array().unwrap()[0].as_object().unwrap()["permissions"].is_number()); - assert!(success.as_array().unwrap()[0].as_object().unwrap()["permissions"].is_number()); - - let request_generator = || { - test::TestRequest::get().uri(&format!( - "/v2/teams?ids=[{uri}]", - uri = urlencoding::encode(&format!("\"{}\"", "1c")) - )) - }; - let (failure, success) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 200, - ) - .await; - assert!(!failure.as_array().unwrap()[0].as_array().unwrap()[0] - .as_object() - .unwrap()["permissions"] - .is_number()); - assert!(success.as_array().unwrap()[0].as_array().unwrap()[0] - .as_object() - .unwrap()["permissions"] - .is_number()); - - // User project reading - // Test user has two projects, one public and one private - let request_generator = || test::TestRequest::get().uri("/v2/user/3/projects"); - let (failure, success) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 200, - ) - .await; - assert!(failure - .as_array() - .unwrap() - .iter() - .find(|x| x["status"] == "processing") - .is_none()); - assert!(success - .as_array() - .unwrap() - .iter() - .find(|x| x["status"] == "processing") - .is_some()); - - // Project metadata reading - let request_generator = - || test::TestRequest::get().uri("/maven/maven/modrinth/G9/maven-metadata.xml"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project), - read_project, - USER_USER_ID, - 404, - ) - .await; + // Change scopes and test again + let req = test::TestRequest::patch() + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "scopes": 0, + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 204); + assert_eq!(mock_pat_test(access_token).await, 401); // No longer works - // Version reading - // First, set version to hidden (which is when the scope is required to read it) - let read_version = Scopes::VERSION_READ; + // Change scopes back, and set expiry to the past, and test again let req = test::TestRequest::patch() - .uri("/v2/version/Hl") - .append_header(("Authorization", "mrp_patuser")) + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ - "status": "draft" + "scopes": Scopes::COLLECTION_CREATE, + "expires": Utc::now() + Duration::seconds(1), // expires in 1 second })) .to_request(); - let resp = test::call_service(&test_app, req).await; - assert_eq!(resp.status(), 204); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 204); - let request_generator = || test::TestRequest::get().uri("/v2/version_file/111111111"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_version), - read_version, - USER_USER_ID, - 404, - ) - .await; + // Wait 1 second before testing again for expiry + tokio::time::sleep(Duration::seconds(1).to_std().unwrap()).await; + assert_eq!(mock_pat_test(access_token).await, 401); // No longer works - let request_generator = || test::TestRequest::get().uri("/v2/version_file/111111111/download"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_version), - read_version, - USER_USER_ID, - 404, - ) - .await; + // Change everything back to normal and test again + let req = test::TestRequest::patch() + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "expires": Utc::now() + Duration::days(1), // no longer expired! + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 204); + assert_eq!(mock_pat_test(access_token).await, 200); // Works again - // TODO: it's weird that this is /POST, no? - // TODO: this scope doesn't actually affect anything, because the Project::get_id contained within disallows hidden versions, which is the point of this scope - // let request_generator = || { - // test::TestRequest::post() - // .uri("/v2/version_file/111111111/update") - // .set_json(json!({})) - // }; - // test_scope(&test_app, &db, request_generator, all_scopes_except(read_version), read_version, USER_USER_ID, 404).await; + // Patching to a bad expiry should fail + let req = test::TestRequest::patch() + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "expires": Utc::now() - Duration::days(1), // Past + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 400); + + // Similar to above with PAT creation, patching to a bad scope should fail + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !Scopes::ALL.contains(scope) { + continue; + } - // TODO: this shold get, no? with query - let request_generator = || { - test::TestRequest::post() - .uri("/v2/version_files") + let req = test::TestRequest::patch() + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ - "hashes": ["111111111"] + "scopes": scope.bits(), })) - }; - let (failure, success) = test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_version), - read_version, - USER_USER_ID, - 200, - ) - .await; - assert!(!failure.as_object().unwrap().contains_key("111111111")); - assert!(success.as_object().unwrap().contains_key("111111111")); - - // Update version file - // TODO: weird that this is post - // TODO: this scope doesn't actually affect anything, because the Project::get_id contained within disallows hidden versions, which is the point of this scope - - // let request_generator = || { - // test::TestRequest::post() - // .uri(&format!("/v2/version_files/update_individual")) - // .set_json(json!({ - // "hashes": [{ - // "hash": "111111111", - // }] - // })) - // }; - // let (failure, success) = test_scope(&test_app, &db, request_generator, all_scopes_except(read_version), read_version, USER_USER_ID, 200).await; - // assert!(!failure.as_object().unwrap().contains_key("111111111")); - // assert!(success.as_object().unwrap().contains_key("111111111")); - - // Update version file - // TODO: this scope doesn't actually affect anything, because the Project::get_id contained within disallows hidden versions, which is the point of this scope - // let request_generator = || { - // test::TestRequest::post() - // .uri(&format!("/v2/version_files/update")) - // .set_json(json!({ - // "hashes": ["111111111"] - // })) - // }; - // let (failure, success) = test_scope(&test_app, &db, request_generator, all_scopes_except(read_version), read_version, USER_USER_ID, 200).await; - // assert!(!failure.as_object().unwrap().contains_key("111111111")); - // assert!(success.as_object().unwrap().contains_key("111111111")); - - // Both project and version reading - let read_project_and_version = Scopes::PROJECT_READ | Scopes::VERSION_READ; - let request_generator = || test::TestRequest::get().uri("/v2/project/G9/version"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(read_project_and_version), - read_project_and_version, - USER_USER_ID, - 404, - ) - .await; - - // TODO: fails for the same reason as above - // let request_generator = || { - // test::TestRequest::get() - // .uri("/v2/project/G9/version/Hl") - // }; - // test_scope(&test_app, &db, request_generator, all_scopes_except(read_project_and_version), read_project_and_version, USER_USER_ID, 404).await; + .to_request(); + let resp = test_env.call(req).await; + assert_eq!( + resp.status().as_u16(), + if scope.restricted() { 400 } else { 204 } + ); + } + + // Delete PAT + let req = test::TestRequest::delete() + .append_header(("Authorization", USER_USER_PAT)) + .uri(&format!("/v2/pat/{}", id)) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 204); // Cleanup test db - db.cleanup().await; + test_env.cleanup().await; } -// Project writing +// Test illegal PAT setting, both in POST and PATCH #[actix_rt::test] -pub async fn test_project_write_scopes() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; - - // Projects writing - let write_project = Scopes::PROJECT_WRITE; - let request_generator = || { - test::TestRequest::patch() - .uri("/v2/project/G9") - .set_json(json!( - { - "title": "test_project_version_write_scopes Title", - } - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - let request_generator = || { - test::TestRequest::patch() - .uri(&format!( - "/v2/projects?ids=[{uri}]", - uri = urlencoding::encode(&format!("\"{}\"", "G9")) - )) - .set_json(json!( - { - "description": "test_project_version_write_scopes Description", - } - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - let request_generator = || { - test::TestRequest::post() - .uri("/v2/project/G8/schedule") // G8 is an *approved* project, so we can schedule it - .set_json(json!( - { - "requested_status": "private", - "time": Utc::now() + Duration::days(1), - } - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; +pub async fn bad_pats() { + let test_env = TestEnvironment::new().await; - // Icons and gallery images - let request_generator = || { - test::TestRequest::patch() - .uri("/v2/project/G9/icon?ext=png") - .set_payload(Bytes::from( - include_bytes!("../tests/files/200x200.png") as &[u8] - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - let request_generator = || test::TestRequest::delete().uri("/v2/project/G9/icon"); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - let request_generator = || { - test::TestRequest::post() - .uri("/v2/project/G9/gallery?ext=png&featured=true") - .set_payload(Bytes::from( - include_bytes!("../tests/files/200x200.png") as &[u8] - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - // Get project, as we need the gallery image url - let request_generator = test::TestRequest::get() - .uri("/v2/project/G9") - .append_header(("Authorization", "mrp_patuser")) + // Creating a PAT with no name should fail + let req = test::TestRequest::post() + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "expires": Utc::now() + Duration::days(1), + })) .to_request(); - let resp = test::call_service(&test_app, request_generator).await; - let project: serde_json::Value = test::read_body_json(resp).await; - let gallery_url = project["gallery"][0]["url"].as_str().unwrap(); - - let request_generator = - || test::TestRequest::patch().uri(&format!("/v2/project/G9/gallery?url={gallery_url}")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - let request_generator = - || test::TestRequest::delete().uri(&format!("/v2/project/G9/gallery?url={gallery_url}")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - // Team scopes - add user 'friend' - let request_generator = || { - test::TestRequest::post() - .uri(&format!("/v2/team/1c/members")) + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 400); + + // Name too short or too long should fail + for name in ["n", "this_name_is_too_long".repeat(16).as_str()] { + let req = test::TestRequest::post() + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ - "user_id": "4" + "name": name, + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "expires": Utc::now() + Duration::days(1), })) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - // Accept team invite as 'friend' - let request_generator = || test::TestRequest::post().uri(&format!("/v2/team/1c/join")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - FRIEND_USER_ID, - 401, - ) - .await; + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 400); + } - // Patch 'friend' user - let request_generator = || { - test::TestRequest::patch() - .uri(&format!("/v2/team/1c/members/4")) + // Creating a PAT with an expiry in the past should fail + let req = test::TestRequest::post() + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "name": "test_pat_scopes Test", + "expires": Utc::now() - Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 400); + + // Make a PAT with each scope, with the result varying by whether that scope is restricted + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !Scopes::ALL.contains(scope) { + continue; + } + let req = test::TestRequest::post() + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ - "permissions": 1 + "scopes": scope.bits(), + "name": format!("test_pat_scopes Name {}", i), + "expires": Utc::now() + Duration::days(1), })) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - // Transfer ownership to 'friend' - let request_generator = || { - test::TestRequest::patch() - .uri(&format!("/v2/team/1c/owner")) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!( + resp.status().as_u16(), + if scope.restricted() { 400 } else { 200 } + ); + } + + // Create a 'good' PAT for patching + let req = test::TestRequest::post() + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, + "name": "test_pat_scopes Test", + "expires": Utc::now() + Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 200); + let success: serde_json::Value = test::read_body_json(resp).await; + let id = success["id"].as_str().unwrap(); + + // Patching to a bad name should fail + for name in ["n", "this_name_is_too_long".repeat(16).as_str()] { + let req = test::TestRequest::post() + .uri(&"/v2/pat".to_string()) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ - "user_id": "4" + "name": name, })) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - USER_USER_ID, - 401, - ) - .await; - - // Now as 'friend', delete 'user' - let request_generator = || test::TestRequest::delete().uri(&format!("/v2/team/1c/members/3")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_project), - write_project, - FRIEND_USER_ID, - 401, - ) - .await; - - // Delete project - // TODO: this route is currently broken, - // because the Project::get_id contained within Project::remove doesnt include hidden versions, meaning that if there - // is a hidden version, it will fail to delete the project (with a 500 error, as the versions of a project are not all deleted) - // let delete_version = Scopes::PROJECT_DELETE; - // let request_generator = || { - // test::TestRequest::delete() - // .uri(&format!("/v2/project/G9")) - // }; - // test_scope(&test_app, &db, request_generator, all_scopes_except(delete_version), delete_version, USER_USER_ID, 401).await; - - // Cleanup test db - db.cleanup().await; -} - -// Version write -#[actix_rt::test] -pub async fn test_version_write_scopes() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 400); + } - let write_version = Scopes::VERSION_WRITE; - - // Schedule version - let request_generator = || { - test::TestRequest::post() - .uri("/v2/version/Hk/schedule") // Hk is an *approved* version, so we can schedule it - .set_json(json!( - { - "requested_status": "archived", - "time": Utc::now() + Duration::days(1), - } - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_version), - write_version, - USER_USER_ID, - 401, - ) - .await; - - // Patch version - let request_generator = || { - test::TestRequest::patch() - .uri("/v2/version/Hk") - .set_json(json!( - { - "version_title": "test_version_write_scopes Title", - } - )) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_version), - write_version, - USER_USER_ID, - 401, - ) - .await; - - // Generate test project data. - // Basic json - let json_segment = common::actix::MultipartSegment { - name: "data".to_string(), - filename: None, - content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text( - serde_json::to_string(&json!( - { - "file_types": { - "simple-zip.zip": "required-resource-pack" - }, - } - )) - .unwrap(), - ), - }; - - // Differently named file, with different content - let content_segment = common::actix::MultipartSegment { - name: "simple-zip.zip".to_string(), - filename: Some("simple-zip.zip".to_string()), - content_type: Some("application/zip".to_string()), - data: common::actix::MultipartSegmentData::Binary( - include_bytes!("../tests/files/simple-zip.zip").to_vec(), - ), - }; - - // Upload version file - let request_generator = || { - test::TestRequest::post() - .uri(&format!("/v2/version/Hk/file")) - .set_multipart(vec![json_segment.clone(), content_segment.clone()]) - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_version), - write_version, - USER_USER_ID, - 401, - ) - .await; - - // Delete version file - // TODO: should this be VERSION_DELETE? - let request_generator = || { - test::TestRequest::delete().uri(&format!("/v2/version_file/000000000")) // Delete from Hk, as we uploaded to Hk, and it needs another file - }; - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(write_version), - write_version, - USER_USER_ID, - 401, - ) - .await; - - // Delete version - let delete_version = Scopes::VERSION_DELETE; - let request_generator = || test::TestRequest::delete().uri(&format!("/v2/version/Hk")); - test_scope( - &test_app, - &db, - request_generator, - all_scopes_except(delete_version), - delete_version, - USER_USER_ID, - 401, - ) - .await; - - // Cleanup test db - db.cleanup().await; -} - -// Report scopes - -// Thread scopes - -// Session scopes - -// Analytics scopes - -// Collection scopes - -// User authentication - -// Pat scopes - -// Organization scopes - -// Some hash/version files functions - -// Meta pat stuff - -#[actix_rt::test] -pub async fn test_user_auth_scopes() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; - - // TODO: Test user auth scopes - - // Cleanup test db - db.cleanup().await; -} - -// A reusable test that works for any scope test that: -// - returns a known 'expected_failure_code' if the scope is not present (probably 401) -// - returns a 200-299 if the scope is present -// - returns the failure and success bodies for requests that are 209 -// Some tests (ie: USER_READ_EMAIL) will still need to have additional checks (ie: email is present/absent) because it doesn't affect the response code -// test_app is the generated test app from init_service -// Closure generates a TestRequest. The authorization header (if any) will be overwritten by the generated PAT -async fn test_scope( - test_app: &impl actix_web::dev::Service< - actix_http::Request, - Response = ServiceResponse, - Error = actix_web::Error, - >, - db: &TemporaryDatabase, - request_generator: T, - failure_scopes: Scopes, - success_scopes: Scopes, - user_id: i64, - expected_failure_code: u16, -) -> (serde_json::Value, serde_json::Value) -where - T: Fn() -> TestRequest, -{ - // First, create a PAT with all OTHER scopes - let access_token_all_others = create_test_pat(failure_scopes, user_id, &db).await; - - // Create a PAT with the given scopes - let access_token = create_test_pat(success_scopes, user_id, &db).await; - - // Perform test twice, once with each PAT - // the first time, we expect a 401 - // the second time, we expect a 200 or 204, and it will return a JSON body of the response - let req = request_generator() - .append_header(("Authorization", access_token_all_others.as_str())) - .to_request(); - let resp = test::call_service(&test_app, req).await; - - assert_eq!(expected_failure_code, resp.status().as_u16()); - let failure_body = if resp.status() == 200 - && resp.headers().contains_key("Content-Type") - && resp.headers().get("Content-Type").unwrap() == "application/json" - { - test::read_body_json(resp).await - } else { - serde_json::Value::Null - }; - - let req = request_generator() - .append_header(("Authorization", access_token.as_str())) + // Patching to a bad expiry should fail + let req = test::TestRequest::patch() + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "expires": Utc::now() - Duration::days(1), // Past + })) .to_request(); - let resp = test::call_service(&test_app, req).await; - println!( - "{}: {}", - resp.status().as_u16(), - resp.status().canonical_reason().unwrap() - ); - assert!(resp.status().is_success() || resp.status().is_redirection()); - let success_body = if resp.status() == 200 - && resp.headers().contains_key("Content-Type") - && resp.headers().get("Content-Type").unwrap() == "application/json" - { - test::read_body_json(resp).await - } else { - serde_json::Value::Null - }; - (failure_body, success_body) -} + let resp = test_env.call(req).await; + assert_eq!(resp.status().as_u16(), 400); + + // Similar to above with PAT creation, patching to a bad scope should fail + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !Scopes::ALL.contains(scope) { + continue; + } -// Creates a PAT with the given scopes, and returns the access token -// this allows us to make PATs with scopes that are not allowed to be created by PATs -async fn create_test_pat(scopes: Scopes, user_id: i64, db: &TemporaryDatabase) -> String { - let mut transaction = db.pool.begin().await.unwrap(); - let id = generate_pat_id(&mut transaction).await.unwrap(); - let pat = database::models::pat_item::PersonalAccessToken { - id, - name: format!("test_pat_{}", scopes.bits()), - access_token: format!("mrp_{}", id.0), - scopes, - user_id: database::models::ids::UserId(user_id), - created: Utc::now(), - expires: Utc::now() + chrono::Duration::days(1), - last_used: None, - }; - pat.insert(&mut transaction).await.unwrap(); - transaction.commit().await.unwrap(); - pat.access_token -} + let req = test::TestRequest::patch() + .uri(&format!("/v2/pat/{}", id)) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "scopes": scope.bits(), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!( + resp.status().as_u16(), + if scope.restricted() { 400 } else { 204 } + ); + } -// Inversion of scopes for testing -// ie: To ensure that ONLY this scope is required, we need to create a PAT with all other scopes -fn all_scopes_except(success_scopes: Scopes) -> Scopes { - Scopes::ALL ^ success_scopes + // Cleanup test db + test_env.cleanup().await; } diff --git a/tests/project.rs b/tests/project.rs index 55157ed0..4050e04e 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -1,9 +1,10 @@ -use actix_web::{test, App}; -use common::database::TemporaryDatabase; +use actix_web::test; use labrinth::database::models::project_item::{PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE}; use serde_json::json; -use crate::common::{actix::AppendsMultipart, setup}; +use crate::common::database::*; + +use crate::common::{actix::AppendsMultipart, environment::TestEnvironment}; // importing common module. mod common; @@ -11,13 +12,10 @@ mod common; #[actix_rt::test] async fn test_get_project() { // Test setup and dummy data - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; + let test_env = TestEnvironment::new().await; // Cache should default to unpopulated - assert!(db + assert!(test_env.db .redis_pool .get::(PROJECTS_NAMESPACE, 1000) .await @@ -25,33 +23,31 @@ async fn test_get_project() { .is_none()); // Perform request on dummy data - println!("Sending request"); let req = test::TestRequest::get() - .uri("/v2/project/G8") - .append_header(("Authorization", "mrp_patuser")) + .uri(&format!("/v2/project/{PROJECT_ALPHA_PROJECT_ID}")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; let status = resp.status(); let body: serde_json::Value = test::read_body_json(resp).await; assert_eq!(status, 200); - assert_eq!(body["id"], json!("G8")); + assert_eq!(body["id"], json!(PROJECT_ALPHA_PROJECT_ID)); assert_eq!(body["slug"], json!("testslug")); let versions = body["versions"].as_array().unwrap(); - assert!(versions.len() > 0); - assert_eq!(versions[0], json!("Hk")); + assert!(!versions.is_empty()); + assert_eq!(versions[0], json!(PROJECT_ALPHA_VERSION_ID)); // Confirm that the request was cached - println!("Confirming cache"); assert_eq!( - db.redis_pool + test_env.db.redis_pool .get::(PROJECTS_SLUGS_NAMESPACE, "testslug") .await .unwrap(), Some(1000) ); - let cached_project = db + let cached_project = test_env.db .redis_pool .get::(PROJECTS_NAMESPACE, 1000) .await @@ -62,48 +58,43 @@ async fn test_get_project() { // Make the request again, this time it should be cached let req = test::TestRequest::get() - .uri("/v2/project/G8") - .append_header(("Authorization", "mrp_patuser")) + .uri(&format!("/v2/project/{PROJECT_ALPHA_PROJECT_ID}")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; let status = resp.status(); assert_eq!(status, 200); let body: serde_json::Value = test::read_body_json(resp).await; - assert_eq!(body["id"], json!("G8")); + assert_eq!(body["id"], json!(PROJECT_ALPHA_PROJECT_ID)); assert_eq!(body["slug"], json!("testslug")); // Request should fail on non-existent project - println!("Requesting non-existent project"); let req = test::TestRequest::get() .uri("/v2/project/nonexistent") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 404); // Similarly, request should fail on non-authorized user, on a yet-to-be-approved or hidden project, with a 404 (hiding the existence of the project) - println!("Requesting project as non-authorized user"); let req = test::TestRequest::get() - .uri("/v2/project/G9") - .append_header(("Authorization", "mrp_patenemy")) + .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}")) + .append_header(("Authorization", ENEMY_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 404); // Cleanup test db - db.cleanup().await; + test_env.cleanup().await; } #[actix_rt::test] async fn test_add_remove_project() { // Test setup and dummy data - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; + let test_env = TestEnvironment::new().await; // Generate test project data. let mut json_data = json!( @@ -185,10 +176,10 @@ async fn test_add_remove_project() { // Add a project- simple, should work. let req = test::TestRequest::post() .uri("/v2/project") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_multipart(vec![json_segment.clone(), file_segment.clone()]) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; let status = resp.status(); assert_eq!(status, 200); @@ -196,10 +187,10 @@ async fn test_add_remove_project() { // Get the project we just made, and confirm that it's correct let req = test::TestRequest::get() .uri("/v2/project/demo") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = test::read_body_json(resp).await; @@ -208,15 +199,15 @@ async fn test_add_remove_project() { let uploaded_version_id = &versions[0]; // Checks files to ensure they were uploaded and correctly identify the file - let hash = sha1::Sha1::from(include_bytes!("../tests/files/basic-mod.jar").to_vec()) + let hash = sha1::Sha1::from(include_bytes!("../tests/files/basic-mod.jar")) .digest() .to_string(); let req = test::TestRequest::get() .uri(&format!("/v2/version_file/{hash}?algorithm=sha1")) - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = test::read_body_json(resp).await; @@ -227,54 +218,48 @@ async fn test_add_remove_project() { // Even if that file is named differently let req = test::TestRequest::post() .uri("/v2/project") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_multipart(vec![ json_diff_slug_file_segment.clone(), // Different slug, different file name file_diff_name_segment.clone(), // Different file name, same content ]) .to_request(); - let resp = test::call_service(&test_app, req).await; - println!("Different slug, same file: {:?}", resp.response().body()); + let resp = test_env.call(req).await; assert_eq!(resp.status(), 400); // Reusing with the same slug and a different file should fail let req = test::TestRequest::post() .uri("/v2/project") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_multipart(vec![ json_diff_file_segment.clone(), // Same slug, different file name file_diff_name_content_segment.clone(), // Different file name, different content ]) .to_request(); - let resp = test::call_service(&test_app, req).await; - println!("Same slug, different file: {:?}", resp.response().body()); + let resp = test_env.call(req).await; assert_eq!(resp.status(), 400); // Different slug, different file should succeed let req = test::TestRequest::post() .uri("/v2/project") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_multipart(vec![ json_diff_slug_file_segment.clone(), // Different slug, different file name file_diff_name_content_segment.clone(), // Different file name, same content ]) .to_request(); - let resp = test::call_service(&test_app, req).await; - println!( - "Different slug, different file: {:?}", - resp.response().body() - ); + let resp = test_env.call(req).await; assert_eq!(resp.status(), 200); // Get let req = test::TestRequest::get() .uri("/v2/project/demo") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = test::read_body_json(resp).await; let id = body["id"].to_string(); @@ -282,21 +267,21 @@ async fn test_add_remove_project() { // Remove the project let req = test::TestRequest::delete() .uri("/v2/project/demo") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 204); // Confirm that the project is gone from the cache assert_eq!( - db.redis_pool + test_env.db.redis_pool .get::(PROJECTS_SLUGS_NAMESPACE, "demo") .await .unwrap(), None ); assert_eq!( - db.redis_pool + test_env.db.redis_pool .get::(PROJECTS_SLUGS_NAMESPACE, id) .await .unwrap(), @@ -306,44 +291,41 @@ async fn test_add_remove_project() { // Old slug no longer works let req = test::TestRequest::get() .uri("/v2/project/demo") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 404); // Cleanup test db - db.cleanup().await; + test_env.cleanup().await; } #[actix_rt::test] pub async fn test_patch_project() { - let db = TemporaryDatabase::create_with_dummy().await; - let labrinth_config = setup(&db).await; - let app = App::new().configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())); - let test_app = test::init_service(app).await; + let test_env = TestEnvironment::new().await; // First, we do some patch requests that should fail. // Failure because the user is not authorized. let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patenemy")) + .append_header(("Authorization", ENEMY_USER_PAT)) .set_json(json!({ "title": "Test_Add_Project project - test 1", })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 401); // Failure because we are setting URL fields to invalid urls. for url_type in ["issues_url", "source_url", "wiki_url", "discord_url"] { let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ url_type: "w.fake.url", })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 400); } @@ -351,12 +333,12 @@ pub async fn test_patch_project() { for req in ["unknown", "processing", "withheld", "scheduled"] { let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ "requested_status": req, })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 400); } @@ -364,52 +346,52 @@ pub async fn test_patch_project() { for key in ["moderation_message", "moderation_message_body"] { let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ key: "test", })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 401); // (should work for a mod, though) let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patmoderator")) + .append_header(("Authorization", MOD_USER_PAT)) .set_json(json!({ key: "test", })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 204); } // Failure because the slug is already taken. let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ "slug": "testslug2", // the other dummy project has this slug })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 400); // Not allowed to directly set status, as 'testslug2' (the other project) is "processing" and cannot have its status changed like this. let req = test::TestRequest::patch() .uri("/v2/project/testslug2") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ "status": "private" })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env .call(req).await; assert_eq!(resp.status(), 401); // Sucessful request to patch many fields. let req = test::TestRequest::patch() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .set_json(json!({ "slug": "newslug", "title": "New successful title", @@ -429,23 +411,23 @@ pub async fn test_patch_project() { }] })) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 204); // Old slug no longer works let req = test::TestRequest::get() .uri("/v2/project/testslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 404); // Old slug no longer works let req = test::TestRequest::get() .uri("/v2/project/newslug") - .append_header(("Authorization", "mrp_patuser")) + .append_header(("Authorization", USER_USER_PAT)) .to_request(); - let resp = test::call_service(&test_app, req).await; + let resp = test_env.call(req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = test::read_body_json(resp).await; @@ -466,8 +448,8 @@ pub async fn test_patch_project() { ); // Cleanup test db - db.cleanup().await; + test_env.cleanup().await; } -// TODO: you are missing a lot of routes on projects here -// TODO: using permissions/scopes, can we SEE projects existence that we are not allowed to? (ie 401 isntead of 404) +// TODO: Missing routes on projects +// TODO: using permissions/scopes, can we SEE projects existence that we are not allowed to? (ie 401 instead of 404) diff --git a/tests/scopes.rs b/tests/scopes.rs new file mode 100644 index 00000000..8ab06e55 --- /dev/null +++ b/tests/scopes.rs @@ -0,0 +1,1001 @@ + +use actix_web::test::{TestRequest, self}; +use bytes::Bytes; +use chrono::{Duration, Utc}; +use common::{actix::AppendsMultipart, database::PROJECT_ALPHA_THREAD_ID}; +use labrinth::models::pats::Scopes; +use serde_json::json; + +use crate::common::{ + database::*, + environment::{TestEnvironment, ScopeTest}, +}; + +// importing common module. +mod common; + +// For each scope, we (using test_scope): +// - create a PAT with a given set of scopes for a function +// - create a PAT with all other scopes for a function +// - test the function with the PAT with the given scopes +// - test the function with the PAT with all other scopes + +// Test for users, emails, and payout scopes (not user auth scope or notifs) +#[actix_rt::test] +async fn user_scopes() { + // Test setup and dummy data + let test_env = TestEnvironment::new().await; + + // User reading + let read_user = Scopes::USER_READ; + let req_gen = || TestRequest::get().uri("/v2/user"); + let (_, success) = ScopeTest::new(&test_env).test(req_gen, read_user).await.unwrap(); + assert!(success["email"].as_str().is_none()); // email should not be present + assert!(success["payout_data"].as_object().is_none()); // payout should not be present + + // Email reading + let read_email = Scopes::USER_READ | Scopes::USER_READ_EMAIL; + let req_gen = || TestRequest::get().uri("/v2/user"); + let (_, success) = ScopeTest::new(&test_env).test(req_gen, read_email).await.unwrap(); + assert_eq!(success["email"], json!("user@modrinth.com")); // email should be present + + // Payout reading + let read_payout = Scopes::USER_READ | Scopes::PAYOUTS_READ; + let req_gen = || TestRequest::get().uri("/v2/user"); + let (_, success) = ScopeTest::new(&test_env).test(req_gen, read_payout).await.unwrap(); + assert!(success["payout_data"].as_object().is_some()); // payout should be present + + // User writing + // We use the Admin PAT for this test, on the 'user' user + let write_user = Scopes::USER_WRITE; + let req_gen = || { + TestRequest::patch() + .uri("/v2/user/user") + .set_json(json!( { + // Do not include 'username', as to not change the rest of the tests + "name": "NewName", + "bio": "New bio", + "location": "New location", + "role": "admin", + "badges": 5, + // Do not include payout info, different scope + })) + }; + ScopeTest::new(&test_env).with_user_id(ADMIN_USER_ID_PARSED).test(req_gen, write_user).await.unwrap(); + + // User payout info writing + let failure_write_user_payout = Scopes::ALL ^ Scopes::PAYOUTS_WRITE; // Failure case should include USER_WRITE + let write_user_payout = Scopes::USER_WRITE | Scopes::PAYOUTS_WRITE; + let req_gen = || { + TestRequest::patch() + .uri("/v2/user/user") + .set_json(json!( { + "payout_data": { + "payout_wallet": "paypal", + "payout_wallet_type": "email", + "payout_address": "test@modrinth.com" + } + })) + }; + ScopeTest::new(&test_env).with_failure_scopes(failure_write_user_payout).test(req_gen, write_user_payout).await.unwrap(); + + // User deletion + // (The failure is first, and this is the last test for this test function, we can delete it and use the same PAT for both tests) + let delete_user = Scopes::USER_DELETE; + let req_gen = || TestRequest::delete().uri("/v2/user/enemy"); + ScopeTest::new(&test_env).with_user_id(ENEMY_USER_ID_PARSED).test(req_gen, delete_user).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Notifications +#[actix_rt::test] +pub async fn notifications_scopes() { + let test_env = TestEnvironment::new().await; + + // We will invite user 'friend' to project team, and use that as a notification + // Get notifications + let req = TestRequest::post() + .uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/members")) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!( { + "user_id": FRIEND_USER_ID // friend + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status(), 204); + + // Notification get + let read_notifications = Scopes::NOTIFICATION_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/user/{FRIEND_USER_ID}/notifications")); + let (_, success) = ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, read_notifications).await.unwrap(); + let notification_id = success.as_array().unwrap()[0]["id"].as_str().unwrap(); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/notifications?ids=[{uri}]", + uri = urlencoding::encode(&format!("\"{notification_id}\"")) + )) + }; + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, read_notifications).await.unwrap(); + + let req_gen = || test::TestRequest::get().uri(&format!("/v2/notification/{notification_id}")); + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, read_notifications).await.unwrap(); + + // Notification mark as read + let write_notifications = Scopes::NOTIFICATION_WRITE; + let req_gen = || { + test::TestRequest::patch().uri(&format!( + "/v2/notifications?ids=[{uri}]", + uri = urlencoding::encode(&format!("\"{notification_id}\"")) + )) + }; + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, write_notifications).await.unwrap(); + + let req_gen = || test::TestRequest::patch().uri(&format!("/v2/notification/{notification_id}")); + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, write_notifications).await.unwrap(); + + // Notification delete + let req_gen = + || test::TestRequest::delete().uri(&format!("/v2/notification/{notification_id}")); + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, write_notifications).await.unwrap(); + + // Mass notification delete + // We invite mod, get the notification ID, and do mass delete using that + let req = test::TestRequest::post() + .uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/members")) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!( { + "user_id": MOD_USER_ID // mod + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status(), 204); + let read_notifications = Scopes::NOTIFICATION_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/user/{MOD_USER_ID}/notifications")); + let (_, success) = ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, read_notifications).await.unwrap(); + let notification_id = success.as_array().unwrap()[0]["id"].as_str().unwrap(); + + let req_gen = || { + test::TestRequest::delete().uri(&format!( + "/v2/notifications?ids=[{uri}]", + uri = urlencoding::encode(&format!("\"{notification_id}\"")) + )) + }; + ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, write_notifications).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Project version creation scopes +#[actix_rt::test] +pub async fn project_version_create_scopes() { + let test_env = TestEnvironment::new().await; + + // Create project + let create_project = Scopes::PROJECT_CREATE; + let json_data = json!( + { + "title": "Test_Add_Project project", + "slug": "demo", + "description": "Example description.", + "body": "Example body.", + "client_side": "required", + "server_side": "optional", + "initial_versions": [{ + "file_parts": ["basic-mod.jar"], + "version_number": "1.2.3", + "version_title": "start", + "dependencies": [], + "game_versions": ["1.20.1"] , + "release_channel": "release", + "loaders": ["fabric"], + "featured": true + }], + "categories": [], + "license_id": "MIT" + } + ); + let json_segment = common::actix::MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + }; + let file_segment = common::actix::MultipartSegment { + name: "basic-mod.jar".to_string(), + filename: Some("basic-mod.jar".to_string()), + content_type: Some("application/java-archive".to_string()), + data: common::actix::MultipartSegmentData::Binary( + include_bytes!("../tests/files/basic-mod.jar").to_vec(), + ), + }; + + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/project")) + .set_multipart(vec![json_segment.clone(), file_segment.clone()]) + }; + let (_, success) = ScopeTest::new(&test_env).test(req_gen, create_project).await.unwrap(); + let project_id = success["id"].as_str().unwrap(); + + // Add version to project + let create_version = Scopes::VERSION_CREATE; + let json_data = json!( + { + "project_id": project_id, + "file_parts": ["basic-mod-different.jar"], + "version_number": "1.2.3.4", + "version_title": "start", + "dependencies": [], + "game_versions": ["1.20.1"] , + "release_channel": "release", + "loaders": ["fabric"], + "featured": true + } + ); + let json_segment = common::actix::MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + }; + let file_segment = common::actix::MultipartSegment { + name: "basic-mod-different.jar".to_string(), + filename: Some("basic-mod.jar".to_string()), + content_type: Some("application/java-archive".to_string()), + data: common::actix::MultipartSegmentData::Binary( + include_bytes!("../tests/files/basic-mod-different.jar").to_vec(), + ), + }; + + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/version")) + .set_multipart(vec![json_segment.clone(), file_segment.clone()]) + }; + ScopeTest::new(&test_env).test(req_gen, create_version).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Project management scopes +#[actix_rt::test] +pub async fn project_version_reads_scopes() { + let test_env = TestEnvironment::new().await; + + // Project reading + // Uses 404 as the expected failure code (or 200 and an empty list for mass reads) + let read_project = Scopes::PROJECT_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project).await.unwrap(); + + let req_gen = || test::TestRequest::get().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/dependencies")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project).await.unwrap(); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/projects?ids=[{uri}]", + uri = urlencoding::encode(&format!("\"{PROJECT_BETA_PROJECT_ID}\"")) + )) + }; + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_project).await.unwrap(); + assert!(failure.as_array().unwrap().is_empty()); + assert!(!success.as_array().unwrap().is_empty()); + + // Team project reading + let req_gen = || test::TestRequest::get().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/members")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project).await.unwrap(); + + // Get team members + // In this case, as these are public endpoints, logging in only is relevant to showing permissions + // So for our test project (with 1 user, 'user') we will check the permissions before and after having the scope. + let req_gen = || test::TestRequest::get().uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/members")); + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_project).await.unwrap(); + assert!(!failure.as_array().unwrap()[0].as_object().unwrap()["permissions"].is_number()); + assert!(success.as_array().unwrap()[0].as_object().unwrap()["permissions"].is_number()); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/teams?ids=[{uri}]", + uri = urlencoding::encode(&format!("\"{PROJECT_ALPHA_TEAM_ID}\"")) + )) + }; + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_project).await.unwrap(); + assert!(!failure.as_array().unwrap()[0].as_array().unwrap()[0] + .as_object() + .unwrap()["permissions"] + .is_number()); + assert!(success.as_array().unwrap()[0].as_array().unwrap()[0] + .as_object() + .unwrap()["permissions"] + .is_number()); + + // User project reading + // Test user has two projects, one public and one private + let req_gen = || test::TestRequest::get().uri(&format!("/v2/user/{USER_USER_ID}/projects")); + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_project).await.unwrap(); + assert!(failure + .as_array() + .unwrap() + .iter() + .find(|x| x["status"] == "processing") + .is_none()); + assert!(success + .as_array() + .unwrap() + .iter() + .find(|x| x["status"] == "processing") + .is_some()); + + // Project metadata reading + let req_gen = || test::TestRequest::get().uri(&format!("/maven/maven/modrinth/{PROJECT_BETA_PROJECT_ID}/maven-metadata.xml")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project).await.unwrap(); + + // Version reading + // First, set version to hidden (which is when the scope is required to read it) + let read_version = Scopes::VERSION_READ; + let req = test::TestRequest::patch() + .uri(&format!("/v2/version/{PROJECT_BETA_VERSION_ID}")) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(json!({ + "status": "draft" + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status(), 204); + + let req_gen = || test::TestRequest::get().uri(&format!("/v2/version_file/{PROJECT_BETA_THREAD_FILE_HASH}")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_version).await.unwrap(); + + let req_gen = || test::TestRequest::get().uri(&format!("/v2/version_file/{PROJECT_BETA_THREAD_FILE_HASH}/download")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_version).await.unwrap(); + + // TODO: Should this be /POST? Looks like /GET + // TODO: this scope doesn't actually affect anything, because the Project::get_id contained within disallows hidden versions, which is the point of this scope + // let req_gen = || { + // test::TestRequest::post() + // .uri(&format!("/v2/version_file/{PROJECT_BETA_THREAD_FILE_HASH}/update")) + // .set_json(json!({})) + // }; + // ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_version).await.unwrap(); + + // TODO: Should this be /POST? Looks like /GET + let req_gen = || { + test::TestRequest::post() + .uri("/v2/version_files") + .set_json(json!({ + "hashes": [PROJECT_BETA_THREAD_FILE_HASH] + })) + }; + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_version).await.unwrap(); + assert!(!failure.as_object().unwrap().contains_key(PROJECT_BETA_THREAD_FILE_HASH)); + assert!(success.as_object().unwrap().contains_key(PROJECT_BETA_THREAD_FILE_HASH)); + + // Update version file + // TODO: Should this be /POST? Looks like /GET + // TODO: this scope doesn't actually affect anything, because the Project::get_id contained within disallows hidden versions, which is the point of this scope + + // let req_gen = || { + // test::TestRequest::post() + // .uri(&format!("/v2/version_files/update_individual")) + // .set_json(json!({ + // "hashes": [{ + // "hash": PROJECT_BETA_THREAD_FILE_HASH, + // }] + // })) + // }; + // let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_version).await.unwrap(); + // assert!(!failure.as_object().unwrap().contains_key(PROJECT_BETA_THREAD_FILE_HASH)); + // assert!(success.as_object().unwrap().contains_key(PROJECT_BETA_THREAD_FILE_HASH)); + + // Update version file + // TODO: this scope doesn't actually affect anything, because the Project::get_id contained within disallows hidden versions, which is the point of this scope + // let req_gen = || { + // test::TestRequest::post() + // .uri(&format!("/v2/version_files/update")) + // .set_json(json!({ + // "hashes": [PROJECT_BETA_THREAD_FILE_HASH] + // })) + // }; + // let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_version).await.unwrap(); + // assert!(!failure.as_object().unwrap().contains_key(PROJECT_BETA_THREAD_FILE_HASH)); + // assert!(success.as_object().unwrap().contains_key(PROJECT_BETA_THREAD_FILE_HASH)); + + // Both project and version reading + let read_project_and_version = Scopes::PROJECT_READ | Scopes::VERSION_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/version")); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project_and_version).await.unwrap(); + + // TODO: fails for the same reason as above + // let req_gen = || { + // test::TestRequest::get() + // .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/version/{PROJECT_BETA_VERSION_ID}")) + // }; + // ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project_and_version).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Project writing +#[actix_rt::test] +pub async fn project_write_scopes() { + let test_env = TestEnvironment::new().await; + + // Projects writing + let write_project = Scopes::PROJECT_WRITE; + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}")) + .set_json(json!( + { + "title": "test_project_version_write_scopes Title", + } + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + let req_gen = || { + test::TestRequest::patch() + .uri(&format!( + "/v2/projects?ids=[{uri}]", + uri = urlencoding::encode(&format!("\"{PROJECT_BETA_PROJECT_ID}\"")) + )) + .set_json(json!( + { + "description": "test_project_version_write_scopes Description", + } + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/project/{PROJECT_ALPHA_PROJECT_ID}/schedule")) // PROJECT_ALPHA_PROJECT_ID is an *approved* project, so we can schedule it + .set_json(json!( + { + "requested_status": "private", + "time": Utc::now() + Duration::days(1), + } + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + // Icons and gallery images + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/icon?ext=png")) + .set_payload(Bytes::from( + include_bytes!("../tests/files/200x200.png") as &[u8] + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + let req_gen = || test::TestRequest::delete().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/icon")); + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/gallery?ext=png&featured=true")) + .set_payload(Bytes::from( + include_bytes!("../tests/files/200x200.png") as &[u8] + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + // Get project, as we need the gallery image url + let req_gen = test::TestRequest::get() + .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}")) + .append_header(("Authorization", USER_USER_PAT)) + .to_request(); + let resp = test_env.call(req_gen).await; + let project: serde_json::Value = test::read_body_json(resp).await; + let gallery_url = project["gallery"][0]["url"].as_str().unwrap(); + + let req_gen = + || test::TestRequest::patch().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/gallery?url={gallery_url}")); + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + let req_gen = + || test::TestRequest::delete().uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}/gallery?url={gallery_url}")); + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + // Team scopes - add user 'friend' + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/members")) + .set_json(json!({ + "user_id": FRIEND_USER_ID + })) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + // Accept team invite as 'friend' + let req_gen = || test::TestRequest::post().uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/join")); + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, write_project).await.unwrap(); + + // Patch 'friend' user + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/members/{FRIEND_USER_ID}")) + .set_json(json!({ + "permissions": 1 + })) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + // Transfer ownership to 'friend' + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/owner")) + .set_json(json!({ + "user_id": FRIEND_USER_ID + })) + }; + ScopeTest::new(&test_env).test(req_gen, write_project).await.unwrap(); + + // Now as 'friend', delete 'user' + let req_gen = || test::TestRequest::delete().uri(&format!("/v2/team/{PROJECT_ALPHA_TEAM_ID}/members/{USER_USER_ID}")); + ScopeTest::new(&test_env).with_user_id(FRIEND_USER_ID_PARSED).test(req_gen, write_project).await.unwrap(); + + // Delete project + // TODO: this route is currently broken, + // because the Project::get_id contained within Project::remove doesnt include hidden versions, meaning that if there + // is a hidden version, it will fail to delete the project (with a 500 error, as the versions of a project are not all deleted) + // let delete_version = Scopes::PROJECT_DELETE; + // let req_gen = || { + // test::TestRequest::delete() + // .uri(&format!("/v2/project/{PROJECT_BETA_PROJECT_ID}")) + // }; + // ScopeTest::new(&test_env).test(req_gen, delete_version).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Version write +#[actix_rt::test] +pub async fn version_write_scopes() { + let test_env = TestEnvironment::new().await; + + let write_version = Scopes::VERSION_WRITE; + + // Schedule version + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/version/{PROJECT_ALPHA_VERSION_ID}/schedule")) // PROJECT_ALPHA_VERSION_ID is an *approved* version, so we can schedule it + .set_json(json!( + { + "requested_status": "archived", + "time": Utc::now() + Duration::days(1), + } + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_version).await.unwrap(); + + // Patch version + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/version/{PROJECT_ALPHA_VERSION_ID}")) + .set_json(json!( + { + "version_title": "test_version_write_scopes Title", + } + )) + }; + ScopeTest::new(&test_env).test(req_gen, write_version).await.unwrap(); + + // Generate test project data. + // Basic json + let json_segment = common::actix::MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: common::actix::MultipartSegmentData::Text( + serde_json::to_string(&json!( + { + "file_types": { + "simple-zip.zip": "required-resource-pack" + }, + } + )) + .unwrap(), + ), + }; + + // Differently named file, with different content + let content_segment = common::actix::MultipartSegment { + name: "simple-zip.zip".to_string(), + filename: Some("simple-zip.zip".to_string()), + content_type: Some("application/zip".to_string()), + data: common::actix::MultipartSegmentData::Binary( + include_bytes!("../tests/files/simple-zip.zip").to_vec(), + ), + }; + + // Upload version file + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/version/{PROJECT_ALPHA_VERSION_ID}/file")) + .set_multipart(vec![json_segment.clone(), content_segment.clone()]) + }; + ScopeTest::new(&test_env).test(req_gen, write_version).await.unwrap(); + + // Delete version file + // TODO: Should this scope be VERSION_DELETE? + let req_gen = || { + test::TestRequest::delete().uri(&format!("/v2/version_file/{PROJECT_ALPHA_THREAD_FILE_HASH}")) // Delete from PROJECT_ALPHA_VERSION_ID, as we uploaded to PROJECT_ALPHA_VERSION_ID and it needs another file + }; + ScopeTest::new(&test_env).test(req_gen, write_version).await.unwrap(); + + // Delete version + let delete_version = Scopes::VERSION_DELETE; + let req_gen = || test::TestRequest::delete().uri(&format!("/v2/version/{PROJECT_ALPHA_VERSION_ID}")); + ScopeTest::new(&test_env).test(req_gen, delete_version).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Report scopes +#[actix_rt::test] +pub async fn report_scopes() { + let test_env = TestEnvironment::new().await; + + // Create report + let report_create = Scopes::REPORT_CREATE; + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/report")) + .set_json(json!({ + "report_type": "copyright", + "item_id": PROJECT_BETA_PROJECT_ID, + "item_type": "project", + "body": "This is a reupload of my mod, ", + })) + }; + ScopeTest::new(&test_env).test(req_gen, report_create).await.unwrap(); + + // Get reports + let report_read = Scopes::REPORT_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/report")); + let (_, success) = ScopeTest::new(&test_env).test(req_gen, report_read).await.unwrap(); + let report_id = success.as_array().unwrap()[0]["id"].as_str().unwrap(); + + let req_gen = || test::TestRequest::get().uri(&format!("/v2/report/{}", report_id)); + ScopeTest::new(&test_env).test(req_gen, report_read).await.unwrap(); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/reports?ids=[{}]", + urlencoding::encode(&format!("\"{}\"", report_id)) + )) + }; + ScopeTest::new(&test_env).test(req_gen, report_read).await.unwrap(); + + // Edit report + let report_edit = Scopes::REPORT_WRITE; + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/report/{}", report_id)) + .set_json(json!({ + "body": "This is a reupload of my mod, G8!", + })) + }; + ScopeTest::new(&test_env).test(req_gen, report_edit).await.unwrap(); + + // Delete report + // We use a moderator PAT here, as only moderators can delete reports + let report_delete = Scopes::REPORT_DELETE; + let req_gen = || test::TestRequest::delete().uri(&format!("/v2/report/{}", report_id)); + ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, report_delete).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Thread scopes +#[actix_rt::test] +pub async fn thread_scopes() { + let test_env = TestEnvironment::new().await; + + // Thread read + let thread_read = Scopes::THREAD_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/thread/{PROJECT_ALPHA_THREAD_ID}")); + ScopeTest::new(&test_env).test(req_gen, thread_read).await.unwrap(); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/threads?ids=[{}]", + urlencoding::encode(&format!("\"{}\"", "U")) + )) + }; + ScopeTest::new(&test_env).test(req_gen, thread_read).await.unwrap(); + + // Check moderation inbox + // Uses moderator PAT, as only moderators can see the moderation inbox + let req_gen = || test::TestRequest::get().uri(&format!("/v2/thread/inbox")); + let (_, success) = ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, thread_read).await.unwrap(); + let thread = success.as_array().unwrap()[0].as_object().unwrap(); + let thread_id = thread["id"].as_str().unwrap(); + + // Moderator 'read' thread + // Uses moderator PAT, as only moderators can see the moderation inbox + let req_gen = || test::TestRequest::post().uri(&format!("/v2/thread/{thread_id}/read")); + ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, thread_read).await.unwrap(); + + // Thread write + let thread_write = Scopes::THREAD_WRITE; + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/thread/{thread_id}")) + .set_json(json!({ + "body": { + "type": "text", + "body": "test_thread_scopes Body" + } + })) + }; + ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, thread_write).await.unwrap(); + + // Delete that message + // First, get message id + let req_gen = test::TestRequest::get() + .uri(&format!("/v2/thread/{thread_id}")) + .append_header(("Authorization", USER_USER_PAT)) + .to_request(); + let resp = test_env.call(req_gen).await; + let success: serde_json::Value = test::read_body_json(resp).await; + let thread_messages = success.as_object().unwrap()["messages"].as_array().unwrap(); + let thread_message_id = thread_messages[0].as_object().unwrap()["id"] + .as_str() + .unwrap(); + let req_gen = || test::TestRequest::delete().uri(&format!("/v2/message/{thread_message_id}")); + ScopeTest::new(&test_env).with_user_id(MOD_USER_ID_PARSED).test(req_gen, thread_write).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Pat scopes +#[actix_rt::test] +pub async fn pat_scopes() { + let test_env = TestEnvironment::new().await; + + // Pat create + let pat_create = Scopes::PAT_CREATE; + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/pat")) + .set_json(json!({ + "scopes": 1, + "name": "test_pat_scopes Name", + "expires": Utc::now() + Duration::days(1), + })) + }; + let (_, success) = ScopeTest::new(&test_env).test(req_gen, pat_create).await.unwrap(); + let pat_id = success["id"].as_str().unwrap(); + + // Pat write + let pat_write = Scopes::PAT_WRITE; + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/pat/{pat_id}")) + .set_json(json!({})) + }; + ScopeTest::new(&test_env).test(req_gen, pat_write).await.unwrap(); + + // Pat read + let pat_read = Scopes::PAT_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/pat")); + ScopeTest::new(&test_env).test(req_gen, pat_read).await.unwrap(); + + // Pat delete + let pat_delete = Scopes::PAT_DELETE; + let req_gen = || test::TestRequest::delete().uri(&format!("/v2/pat/{pat_id}")); + ScopeTest::new(&test_env).test(req_gen, pat_delete).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Collection scopes +#[actix_rt::test] +pub async fn collections_scopes() { + let test_env = TestEnvironment::new().await; + + // Create collection + let collection_create = Scopes::COLLECTION_CREATE; + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/collection")) + .set_json(json!({ + "title": "Test Collection", + "description": "Test Collection Description", + "projects": [PROJECT_ALPHA_PROJECT_ID] + })) + }; + let (_, success) = ScopeTest::new(&test_env).test(req_gen, collection_create).await.unwrap(); + let collection_id = success["id"].as_str().unwrap(); + + // Patch collection + // Collections always initialize to public, so we do patch before Get testing + let collection_write = Scopes::COLLECTION_WRITE; + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/collection/{collection_id}")) + .set_json(json!({ + "title": "Test Collection patch", + "status": "private", + })) + }; + ScopeTest::new(&test_env).test(req_gen, collection_write).await.unwrap(); + + // Read collection + let collection_read = Scopes::COLLECTION_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/collection/{}", collection_id)); + ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, collection_read).await.unwrap(); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/collections?ids=[{}]", + urlencoding::encode(&format!("\"{}\"", collection_id)) + )) + }; + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, collection_read).await.unwrap(); + assert_eq!(failure.as_array().unwrap().len(), 0); + assert_eq!(success.as_array().unwrap().len(), 1); + + let req_gen = || test::TestRequest::get().uri(&format!("/v2/user/{USER_USER_ID}/collections")); + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, collection_read).await.unwrap(); + assert_eq!(failure.as_array().unwrap().len(), 0); + assert_eq!(success.as_array().unwrap().len(), 1); + + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/collection/{collection_id}/icon?ext=png")) + .set_payload(Bytes::from( + include_bytes!("../tests/files/200x200.png") as &[u8] + )) + }; + ScopeTest::new(&test_env).test(req_gen, collection_write).await.unwrap(); + + let req_gen = + || test::TestRequest::delete().uri(&format!("/v2/collection/{collection_id}/icon")); + ScopeTest::new(&test_env).test(req_gen, collection_write).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// Organization scopes (and a couple PROJECT_WRITE scopes that are only allowed for orgs) +#[actix_rt::test] +pub async fn organization_scopes() { + let test_env = TestEnvironment::new().await; + + // Create organization + let organization_create = Scopes::ORGANIZATION_CREATE; + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/organization")) + .set_json(json!({ + "title": "TestOrg", + "description": "TestOrg Description", + })) + }; + let (_, success) = ScopeTest::new(&test_env).test(req_gen, organization_create).await.unwrap(); + let organization_id = success["id"].as_str().unwrap(); + + // Patch organization + let organization_edit = Scopes::ORGANIZATION_WRITE; + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/organization/{organization_id}")) + .set_json(json!({ + "description": "TestOrg Patch Description", + })) + }; + ScopeTest::new(&test_env).test(req_gen, organization_edit).await.unwrap(); + + let req_gen = || { + test::TestRequest::patch() + .uri(&format!("/v2/organization/{organization_id}/icon?ext=png")) + .set_payload(Bytes::from( + include_bytes!("../tests/files/200x200.png") as &[u8] + )) + }; + ScopeTest::new(&test_env).test(req_gen, organization_edit).await.unwrap(); + + let req_gen = + || test::TestRequest::delete().uri(&format!("/v2/organization/{organization_id}/icon")); + ScopeTest::new(&test_env).test(req_gen, organization_edit).await.unwrap(); + + // add project + let organization_project_edit = Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE; + let req_gen = || { + test::TestRequest::post() + .uri(&format!("/v2/organization/{organization_id}/projects")) + .set_json(json!({ + "project_id": PROJECT_BETA_PROJECT_ID + })) + }; + ScopeTest::new(&test_env).with_failure_scopes(Scopes::ALL ^ Scopes::ORGANIZATION_WRITE).test(req_gen, organization_project_edit).await.unwrap(); + + // Organization reads + let organization_read = Scopes::ORGANIZATION_READ; + let req_gen = || test::TestRequest::get().uri(&format!("/v2/organization/{organization_id}")); + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, organization_read).await.unwrap(); + assert!( + failure.as_object().unwrap()["members"].as_array().unwrap()[0] + .as_object() + .unwrap()["permissions"] + .is_null() + ); + assert!( + !success.as_object().unwrap()["members"].as_array().unwrap()[0] + .as_object() + .unwrap()["permissions"] + .is_null() + ); + + let req_gen = || { + test::TestRequest::get().uri(&format!( + "/v2/organizations?ids=[{}]", + urlencoding::encode(&format!("\"{}\"", organization_id)) + )) + }; + + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, organization_read).await.unwrap(); + assert!( + failure.as_array().unwrap()[0].as_object().unwrap()["members"] + .as_array() + .unwrap()[0] + .as_object() + .unwrap()["permissions"] + .is_null() + ); + assert!( + !success.as_array().unwrap()[0].as_object().unwrap()["members"] + .as_array() + .unwrap()[0] + .as_object() + .unwrap()["permissions"] + .is_null() + ); + + let organization_project_read = Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ; + let req_gen = + || test::TestRequest::get().uri(&format!("/v2/organization/{organization_id}/projects")); + let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).with_failure_scopes(Scopes::ALL ^ Scopes::ORGANIZATION_READ).test(req_gen, organization_project_read).await.unwrap(); + assert!(failure.as_array().unwrap().len() == 0); + assert!(success.as_array().unwrap().len() == 1); + + // remove project (now that we've checked) + let req_gen = || { + test::TestRequest::delete().uri(&format!("/v2/organization/{organization_id}/projects/{PROJECT_BETA_PROJECT_ID}")) + }; + ScopeTest::new(&test_env).with_failure_scopes(Scopes::ALL ^ Scopes::ORGANIZATION_WRITE).test(req_gen, organization_project_edit).await.unwrap(); + + // Delete organization + let organization_delete = Scopes::ORGANIZATION_DELETE; + let req_gen = + || test::TestRequest::delete().uri(&format!("/v2/organization/{organization_id}")); + ScopeTest::new(&test_env).test(req_gen, organization_delete).await.unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} + +// TODO: Analytics scopes + +// TODO: User authentication, and Session scopes + +// TODO: Some hash/version files functions + +// TODO: Meta pat stuff + +// TODO: Image scopes \ No newline at end of file