diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index dca7e7e1..59e05a71 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -24,6 +24,6 @@ jobs: - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - name: Test Coverage - run: cargo llvm-cov nextest + run: cargo llvm-cov nextest - name: E2E Tests run: ./docker/bin/run-e2e-tests.sh diff --git a/bin/install.sh b/bin/install.sh index 041863b2..d4d33a43 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -8,7 +8,7 @@ fi # Generate storage directory if it does not exist mkdir -p "./storage/database" -# Generate the sqlite database for the index baclend if it does not exist +# Generate the sqlite database for the index backend if it does not exist if ! [ -f "./storage/database/data.db" ]; then # todo: it should get the path from config.toml and only do it when we use sqlite touch ./storage/database/data.db diff --git a/docker/bin/e2e-env-reset.sh b/docker/bin/e2e-env-reset.sh new file mode 100755 index 00000000..ae7e3aff --- /dev/null +++ b/docker/bin/e2e-env-reset.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Delete the SQLite databases and recreate them. + +./docker/bin/e2e-env-down.sh + +rm -f ./storage/database/torrust_index_backend_e2e_testing.db +rm -f ./storage/database/torrust_tracker_e2e_testing.db + +# Generate storage directory if it does not exist +mkdir -p "./storage/database" + +# Generate the sqlite database for the index backend if it does not exist +if ! [ -f "./storage/database/torrust_index_backend_e2e_testing.db" ]; then + # todo: it should get the path from config.toml and only do it when we use sqlite + touch ./storage/database/torrust_index_backend_e2e_testing.db + echo ";" | sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db +fi + +# Generate the sqlite database for the tracker if it does not exist +if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then + touch ./storage/database/torrust_tracker_e2e_testing.db + echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db +fi + +./docker/bin/e2e-env-up.sh diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh index 5eb63c33..cc3b5351 100755 --- a/docker/bin/run-e2e-tests.sh +++ b/docker/bin/run-e2e-tests.sh @@ -35,6 +35,9 @@ wait_for_container_to_be_healthy() { return 1 } +# Install tool to create torrent files +cargo install imdl + cp .env.local .env ./bin/install.sh diff --git a/project-words.txt b/project-words.txt index e2e24938..220cba8b 100644 --- a/project-words.txt +++ b/project-words.txt @@ -17,6 +17,8 @@ hasher Hasher httpseeds imagoodboy +imdl +infohash jsonwebtoken leechers Leechers @@ -41,6 +43,8 @@ strftime sublicensable sublist subpoints +tempdir +tempfile torrust Torrust upgrader diff --git a/tests/e2e/asserts.rs b/tests/e2e/asserts.rs index 115637e7..f2968904 100644 --- a/tests/e2e/asserts.rs +++ b/tests/e2e/asserts.rs @@ -1,8 +1,8 @@ -use crate::e2e::response::Response; +use crate::e2e::responses::TextResponse; // Text responses -pub fn assert_response_title(response: &Response, title: &str) { +pub fn assert_response_title(response: &TextResponse, title: &str) { let title_element = format!("{title}"); assert!( @@ -11,14 +11,14 @@ pub fn assert_response_title(response: &Response, title: &str) { ); } -pub fn assert_text_ok(response: &Response) { +pub fn assert_text_ok(response: &TextResponse) { assert_eq!(response.status, 200); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "text/html; charset=utf-8"); } } -pub fn _assert_text_bad_request(response: &Response) { +pub fn _assert_text_bad_request(response: &TextResponse) { assert_eq!(response.status, 400); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "text/plain; charset=utf-8"); @@ -27,7 +27,7 @@ pub fn _assert_text_bad_request(response: &Response) { // JSON responses -pub fn assert_json_ok(response: &Response) { +pub fn assert_json_ok(response: &TextResponse) { assert_eq!(response.status, 200); if let Some(content_type) = &response.content_type { assert_eq!(content_type, "application/json"); diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs index e668b3fb..727299aa 100644 --- a/tests/e2e/client.rs +++ b/tests/e2e/client.rs @@ -1,11 +1,14 @@ +use reqwest::multipart; use serde::Serialize; use super::contexts::category::{AddCategoryForm, DeleteCategoryForm}; use super::contexts::settings::UpdateSettingsForm; +use super::contexts::torrent::requests::{TorrentId, UpdateTorrentFrom}; use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; +use super::responses::{self, BinaryResponse}; use crate::e2e::connection_info::ConnectionInfo; use crate::e2e::http::{Query, ReqwestQuery}; -use crate::e2e::response::Response; +use crate::e2e::responses::TextResponse; /// API Client pub struct Client { @@ -21,71 +24,99 @@ impl Client { // Context: about - pub async fn about(&self) -> Response { + pub async fn about(&self) -> TextResponse { self.http_client.get("about", Query::empty()).await } - pub async fn license(&self) -> Response { + pub async fn license(&self) -> TextResponse { self.http_client.get("about/license", Query::empty()).await } // Context: category - pub async fn get_categories(&self) -> Response { + pub async fn get_categories(&self) -> TextResponse { self.http_client.get("category", Query::empty()).await } - pub async fn add_category(&self, add_category_form: AddCategoryForm) -> Response { + pub async fn add_category(&self, add_category_form: AddCategoryForm) -> TextResponse { self.http_client.post("category", &add_category_form).await } - pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> Response { + pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> TextResponse { self.http_client.delete_with_body("category", &delete_category_form).await } // Context: root - pub async fn root(&self) -> Response { + pub async fn root(&self) -> TextResponse { self.http_client.get("", Query::empty()).await } // Context: settings - pub async fn get_public_settings(&self) -> Response { + pub async fn get_public_settings(&self) -> TextResponse { self.http_client.get("settings/public", Query::empty()).await } - pub async fn get_site_name(&self) -> Response { + pub async fn get_site_name(&self) -> TextResponse { self.http_client.get("settings/name", Query::empty()).await } - pub async fn get_settings(&self) -> Response { + pub async fn get_settings(&self) -> TextResponse { self.http_client.get("settings", Query::empty()).await } - pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> Response { + pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> TextResponse { self.http_client.post("settings", &update_settings_form).await } + // Context: torrent + + pub async fn get_torrents(&self) -> TextResponse { + self.http_client.get("torrents", Query::empty()).await + } + + pub async fn get_torrent(&self, id: TorrentId) -> TextResponse { + self.http_client.get(&format!("torrent/{id}"), Query::empty()).await + } + + pub async fn delete_torrent(&self, id: TorrentId) -> TextResponse { + self.http_client.delete(&format!("torrent/{id}")).await + } + + pub async fn update_torrent(&self, id: TorrentId, update_torrent_form: UpdateTorrentFrom) -> TextResponse { + self.http_client.put(&format!("torrent/{id}"), &update_torrent_form).await + } + + pub async fn upload_torrent(&self, form: multipart::Form) -> TextResponse { + self.http_client.post_multipart("torrent/upload", form).await + } + + pub async fn download_torrent(&self, id: TorrentId) -> responses::BinaryResponse { + self.http_client + .get_binary(&format!("torrent/download/{id}"), Query::empty()) + .await + } + // Context: user - pub async fn register_user(&self, registration_form: RegistrationForm) -> Response { + pub async fn register_user(&self, registration_form: RegistrationForm) -> TextResponse { self.http_client.post("user/register", ®istration_form).await } - pub async fn login_user(&self, registration_form: LoginForm) -> Response { + pub async fn login_user(&self, registration_form: LoginForm) -> TextResponse { self.http_client.post("user/login", ®istration_form).await } - pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Response { + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> TextResponse { self.http_client.post("user/token/verify", &token_verification_form).await } - pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Response { + pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> TextResponse { self.http_client.post("user/token/renew", &token_verification_form).await } - pub async fn ban_user(&self, username: Username) -> Response { + pub async fn ban_user(&self, username: Username) -> TextResponse { self.http_client.delete(&format!("user/ban/{}", &username.value)).await } } @@ -104,7 +135,30 @@ impl Http { } } - pub async fn get(&self, path: &str, params: Query) -> Response { + pub async fn get(&self, path: &str, params: Query) -> TextResponse { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await + .unwrap(), + }; + TextResponse::from(response).await + } + + pub async fn get_binary(&self, path: &str, params: Query) -> BinaryResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() .build() @@ -124,10 +178,10 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + BinaryResponse::from(response).await } - pub async fn post(&self, path: &str, form: &T) -> Response { + pub async fn post(&self, path: &str, form: &T) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() .post(self.base_url(path).clone()) @@ -143,10 +197,52 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + TextResponse::from(response).await + } + + pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> TextResponse { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::builder() + .build() + .unwrap() + .post(self.base_url(path).clone()) + .multipart(form) + .bearer_auth(token) + .send() + .await + .unwrap(), + None => reqwest::Client::builder() + .build() + .unwrap() + .post(self.base_url(path).clone()) + .multipart(form) + .send() + .await + .unwrap(), + }; + TextResponse::from(response).await + } + + pub async fn put(&self, path: &str, form: &T) -> TextResponse { + let response = match &self.connection_info.token { + Some(token) => reqwest::Client::new() + .put(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await + .unwrap(), + None => reqwest::Client::new() + .put(self.base_url(path).clone()) + .json(&form) + .send() + .await + .unwrap(), + }; + TextResponse::from(response).await } - async fn delete(&self, path: &str) -> Response { + async fn delete(&self, path: &str) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() .delete(self.base_url(path).clone()) @@ -160,10 +256,10 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + TextResponse::from(response).await } - async fn delete_with_body(&self, path: &str, form: &T) -> Response { + async fn delete_with_body(&self, path: &str, form: &T) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::new() .delete(self.base_url(path).clone()) @@ -179,7 +275,7 @@ impl Http { .await .unwrap(), }; - Response::from(response).await + TextResponse::from(response).await } fn base_url(&self, path: &str) -> String { diff --git a/tests/e2e/contexts/category.rs b/tests/e2e/contexts/category.rs index 417b2516..ca6ed51a 100644 --- a/tests/e2e/contexts/category.rs +++ b/tests/e2e/contexts/category.rs @@ -217,9 +217,17 @@ pub mod fixtures { use super::AddCategoryForm; use crate::e2e::contexts::user::fixtures::logged_in_admin; use crate::e2e::environment::TestEnv; - use crate::e2e::response::Response; + use crate::e2e::responses::TextResponse; - pub async fn add_category(category_name: &str) -> Response { + pub fn software_predefined_category_name() -> String { + "software".to_string() + } + + pub fn software_predefined_category_id() -> i64 { + 5 + } + + pub async fn add_category(category_name: &str) -> TextResponse { let logged_in_admin = logged_in_admin().await; let client = TestEnv::default().authenticated_client(&logged_in_admin.token); diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs index 1ba75c5e..a6f14141 100644 --- a/tests/e2e/contexts/mod.rs +++ b/tests/e2e/contexts/mod.rs @@ -2,4 +2,5 @@ pub mod about; pub mod category; pub mod root; pub mod settings; +pub mod torrent; pub mod user; diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs new file mode 100644 index 00000000..d4b2be4a --- /dev/null +++ b/tests/e2e/contexts/torrent/asserts.rs @@ -0,0 +1,45 @@ +use super::responses::TorrentDetails; + +/// Assert that the torrent details match the expected ones. +/// It ignores some fields that are not relevant for the E2E tests +/// or hard to assert due to the concurrent nature of the tests. +pub fn assert_expected_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) { + assert_eq!( + torrent.torrent_id, expected_torrent.torrent_id, + "torrent `file_size` mismatch" + ); + assert_eq!(torrent.uploader, expected_torrent.uploader, "torrent `uploader` mismatch"); + assert_eq!(torrent.info_hash, expected_torrent.info_hash, "torrent `info_hash` mismatch"); + assert_eq!(torrent.title, expected_torrent.title, "torrent `title` mismatch"); + assert_eq!( + torrent.description, expected_torrent.description, + "torrent `description` mismatch" + ); + assert_eq!( + torrent.category.category_id, expected_torrent.category.category_id, + "torrent `category.category_id` mismatch" + ); + assert_eq!( + torrent.category.name, expected_torrent.category.name, + "torrent `category.name` mismatch" + ); + // assert_eq!(torrent.category.num_torrents, expected_torrent.category.num_torrents, "torrent `category.num_torrents` mismatch"); // Ignored + // assert_eq!(torrent.upload_date, expected_torrent.upload_date, "torrent `upload_date` mismatch"); // Ignored, can't mock time easily for now. + assert_eq!(torrent.file_size, expected_torrent.file_size, "torrent `file_size` mismatch"); + assert_eq!(torrent.seeders, expected_torrent.seeders, "torrent `seeders` mismatch"); + assert_eq!(torrent.leechers, expected_torrent.leechers, "torrent `leechers` mismatch"); + assert_eq!(torrent.files, expected_torrent.files, "torrent `files` mismatch"); + assert_eq!(torrent.trackers, expected_torrent.trackers, "torrent `trackers` mismatch"); + assert_eq!( + torrent.magnet_link, expected_torrent.magnet_link, + "torrent `magnet_link` mismatch" + ); +} + +/// Full assert for test debugging purposes. +pub fn _assert_eq_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) { + assert_eq!( + torrent, expected_torrent, + "\nUnexpected torrent details:\n{torrent:#?} torrent details should match the previously indexed torrent:\n{expected_torrent:#?}" + ); +} diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs new file mode 100644 index 00000000..e7521de8 --- /dev/null +++ b/tests/e2e/contexts/torrent/contract.rs @@ -0,0 +1,345 @@ +//! API contract for `torrent` context. + +/* +todo: + +Download torrent file: + +- It should allow authenticated users to download a torrent with a personal tracker url + +Delete torrent: + +- After deleting a torrent, it should be removed from the tracker whitelist + +Get torrent info: + +- The torrent info: + - should contain the tracker URL + - If no user owned tracker key can be found, it should use the default tracker url + - If user owned tracker key can be found, it should use the personal tracker url + - should contain the magnet link with the trackers from the torrent file + - should contain realtime seeders and leechers from the tracker +*/ + +mod for_guests { + use torrust_index_backend::utils::parse_torrent::decode_torrent; + + use crate::e2e::contexts::category::fixtures::software_predefined_category_id; + use crate::e2e::contexts::torrent::asserts::assert_expected_torrent_details; + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::responses::{Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse}; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_guests_to_get_torrents() { + let uploader = logged_in_user().await; + let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_torrents().await; + + let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap(); + + assert!(torrent_list_response.data.total > 0); + assert!(torrent_list_response.data.contains(indexed_torrent.torrent_id)); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_guests_to_get_torrent_details_searching_by_id() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.get_torrent(uploaded_torrent.torrent_id).await; + + let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap(); + + let expected_torrent = TorrentDetails { + torrent_id: uploaded_torrent.torrent_id, + uploader: uploader.username, + info_hash: test_torrent.file_info.info_hash.to_uppercase(), + title: test_torrent.index_info.title.clone(), + description: test_torrent.index_info.description, + category: Category { + category_id: software_predefined_category_id(), + name: test_torrent.index_info.category, + num_torrents: 19, // Ignored in assertion + }, + upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion + file_size: test_torrent.file_info.content_size, + seeders: 0, + leechers: 0, + files: vec![File { + path: vec![test_torrent.file_info.files[0].clone()], + // Using one file torrent for testing: content_size = first file size + length: test_torrent.file_info.content_size, + md5sum: None, + }], + // code-review: why is this duplicated? + trackers: vec!["udp://tracker:6969".to_string(), "udp://tracker:6969".to_string()], + magnet_link: format!( + // cspell:disable-next-line + "magnet:?xt=urn:btih:{}&dn={}&tr=udp%3A%2F%2Ftracker%3A6969&tr=udp%3A%2F%2Ftracker%3A6969", + test_torrent.file_info.info_hash.to_uppercase(), + test_torrent.index_info.title + ), + }; + + assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_id() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.download_torrent(uploaded_torrent.torrent_id).await; + + let torrent = decode_torrent(&response.bytes).unwrap(); + let mut expected_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap(); + + // code-review: The backend does not generate exactly the same torrent + // that was uploaded and created by the `imdl` command-line tool. + // So we need to update the expected torrent to match the one generated + // by the backend. For some of them it makes sense (`announce` and `announce_list`), + // for others it does not. + expected_torrent.info.private = Some(0); + expected_torrent.announce = Some("udp://tracker:6969".to_string()); + expected_torrent.encoding = None; + expected_torrent.announce_list = Some(vec![vec!["udp://tracker:6969".to_string()]]); + expected_torrent.creation_date = None; + expected_torrent.created_by = None; + + assert_eq!(torrent, expected_torrent); + assert!(response.is_bittorrent_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_guests_to_delete_torrents() { + let uploader = logged_in_user().await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().unauthenticated_client(); + + let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + + assert_eq!(response.status, 401); + } +} + +mod for_authenticated_users { + + use crate::e2e::contexts::torrent::fixtures::random_torrent; + use crate::e2e::contexts::torrent::requests::UploadTorrentMultipartForm; + use crate::e2e::contexts::torrent::responses::UploadedTorrentResponse; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_authenticated_users_to_upload_new_torrents() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + let test_torrent = random_torrent(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + let _uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + // code-review: the response only returns the torrent autoincrement ID + // generated by the DB. So we can't assert that the torrent was uploaded. + // We could return the infohash. + // We are going to use the infohash to get the torrent. See issue: + // https://github.com/torrust/torrust-index-backend/issues/115 + + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + let mut test_torrent = random_torrent(); + + test_torrent.index_info.category = "non-existing-category".to_string(); + + let form: UploadTorrentMultipartForm = test_torrent.index_info.into(); + + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same title as the first one + let mut second_torrent = random_torrent(); + second_torrent.index_info.title = first_torrent_title; + let form: UploadTorrentMultipartForm = second_torrent.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() { + let uploader = logged_in_user().await; + let client = TestEnv::default().authenticated_client(&uploader.token); + + // Upload the first torrent + let first_torrent = random_torrent(); + let mut first_torrent_clone = first_torrent.clone(); + let first_torrent_title = first_torrent.index_info.title.clone(); + let form: UploadTorrentMultipartForm = first_torrent.index_info.into(); + let _response = client.upload_torrent(form.into()).await; + + // Upload the second torrent with the same infohash as the first one. + // We need to change the title otherwise the torrent will be rejected + // because of the duplicate title. + first_torrent_clone.index_info.title = format!("{}-clone", first_torrent_title); + let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into(); + let response = client.upload_torrent(form.into()).await; + + assert_eq!(response.status, 400); + } + + mod and_non_admins { + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_not_allow_non_admins_to_delete_torrents() { + let uploader = logged_in_user().await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().authenticated_client(&uploader.token); + + let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + + assert_eq!(response.status, 403); + } + } + + mod and_torrent_owners { + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom; + use crate::e2e::contexts::torrent::responses::UpdatedTorrentResponse; + use crate::e2e::contexts::user::fixtures::logged_in_user; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_torrent_owners_to_update_their_torrents() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let client = TestEnv::default().authenticated_client(&uploader.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + uploaded_torrent.torrent_id, + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + } + + mod and_admins { + use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index; + use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom; + use crate::e2e::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse}; + use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_admins_to_delete_torrents_searching_by_id() { + let uploader = logged_in_user().await; + let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&admin.token); + + let response = client.delete_torrent(uploaded_torrent.torrent_id).await; + + let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id); + assert!(response.is_json_and_ok()); + } + + #[tokio::test] + #[cfg_attr(not(feature = "e2e-tests"), ignore)] + async fn it_should_allow_admins_to_update_someone_elses_torrents() { + let uploader = logged_in_user().await; + let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await; + + let logged_in_admin = logged_in_admin().await; + let client = TestEnv::default().authenticated_client(&logged_in_admin.token); + + let new_title = format!("{}-new-title", test_torrent.index_info.title); + let new_description = format!("{}-new-description", test_torrent.index_info.description); + + let response = client + .update_torrent( + uploaded_torrent.torrent_id, + UpdateTorrentFrom { + title: Some(new_title.clone()), + description: Some(new_description.clone()), + }, + ) + .await; + + let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + let torrent = updated_torrent_response.data; + + assert_eq!(torrent.title, new_title); + assert_eq!(torrent.description, new_description); + assert!(response.is_json_and_ok()); + } + } +} diff --git a/tests/e2e/contexts/torrent/file.rs b/tests/e2e/contexts/torrent/file.rs new file mode 100644 index 00000000..e06b1385 --- /dev/null +++ b/tests/e2e/contexts/torrent/file.rs @@ -0,0 +1,83 @@ +//! Utility functions for torrent files. +//! +//! It's a wrapper around the [imdl](https://crates.io/crates/imdl) program. +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::Deserialize; +use which::which; + +/// Attributes parsed from a torrent file. +#[derive(Deserialize, Clone)] +pub struct TorrentFileInfo { + pub name: String, + pub comment: Option, + pub creation_date: u64, + pub created_by: String, + pub source: Option, + pub info_hash: String, + pub torrent_size: u64, + pub content_size: u64, + pub private: bool, + pub tracker: Option, + pub announce_list: Vec, + pub update_url: Option, + pub dht_nodes: Vec, + pub piece_size: u64, + pub piece_count: u64, + pub file_count: u64, + pub files: Vec, +} + +/// Creates a torrent file for the given file. +/// This function requires the `imdl` program to be installed. +/// +pub fn create_torrent(dir: &Path, file_name: &str) -> PathBuf { + guard_that_torrent_edition_cmd_is_installed(); + + let input_file_path = Path::new(dir).join(file_name); + let output_file_path = Path::new(dir).join(format!("{file_name}.torrent")); + + let _output = Command::new("imdl") + .args(["torrent", "create", "--show"]) + .args(["--input", &format!("{}", input_file_path.to_string_lossy())]) + .args(["--output", &format!("{}", output_file_path.to_string_lossy())]) + .output() + .unwrap_or_else(|_| panic!("failed to create torrent file: {:?}", output_file_path.to_string_lossy())); + + //io::stdout().write_all(&output.stdout).unwrap(); + //io::stderr().write_all(&output.stderr).unwrap(); + + output_file_path +} + +/// Parses torrent file. +/// This function requires the `imdl` program to be installed. +/// +pub fn parse_torrent(torrent_file_path: &Path) -> TorrentFileInfo { + guard_that_torrent_edition_cmd_is_installed(); + + let output = Command::new("imdl") + .args(["torrent", "show", "--json", &torrent_file_path.to_string_lossy()]) + .output() + .unwrap_or_else(|_| panic!("failed to open torrent file: {:?}", &torrent_file_path.to_string_lossy())); + + match std::str::from_utf8(&output.stdout) { + Ok(parsed_torrent_json) => { + let res: TorrentFileInfo = serde_json::from_str(parsed_torrent_json).unwrap(); + res + } + Err(err) => panic!("got non UTF-8 data from 'imdl'. Error: {err}"), + } +} + +/// It panics if the `imdl` console application is not installed. +fn guard_that_torrent_edition_cmd_is_installed() { + const IMDL_BINARY: &str = "imdl"; + match which(IMDL_BINARY) { + Ok(_path) => (), + Err(err) => { + panic!("Can't create torrent with \"imdl\": {err}. Please install it with: `cargo install imdl`"); + } + } +} diff --git a/tests/e2e/contexts/torrent/fixtures.rs b/tests/e2e/contexts/torrent/fixtures.rs new file mode 100644 index 00000000..96d69d9a --- /dev/null +++ b/tests/e2e/contexts/torrent/fixtures.rs @@ -0,0 +1,145 @@ +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use tempfile::{tempdir, TempDir}; +use uuid::Uuid; + +use super::file::{create_torrent, parse_torrent, TorrentFileInfo}; +use super::requests::{BinaryFile, UploadTorrentMultipartForm}; +use super::responses::{Id, UploadedTorrentResponse}; +use crate::e2e::contexts::category::fixtures::software_predefined_category_name; +use crate::e2e::contexts::user::LoggedInUserData; +use crate::e2e::environment::TestEnv; + +/// Information about a torrent that is going to added to the index. +#[derive(Clone)] +pub struct TorrentIndexInfo { + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +impl From for UploadTorrentMultipartForm { + fn from(indexed_torrent: TorrentIndexInfo) -> UploadTorrentMultipartForm { + UploadTorrentMultipartForm { + title: indexed_torrent.title, + description: indexed_torrent.description, + category: indexed_torrent.category, + torrent_file: indexed_torrent.torrent_file, + } + } +} + +/// Torrent that has been added to the index. +pub struct TorrentListedInIndex { + pub torrent_id: Id, + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +impl TorrentListedInIndex { + pub fn from(torrent_to_index: TorrentIndexInfo, torrent_id: Id) -> Self { + Self { + torrent_id, + title: torrent_to_index.title, + description: torrent_to_index.description, + category: torrent_to_index.category, + torrent_file: torrent_to_index.torrent_file, + } + } +} + +/// Add a new random torrent to the index +pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) { + let random_torrent = random_torrent(); + let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info).await; + (random_torrent, indexed_torrent) +} + +/// Upload a torrent to the index +pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex { + let client = TestEnv::default().authenticated_client(&uploader.token); + + let form: UploadTorrentMultipartForm = torrent.clone().into(); + + let response = client.upload_torrent(form.into()).await; + + let res: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap(); + + TorrentListedInIndex::from(torrent.clone(), res.data.torrent_id) +} + +#[derive(Clone)] +pub struct TestTorrent { + /// Parsed info from torrent file. + pub file_info: TorrentFileInfo, + /// Torrent info needed to add the torrent to the index. + pub index_info: TorrentIndexInfo, +} + +impl TestTorrent { + pub fn random() -> Self { + let temp_dir = temp_dir(); + + let torrents_dir_path = temp_dir.path().to_owned(); + + // Random ID to identify all the torrent related entities: files, fields, ... + // That makes easier to debug the tests outputs. + let id = Uuid::new_v4(); + + // Create a random torrent file + let torrent_path = random_torrent_file(&torrents_dir_path, &id); + + // Load torrent binary file + let torrent_file = BinaryFile::from_file_at_path(&torrent_path); + + // Load torrent file metadata + let torrent_info = parse_torrent(&torrent_path); + + let torrent_to_index = TorrentIndexInfo { + title: format!("title-{id}"), + description: format!("description-{id}"), + category: software_predefined_category_name(), + torrent_file, + }; + + TestTorrent { + file_info: torrent_info, + index_info: torrent_to_index, + } + } +} + +pub fn random_torrent() -> TestTorrent { + TestTorrent::random() +} + +pub fn random_torrent_file(dir: &Path, id: &Uuid) -> PathBuf { + // Create random text file + let file_name = random_txt_file(dir, id); + + // Create torrent file for the text file + create_torrent(dir, &file_name) +} + +pub fn random_txt_file(dir: &Path, id: &Uuid) -> String { + // Sample file name + let file_name = format!("file-{id}.txt"); + + // Sample file path + let file_path = dir.join(file_name.clone()); + + // Write sample text to the temporary file + let mut file = File::create(file_path).unwrap(); + file.write_all(id.as_bytes()).unwrap(); + + file_name +} + +pub fn temp_dir() -> TempDir { + tempdir().unwrap() +} diff --git a/tests/e2e/contexts/torrent/mod.rs b/tests/e2e/contexts/torrent/mod.rs new file mode 100644 index 00000000..4f3882e6 --- /dev/null +++ b/tests/e2e/contexts/torrent/mod.rs @@ -0,0 +1,6 @@ +pub mod asserts; +pub mod contract; +pub mod file; +pub mod fixtures; +pub mod requests; +pub mod responses; diff --git a/tests/e2e/contexts/torrent/requests.rs b/tests/e2e/contexts/torrent/requests.rs new file mode 100644 index 00000000..fb1ec578 --- /dev/null +++ b/tests/e2e/contexts/torrent/requests.rs @@ -0,0 +1,51 @@ +use std::fs; +use std::path::Path; + +use reqwest::multipart::Form; +use serde::{Deserialize, Serialize}; + +pub type TorrentId = i64; + +pub struct UploadTorrentMultipartForm { + pub title: String, + pub description: String, + pub category: String, + pub torrent_file: BinaryFile, +} + +#[derive(Clone)] +pub struct BinaryFile { + pub name: String, + pub contents: Vec, +} + +impl BinaryFile { + pub fn from_file_at_path(path: &Path) -> Self { + BinaryFile { + name: path.file_name().unwrap().to_owned().into_string().unwrap(), + contents: fs::read(path).unwrap(), + } + } +} + +impl From for Form { + fn from(form: UploadTorrentMultipartForm) -> Self { + Form::new() + .text("title", form.title) + .text("description", form.description) + .text("category", form.category) + .part( + "torrent", + reqwest::multipart::Part::bytes(form.torrent_file.contents) + .file_name(form.torrent_file.name) + .mime_str("application/x-bittorrent") + .unwrap(), + ) + } +} + +#[derive(Deserialize, Serialize)] +pub struct UpdateTorrentFrom { + pub title: Option, + pub description: Option, +} diff --git a/tests/e2e/contexts/torrent/responses.rs b/tests/e2e/contexts/torrent/responses.rs new file mode 100644 index 00000000..f2b6739c --- /dev/null +++ b/tests/e2e/contexts/torrent/responses.rs @@ -0,0 +1,104 @@ +use serde::Deserialize; + +pub type Id = i64; +pub type CategoryId = i64; +pub type UtcDateTime = String; // %Y-%m-%d %H:%M:%S + +#[derive(Deserialize, PartialEq, Debug)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Deserialize)] +pub struct TorrentListResponse { + pub data: TorrentList, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentList { + pub total: u32, + pub results: Vec, +} + +impl TorrentList { + pub fn contains(&self, torrent_id: Id) -> bool { + self.results.iter().any(|item| item.torrent_id == torrent_id) + } +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct ListItem { + pub torrent_id: i64, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub description: Option, + pub category_id: i64, + pub date_uploaded: String, + pub file_size: i64, + pub seeders: i64, + pub leechers: i64, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentDetailsResponse { + pub data: TorrentDetails, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct TorrentDetails { + pub torrent_id: Id, + pub uploader: String, + pub info_hash: String, + pub title: String, + pub description: String, + pub category: Category, + pub upload_date: UtcDateTime, + pub file_size: u64, + pub seeders: u64, + pub leechers: u64, + pub files: Vec, + pub trackers: Vec, + pub magnet_link: String, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Category { + pub category_id: CategoryId, + pub name: String, + pub num_torrents: u64, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct File { + pub path: Vec, + pub length: u64, + pub md5sum: Option, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UploadedTorrentResponse { + pub data: UploadedTorrent, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UploadedTorrent { + pub torrent_id: Id, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct DeletedTorrentResponse { + pub data: DeletedTorrent, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct DeletedTorrent { + pub torrent_id: Id, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct UpdatedTorrentResponse { + pub data: UpdatedTorrent, +} + +pub type UpdatedTorrent = TorrentDetails; diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 80c340b7..ce9ef1d1 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -35,4 +35,4 @@ mod connection_info; mod contexts; mod environment; mod http; -mod response; +mod responses; diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs deleted file mode 100644 index 70261cf2..00000000 --- a/tests/e2e/response.rs +++ /dev/null @@ -1,21 +0,0 @@ -use reqwest::Response as ReqwestResponse; - -#[derive(Debug)] -pub struct Response { - pub status: u16, - pub content_type: Option, - pub body: String, -} - -impl Response { - pub async fn from(response: ReqwestResponse) -> Self { - Self { - status: response.status().as_u16(), - content_type: response - .headers() - .get("content-type") - .map(|content_type| content_type.to_str().unwrap().to_owned()), - body: response.text().await.unwrap(), - } - } -} diff --git a/tests/e2e/responses.rs b/tests/e2e/responses.rs new file mode 100644 index 00000000..0be8bbd6 --- /dev/null +++ b/tests/e2e/responses.rs @@ -0,0 +1,70 @@ +use reqwest::Response as ReqwestResponse; + +#[derive(Debug)] +pub struct TextResponse { + pub status: u16, + pub content_type: Option, + pub body: String, +} + +impl TextResponse { + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), + body: response.text().await.unwrap(), + } + } + + pub fn is_json_and_ok(&self) -> bool { + self.is_ok() && self.is_json() + } + + pub fn is_json(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/json"; + } + false + } + + pub fn is_ok(&self) -> bool { + self.status == 200 + } +} + +#[derive(Debug)] +pub struct BinaryResponse { + pub status: u16, + pub content_type: Option, + pub bytes: Vec, +} + +impl BinaryResponse { + pub async fn from(response: ReqwestResponse) -> Self { + Self { + status: response.status().as_u16(), + content_type: response + .headers() + .get("content-type") + .map(|content_type| content_type.to_str().unwrap().to_owned()), + bytes: response.bytes().await.unwrap().to_vec(), + } + } + pub fn is_bittorrent_and_ok(&self) -> bool { + self.is_ok() && self.is_bittorrent() + } + + pub fn is_bittorrent(&self) -> bool { + if let Some(content_type) = &self.content_type { + return content_type == "application/x-bittorrent"; + } + false + } + + pub fn is_ok(&self) -> bool { + self.status == 200 + } +}