diff --git a/src/tracker/auth.rs b/src/tracker/auth.rs index 02450dc82..406ef7033 100644 --- a/src/tracker/auth.rs +++ b/src/tracker/auth.rs @@ -4,7 +4,7 @@ use derive_more::{Display, Error}; use log::debug; use rand::distributions::Alphanumeric; use rand::{thread_rng, Rng}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::protocol::clock::{Current, DurationSinceUnixEpoch, Time, TimeNow}; use crate::protocol::common::AUTH_KEY_LENGTH; @@ -48,7 +48,7 @@ pub fn verify(auth_key: &Key) -> Result<(), Error> { } } -#[derive(Serialize, Debug, Eq, PartialEq, Clone)] +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] pub struct Key { pub key: String, pub valid_until: Option, diff --git a/tests/api.rs b/tests/api.rs deleted file mode 100644 index dfb8d81b3..000000000 --- a/tests/api.rs +++ /dev/null @@ -1,382 +0,0 @@ -/// Integration tests for the tracker API -/// -/// cargo test `tracker_api` -- --nocapture -extern crate rand; - -mod common; - -mod tracker_api { - use core::panic; - use std::env; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use std::str::FromStr; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - - use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; - use reqwest::Response; - use tokio::task::JoinHandle; - use torrust_tracker::api::resource; - use torrust_tracker::api::resource::auth_key::AuthKey; - use torrust_tracker::api::resource::stats::Stats; - use torrust_tracker::api::resource::torrent::{self, Torrent}; - use torrust_tracker::config::Configuration; - use torrust_tracker::jobs::tracker_api; - use torrust_tracker::protocol::clock::DurationSinceUnixEpoch; - use torrust_tracker::protocol::info_hash::InfoHash; - use torrust_tracker::tracker::statistics::Keeper; - use torrust_tracker::tracker::{auth, peer}; - use torrust_tracker::{ephemeral_instance_keys, logging, static_time, tracker}; - - use crate::common::ephemeral_random_port; - - #[tokio::test] - async fn should_allow_generating_a_new_auth_key() { - let api_server = ApiServer::new_running_instance().await; - - let seconds_valid = 60; - - let auth_key = ApiClient::new(api_server.get_connection_info().unwrap()) - .generate_auth_key(seconds_valid) - .await; - - // Verify the key with the tracker - assert!(api_server - .tracker - .unwrap() - .verify_auth_key(&auth::Key::from(auth_key)) - .await - .is_ok()); - } - - #[tokio::test] - async fn should_allow_whitelisting_a_torrent() { - let api_server = ApiServer::new_running_instance().await; - - let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - let res = ApiClient::new(api_server.get_connection_info().unwrap()) - .whitelist_a_torrent(&info_hash) - .await; - - assert_eq!(res.status(), 200); - assert!( - api_server - .tracker - .unwrap() - .is_info_hash_whitelisted(&InfoHash::from_str(&info_hash).unwrap()) - .await - ); - } - - #[tokio::test] - async fn should_allow_whitelisting_a_torrent_that_has_been_already_whitelisted() { - let api_server = ApiServer::new_running_instance().await; - - let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - let api_client = ApiClient::new(api_server.get_connection_info().unwrap()); - - let res = api_client.whitelist_a_torrent(&info_hash).await; - assert_eq!(res.status(), 200); - - let res = api_client.whitelist_a_torrent(&info_hash).await; - assert_eq!(res.status(), 200); - } - - #[tokio::test] - async fn should_allow_getting_a_torrent_info() { - let api_server = ApiServer::new_running_instance().await; - let api_connection_info = api_server.get_connection_info().unwrap(); - - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); - - let (peer, peer_resource) = sample_torrent_peer(); - - // Add a torrent to the tracker - api_server - .tracker - .unwrap() - .update_torrent_with_peer_and_get_stats(&info_hash, &peer) - .await; - - let torrent_resource = ApiClient::new(api_connection_info).get_torrent(&info_hash.to_string()).await; - - assert_eq!( - torrent_resource, - Torrent { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), - seeders: 1, - completed: 0, - leechers: 0, - peers: Some(vec![peer_resource]) - } - ); - } - - #[tokio::test] - async fn should_allow_getting_torrents() { - let api_server = ApiServer::new_running_instance().await; - - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); - - let (peer, _peer_resource) = sample_torrent_peer(); - - let api_connection_info = api_server.get_connection_info().unwrap(); - - // Add a torrent to the tracker - api_server - .tracker - .unwrap() - .update_torrent_with_peer_and_get_stats(&info_hash, &peer) - .await; - - let torrent_resources = ApiClient::new(api_connection_info).get_torrents().await; - - assert_eq!( - torrent_resources, - vec![torrent::ListItem { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), - seeders: 1, - completed: 0, - leechers: 0, - peers: None // Torrent list does not include peer list - }] - ); - } - - #[tokio::test] - async fn should_allow_getting_tracker_statistics() { - let api_server = ApiServer::new_running_instance().await; - - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); - - let (peer, _peer_resource) = sample_torrent_peer(); - - let api_connection_info = api_server.get_connection_info().unwrap(); - - // Add a torrent to the tracker - api_server - .tracker - .unwrap() - .update_torrent_with_peer_and_get_stats(&info_hash, &peer) - .await; - - let stats_resource = ApiClient::new(api_connection_info).get_tracker_statistics().await; - - assert_eq!( - stats_resource, - Stats { - torrents: 1, - seeders: 1, - completed: 0, - leechers: 0, - tcp4_connections_handled: 0, - tcp4_announces_handled: 0, - tcp4_scrapes_handled: 0, - tcp6_connections_handled: 0, - tcp6_announces_handled: 0, - tcp6_scrapes_handled: 0, - udp4_connections_handled: 0, - udp4_announces_handled: 0, - udp4_scrapes_handled: 0, - udp6_connections_handled: 0, - udp6_announces_handled: 0, - udp6_scrapes_handled: 0, - } - ); - } - - fn sample_torrent_peer() -> (peer::Peer, resource::peer::Peer) { - let torrent_peer = peer::Peer { - peer_id: peer::Id(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes(0), - downloaded: NumberOfBytes(0), - left: NumberOfBytes(0), - event: AnnounceEvent::Started, - }; - let torrent_peer_resource = resource::peer::Peer::from(torrent_peer); - - (torrent_peer, torrent_peer_resource) - } - - fn tracker_configuration() -> Arc { - let mut config = Configuration { - log_level: Some("off".to_owned()), - ..Default::default() - }; - - // Ephemeral socket address - let port = ephemeral_random_port(); - config.http_api.bind_address = format!("127.0.0.1:{}", &port); - - // Ephemeral database - let temp_directory = env::temp_dir(); - let temp_file = temp_directory.join(format!("data_{}.db", &port)); - config.db_path = temp_file.to_str().unwrap().to_owned(); - - Arc::new(config) - } - - #[derive(Clone)] - struct ApiConnectionInfo { - pub bind_address: String, - pub api_token: String, - } - - impl ApiConnectionInfo { - pub fn new(bind_address: &str, api_token: &str) -> Self { - Self { - bind_address: bind_address.to_string(), - api_token: api_token.to_string(), - } - } - } - - struct ApiServer { - pub started: AtomicBool, - pub job: Option>, - pub tracker: Option>, - pub connection_info: Option, - } - - impl ApiServer { - pub fn new() -> Self { - Self { - started: AtomicBool::new(false), - job: None, - tracker: None, - connection_info: None, - } - } - - pub async fn new_running_instance() -> ApiServer { - let configuration = tracker_configuration(); - ApiServer::new_running_custom_instance(configuration.clone()).await - } - - async fn new_running_custom_instance(configuration: Arc) -> ApiServer { - let mut api_server = ApiServer::new(); - api_server.start(configuration).await; - api_server - } - - pub async fn start(&mut self, configuration: Arc) { - if !self.started.load(Ordering::Relaxed) { - self.connection_info = Some(ApiConnectionInfo::new( - &configuration.http_api.bind_address.clone(), - &configuration.http_api.access_tokens.get_key_value("admin").unwrap().1.clone(), - )); - - // Set the time of Torrust app starting - lazy_static::initialize(&static_time::TIME_AT_APP_START); - - // Initialize the Ephemeral Instance Random Seed - lazy_static::initialize(&ephemeral_instance_keys::RANDOM_SEED); - - // Initialize stats tracker - let (stats_event_sender, stats_repository) = Keeper::new_active_instance(); - - // Initialize Torrust tracker - let tracker = match tracker::Tracker::new(&configuration.clone(), Some(stats_event_sender), stats_repository) { - Ok(tracker) => Arc::new(tracker), - Err(error) => { - panic!("{}", error) - } - }; - self.tracker = Some(tracker.clone()); - - // Initialize logging - logging::setup(&configuration); - - // Start the HTTP API job - self.job = Some(tracker_api::start_job(&configuration.http_api, tracker).await); - - self.started.store(true, Ordering::Relaxed); - } - } - - pub fn get_connection_info(&self) -> Option { - self.connection_info.clone() - } - } - - struct ApiClient { - connection_info: ApiConnectionInfo, - } - - impl ApiClient { - pub fn new(connection_info: ApiConnectionInfo) -> Self { - Self { connection_info } - } - - pub async fn generate_auth_key(&self, seconds_valid: i32) -> AuthKey { - let url = format!( - "http://{}/api/key/{}?token={}", - &self.connection_info.bind_address, &seconds_valid, &self.connection_info.api_token - ); - reqwest::Client::new().post(url).send().await.unwrap().json().await.unwrap() - } - - pub async fn whitelist_a_torrent(&self, info_hash: &str) -> Response { - let url = format!( - "http://{}/api/whitelist/{}?token={}", - &self.connection_info.bind_address, &info_hash, &self.connection_info.api_token - ); - reqwest::Client::new().post(url.clone()).send().await.unwrap() - } - - pub async fn get_torrent(&self, info_hash: &str) -> Torrent { - let url = format!( - "http://{}/api/torrent/{}?token={}", - &self.connection_info.bind_address, &info_hash, &self.connection_info.api_token - ); - reqwest::Client::builder() - .build() - .unwrap() - .get(url) - .send() - .await - .unwrap() - .json::() - .await - .unwrap() - } - - pub async fn get_torrents(&self) -> Vec { - let url = format!( - "http://{}/api/torrents?token={}", - &self.connection_info.bind_address, &self.connection_info.api_token - ); - reqwest::Client::builder() - .build() - .unwrap() - .get(url) - .send() - .await - .unwrap() - .json::>() - .await - .unwrap() - } - - pub async fn get_tracker_statistics(&self) -> Stats { - let url = format!( - "http://{}/api/stats?token={}", - &self.connection_info.bind_address, &self.connection_info.api_token - ); - reqwest::Client::builder() - .build() - .unwrap() - .get(url) - .send() - .await - .unwrap() - .json::() - .await - .unwrap() - } - } -} diff --git a/tests/api/mod.rs b/tests/api/mod.rs new file mode 100644 index 000000000..1528888bf --- /dev/null +++ b/tests/api/mod.rs @@ -0,0 +1,283 @@ +use core::panic; +use std::env; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; + +use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; +use reqwest::Response; +use torrust_tracker::config::Configuration; +use torrust_tracker::jobs::tracker_api; +use torrust_tracker::protocol::clock::DurationSinceUnixEpoch; +use torrust_tracker::protocol::info_hash::InfoHash; +use torrust_tracker::tracker::peer::{self, Peer}; +use torrust_tracker::tracker::statistics::Keeper; +use torrust_tracker::{ephemeral_instance_keys, logging, static_time, tracker}; + +use crate::common::ephemeral_random_port; + +pub fn sample_peer() -> peer::Peer { + peer::Peer { + peer_id: peer::Id(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes(0), + downloaded: NumberOfBytes(0), + left: NumberOfBytes(0), + event: AnnounceEvent::Started, + } +} + +pub fn tracker_configuration() -> Arc { + let mut config = Configuration { + log_level: Some("off".to_owned()), + ..Default::default() + }; + + // Ephemeral socket address + let port = ephemeral_random_port(); + config.http_api.bind_address = format!("127.0.0.1:{}", &port); + + // Ephemeral database + let temp_directory = env::temp_dir(); + let temp_file = temp_directory.join(format!("data_{}.db", &port)); + config.db_path = temp_file.to_str().unwrap().to_owned(); + + Arc::new(config) +} + +#[derive(Clone)] +pub struct ConnectionInfo { + pub bind_address: String, + pub api_token: Option, +} + +impl ConnectionInfo { + pub fn authenticated(bind_address: &str, api_token: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + api_token: Some(api_token.to_string()), + } + } + + pub fn anonymous(bind_address: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + api_token: None, + } + } +} + +pub async fn start_default_api_server() -> Server { + let configuration = tracker_configuration(); + start_custom_api_server(configuration.clone()).await +} + +pub async fn start_custom_api_server(configuration: Arc) -> Server { + start(configuration).await +} + +async fn start(configuration: Arc) -> Server { + let connection_info = ConnectionInfo::authenticated( + &configuration.http_api.bind_address.clone(), + &configuration.http_api.access_tokens.get_key_value("admin").unwrap().1.clone(), + ); + + // Set the time of Torrust app starting + lazy_static::initialize(&static_time::TIME_AT_APP_START); + + // Initialize the Ephemeral Instance Random Seed + lazy_static::initialize(&ephemeral_instance_keys::RANDOM_SEED); + + // Initialize stats tracker + let (stats_event_sender, stats_repository) = Keeper::new_active_instance(); + + // Initialize Torrust tracker + let tracker = match tracker::Tracker::new(&configuration.clone(), Some(stats_event_sender), stats_repository) { + Ok(tracker) => Arc::new(tracker), + Err(error) => { + panic!("{}", error) + } + }; + + // Initialize logging + logging::setup(&configuration); + + // Start the HTTP API job + tracker_api::start_job(&configuration.http_api, tracker.clone()).await; + + Server { + tracker, + connection_info, + } +} + +pub struct Server { + pub tracker: Arc, + pub connection_info: ConnectionInfo, +} + +impl Server { + pub fn get_connection_info(&self) -> ConnectionInfo { + self.connection_info.clone() + } + + pub fn get_bind_address(&self) -> String { + self.connection_info.bind_address.clone() + } + + /// Add a torrent to the tracker + pub async fn add_torrent(&self, info_hash: &InfoHash, peer: &Peer) { + self.tracker.update_torrent_with_peer_and_get_stats(info_hash, peer).await; + } +} + +pub struct Client { + connection_info: ConnectionInfo, +} + +type ReqwestQuery = Vec; +type ReqwestQueryParam = (String, String); + +#[derive(Default, Debug)] +pub struct Query { + params: Vec, +} + +impl Query { + pub fn empty() -> Self { + Self { params: vec![] } + } + + pub fn params(params: Vec) -> Self { + Self { params } + } + + pub fn add_param(&mut self, param: QueryParam) { + self.params.push(param); + } + + fn with_token(token: &str) -> Self { + Self { + params: vec![QueryParam::new("token", token)], + } + } +} + +impl From for ReqwestQuery { + fn from(url_search_params: Query) -> Self { + url_search_params + .params + .iter() + .map(|param| ReqwestQueryParam::from((*param).clone())) + .collect() + } +} + +#[derive(Clone, Debug)] +pub struct QueryParam { + name: String, + value: String, +} + +impl QueryParam { + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl From for ReqwestQueryParam { + fn from(param: QueryParam) -> Self { + (param.name, param.value) + } +} + +impl Client { + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { connection_info } + } + + pub async fn generate_auth_key(&self, seconds_valid: i32) -> Response { + self.post(&format!("key/{}", &seconds_valid)).await + } + + pub async fn delete_auth_key(&self, key: &str) -> Response { + self.delete(&format!("key/{}", &key)).await + } + + pub async fn reload_keys(&self) -> Response { + self.get("keys/reload", Query::default()).await + } + + pub async fn whitelist_a_torrent(&self, info_hash: &str) -> Response { + self.post(&format!("whitelist/{}", &info_hash)).await + } + + pub async fn remove_torrent_from_whitelist(&self, info_hash: &str) -> Response { + self.delete(&format!("whitelist/{}", &info_hash)).await + } + + pub async fn reload_whitelist(&self) -> Response { + self.get("whitelist/reload", Query::default()).await + } + + pub async fn get_torrent(&self, info_hash: &str) -> Response { + self.get(&format!("torrent/{}", &info_hash), Query::default()).await + } + + pub async fn get_torrents(&self, params: Query) -> Response { + self.get("torrents", params).await + } + + pub async fn get_tracker_statistics(&self) -> Response { + self.get("stats", Query::default()).await + } + + async fn get(&self, path: &str, params: Query) -> Response { + let mut query: Query = params; + + if let Some(token) = &self.connection_info.api_token { + query.add_param(QueryParam::new("token", token)); + }; + + reqwest::Client::builder() + .build() + .unwrap() + .get(self.base_url(path)) + .query(&ReqwestQuery::from(query)) + .send() + .await + .unwrap() + } + + async fn post(&self, path: &str) -> Response { + reqwest::Client::new() + .post(self.base_url(path).clone()) + .query(&ReqwestQuery::from(self.query_with_token())) + .send() + .await + .unwrap() + } + + async fn delete(&self, path: &str) -> Response { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .query(&ReqwestQuery::from(self.query_with_token())) + .send() + .await + .unwrap() + } + + fn base_url(&self, path: &str) -> String { + format!("http://{}/api/{path}", &self.connection_info.bind_address) + } + + fn query_with_token(&self) -> Query { + match &self.connection_info.api_token { + Some(token) => Query::with_token(token), + None => Query::default(), + } + } +} diff --git a/tests/tracker_api.rs b/tests/tracker_api.rs new file mode 100644 index 000000000..d02f29374 --- /dev/null +++ b/tests/tracker_api.rs @@ -0,0 +1,552 @@ +/// Integration tests for the tracker API +/// +/// ```text +/// cargo test tracker_api -- --nocapture +/// ``` +extern crate rand; + +mod api; +mod common; + +mod tracker_api { + + /* + + Endpoints: + + Stats: + GET /api/stats + + Torrents: + GET /api/torrents?offset=:u32&limit=:u32 + GET /api/torrent/:info_hash + + Whitelisted torrents: + POST /api/whitelist/:info_hash + DELETE /api/whitelist/:info_hash + + Whitelist command: + GET /api/whitelist/reload + + Keys: + POST /api/key/:seconds_valid + GET /api/keys/reload + DELETE /api/key/:key + + */ + + use reqwest::Response; + + use crate::api::ConnectionInfo; + + async fn assert_token_not_valid(response: Response) { + assert_eq!(response.status(), 500); + assert_eq!( + response.text().await.unwrap(), + "Unhandled rejection: Err { reason: \"token not valid\" }" + ); + } + + async fn assert_unauthorized(response: Response) { + assert_eq!(response.status(), 500); + assert_eq!( + response.text().await.unwrap(), + "Unhandled rejection: Err { reason: \"unauthorized\" }" + ); + } + + fn connection_with_invalid_token(bind_address: &str) -> ConnectionInfo { + ConnectionInfo::authenticated(bind_address, "invalid token") + } + + fn connection_with_no_token(bind_address: &str) -> ConnectionInfo { + ConnectionInfo::anonymous(bind_address) + } + + mod for_stats_resources { + use std::str::FromStr; + + use torrust_tracker::api::resource::stats::Stats; + use torrust_tracker::protocol::info_hash::InfoHash; + + use super::{connection_with_invalid_token, connection_with_no_token}; + use crate::api::{sample_peer, start_default_api_server, Client}; + use crate::tracker_api::{assert_token_not_valid, assert_unauthorized}; + + #[tokio::test] + async fn should_allow_getting_tracker_statistics() { + let api_server = start_default_api_server().await; + + api_server + .add_torrent( + &InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), + &sample_peer(), + ) + .await; + + let response = Client::new(api_server.get_connection_info()).get_tracker_statistics().await; + + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await.unwrap(), + Stats { + torrents: 1, + seeders: 1, + completed: 0, + leechers: 0, + tcp4_connections_handled: 0, + tcp4_announces_handled: 0, + tcp4_scrapes_handled: 0, + tcp6_connections_handled: 0, + tcp6_announces_handled: 0, + tcp6_scrapes_handled: 0, + udp4_connections_handled: 0, + udp4_announces_handled: 0, + udp4_scrapes_handled: 0, + udp6_connections_handled: 0, + udp6_announces_handled: 0, + udp6_scrapes_handled: 0, + } + ); + } + + #[tokio::test] + async fn should_not_allow_getting_tracker_statistics_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .get_tracker_statistics() + .await; + + assert_token_not_valid(response).await; + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .get_tracker_statistics() + .await; + + assert_unauthorized(response).await; + } + } + + mod for_torrent_resources { + use std::str::FromStr; + + use torrust_tracker::api::resource; + use torrust_tracker::api::resource::torrent::{self, Torrent}; + use torrust_tracker::protocol::info_hash::InfoHash; + + use super::{connection_with_invalid_token, connection_with_no_token}; + use crate::api::{sample_peer, start_default_api_server, Client, Query, QueryParam}; + use crate::tracker_api::{assert_token_not_valid, assert_unauthorized}; + + #[tokio::test] + async fn should_allow_getting_torrents() { + let api_server = start_default_api_server().await; + + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + + api_server.add_torrent(&info_hash, &sample_peer()).await; + + let response = Client::new(api_server.get_connection_info()) + .get_torrents(Query::empty()) + .await; + + assert_eq!(response.status(), 200); + assert_eq!( + response.json::>().await.unwrap(), + vec![torrent::ListItem { + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + seeders: 1, + completed: 0, + leechers: 0, + peers: None // Torrent list does not include the peer list for each torrent + }] + ); + } + + #[tokio::test] + async fn should_allow_limiting_the_torrents_in_the_result() { + let api_server = start_default_api_server().await; + + // torrents are ordered alphabetically by infohashes + let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); + + api_server.add_torrent(&info_hash_1, &sample_peer()).await; + api_server.add_torrent(&info_hash_2, &sample_peer()).await; + + let response = Client::new(api_server.get_connection_info()) + .get_torrents(Query::params([QueryParam::new("limit", "1")].to_vec())) + .await; + + assert_eq!(response.status(), 200); + assert_eq!( + response.json::>().await.unwrap(), + vec![torrent::ListItem { + info_hash: "0b3aea4adc213ce32295be85d3883a63bca25446".to_string(), + seeders: 1, + completed: 0, + leechers: 0, + peers: None // Torrent list does not include the peer list for each torrent + }] + ); + } + + #[tokio::test] + async fn should_allow_the_torrents_result_pagination() { + let api_server = start_default_api_server().await; + + // torrents are ordered alphabetically by infohashes + let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); + + api_server.add_torrent(&info_hash_1, &sample_peer()).await; + api_server.add_torrent(&info_hash_2, &sample_peer()).await; + + let response = Client::new(api_server.get_connection_info()) + .get_torrents(Query::params([QueryParam::new("offset", "1")].to_vec())) + .await; + + assert_eq!(response.status(), 200); + assert_eq!( + response.json::>().await.unwrap(), + vec![torrent::ListItem { + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + seeders: 1, + completed: 0, + leechers: 0, + peers: None // Torrent list does not include the peer list for each torrent + }] + ); + } + + #[tokio::test] + async fn should_not_allow_getting_torrents_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .get_torrents(Query::empty()) + .await; + + assert_token_not_valid(response).await; + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .get_torrents(Query::default()) + .await; + + assert_unauthorized(response).await; + } + + #[tokio::test] + async fn should_allow_getting_a_torrent_info() { + let api_server = start_default_api_server().await; + + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + + let peer = sample_peer(); + + api_server.add_torrent(&info_hash, &peer).await; + + let response = Client::new(api_server.get_connection_info()) + .get_torrent(&info_hash.to_string()) + .await; + + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await.unwrap(), + Torrent { + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + seeders: 1, + completed: 0, + leechers: 0, + peers: Some(vec![resource::peer::Peer::from(peer)]) + } + ); + } + + #[tokio::test] + async fn should_not_allow_getting_a_torrent_info_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + + api_server.add_torrent(&info_hash, &sample_peer()).await; + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .get_torrent(&info_hash.to_string()) + .await; + + assert_token_not_valid(response).await; + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .get_torrent(&info_hash.to_string()) + .await; + + assert_unauthorized(response).await; + } + } + + mod for_whitelisted_torrent_resources { + use std::str::FromStr; + + use torrust_tracker::protocol::info_hash::InfoHash; + + use super::{assert_token_not_valid, connection_with_invalid_token, connection_with_no_token}; + use crate::api::{start_default_api_server, Client}; + use crate::tracker_api::assert_unauthorized; + + #[tokio::test] + async fn should_allow_whitelisting_a_torrent() { + let api_server = start_default_api_server().await; + + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); + + let res = Client::new(api_server.get_connection_info()) + .whitelist_a_torrent(&info_hash) + .await; + + assert_eq!(res.status(), 200); + assert!( + api_server + .tracker + .is_info_hash_whitelisted(&InfoHash::from_str(&info_hash).unwrap()) + .await + ); + } + + #[tokio::test] + async fn should_allow_whitelisting_a_torrent_that_has_been_already_whitelisted() { + let api_server = start_default_api_server().await; + + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); + + let api_client = Client::new(api_server.get_connection_info()); + + let res = api_client.whitelist_a_torrent(&info_hash).await; + assert_eq!(res.status(), 200); + + let res = api_client.whitelist_a_torrent(&info_hash).await; + assert_eq!(res.status(), 200); + } + + #[tokio::test] + async fn should_not_allow_whitelisting_a_torrent_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .whitelist_a_torrent(&info_hash) + .await; + + assert_token_not_valid(response).await; + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .whitelist_a_torrent(&info_hash) + .await; + + assert_unauthorized(response).await; + } + + #[tokio::test] + async fn should_allow_removing_a_torrent_from_the_whitelist() { + let api_server = start_default_api_server().await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); + let info_hash = InfoHash::from_str(&hash).unwrap(); + api_server.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); + + let response = Client::new(api_server.get_connection_info()) + .remove_torrent_from_whitelist(&hash) + .await; + + assert_eq!(response.status(), 200); + assert!(!api_server.tracker.is_info_hash_whitelisted(&info_hash).await); + } + + #[tokio::test] + async fn should_not_allow_removing_a_torrent_from_the_whitelist_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); + let info_hash = InfoHash::from_str(&hash).unwrap(); + + api_server.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .remove_torrent_from_whitelist(&hash) + .await; + + assert_token_not_valid(response).await; + + api_server.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .remove_torrent_from_whitelist(&hash) + .await; + + assert_unauthorized(response).await; + } + + #[tokio::test] + async fn should_allow_reload_the_whitelist_from_the_database() { + let api_server = start_default_api_server().await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); + let info_hash = InfoHash::from_str(&hash).unwrap(); + api_server.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); + + let response = Client::new(api_server.get_connection_info()).reload_whitelist().await; + + assert_eq!(response.status(), 200); + /* This assert fails because the whitelist has not been reloaded yet. + We could add a new endpoint GET /api/whitelist/:info_hash to check if a torrent + is whitelisted and use that endpoint to check if the torrent is still there after reloading. + assert!( + !(api_server + .tracker + .is_info_hash_whitelisted(&InfoHash::from_str(&info_hash).unwrap()) + .await) + ); + */ + } + } + + mod for_key_resources { + use std::time::Duration; + + use torrust_tracker::api::resource::auth_key::AuthKey; + use torrust_tracker::tracker::auth::Key; + + use super::{connection_with_invalid_token, connection_with_no_token}; + use crate::api::{start_default_api_server, Client}; + use crate::tracker_api::{assert_token_not_valid, assert_unauthorized}; + + #[tokio::test] + async fn should_allow_generating_a_new_auth_key() { + let api_server = start_default_api_server().await; + + let seconds_valid = 60; + + let response = Client::new(api_server.get_connection_info()) + .generate_auth_key(seconds_valid) + .await; + + // Verify the key with the tracker + assert!(api_server + .tracker + .verify_auth_key(&Key::from(response.json::().await.unwrap())) + .await + .is_ok()); + } + + #[tokio::test] + async fn should_not_allow_generating_a_new_auth_key_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let seconds_valid = 60; + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .generate_auth_key(seconds_valid) + .await; + + assert_token_not_valid(response).await; + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .generate_auth_key(seconds_valid) + .await; + + assert_unauthorized(response).await; + } + + #[tokio::test] + async fn should_allow_deleting_an_auth_key() { + let api_server = start_default_api_server().await; + + let seconds_valid = 60; + let auth_key = api_server + .tracker + .generate_auth_key(Duration::from_secs(seconds_valid)) + .await + .unwrap(); + + let response = Client::new(api_server.get_connection_info()) + .delete_auth_key(&auth_key.key) + .await; + + assert_eq!(response.status(), 200); + assert_eq!(response.text().await.unwrap(), "{\"status\":\"ok\"}"); + } + + #[tokio::test] + async fn should_not_allow_deleting_an_auth_key_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let seconds_valid = 60; + + // Generate new auth key + let auth_key = api_server + .tracker + .generate_auth_key(Duration::from_secs(seconds_valid)) + .await + .unwrap(); + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .delete_auth_key(&auth_key.key) + .await; + + assert_token_not_valid(response).await; + + // Generate new auth key + let auth_key = api_server + .tracker + .generate_auth_key(Duration::from_secs(seconds_valid)) + .await + .unwrap(); + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .delete_auth_key(&auth_key.key) + .await; + + assert_unauthorized(response).await; + } + + #[tokio::test] + async fn should_allow_reloading_keys() { + let api_server = start_default_api_server().await; + + let seconds_valid = 60; + api_server + .tracker + .generate_auth_key(Duration::from_secs(seconds_valid)) + .await + .unwrap(); + + let response = Client::new(api_server.get_connection_info()).reload_keys().await; + + assert_eq!(response.status(), 200); + } + + #[tokio::test] + async fn should_not_allow_reloading_keys_for_unauthenticated_users() { + let api_server = start_default_api_server().await; + + let seconds_valid = 60; + api_server + .tracker + .generate_auth_key(Duration::from_secs(seconds_valid)) + .await + .unwrap(); + + let response = Client::new(connection_with_invalid_token(&api_server.get_bind_address())) + .reload_keys() + .await; + + assert_token_not_valid(response).await; + + let response = Client::new(connection_with_no_token(&api_server.get_bind_address())) + .reload_keys() + .await; + + assert_unauthorized(response).await; + } + } +}