diff --git a/src/routes/v3/analytics_get.rs b/src/routes/v3/analytics_get.rs index dc31c69c..ee12e02e 100644 --- a/src/routes/v3/analytics_get.rs +++ b/src/routes/v3/analytics_get.rs @@ -1,8 +1,10 @@ use super::ApiError; +use crate::database; use crate::database::redis::RedisPool; +use crate::models::teams::ProjectPermissions; use crate::{ - auth::{filter_authorized_projects, filter_authorized_versions, get_user_from_headers}, - database::models::{project_item, user_item, version_item}, + auth::get_user_from_headers, + database::models::user_item, models::{ ids::{ base62_impl::{parse_base62, to_base62}, @@ -351,6 +353,7 @@ pub async fn revenue_get( .try_into() .map_err(|_| ApiError::InvalidInput("Invalid resolution_minutes".to_string()))?; // Get the revenue data + let project_ids = project_ids.unwrap_or_default(); let payouts_values = sqlx::query!( " SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start @@ -358,7 +361,7 @@ pub async fn revenue_get( WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3 GROUP by mod_id, interval_start ORDER BY interval_start ", - &project_ids.unwrap_or_default().into_iter().map(|x| x.0 as i64).collect::>(), + &project_ids.iter().map(|x| x.0 as i64).collect::>(), start_date, end_date, duration, @@ -366,7 +369,10 @@ pub async fn revenue_get( .fetch_all(&**pool) .await?; - let mut hm = HashMap::new(); + let mut hm: HashMap<_, _> = project_ids + .into_iter() + .map(|x| (x.to_string(), HashMap::new())) + .collect::>(); for value in payouts_values { if let Some(mod_id) = value.mod_id { if let Some(amount) = value.amount_sum { @@ -559,7 +565,7 @@ async fn filter_allowed_ids( )); } - // If no project_ids or version_ids are provided, we default to all projects the user has access to + // If no project_ids or version_ids are provided, we default to all projects the user has *public* access to if project_ids.is_none() && version_ids.is_none() { project_ids = Some( user_item::User::get_projects(user.id.into(), &***pool, redis) @@ -572,35 +578,154 @@ async fn filter_allowed_ids( // Convert String list to list of ProjectIds or VersionIds // - Filter out unauthorized projects/versions + let project_ids = if let Some(project_strings) = project_ids { + let projects_data = + database::models::Project::get_many(&project_strings, &***pool, redis).await?; - let project_ids = if let Some(project_ids) = project_ids { - // Submitted project_ids are filtered by the user's permissions - let ids = project_ids + let team_ids = projects_data .iter() - .map(|id| Ok(ProjectId(parse_base62(id)?).into())) - .collect::, ApiError>>()?; - let projects = project_item::Project::get_many_ids(&ids, &***pool, redis).await?; - let ids: Vec = filter_authorized_projects(projects, &Some(user.clone()), pool) - .await? + .map(|x| x.inner.team_id) + .collect::>(); + let team_members = + database::models::TeamMember::get_from_team_full_many(&team_ids, &***pool, redis) + .await?; + + let organization_ids = projects_data + .iter() + .filter_map(|x| x.inner.organization_id) + .collect::>(); + let organizations = + database::models::Organization::get_many_ids(&organization_ids, &***pool, redis) + .await?; + + let organization_team_ids = organizations + .iter() + .map(|x| x.team_id) + .collect::>(); + let organization_team_members = database::models::TeamMember::get_from_team_full_many( + &organization_team_ids, + &***pool, + redis, + ) + .await?; + + let ids = projects_data .into_iter() - .map(|x| x.id) + .filter(|project| { + let team_member = team_members + .iter() + .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()); + + let organization = project + .inner + .organization_id + .and_then(|oid| organizations.iter().find(|x| x.id == oid)); + + let organization_team_member = if let Some(organization) = organization { + organization_team_members + .iter() + .find(|x| x.team_id == organization.team_id && x.user_id == user.id.into()) + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.cloned(), + &organization_team_member.cloned(), + ) + .unwrap_or_default(); + + permissions.contains(ProjectPermissions::VIEW_ANALYTICS) + }) + .map(|x| x.inner.id.into()) .collect::>(); + Some(ids) } else { None }; + let version_ids = if let Some(version_ids) = version_ids { // Submitted version_ids are filtered by the user's permissions let ids = version_ids .iter() .map(|id| Ok(VersionId(parse_base62(id)?).into())) .collect::, ApiError>>()?; - let versions = version_item::Version::get_many(&ids, &***pool, redis).await?; - let ids: Vec = filter_authorized_versions(versions, &Some(user), pool) - .await? + let versions_data = database::models::Version::get_many(&ids, &***pool, redis).await?; + let project_ids = versions_data + .iter() + .map(|x| x.inner.project_id) + .collect::>(); + + let projects_data = + database::models::Project::get_many_ids(&project_ids, &***pool, redis).await?; + + let team_ids = projects_data + .iter() + .map(|x| x.inner.team_id) + .collect::>(); + let team_members = + database::models::TeamMember::get_from_team_full_many(&team_ids, &***pool, redis) + .await?; + + let organization_ids = projects_data + .iter() + .filter_map(|x| x.inner.organization_id) + .collect::>(); + let organizations = + database::models::Organization::get_many_ids(&organization_ids, &***pool, redis) + .await?; + + let organization_team_ids = organizations + .iter() + .map(|x| x.team_id) + .collect::>(); + let organization_team_members = database::models::TeamMember::get_from_team_full_many( + &organization_team_ids, + &***pool, + redis, + ) + .await?; + + let ids = projects_data + .into_iter() + .filter(|project| { + let team_member = team_members + .iter() + .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()); + + let organization = project + .inner + .organization_id + .and_then(|oid| organizations.iter().find(|x| x.id == oid)); + + let organization_team_member = if let Some(organization) = organization { + organization_team_members + .iter() + .find(|x| x.team_id == organization.team_id && x.user_id == user.id.into()) + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.cloned(), + &organization_team_member.cloned(), + ) + .unwrap_or_default(); + + permissions.contains(ProjectPermissions::VIEW_ANALYTICS) + }) + .map(|x| x.inner.id) + .collect::>(); + + let ids = versions_data .into_iter() - .map(|x| x.id) + .filter(|version| ids.contains(&version.inner.project_id)) + .map(|x| x.inner.id.into()) .collect::>(); + Some(ids) } else { None diff --git a/tests/analytics.rs b/tests/analytics.rs index bc3d80d4..c1f7806d 100644 --- a/tests/analytics.rs +++ b/tests/analytics.rs @@ -1,8 +1,11 @@ +use actix_web::test; use chrono::{DateTime, Duration, Utc}; -use common::database::*; use common::environment::TestEnvironment; +use common::permissions::PermissionsTest; +use common::{database::*, permissions::PermissionsTestContext}; use itertools::Itertools; use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::teams::ProjectPermissions; use rust_decimal::{prelude::ToPrimitive, Decimal}; mod common; @@ -70,6 +73,7 @@ pub async fn analytics_revenue() { let analytics = api .get_analytics_revenue_deserialized( vec![&alpha_project_id], + false, None, None, None, @@ -99,6 +103,7 @@ pub async fn analytics_revenue() { let analytics = api .get_analytics_revenue_deserialized( vec![&alpha_project_id], + false, Some(Utc::now() - Duration::days(801)), None, None, @@ -133,3 +138,92 @@ fn to_f64_rounded_up(d: Decimal) -> f64 { fn to_f64_vec_rounded_up(d: Vec) -> Vec { d.into_iter().map(to_f64_rounded_up).collect_vec() } + +#[actix_rt::test] +pub async fn permissions_analytics_revenue() { + let test_env = TestEnvironment::build(None).await; + + let alpha_project_id = test_env + .dummy + .as_ref() + .unwrap() + .project_alpha + .project_id + .clone(); + let alpha_version_id = test_env + .dummy + .as_ref() + .unwrap() + .project_alpha + .version_id + .clone(); + let alpha_team_id = test_env + .dummy + .as_ref() + .unwrap() + .project_alpha + .team_id + .clone(); + + let view_analytics = ProjectPermissions::VIEW_ANALYTICS; + + // first, do check with a project + let req_gen = |ctx: &PermissionsTestContext| { + let projects_string = serde_json::to_string(&vec![ctx.project_id]).unwrap(); + let projects_string = urlencoding::encode(&projects_string); + test::TestRequest::get().uri(&format!( + "/v3/analytics/revenue?project_ids={projects_string}&resolution_minutes=5", + )) + }; + + PermissionsTest::new(&test_env) + .with_failure_codes(vec![200, 401]) + .with_200_json_checks( + // On failure, should have 0 projects returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 0); + }, + // On success, should have 1 project returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 1); + }, + ) + .simple_project_permissions_test(view_analytics, req_gen) + .await + .unwrap(); + + // Now with a version + // Need to use alpha + let req_gen = |_: &PermissionsTestContext| { + let versions_string = serde_json::to_string(&vec![alpha_version_id.clone()]).unwrap(); + let versions_string = urlencoding::encode(&versions_string); + test::TestRequest::get().uri(&format!( + "/v3/analytics/revenue?version_ids={versions_string}&resolution_minutes=5", + )) + }; + + PermissionsTest::new(&test_env) + .with_failure_codes(vec![200, 401]) + .with_existing_project(&alpha_project_id, &alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .with_200_json_checks( + // On failure, should have 0 versions returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 0); + }, + // On success, should have 1 versions returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 1); + }, + ) + .simple_project_permissions_test(view_analytics, req_gen) + .await + .unwrap(); + + // Cleanup test db + test_env.cleanup().await; +} diff --git a/tests/common/api_v3/project.rs b/tests/common/api_v3/project.rs index b4365d9c..2ffc1d7a 100644 --- a/tests/common/api_v3/project.rs +++ b/tests/common/api_v3/project.rs @@ -204,13 +204,21 @@ impl ApiV3 { pub async fn get_analytics_revenue( &self, id_or_slugs: Vec<&str>, + ids_are_version_ids: bool, start_date: Option>, end_date: Option>, resolution_minutes: Option, pat: &str, ) -> ServiceResponse { - let projects_string = serde_json::to_string(&id_or_slugs).unwrap(); - let projects_string = urlencoding::encode(&projects_string); + let pv_string = if ids_are_version_ids { + let version_string: String = serde_json::to_string(&id_or_slugs).unwrap(); + let version_string = urlencoding::encode(&version_string); + format!("version_ids={}", version_string) + } else { + let projects_string: String = serde_json::to_string(&id_or_slugs).unwrap(); + let projects_string = urlencoding::encode(&projects_string); + format!("project_ids={}", projects_string) + }; let mut extra_args = String::new(); if let Some(start_date) = start_date { @@ -230,9 +238,7 @@ impl ApiV3 { } let req = test::TestRequest::get() - .uri(&format!( - "/v3/analytics/revenue?{projects_string}{extra_args}", - )) + .uri(&format!("/v3/analytics/revenue?{pv_string}{extra_args}",)) .append_header(("Authorization", pat)) .to_request(); @@ -242,13 +248,21 @@ impl ApiV3 { pub async fn get_analytics_revenue_deserialized( &self, id_or_slugs: Vec<&str>, + ids_are_version_ids: bool, start_date: Option>, end_date: Option>, resolution_minutes: Option, pat: &str, ) -> HashMap> { let resp = self - .get_analytics_revenue(id_or_slugs, start_date, end_date, resolution_minutes, pat) + .get_analytics_revenue( + id_or_slugs, + ids_are_version_ids, + start_date, + end_date, + resolution_minutes, + pat, + ) .await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 1bb2e20a..c960b72f 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -1,4 +1,5 @@ #![allow(dead_code)] +use actix_http::StatusCode; use actix_web::test::{self, TestRequest}; use itertools::Itertools; use labrinth::models::teams::{OrganizationPermissions, ProjectPermissions}; @@ -45,6 +46,12 @@ pub struct PermissionsTest<'a> { // The codes that is allow to be returned if the scope is not present. // (for instance, we might expect a 401, but not a 400) allowed_failure_codes: Vec, + + // Closures that check the JSON body of the response for failure and success cases. + // These are used to perform more complex tests than just checking the status code. + // (eg: checking that the response contains the correct data) + failure_json_check: Option>, + success_json_check: Option>, } pub struct PermissionsTestContext<'a> { @@ -71,6 +78,8 @@ impl<'a> PermissionsTest<'a> { project_team_id: None, organization_team_id: None, allowed_failure_codes: vec![401, 404], + failure_json_check: None, + success_json_check: None, } } @@ -87,6 +96,20 @@ impl<'a> PermissionsTest<'a> { self } + // Set check closures for the JSON body of the response + // These are used to perform more complex tests than just checking the status code. + // If not set, no checks will be performed (and the status code is the only check). + // This is useful if, say, both expected status codes are 200. + pub fn with_200_json_checks( + mut self, + failure_json_check: impl Fn(&serde_json::Value) + Send + 'static, + success_json_check: impl Fn(&serde_json::Value) + Send + 'static, + ) -> Self { + self.failure_json_check = Some(Box::new(failure_json_check)); + self.success_json_check = Some(Box::new(success_json_check)); + self + } + // Set the user ID to use // (eg: a moderator, or friend) // remove_user: Whether or not the user ID should be removed from the project/organization team after the test @@ -181,6 +204,11 @@ impl<'a> PermissionsTest<'a> { resp.status().as_u16() )); } + if resp.status() == StatusCode::OK { + if let Some(failure_json_check) = &self.failure_json_check { + failure_json_check(&test::read_body_json(resp).await); + } + } // Failure test- logged in on a non-team user let request = req_gen(&PermissionsTestContext { @@ -202,6 +230,11 @@ impl<'a> PermissionsTest<'a> { resp.status().as_u16() )); } + if resp.status() == StatusCode::OK { + if let Some(failure_json_check) = &self.failure_json_check { + failure_json_check(&test::read_body_json(resp).await); + } + } // Failure test- logged in with EVERY non-relevant permission let request = req_gen(&PermissionsTestContext { @@ -223,6 +256,11 @@ impl<'a> PermissionsTest<'a> { resp.status().as_u16() )); } + if resp.status() == StatusCode::OK { + if let Some(failure_json_check) = &self.failure_json_check { + failure_json_check(&test::read_body_json(resp).await); + } + } // Patch user's permissions to success permissions modify_user_team_permissions( @@ -250,6 +288,11 @@ impl<'a> PermissionsTest<'a> { resp.status().as_u16() )); } + if resp.status() == StatusCode::OK { + if let Some(success_json_check) = &self.success_json_check { + success_json_check(&test::read_body_json(resp).await); + } + } // If the remove_user flag is set, remove the user from the project // Relevant for existing projects/users