You've already forked AstralRinth
forked from didirus/AstralRinth
Tests (#719)
* computer switch * some fixes; github action * added pr to master * sqlx database setup * switched intial GHA test db * removed sqlx database setup * unfinished patch route * bug fixes + tests * more tests, more fixes, cargo fmt * merge fixes * more tests, full reorganization * fmt, clippy * sqlx-data * revs * removed comments * delete revs
This commit is contained in:
82
tests/common/actix.rs
Normal file
82
tests/common/actix.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
pub filename: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub data: MultipartSegmentData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum MultipartSegmentData {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
pub trait AppendsMultipart {
|
||||
fn set_multipart(self, data: Vec<MultipartSegment>) -> Self;
|
||||
}
|
||||
|
||||
impl AppendsMultipart for TestRequest {
|
||||
fn set_multipart(self, data: Vec<MultipartSegment>) -> Self {
|
||||
let (boundary, payload) = generate_multipart(data);
|
||||
self.append_header((
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={}", boundary),
|
||||
))
|
||||
.set_payload(payload)
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_multipart(data: Vec<MultipartSegment>) -> (String, Bytes) {
|
||||
let mut boundary = String::from("----WebKitFormBoundary");
|
||||
boundary.push_str(&rand::random::<u64>().to_string());
|
||||
boundary.push_str(&rand::random::<u64>().to_string());
|
||||
boundary.push_str(&rand::random::<u64>().to_string());
|
||||
|
||||
let mut payload = BytesMut::new();
|
||||
|
||||
for segment in data {
|
||||
payload.extend_from_slice(
|
||||
format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"",
|
||||
boundary = boundary,
|
||||
name = segment.name
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
if let Some(filename) = &segment.filename {
|
||||
payload.extend_from_slice(
|
||||
format!("; filename=\"{filename}\"", filename = filename).as_bytes(),
|
||||
);
|
||||
}
|
||||
if let Some(content_type) = &segment.content_type {
|
||||
payload.extend_from_slice(
|
||||
format!(
|
||||
"\r\nContent-Type: {content_type}",
|
||||
content_type = content_type
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
payload.extend_from_slice(b"\r\n\r\n");
|
||||
|
||||
match &segment.data {
|
||||
MultipartSegmentData::Text(text) => {
|
||||
payload.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
MultipartSegmentData::Binary(binary) => {
|
||||
payload.extend_from_slice(binary);
|
||||
}
|
||||
}
|
||||
payload.extend_from_slice(b"\r\n");
|
||||
}
|
||||
payload.extend_from_slice(format!("--{boundary}--\r\n", boundary = boundary).as_bytes());
|
||||
|
||||
(boundary, Bytes::from(payload))
|
||||
}
|
||||
134
tests/common/database.rs
Normal file
134
tests/common/database.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use labrinth::database::redis::RedisPool;
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
// The dummy test database adds a fair bit of 'dummy' data to test with.
|
||||
// Some constants are used to refer to that data, and are described here.
|
||||
// The rest can be accessed in the TestEnvironment 'dummy' field.
|
||||
|
||||
// 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";
|
||||
|
||||
pub struct TemporaryDatabase {
|
||||
pub pool: PgPool,
|
||||
pub redis_pool: RedisPool,
|
||||
pub database_name: String,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let database_url = dotenvy::var("DATABASE_URL").expect("No database URL");
|
||||
let mut url = Url::parse(&database_url).expect("Invalid database URL");
|
||||
let pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("Connection to database failed");
|
||||
|
||||
// Create the temporary database
|
||||
let create_db_query = format!("CREATE DATABASE {}", &temp_database_name);
|
||||
|
||||
sqlx::query(&create_db_query)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Database creation failed");
|
||||
|
||||
pool.close().await;
|
||||
|
||||
// Modify the URL to switch to the temporary database
|
||||
url.set_path(&format!("/{}", &temp_database_name));
|
||||
let temp_db_url = url.to_string();
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.min_connections(0)
|
||||
.max_connections(4)
|
||||
.max_lifetime(Some(Duration::from_secs(60 * 60)))
|
||||
.connect(&temp_db_url)
|
||||
.await
|
||||
.expect("Connection to temporary database failed");
|
||||
|
||||
// Performs migrations
|
||||
let migrations = sqlx::migrate!("./migrations");
|
||||
migrations.run(&pool).await.expect("Migrations failed");
|
||||
|
||||
// Gets new Redis pool
|
||||
let redis_pool = RedisPool::new(Some(temp_database_name.clone()));
|
||||
|
||||
Self {
|
||||
pool,
|
||||
database_name: temp_database_name,
|
||||
redis_pool,
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
self.pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("Connection to main database failed");
|
||||
|
||||
// Forcibly terminate all existing connections to this version of the temporary database
|
||||
// We are done and deleting it, so we don't need them anymore
|
||||
let terminate_query = format!(
|
||||
"SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()",
|
||||
&self.database_name
|
||||
);
|
||||
sqlx::query(&terminate_query)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Execute the deletion query asynchronously
|
||||
let drop_db_query = format!("DROP DATABASE IF EXISTS {}", &self.database_name);
|
||||
sqlx::query(&drop_db_query)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.expect("Database deletion failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_random_database_name() -> String {
|
||||
// Generate a random database name here
|
||||
// You can use your logic to create a unique name
|
||||
// For example, you can use a random string as you did before
|
||||
// or append a timestamp, etc.
|
||||
|
||||
// We will use a random string starting with "labrinth_tests_db_"
|
||||
// and append a 6-digit number to it.
|
||||
let mut database_name = String::from("labrinth_tests_db_");
|
||||
database_name.push_str(&rand::random::<u64>().to_string()[..6]);
|
||||
database_name
|
||||
}
|
||||
229
tests/common/dummy_data.rs
Normal file
229
tests/common/dummy_data.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use labrinth::{models::projects::Project, models::projects::Version};
|
||||
use serde_json::json;
|
||||
use sqlx::Executor;
|
||||
|
||||
use crate::common::{
|
||||
actix::AppendsMultipart,
|
||||
database::{MOD_USER_PAT, USER_USER_PAT},
|
||||
};
|
||||
|
||||
use super::{
|
||||
actix::{MultipartSegment, MultipartSegmentData},
|
||||
environment::TestEnvironment,
|
||||
};
|
||||
|
||||
pub struct DummyData {
|
||||
pub alpha_team_id: String,
|
||||
pub beta_team_id: String,
|
||||
|
||||
pub alpha_project_id: String,
|
||||
pub beta_project_id: String,
|
||||
|
||||
pub alpha_project_slug: String,
|
||||
pub beta_project_slug: String,
|
||||
|
||||
pub alpha_version_id: String,
|
||||
pub beta_version_id: String,
|
||||
|
||||
pub alpha_thread_id: String,
|
||||
pub beta_thread_id: String,
|
||||
|
||||
pub alpha_file_hash: String,
|
||||
pub beta_file_hash: String,
|
||||
}
|
||||
|
||||
pub async fn add_dummy_data(test_env: &TestEnvironment) -> DummyData {
|
||||
// Adds basic dummy data to the database directly with sql (user, pats)
|
||||
let pool = &test_env.db.pool.clone();
|
||||
pool.execute(include_str!("../files/dummy_data.sql"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (alpha_project, alpha_version) = add_project_alpha(test_env).await;
|
||||
let (beta_project, beta_version) = add_project_beta(test_env).await;
|
||||
|
||||
DummyData {
|
||||
alpha_team_id: alpha_project.team.to_string(),
|
||||
beta_team_id: beta_project.team.to_string(),
|
||||
|
||||
alpha_project_id: alpha_project.id.to_string(),
|
||||
beta_project_id: beta_project.id.to_string(),
|
||||
|
||||
alpha_project_slug: alpha_project.slug.unwrap(),
|
||||
beta_project_slug: beta_project.slug.unwrap(),
|
||||
|
||||
alpha_version_id: alpha_version.id.to_string(),
|
||||
beta_version_id: beta_version.id.to_string(),
|
||||
|
||||
alpha_thread_id: alpha_project.thread_id.to_string(),
|
||||
beta_thread_id: beta_project.thread_id.to_string(),
|
||||
|
||||
alpha_file_hash: alpha_version.files[0].hashes["sha1"].clone(),
|
||||
beta_file_hash: beta_version.files[0].hashes["sha1"].clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) {
|
||||
// Adds dummy data to the database with sqlx (projects, versions, threads)
|
||||
// Generate test project data.
|
||||
let json_data = json!(
|
||||
{
|
||||
"title": "Test Project Alpha",
|
||||
"slug": "alpha",
|
||||
"description": "A dummy project for testing with.",
|
||||
"body": "This project is approved, and versions are listed.",
|
||||
"client_side": "required",
|
||||
"server_side": "optional",
|
||||
"initial_versions": [{
|
||||
"file_parts": ["dummy-project-alpha.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"
|
||||
}
|
||||
);
|
||||
|
||||
// Basic json
|
||||
let json_segment = MultipartSegment {
|
||||
name: "data".to_string(),
|
||||
filename: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()),
|
||||
};
|
||||
|
||||
// Basic file
|
||||
let file_segment = MultipartSegment {
|
||||
name: "dummy-project-alpha.jar".to_string(),
|
||||
filename: Some("dummy-project-alpha.jar".to_string()),
|
||||
content_type: Some("application/java-archive".to_string()),
|
||||
data: MultipartSegmentData::Binary(
|
||||
include_bytes!("../../tests/files/dummy-project-alpha.jar").to_vec(),
|
||||
),
|
||||
};
|
||||
|
||||
// Add a project.
|
||||
let req = TestRequest::post()
|
||||
.uri("/v2/project")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_multipart(vec![json_segment.clone(), file_segment.clone()])
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Approve as a moderator.
|
||||
let req = TestRequest::patch()
|
||||
.uri("/v2/project/alpha")
|
||||
.append_header(("Authorization", MOD_USER_PAT))
|
||||
.set_json(json!(
|
||||
{
|
||||
"status": "approved"
|
||||
}
|
||||
))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 204);
|
||||
|
||||
// Get project
|
||||
let req = TestRequest::get()
|
||||
.uri("/v2/project/alpha")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
let project: Project = test::read_body_json(resp).await;
|
||||
|
||||
// Get project's versions
|
||||
let req = TestRequest::get()
|
||||
.uri("/v2/project/alpha/version")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
let versions: Vec<Version> = test::read_body_json(resp).await;
|
||||
let version = versions.into_iter().next().unwrap();
|
||||
|
||||
(project, version)
|
||||
}
|
||||
|
||||
pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version) {
|
||||
// Adds dummy data to the database with sqlx (projects, versions, threads)
|
||||
// Generate test project data.
|
||||
let json_data = json!(
|
||||
{
|
||||
"title": "Test Project Beta",
|
||||
"slug": "beta",
|
||||
"description": "A dummy project for testing with.",
|
||||
"body": "This project is not-yet-approved, and versions are draft.",
|
||||
"client_side": "required",
|
||||
"server_side": "optional",
|
||||
"initial_versions": [{
|
||||
"file_parts": ["dummy-project-beta.jar"],
|
||||
"version_number": "1.2.3",
|
||||
"version_title": "start",
|
||||
"status": "unlisted",
|
||||
"requested_status": "unlisted",
|
||||
"dependencies": [],
|
||||
"game_versions": ["1.20.1"] ,
|
||||
"release_channel": "release",
|
||||
"loaders": ["fabric"],
|
||||
"featured": true
|
||||
}],
|
||||
"status": "private",
|
||||
"requested_status": "private",
|
||||
"categories": [],
|
||||
"license_id": "MIT"
|
||||
}
|
||||
);
|
||||
|
||||
// Basic json
|
||||
let json_segment = MultipartSegment {
|
||||
name: "data".to_string(),
|
||||
filename: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()),
|
||||
};
|
||||
|
||||
// Basic file
|
||||
let file_segment = MultipartSegment {
|
||||
name: "dummy-project-beta.jar".to_string(),
|
||||
filename: Some("dummy-project-beta.jar".to_string()),
|
||||
content_type: Some("application/java-archive".to_string()),
|
||||
data: MultipartSegmentData::Binary(
|
||||
include_bytes!("../../tests/files/dummy-project-beta.jar").to_vec(),
|
||||
),
|
||||
};
|
||||
|
||||
// Add a project.
|
||||
let req = TestRequest::post()
|
||||
.uri("/v2/project")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_multipart(vec![json_segment.clone(), file_segment.clone()])
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Get project
|
||||
let req = TestRequest::get()
|
||||
.uri("/v2/project/beta")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
let project: Project = test::read_body_json(resp).await;
|
||||
|
||||
// Get project's versions
|
||||
let req = TestRequest::get()
|
||||
.uri("/v2/project/beta/version")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
let versions: Vec<Version> = test::read_body_json(resp).await;
|
||||
let version = versions.into_iter().next().unwrap();
|
||||
|
||||
(project, version)
|
||||
}
|
||||
71
tests/common/environment.rs
Normal file
71
tests/common/environment.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::{database::TemporaryDatabase, dummy_data};
|
||||
use crate::common::setup;
|
||||
use actix_web::{dev::ServiceResponse, test, App};
|
||||
|
||||
// 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<dyn LocalService>,
|
||||
pub db: TemporaryDatabase,
|
||||
|
||||
pub dummy: Option<dummy_data::DummyData>,
|
||||
}
|
||||
|
||||
impl TestEnvironment {
|
||||
pub async fn build_with_dummy() -> Self {
|
||||
let mut test_env = Self::build().await;
|
||||
let dummy = dummy_data::add_dummy_data(&test_env).await;
|
||||
test_env.dummy = Some(dummy);
|
||||
test_env
|
||||
}
|
||||
|
||||
pub async fn build() -> Self {
|
||||
let db = TemporaryDatabase::create().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,
|
||||
dummy: None,
|
||||
}
|
||||
}
|
||||
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<
|
||||
Box<dyn std::future::Future<Output = Result<ServiceResponse, actix_web::Error>>>,
|
||||
>;
|
||||
}
|
||||
impl<S> LocalService for S
|
||||
where
|
||||
S: actix_web::dev::Service<
|
||||
actix_http::Request,
|
||||
Response = ServiceResponse,
|
||||
Error = actix_web::Error,
|
||||
>,
|
||||
S::Future: 'static,
|
||||
{
|
||||
fn call(
|
||||
&self,
|
||||
req: actix_http::Request,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<ServiceResponse, actix_web::Error>>>,
|
||||
> {
|
||||
Box::pin(self.call(req))
|
||||
}
|
||||
}
|
||||
40
tests/common/mod.rs
Normal file
40
tests/common/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use labrinth::{check_env_vars, clickhouse};
|
||||
use labrinth::{file_hosting, queue, LabrinthConfig};
|
||||
use std::sync::Arc;
|
||||
|
||||
use self::database::TemporaryDatabase;
|
||||
|
||||
pub mod actix;
|
||||
pub mod database;
|
||||
pub mod dummy_data;
|
||||
pub mod environment;
|
||||
pub mod pats;
|
||||
pub mod scopes;
|
||||
|
||||
// Testing equivalent to 'setup' function, producing a LabrinthConfig
|
||||
// If making a test, you should probably use environment::TestEnvironment::build_with_dummy() (which calls this)
|
||||
pub async fn setup(db: &TemporaryDatabase) -> LabrinthConfig {
|
||||
println!("Setting up labrinth config");
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
if check_env_vars() {
|
||||
println!("Some environment variables are missing!");
|
||||
}
|
||||
|
||||
let pool = db.pool.clone();
|
||||
let redis_pool = db.redis_pool.clone();
|
||||
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
|
||||
Arc::new(file_hosting::MockHost::new());
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
|
||||
let maxmind_reader = Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap());
|
||||
|
||||
labrinth::app_setup(
|
||||
pool.clone(),
|
||||
redis_pool.clone(),
|
||||
&mut clickhouse,
|
||||
file_host.clone(),
|
||||
maxmind_reader.clone(),
|
||||
)
|
||||
}
|
||||
30
tests/common/pats.rs
Normal file
30
tests/common/pats.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use chrono::Utc;
|
||||
use labrinth::{
|
||||
database::{self, models::generate_pat_id},
|
||||
models::pats::Scopes,
|
||||
};
|
||||
|
||||
use super::database::TemporaryDatabase;
|
||||
|
||||
// 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
|
||||
pub 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
|
||||
}
|
||||
124
tests/common/scopes.rs
Normal file
124
tests/common/scopes.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
#![allow(dead_code)]
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use labrinth::models::pats::Scopes;
|
||||
|
||||
use super::{database::USER_USER_ID_PARSED, environment::TestEnvironment, pats::create_test_pat};
|
||||
|
||||
// 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<Scopes>,
|
||||
// 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<T>(
|
||||
&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.call(req).await;
|
||||
|
||||
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.call(req).await;
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
BIN
tests/files/200x200.png
Normal file
BIN
tests/files/200x200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 606 B |
BIN
tests/files/basic-mod-different.jar
Normal file
BIN
tests/files/basic-mod-different.jar
Normal file
Binary file not shown.
BIN
tests/files/basic-mod.jar
Normal file
BIN
tests/files/basic-mod.jar
Normal file
Binary file not shown.
BIN
tests/files/dummy-project-alpha.jar
Normal file
BIN
tests/files/dummy-project-alpha.jar
Normal file
Binary file not shown.
BIN
tests/files/dummy-project-beta.jar
Normal file
BIN
tests/files/dummy-project-beta.jar
Normal file
Binary file not shown.
36
tests/files/dummy_data.sql
Normal file
36
tests/files/dummy_data.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- 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');
|
||||
INSERT INTO users (id, username, name, email, role) VALUES (3, 'user', 'User Test', 'user@modrinth.com', 'developer');
|
||||
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');
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, 3, 'user-pat', 'mrp_patuser', B'11111111111111111111111111111111111'::BIGINT, '2030-08-18 15:48:58.435729+00');
|
||||
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()));
|
||||
|
||||
INSERT INTO loaders (id, loader) VALUES (1, 'fabric');
|
||||
INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,1);
|
||||
INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,2);
|
||||
|
||||
INSERT INTO categories (id, category, project_type) VALUES (1, 'combat', 1);
|
||||
INSERT INTO categories (id, category, project_type) VALUES (2, 'decoration', 1);
|
||||
INSERT INTO categories (id, category, project_type) VALUES (3, 'economy', 1);
|
||||
|
||||
INSERT INTO categories (id, category, project_type) VALUES (4, 'combat', 2);
|
||||
INSERT INTO categories (id, category, project_type) VALUES (5, 'decoration', 2);
|
||||
INSERT INTO categories (id, category, project_type) VALUES (6, 'economy', 2);
|
||||
BIN
tests/files/simple-zip.zip
Normal file
BIN
tests/files/simple-zip.zip
Normal file
Binary file not shown.
292
tests/pats.rs
Normal file
292
tests/pats.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use actix_web::test;
|
||||
use chrono::{Duration, Utc};
|
||||
use common::database::*;
|
||||
use labrinth::models::pats::Scopes;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::environment::TestEnvironment;
|
||||
|
||||
// importing common module.
|
||||
mod common;
|
||||
|
||||
// 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]
|
||||
pub async fn pat_full_test() {
|
||||
let test_env = TestEnvironment::build_with_dummy().await;
|
||||
|
||||
// Create a PAT for a full test
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/v2/pat")
|
||||
.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(), 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();
|
||||
|
||||
// Get PAT again
|
||||
let req = test::TestRequest::get()
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.uri("/v2/pat")
|
||||
.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;
|
||||
|
||||
// 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")
|
||||
.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()
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(mock_pat_test(access_token).await, 200);
|
||||
|
||||
// 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
|
||||
|
||||
// Change scopes back, and set expiry to the past, and test again
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/pat/{}", id))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"scopes": Scopes::COLLECTION_CREATE,
|
||||
"expires": Utc::now() + Duration::seconds(1), // expires in 1 second
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status().as_u16(), 204);
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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.is_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
|
||||
test_env.cleanup().await;
|
||||
}
|
||||
|
||||
// Test illegal PAT setting, both in POST and PATCH
|
||||
#[actix_rt::test]
|
||||
pub async fn bad_pats() {
|
||||
let test_env = TestEnvironment::build_with_dummy().await;
|
||||
|
||||
// Creating a PAT with no name should fail
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/v2/pat")
|
||||
.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_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")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"name": name,
|
||||
"scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example
|
||||
"expires": Utc::now() + Duration::days(1),
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status().as_u16(), 400);
|
||||
}
|
||||
|
||||
// Creating a PAT with an expiry in the past should fail
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/v2/pat")
|
||||
.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")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"scopes": scope.bits(),
|
||||
"name": format!("test_pat_scopes Name {}", i),
|
||||
"expires": Utc::now() + Duration::days(1),
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
if scope.is_restricted() { 400 } else { 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create a 'good' PAT for patching
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/v2/pat")
|
||||
.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")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"name": name,
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status().as_u16(), 400);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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.is_restricted() { 400 } else { 204 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup test db
|
||||
test_env.cleanup().await;
|
||||
}
|
||||
461
tests/project.rs
Normal file
461
tests/project.rs
Normal file
@@ -0,0 +1,461 @@
|
||||
use actix_web::test;
|
||||
use labrinth::database::models::project_item::{PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE};
|
||||
use labrinth::models::ids::base62_impl::parse_base62;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::database::*;
|
||||
|
||||
use crate::common::{actix::AppendsMultipart, environment::TestEnvironment};
|
||||
|
||||
// importing common module.
|
||||
mod common;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_get_project() {
|
||||
// Test setup and dummy data
|
||||
let test_env = TestEnvironment::build_with_dummy().await;
|
||||
let alpha_project_id = &test_env.dummy.as_ref().unwrap().alpha_project_id;
|
||||
let beta_project_id = &test_env.dummy.as_ref().unwrap().beta_project_id;
|
||||
let alpha_project_slug = &test_env.dummy.as_ref().unwrap().alpha_project_slug;
|
||||
let alpha_version_id = &test_env.dummy.as_ref().unwrap().alpha_version_id;
|
||||
|
||||
// Perform request on dummy data
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/project/{alpha_project_id}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
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!(alpha_project_id));
|
||||
assert_eq!(body["slug"], json!(alpha_project_slug));
|
||||
let versions = body["versions"].as_array().unwrap();
|
||||
assert!(!versions.is_empty());
|
||||
assert_eq!(versions[0], json!(alpha_version_id));
|
||||
|
||||
// Confirm that the request was cached
|
||||
assert_eq!(
|
||||
test_env
|
||||
.db
|
||||
.redis_pool
|
||||
.get::<i64, _>(PROJECTS_SLUGS_NAMESPACE, alpha_project_slug)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(parse_base62(alpha_project_id).unwrap() as i64)
|
||||
);
|
||||
|
||||
let cached_project = test_env
|
||||
.db
|
||||
.redis_pool
|
||||
.get::<String, _>(PROJECTS_NAMESPACE, parse_base62(alpha_project_id).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let cached_project: serde_json::Value = serde_json::from_str(&cached_project).unwrap();
|
||||
assert_eq!(cached_project["inner"]["slug"], json!(alpha_project_slug));
|
||||
|
||||
// Make the request again, this time it should be cached
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/project/{alpha_project_id}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
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!(alpha_project_id));
|
||||
assert_eq!(body["slug"], json!(alpha_project_slug));
|
||||
|
||||
// Request should fail on non-existent project
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v2/project/nonexistent")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
|
||||
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)
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/project/{beta_project_id}"))
|
||||
.append_header(("Authorization", ENEMY_USER_PAT))
|
||||
.to_request();
|
||||
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// Cleanup test db
|
||||
test_env.cleanup().await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_add_remove_project() {
|
||||
// Test setup and dummy data
|
||||
let test_env = TestEnvironment::build_with_dummy().await;
|
||||
|
||||
// Generate test project data.
|
||||
let mut 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"
|
||||
}
|
||||
);
|
||||
|
||||
// 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_data).unwrap()),
|
||||
};
|
||||
|
||||
// Basic json, with a different file
|
||||
json_data["initial_versions"][0]["file_parts"][0] = json!("basic-mod-different.jar");
|
||||
let json_diff_file_segment = common::actix::MultipartSegment {
|
||||
data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()),
|
||||
..json_segment.clone()
|
||||
};
|
||||
|
||||
// Basic json, with a different file, and a different slug
|
||||
json_data["slug"] = json!("new_demo");
|
||||
json_data["initial_versions"][0]["file_parts"][0] = json!("basic-mod-different.jar");
|
||||
let json_diff_slug_file_segment = common::actix::MultipartSegment {
|
||||
data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()),
|
||||
..json_segment.clone()
|
||||
};
|
||||
|
||||
// Basic file
|
||||
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(),
|
||||
),
|
||||
};
|
||||
|
||||
// Differently named file, with the same content (for hash testing)
|
||||
let file_diff_name_segment = common::actix::MultipartSegment {
|
||||
name: "basic-mod-different.jar".to_string(),
|
||||
filename: Some("basic-mod-different.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(),
|
||||
),
|
||||
};
|
||||
|
||||
// Differently named file, with different content
|
||||
let file_diff_name_content_segment = common::actix::MultipartSegment {
|
||||
name: "basic-mod-different.jar".to_string(),
|
||||
filename: Some("basic-mod-different.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(),
|
||||
),
|
||||
};
|
||||
|
||||
// Add a project- simple, should work.
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/v2/project")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_multipart(vec![json_segment.clone(), file_segment.clone()])
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
|
||||
let status = resp.status();
|
||||
assert_eq!(status, 200);
|
||||
|
||||
// Get the project we just made, and confirm that it's correct
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v2/project/demo")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = test::read_body_json(resp).await;
|
||||
let versions = body["versions"].as_array().unwrap();
|
||||
assert!(versions.len() == 1);
|
||||
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"))
|
||||
.digest()
|
||||
.to_string();
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/version_file/{hash}?algorithm=sha1"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = test::read_body_json(resp).await;
|
||||
let file_version_id = &body["id"];
|
||||
assert_eq!(&file_version_id, &uploaded_version_id);
|
||||
|
||||
// Reusing with a different slug and the same file should fail
|
||||
// Even if that file is named differently
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/v2/project")
|
||||
.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_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", 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_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", 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_env.call(req).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Get
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v2/project/demo")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
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();
|
||||
|
||||
// Remove the project
|
||||
let req = test::TestRequest::delete()
|
||||
.uri("/v2/project/demo")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 204);
|
||||
|
||||
// Confirm that the project is gone from the cache
|
||||
assert_eq!(
|
||||
test_env
|
||||
.db
|
||||
.redis_pool
|
||||
.get::<i64, _>(PROJECTS_SLUGS_NAMESPACE, "demo")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
test_env
|
||||
.db
|
||||
.redis_pool
|
||||
.get::<i64, _>(PROJECTS_SLUGS_NAMESPACE, id)
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
// Old slug no longer works
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v2/project/demo")
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
// Cleanup test db
|
||||
test_env.cleanup().await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
pub async fn test_patch_project() {
|
||||
let test_env = TestEnvironment::build_with_dummy().await;
|
||||
let alpha_project_slug = &test_env.dummy.as_ref().unwrap().alpha_project_slug;
|
||||
let beta_project_slug = &test_env.dummy.as_ref().unwrap().beta_project_slug;
|
||||
|
||||
// First, we do some patch requests that should fail.
|
||||
// Failure because the user is not authorized.
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", ENEMY_USER_PAT))
|
||||
.set_json(json!({
|
||||
"title": "Test_Add_Project project - test 1",
|
||||
}))
|
||||
.to_request();
|
||||
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(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
url_type: "w.fake.url",
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
// Failure because these are illegal requested statuses for a normal user.
|
||||
for req in ["unknown", "processing", "withheld", "scheduled"] {
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"requested_status": req,
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
// Failure because these should not be able to be set by a non-mod
|
||||
for key in ["moderation_message", "moderation_message_body"] {
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
key: "test",
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 401);
|
||||
|
||||
// (should work for a mod, though)
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", MOD_USER_PAT))
|
||||
.set_json(json!({
|
||||
key: "test",
|
||||
}))
|
||||
.to_request();
|
||||
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(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"slug": beta_project_slug, // the other dummy project has this slug
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// Not allowed to directly set status, as 'beta_project_slug' (the other project) is "processing" and cannot have its status changed like this.
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{beta_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"status": "private"
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 401);
|
||||
|
||||
// Sucessful request to patch many fields.
|
||||
let req = test::TestRequest::patch()
|
||||
.uri(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.set_json(json!({
|
||||
"slug": "newslug",
|
||||
"title": "New successful title",
|
||||
"description": "New successful description",
|
||||
"body": "New successful body",
|
||||
"categories": ["combat"],
|
||||
"license_id": "MIT",
|
||||
"issues_url": "https://github.com",
|
||||
"discord_url": "https://discord.gg",
|
||||
"wiki_url": "https://wiki.com",
|
||||
"client_side": "optional",
|
||||
"server_side": "required",
|
||||
"donation_urls": [{
|
||||
"id": "patreon",
|
||||
"platform": "Patreon",
|
||||
"url": "https://patreon.com"
|
||||
}]
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 204);
|
||||
|
||||
// Old slug no longer works
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/project/{alpha_project_slug}"))
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
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", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = test::read_body_json(resp).await;
|
||||
assert_eq!(body["slug"], json!("newslug"));
|
||||
assert_eq!(body["title"], json!("New successful title"));
|
||||
assert_eq!(body["description"], json!("New successful description"));
|
||||
assert_eq!(body["body"], json!("New successful body"));
|
||||
assert_eq!(body["categories"], json!(["combat"]));
|
||||
assert_eq!(body["license"]["id"], json!("MIT"));
|
||||
assert_eq!(body["issues_url"], json!("https://github.com"));
|
||||
assert_eq!(body["discord_url"], json!("https://discord.gg"));
|
||||
assert_eq!(body["wiki_url"], json!("https://wiki.com"));
|
||||
assert_eq!(body["client_side"], json!("optional"));
|
||||
assert_eq!(body["server_side"], json!("required"));
|
||||
assert_eq!(
|
||||
body["donation_urls"][0]["url"],
|
||||
json!("https://patreon.com")
|
||||
);
|
||||
|
||||
// Cleanup test db
|
||||
test_env.cleanup().await;
|
||||
}
|
||||
|
||||
// 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)
|
||||
1331
tests/scopes.rs
Normal file
1331
tests/scopes.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user