From 847c26ddfee1198b6044b5d1d3692000b6ba7abc Mon Sep 17 00:00:00 2001 From: Antoine Gersant Date: Mon, 30 Nov 2020 01:26:55 -0800 Subject: [PATCH] Service unit tests improvements (#103) - Simpler API for TestService - More granular tests - Tests for authentication requirements - Better error handling (and HTTP response codes) for various bad inputs --- Cargo.lock | 38 +++ Cargo.toml | 1 + src/index/query.rs | 43 +++- src/playlist.rs | 152 +++++++----- src/service/error.rs | 6 + src/service/rocket/api.rs | 63 ++++- src/service/rocket/test.rs | 171 ++++++------- src/service/test.rs | 417 -------------------------------- src/service/test/admin.rs | 81 +++++++ src/service/test/auth.rs | 94 +++++++ src/service/test/collection.rs | 194 +++++++++++++++ src/service/test/constants.rs | 7 + src/service/test/media.rs | 123 ++++++++++ src/service/test/mod.rs | 112 +++++++++ src/service/test/playlist.rs | 133 ++++++++++ src/service/test/preferences.rs | 47 ++++ src/service/test/protocol.rs | 214 ++++++++++++++++ src/service/test/settings.rs | 90 +++++++ src/service/test/swagger.rs | 12 + src/service/test/web.rs | 9 + 20 files changed, 1414 insertions(+), 593 deletions(-) delete mode 100644 src/service/test.rs create mode 100644 src/service/test/admin.rs create mode 100644 src/service/test/auth.rs create mode 100644 src/service/test/collection.rs create mode 100644 src/service/test/constants.rs create mode 100644 src/service/test/media.rs create mode 100644 src/service/test/mod.rs create mode 100644 src/service/test/playlist.rs create mode 100644 src/service/test/preferences.rs create mode 100644 src/service/test/protocol.rs create mode 100644 src/service/test/settings.rs create mode 100644 src/service/test/swagger.rs create mode 100644 src/service/test/web.rs diff --git a/Cargo.lock b/Cargo.lock index e3286b0..50ce199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,6 +982,31 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +[[package]] +name = "headers" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed18eb2459bf1a09ad2d6b1547840c3e5e62882fa09b9a6a20b1de8e3228848f" +dependencies = [ + "base64 0.12.3", + "bitflags", + "bytes 0.5.6", + "headers-core", + "http 0.2.1", + "mime 0.3.16", + "sha-1", + "time 0.1.44", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http 0.2.1", +] + [[package]] name = "hermit-abi" version = "0.1.17" @@ -1819,6 +1844,7 @@ dependencies = [ "flame", "flamer", "getopts", + "headers", "http 0.2.1", "id3", "image", @@ -2464,6 +2490,18 @@ dependencies = [ "url 1.7.2", ] +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", + "fake-simd", + "opaque-debug 0.2.3", +] + [[package]] name = "sha1" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 372b050..3e62473 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ unix-daemonize = "0.1.2" percent-encoding = "2.1" cookie = "0.14.0" http = "0.2.1" +headers = "0.3" [profile.release.build-override] opt-level = 0 diff --git a/src/index/query.rs b/src/index/query.rs index fa643e4..47cf985 100644 --- a/src/index/query.rs +++ b/src/index/query.rs @@ -11,6 +11,20 @@ use crate::db::{directories, songs, DB}; use crate::index::*; use crate::vfs::VFSSource; +#[derive(thiserror::Error, Debug)] +pub enum QueryError { + #[error("VFS path not found")] + VFSPathNotFound, + #[error("Unspecified")] + Unspecified, +} + +impl From for QueryError { + fn from(_: anyhow::Error) -> Self { + QueryError::Unspecified + } +} + no_arg_sql_function!( random, sql_types::Integer, @@ -47,7 +61,7 @@ fn virtualize_directory(vfs: &VFS, mut directory: Directory) -> Option(db: &DB, virtual_path: P) -> Result> +pub fn browse

(db: &DB, virtual_path: P) -> Result, QueryError> where P: AsRef, { @@ -59,20 +73,24 @@ where // Browse top-level let real_directories: Vec = directories::table .filter(directories::parent.is_null()) - .load(&connection)?; + .load(&connection) + .map_err(anyhow::Error::new)?; let virtual_directories = real_directories .into_iter() .filter_map(|s| virtualize_directory(&vfs, s)); output.extend(virtual_directories.map(CollectionFile::Directory)); } else { // Browse sub-directory - let real_path = vfs.virtual_to_real(virtual_path)?; + let real_path = vfs + .virtual_to_real(virtual_path) + .map_err(|_| QueryError::VFSPathNotFound)?; let real_path_string = real_path.as_path().to_string_lossy().into_owned(); let real_directories: Vec = directories::table .filter(directories::parent.eq(&real_path_string)) .order(sql::("path COLLATE NOCASE ASC")) - .load(&connection)?; + .load(&connection) + .map_err(anyhow::Error::new)?; let virtual_directories = real_directories .into_iter() .filter_map(|s| virtualize_directory(&vfs, s)); @@ -81,7 +99,8 @@ where let real_songs: Vec = songs::table .filter(songs::parent.eq(&real_path_string)) .order(sql::("path COLLATE NOCASE ASC")) - .load(&connection)?; + .load(&connection) + .map_err(anyhow::Error::new)?; let virtual_songs = real_songs .into_iter() .filter_map(|s| virtualize_song(&vfs, s)); @@ -91,7 +110,7 @@ where Ok(output) } -pub fn flatten

(db: &DB, virtual_path: P) -> Result> +pub fn flatten

(db: &DB, virtual_path: P) -> Result, QueryError> where P: AsRef, { @@ -100,7 +119,9 @@ where let connection = db.connect()?; let real_songs: Vec = if virtual_path.as_ref().parent() != None { - let real_path = vfs.virtual_to_real(virtual_path)?; + let real_path = vfs + .virtual_to_real(virtual_path) + .map_err(|_| QueryError::VFSPathNotFound)?; let song_path_filter = { let mut path_buf = real_path.clone(); path_buf.push("%"); @@ -109,9 +130,13 @@ where songs .filter(path.like(&song_path_filter)) .order(path) - .load(&connection)? + .load(&connection) + .map_err(anyhow::Error::new)? } else { - songs.order(path).load(&connection)? + songs + .order(path) + .load(&connection) + .map_err(anyhow::Error::new)? }; let virtual_songs = real_songs diff --git a/src/playlist.rs b/src/playlist.rs index 2623102..84f45c6 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -5,6 +5,9 @@ use diesel::prelude::*; use diesel::sql_types; use diesel::BelongingToDsl; use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; +use thiserror::Error; #[cfg(test)] use crate::db; @@ -13,6 +16,22 @@ use crate::db::{playlist_songs, playlists, users}; use crate::index::{self, Song}; use crate::vfs::VFSSource; +#[derive(Error, Debug)] +pub enum PlaylistError { + #[error("User not found")] + UserNotFound, + #[error("Playlist not found")] + PlaylistNotFound, + #[error("Unspecified")] + Unspecified, +} + +impl From for PlaylistError { + fn from(_: anyhow::Error) -> Self { + PlaylistError::Unspecified + } +} + #[derive(Insertable)] #[table_name = "playlists"] struct NewPlaylist { @@ -47,29 +66,36 @@ pub struct NewPlaylistSong { ordering: i32, } -pub fn list_playlists(owner: &str, db: &DB) -> Result> { +pub fn list_playlists(owner: &str, db: &DB) -> Result, PlaylistError> { let connection = db.connect()?; - let user: User; - { + let user: User = { use self::users::dsl::*; - user = users + users .filter(name.eq(owner)) .select((id,)) - .first(&connection)?; - } + .first(&connection) + .optional() + .map_err(anyhow::Error::new)? + .ok_or(PlaylistError::UserNotFound)? + }; { use self::playlists::dsl::*; let found_playlists: Vec = Playlist::belonging_to(&user) .select(name) - .load(&connection)?; + .load(&connection) + .map_err(anyhow::Error::new)?; Ok(found_playlists) } } -pub fn save_playlist(playlist_name: &str, owner: &str, content: &[String], db: &DB) -> Result<()> { - let user: User; +pub fn save_playlist( + playlist_name: &str, + owner: &str, + content: &[String], + db: &DB, +) -> Result<(), PlaylistError> { let new_playlist: NewPlaylist; let playlist: Playlist; let vfs = db.get_vfs()?; @@ -78,13 +104,16 @@ pub fn save_playlist(playlist_name: &str, owner: &str, content: &[String], db: & let connection = db.connect()?; // Find owner - { + let user: User = { use self::users::dsl::*; - user = users + users .filter(name.eq(owner)) .select((id,)) - .get_result(&connection)?; - } + .first(&connection) + .optional() + .map_err(anyhow::Error::new)? + .ok_or(PlaylistError::UserNotFound)? + }; // Create playlist new_playlist = NewPlaylist { @@ -94,14 +123,16 @@ pub fn save_playlist(playlist_name: &str, owner: &str, content: &[String], db: & diesel::insert_into(playlists::table) .values(&new_playlist) - .execute(&connection)?; + .execute(&connection) + .map_err(anyhow::Error::new)?; - { + playlist = { use self::playlists::dsl::*; - playlist = playlists + playlists .select((id, owner)) .filter(name.eq(playlist_name).and(owner.eq(user.id))) - .get_result(&connection)?; + .get_result(&connection) + .map_err(anyhow::Error::new)? } } @@ -125,48 +156,58 @@ pub fn save_playlist(playlist_name: &str, owner: &str, content: &[String], db: & { let connection = db.connect()?; - connection.transaction::<_, diesel::result::Error, _>(|| { - // Delete old content (if any) - let old_songs = PlaylistSong::belonging_to(&playlist); - diesel::delete(old_songs).execute(&connection)?; + connection + .transaction::<_, diesel::result::Error, _>(|| { + // Delete old content (if any) + let old_songs = PlaylistSong::belonging_to(&playlist); + diesel::delete(old_songs).execute(&connection)?; - // Insert content - diesel::insert_into(playlist_songs::table) - .values(&new_songs) - .execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822 - Ok(()) - })?; + // Insert content + diesel::insert_into(playlist_songs::table) + .values(&new_songs) + .execute(&*connection)?; // TODO https://github.com/diesel-rs/diesel/issues/1822 + Ok(()) + }) + .map_err(anyhow::Error::new)?; } Ok(()) } -pub fn read_playlist(playlist_name: &str, owner: &str, db: &DB) -> Result> { +pub fn read_playlist( + playlist_name: &str, + owner: &str, + db: &DB, +) -> Result, PlaylistError> { let vfs = db.get_vfs()?; let songs: Vec; { let connection = db.connect()?; - let user: User; - let playlist: Playlist; // Find owner - { + let user: User = { use self::users::dsl::*; - user = users + users .filter(name.eq(owner)) .select((id,)) - .get_result(&connection)?; - } + .first(&connection) + .optional() + .map_err(anyhow::Error::new)? + .ok_or(PlaylistError::UserNotFound)? + }; // Find playlist - { + let playlist: Playlist = { use self::playlists::dsl::*; - playlist = playlists + playlists .select((id, owner)) .filter(name.eq(playlist_name).and(owner.eq(user.id))) - .get_result(&connection)?; - } + .get_result(&connection) + .optional() + .map_err(anyhow::Error::new)? + .ok_or(PlaylistError::PlaylistNotFound)? + }; // Select songs. Not using Diesel because we need to LEFT JOIN using a custom column let query = diesel::sql_query( @@ -179,7 +220,7 @@ pub fn read_playlist(playlist_name: &str, owner: &str, db: &DB) -> Result(playlist.id); - songs = query.get_results(&connection)?; + songs = query.get_results(&connection).map_err(anyhow::Error::new)?; } // Map real path to virtual paths @@ -191,25 +232,31 @@ pub fn read_playlist(playlist_name: &str, owner: &str, db: &DB) -> Result Result<()> { +pub fn delete_playlist(playlist_name: &str, owner: &str, db: &DB) -> Result<(), PlaylistError> { let connection = db.connect()?; - let user: User; - { + let user: User = { use self::users::dsl::*; - user = users + users .filter(name.eq(owner)) .select((id,)) - .first(&connection)?; - } + .first(&connection) + .optional() + .map_err(anyhow::Error::new)? + .ok_or(PlaylistError::UserNotFound)? + }; { use self::playlists::dsl::*; let q = Playlist::belonging_to(&user).filter(name.eq(playlist_name)); - diesel::delete(q).execute(&connection)?; + match diesel::delete(q) + .execute(&connection) + .map_err(anyhow::Error::new)? + { + 0 => Err(PlaylistError::PlaylistNotFound), + _ => Ok(()), + } } - - Ok(()) } #[test] @@ -272,12 +319,9 @@ fn test_fill_playlist() { assert_eq!(songs[0].title, Some("Above The Water".to_owned())); assert_eq!(songs[13].title, Some("Above The Water".to_owned())); - use std::path::PathBuf; - let mut first_song_path = PathBuf::new(); - first_song_path.push("root"); - first_song_path.push("Khemmis"); - first_song_path.push("Hunted"); - first_song_path.push("01 - Above The Water.mp3"); + let first_song_path: PathBuf = ["root", "Khemmis", "Hunted", "01 - Above The Water.mp3"] + .iter() + .collect(); assert_eq!(songs[0].path, first_song_path.to_str().unwrap()); // Save again to verify that we don't dupe the content diff --git a/src/service/error.rs b/src/service/error.rs index 214d3bc..1b58379 100644 --- a/src/service/error.rs +++ b/src/service/error.rs @@ -6,6 +6,12 @@ pub enum APIError { IncorrectCredentials, #[error("Cannot remove own admin privilege")] OwnAdminPrivilegeRemoval, + #[error("Path not found in virtual filesystem")] + VFSPathNotFound, + #[error("User not found")] + UserNotFound, + #[error("Playlist not found")] + PlaylistNotFound, #[error("Unspecified")] Unspecified, } diff --git a/src/service/rocket/api.rs b/src/service/rocket/api.rs index cf8cb80..e15cdfe 100644 --- a/src/service/rocket/api.rs +++ b/src/service/rocket/api.rs @@ -15,10 +15,9 @@ use time::Duration; use super::serve; use crate::config::{self, Config, Preferences}; use crate::db::DB; -use crate::index; -use crate::index::Index; +use crate::index::{self, Index, QueryError}; use crate::lastfm; -use crate::playlist; +use crate::playlist::{self, PlaylistError}; use crate::service::constants::*; use crate::service::dto; use crate::service::error::APIError; @@ -62,12 +61,34 @@ impl<'r> rocket::response::Responder<'r> for APIError { let status = match self { APIError::IncorrectCredentials => rocket::http::Status::Unauthorized, APIError::OwnAdminPrivilegeRemoval => rocket::http::Status::Conflict, + APIError::VFSPathNotFound => rocket::http::Status::NotFound, + APIError::UserNotFound => rocket::http::Status::NotFound, + APIError::PlaylistNotFound => rocket::http::Status::NotFound, APIError::Unspecified => rocket::http::Status::InternalServerError, }; rocket::response::Response::build().status(status).ok() } } +impl From for APIError { + fn from(error: PlaylistError) -> APIError { + match error { + PlaylistError::PlaylistNotFound => APIError::PlaylistNotFound, + PlaylistError::UserNotFound => APIError::UserNotFound, + PlaylistError::Unspecified => APIError::Unspecified, + } + } +} + +impl From for APIError { + fn from(error: QueryError) -> APIError { + match error { + QueryError::VFSPathNotFound => APIError::VFSPathNotFound, + QueryError::Unspecified => APIError::Unspecified, + } + } +} + struct Auth { username: String, } @@ -280,7 +301,7 @@ fn browse( db: State<'_, DB>, _auth: Auth, path: VFSPathBuf, -) -> Result>> { +) -> Result>, APIError> { let result = index::browse(db.deref().deref(), &path.into() as &PathBuf)?; Ok(Json(result)) } @@ -292,7 +313,11 @@ fn flatten_root(db: State<'_, DB>, _auth: Auth) -> Result> } #[get("/flatten/")] -fn flatten(db: State<'_, DB>, _auth: Auth, path: VFSPathBuf) -> Result>> { +fn flatten( + db: State<'_, DB>, + _auth: Auth, + path: VFSPathBuf, +) -> Result>, APIError> { let result = index::flatten(db.deref().deref(), &path.into() as &PathBuf)?; Ok(Json(result)) } @@ -326,10 +351,16 @@ fn search( } #[get("/audio/")] -fn audio(db: State<'_, DB>, _auth: Auth, path: VFSPathBuf) -> Result> { +fn audio( + db: State<'_, DB>, + _auth: Auth, + path: VFSPathBuf, +) -> Result, APIError> { let vfs = db.get_vfs()?; - let real_path = vfs.virtual_to_real(&path.into() as &PathBuf)?; - let file = File::open(&real_path)?; + let real_path = vfs + .virtual_to_real(&path.into() as &PathBuf) + .map_err(|_| APIError::VFSPathNotFound)?; + let file = File::open(&real_path).map_err(|_| APIError::Unspecified)?; Ok(serve::RangeResponder::new(file)) } @@ -340,13 +371,15 @@ fn thumbnail( _auth: Auth, path: VFSPathBuf, pad: Option, -) -> Result { +) -> Result { let vfs = db.get_vfs()?; - let image_path = vfs.virtual_to_real(&path.into() as &PathBuf)?; + let image_path = vfs + .virtual_to_real(&path.into() as &PathBuf) + .map_err(|_| APIError::VFSPathNotFound)?; let mut options = ThumbnailOptions::default(); options.pad_to_square = pad.unwrap_or(options.pad_to_square); let thumbnail_path = thumbnails_manager.get_thumbnail(&image_path, &options)?; - let file = File::open(thumbnail_path)?; + let file = File::open(thumbnail_path).map_err(|_| APIError::Unspecified)?; Ok(file) } @@ -373,13 +406,17 @@ fn save_playlist( } #[get("/playlist/")] -fn read_playlist(db: State<'_, DB>, auth: Auth, name: String) -> Result>> { +fn read_playlist( + db: State<'_, DB>, + auth: Auth, + name: String, +) -> Result>, APIError> { let songs = playlist::read_playlist(&name, &auth.username, db.deref().deref())?; Ok(Json(songs)) } #[delete("/playlist/")] -fn delete_playlist(db: State<'_, DB>, auth: Auth, name: String) -> Result<()> { +fn delete_playlist(db: State<'_, DB>, auth: Auth, name: String) -> Result<(), APIError> { playlist::delete_playlist(&name, &auth.username, db.deref().deref())?; Ok(()) } diff --git a/src/service/rocket/test.rs b/src/service/rocket/test.rs index 9dd452b..9fdd438 100644 --- a/src/service/rocket/test.rs +++ b/src/service/rocket/test.rs @@ -1,65 +1,73 @@ -use http::response::{Builder, Response}; -use http::{HeaderMap, HeaderValue}; +use http::{header::HeaderName, method::Method, response::Builder, HeaderValue, Request, Response}; use rocket; -use rocket::local::Client; +use rocket::local::{Client, LocalResponse}; use serde::de::DeserializeOwned; use serde::Serialize; use std::fs; -use std::ops::DerefMut; use std::path::PathBuf; use super::server; use crate::db::DB; use crate::index; -use crate::service::test::TestService; +use crate::service::test::{protocol, TestService}; use crate::thumbnails::ThumbnailsManager; -pub struct RocketResponse<'r, 's> { - response: &'s mut rocket::Response<'r>, -} - -impl<'r, 's> RocketResponse<'r, 's> { - fn builder(&self) -> Builder { - let mut builder = Response::builder().status(self.response.status().code); - for header in self.response.headers().iter() { - builder = builder.header(header.name(), header.value()); - } - builder - } - - fn to_void(&self) -> Response<()> { - let builder = self.builder(); - builder.body(()).unwrap() - } - - fn to_bytes(&mut self) -> Response> { - let body = self.response.body().unwrap(); - let body = body.into_bytes().unwrap(); - let builder = self.builder(); - builder.body(body).unwrap() - } - - fn to_object(&mut self) -> Response { - let body = self.response.body_string().unwrap(); - let body = serde_json::from_str(&body).unwrap(); - let builder = self.builder(); - builder.body(body).unwrap() - } -} - pub struct RocketTestService { client: Client, + request_builder: protocol::RequestBuilder, } pub type ServiceType = RocketTestService; +impl RocketTestService { + fn process_internal(&mut self, request: &Request) -> (LocalResponse, Builder) { + let rocket_response = { + let url = request.uri().to_string(); + let mut rocket_request = match *request.method() { + Method::GET => self.client.get(url), + Method::POST => self.client.post(url), + Method::PUT => self.client.put(url), + Method::DELETE => self.client.delete(url), + _ => unimplemented!(), + }; + + for (name, value) in request.headers() { + rocket_request.add_header(rocket::http::Header::new( + name.as_str().to_owned(), + value.to_str().unwrap().to_owned(), + )); + } + + let payload = request.body(); + let body = serde_json::to_string(payload).unwrap(); + rocket_request.set_body(body); + + let content_type = rocket::http::ContentType::JSON; + rocket_request.add_header(content_type); + + rocket_request.dispatch() + }; + + let mut builder = Response::builder().status(rocket_response.status().code); + let headers = builder.headers_mut().unwrap(); + for header in rocket_response.headers().iter() { + headers.append( + HeaderName::from_bytes(header.name.as_str().as_bytes()).unwrap(), + HeaderValue::from_str(header.value.as_ref()).unwrap(), + ); + } + + (rocket_response, builder) + } +} + impl TestService for RocketTestService { - fn new(db_name: &str) -> Self { + fn new(unique_db_name: &str) -> Self { let mut db_path = PathBuf::new(); db_path.push("test-output"); fs::create_dir_all(&db_path).unwrap(); - db_path.push(format!("{}.sqlite", db_name)); + db_path.push(format!("{}.sqlite", unique_db_name)); if db_path.exists() { fs::remove_file(&db_path).unwrap(); } @@ -74,7 +82,7 @@ impl TestService for RocketTestService { let mut thumbnails_path = PathBuf::new(); thumbnails_path.push("test-output"); thumbnails_path.push("thumbnails"); - thumbnails_path.push(db_name); + thumbnails_path.push(unique_db_name); let thumbnails_manager = ThumbnailsManager::new(thumbnails_path.as_path()); let auth_secret: [u8; 32] = [0; 32]; @@ -93,72 +101,35 @@ impl TestService for RocketTestService { ) .unwrap(); let client = Client::new(server).unwrap(); - RocketTestService { client } + let request_builder = protocol::RequestBuilder::new(); + RocketTestService { + request_builder, + client, + } } - fn get(&mut self, url: &str) -> Response<()> { - let mut response = self.client.get(url).dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_void() + fn request_builder(&self) -> &protocol::RequestBuilder { + &self.request_builder } - fn get_bytes(&mut self, url: &str, headers: &HeaderMap) -> Response> { - let mut request = self.client.get(url); - for (name, value) in headers.iter() { - request.add_header(rocket::http::Header::new( - name.as_str().to_owned(), - value.to_str().unwrap().to_owned(), - )) - } - let mut response = request.dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_bytes() + fn fetch(&mut self, request: &Request) -> Response<()> { + let (_, builder) = self.process_internal(request); + builder.body(()).unwrap() } - fn post(&mut self, url: &str) -> Response<()> { - let mut response = self.client.post(url).dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_void() + fn fetch_bytes(&mut self, request: &Request) -> Response> { + let (mut rocket_response, builder) = self.process_internal(request); + let body = rocket_response.body().unwrap().into_bytes().unwrap(); + builder.body(body).unwrap() } - fn delete(&mut self, url: &str) -> Response<()> { - let mut response = self.client.delete(url).dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_void() - } - - fn get_json(&mut self, url: &str) -> Response { - let mut response = self.client.get(url).dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_object() - } - - fn put_json(&mut self, url: &str, payload: &T) -> Response<()> { - let client = &self.client; - let body = serde_json::to_string(payload).unwrap(); - let mut response = client.put(url).body(&body).dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_void() - } - - fn post_json(&mut self, url: &str, payload: &T) -> Response<()> { - let body = serde_json::to_string(payload).unwrap(); - let mut response = self.client.post(url).body(&body).dispatch(); - RocketResponse { - response: response.deref_mut(), - } - .to_void() + fn fetch_json( + &mut self, + request: &Request, + ) -> Response { + let (mut rocket_response, builder) = self.process_internal(request); + let body = rocket_response.body_string().unwrap(); + let body = serde_json::from_str(&body).unwrap(); + builder.body(body).unwrap() } } diff --git a/src/service/test.rs b/src/service/test.rs deleted file mode 100644 index 7384ec1..0000000 --- a/src/service/test.rs +++ /dev/null @@ -1,417 +0,0 @@ -use cookie::Cookie; -use http::header::*; -use http::{HeaderMap, HeaderValue, Response, StatusCode}; -use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; -use serde::de::DeserializeOwned; -use serde::Serialize; -use std::path::PathBuf; -use std::time::Duration; - -use crate::service::constants::*; -use crate::service::dto; -use crate::{config, index, vfs}; - -#[cfg(feature = "service-rocket")] -pub use crate::service::rocket::test::ServiceType; - -const TEST_DB_PREFIX: &str = "service-test-"; -const TEST_USERNAME: &str = "test_user"; -const TEST_PASSWORD: &str = "test_password"; -const TEST_MOUNT_NAME: &str = "collection"; -const TEST_MOUNT_SOURCE: &str = "test-data/small-collection"; - -pub trait TestService { - fn new(db_name: &str) -> Self; - fn get(&mut self, url: &str) -> Response<()>; - fn get_bytes(&mut self, url: &str, headers: &HeaderMap) -> Response>; - fn post(&mut self, url: &str) -> Response<()>; - fn delete(&mut self, url: &str) -> Response<()>; - fn get_json(&mut self, url: &str) -> Response; - fn put_json(&mut self, url: &str, payload: &T) -> Response<()>; - fn post_json(&mut self, url: &str, payload: &T) -> Response<()>; - - fn complete_initial_setup(&mut self) { - let configuration = config::Config { - album_art_pattern: None, - reindex_every_n_seconds: None, - ydns: None, - users: Some(vec![config::ConfigUser { - name: TEST_USERNAME.into(), - password: TEST_PASSWORD.into(), - admin: true, - }]), - mount_dirs: Some(vec![vfs::MountPoint { - name: TEST_MOUNT_NAME.into(), - source: TEST_MOUNT_SOURCE.into(), - }]), - }; - self.put_json("/api/settings", &configuration); - } - - fn login(&mut self) { - let credentials = dto::AuthCredentials { - username: TEST_USERNAME.into(), - password: TEST_PASSWORD.into(), - }; - self.post_json("/api/auth", &credentials); - } - - fn index(&mut self) { - assert!(self.post("/api/trigger_index").status() == StatusCode::OK); - - loop { - let response = self.get_json::>("/api/browse"); - let entries = response.body(); - if entries.len() > 0 { - break; - } - std::thread::sleep(Duration::from_secs(1)); - } - - loop { - let response = self.get_json::>("/api/flatten"); - let entries = response.body(); - if entries.len() > 0 { - break; - } - std::thread::sleep(Duration::from_secs(1)); - } - } -} - -#[test] -fn test_service_index() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.get("/"); -} - -#[test] -fn test_service_swagger_index() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - assert_eq!( - service.get("/swagger").status(), - StatusCode::PERMANENT_REDIRECT - ); -} - -#[test] -fn test_service_swagger_index_with_trailing_slash() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - assert_eq!(service.get("/swagger/").status(), StatusCode::OK); -} - -#[test] -fn test_service_version() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - let response = service.get_json::("/api/version"); - let version = response.body(); - assert_eq!(version, &dto::Version { major: 5, minor: 0 }); -} - -#[test] -fn test_service_initial_setup() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - { - let response = service.get_json::("/api/initial_setup"); - let initial_setup = response.body(); - assert_eq!( - initial_setup, - &dto::InitialSetup { - has_any_users: false - } - ); - } - service.complete_initial_setup(); - { - let response = service.get_json::("/api/initial_setup"); - let initial_setup = response.body(); - assert_eq!( - initial_setup, - &dto::InitialSetup { - has_any_users: true - } - ); - } -} - -#[test] -fn test_service_settings() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - - assert!(service.get("/api/settings").status() == StatusCode::UNAUTHORIZED); - service.login(); - - let response = service.get_json::("/api/settings"); - assert_eq!(response.status(), StatusCode::OK); - - let configuration = config::Config::default(); - let response = service.put_json("/api/settings", &configuration); - assert_eq!(response.status(), StatusCode::OK); -} - -#[test] -fn test_service_settings_cannot_unadmin_self() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - - let mut configuration = config::Config::default(); - configuration.users = Some(vec![config::ConfigUser { - name: TEST_USERNAME.into(), - password: "".into(), - admin: false, - }]); - let response = service.put_json("/api/settings", &configuration); - assert_eq!(response.status(), StatusCode::CONFLICT); -} - -#[test] -fn test_service_preferences() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - let response = service.get_json::("/api/preferences"); - assert_eq!(response.status(), StatusCode::OK); - - let preferences = config::Preferences::default(); - let response = service.put_json("/api/preferences", &preferences); - assert_eq!(response.status(), StatusCode::OK); -} - -#[test] -fn test_service_trigger_index() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - - let response = service.get_json::>("/api/random"); - let entries = response.body(); - assert_eq!(entries.len(), 0); - - service.index(); - - let response = service.get_json::>("/api/random"); - let entries = response.body(); - assert_eq!(entries.len(), 3); -} - -#[test] -fn test_service_auth() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - - { - let credentials = dto::AuthCredentials { - username: "garbage".into(), - password: "garbage".into(), - }; - assert!(service.post_json("/api/auth", &credentials).status() == StatusCode::UNAUTHORIZED); - } - { - let credentials = dto::AuthCredentials { - username: TEST_USERNAME.into(), - password: "garbage".into(), - }; - assert!(service.post_json("/api/auth", &credentials).status() == StatusCode::UNAUTHORIZED); - } - { - let credentials = dto::AuthCredentials { - username: TEST_USERNAME.into(), - password: TEST_PASSWORD.into(), - }; - let response = service.post_json("/api/auth", &credentials); - assert!(response.status() == StatusCode::OK); - let cookies: Vec = response - .headers() - .get_all(SET_COOKIE) - .iter() - .map(|c| Cookie::parse(c.to_str().unwrap()).unwrap()) - .collect(); - assert!(cookies.iter().any(|c| c.name() == COOKIE_SESSION)); - assert!(cookies.iter().any(|c| c.name() == COOKIE_USERNAME)); - assert!(cookies.iter().any(|c| c.name() == COOKIE_ADMIN)); - } -} - -#[test] -fn test_service_browse() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let response = service.get_json::>("/api/browse"); - let entries = response.body(); - assert_eq!(entries.len(), 1); - - let mut path = PathBuf::new(); - path.push("collection"); - path.push("Khemmis"); - path.push("Hunted"); - let uri = format!( - "/api/browse/{}", - percent_encode(path.to_string_lossy().as_ref().as_bytes(), NON_ALPHANUMERIC) - ); - - let response = service.get_json::>(&uri); - let entries = response.body(); - assert_eq!(entries.len(), 5); -} - -#[test] -fn test_service_flatten() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let response = service.get_json::>("/api/flatten"); - let entries = response.body(); - assert_eq!(entries.len(), 13); - - let response = service.get_json::>("/api/flatten/collection"); - let entries = response.body(); - assert_eq!(entries.len(), 13); -} - -#[test] -fn test_service_random() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let response = service.get_json::>("/api/random"); - let entries = response.body(); - assert_eq!(entries.len(), 3); -} - -#[test] -fn test_service_recent() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let response = service.get_json::>("/api/recent"); - let entries = response.body(); - assert_eq!(entries.len(), 3); -} - -#[test] -fn test_service_search_root() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - let response = service.get_json::>("/api/search"); - assert_eq!(response.status(), StatusCode::OK); -} - -#[test] -fn test_service_search() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let response = service.get_json::>("/api/search/door"); - let results = response.body(); - assert_eq!(results.len(), 1); - match results[0] { - index::CollectionFile::Song(ref s) => assert_eq!(s.title, Some("Beyond The Door".into())), - _ => panic!(), - } -} -#[test] -fn test_service_serve() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let mut path = PathBuf::new(); - path.push("collection"); - path.push("Khemmis"); - path.push("Hunted"); - path.push("02 - Candlelight.mp3"); - let uri = format!( - "/api/audio/{}", - percent_encode(path.to_string_lossy().as_ref().as_bytes(), NON_ALPHANUMERIC) - ); - - let response = service.get_bytes(&uri, &HeaderMap::new()); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.body().len(), 24_142); - - { - let mut headers = HeaderMap::new(); - headers.append(RANGE, HeaderValue::from_str("bytes=100-299").unwrap()); - let response = service.get_bytes(&uri, &headers); - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.body().len(), 200); - assert_eq!(response.headers().get(CONTENT_LENGTH).unwrap(), "200"); - } -} - -#[test] -fn test_service_playlists() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let response = service.get_json::>("/api/playlists"); - let playlists = response.body(); - assert_eq!(playlists.len(), 0); - - let response = service.get_json::>("/api/flatten"); - let mut my_songs = response.into_body(); - my_songs.pop(); - my_songs.pop(); - let my_playlist = dto::SavePlaylistInput { - tracks: my_songs.iter().map(|s| s.path.clone()).collect(), - }; - service.put_json("/api/playlist/my_playlist", &my_playlist); - - let response = service.get_json::>("/api/playlists"); - let playlists = response.body(); - assert_eq!( - playlists, - &vec![dto::ListPlaylistsEntry { - name: "my_playlist".into() - }] - ); - - let response = service.get_json::>("/api/playlist/my_playlist"); - let songs = response.body(); - assert_eq!(songs, &my_songs); - - service.delete("/api/playlist/my_playlist"); - - let response = service.get_json::>("/api/playlists"); - let playlists = response.body(); - assert_eq!(playlists.len(), 0); -} - -#[test] -fn test_service_thumbnail() { - let mut service = ServiceType::new(&format!("{}{}", TEST_DB_PREFIX, line!())); - service.complete_initial_setup(); - service.login(); - service.index(); - - let mut path = PathBuf::new(); - path.push("collection"); - path.push("Khemmis"); - path.push("Hunted"); - path.push("Folder.jpg"); - let uri = format!( - "/api/thumbnail/{}", - percent_encode(path.to_string_lossy().as_ref().as_bytes(), NON_ALPHANUMERIC) - ); - - let response = service.get(&uri); - assert_eq!(response.status(), StatusCode::OK); -} diff --git a/src/service/test/admin.rs b/src/service/test/admin.rs new file mode 100644 index 0000000..3a008f5 --- /dev/null +++ b/src/service/test/admin.rs @@ -0,0 +1,81 @@ +use http::StatusCode; + +use crate::index; +use crate::service::dto; +use crate::service::test::{ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_returns_api_version() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().version(); + let response = service.fetch_json::<_, dto::Version>(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_initial_setup_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().initial_setup(); + { + let response = service.fetch_json::<_, dto::InitialSetup>(&request); + assert_eq!(response.status(), StatusCode::OK); + let initial_setup = response.body(); + assert_eq!( + initial_setup, + &dto::InitialSetup { + has_any_users: false + } + ); + } + service.complete_initial_setup(); + { + let response = service.fetch_json::<_, dto::InitialSetup>(&request); + assert_eq!(response.status(), StatusCode::OK); + let initial_setup = response.body(); + assert_eq!( + initial_setup, + &dto::InitialSetup { + has_any_users: true + } + ); + } +} + +#[test] +fn test_trigger_index_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + + let request = service.request_builder().random(); + + let response = service.fetch_json::<_, Vec>(&request); + let entries = response.body(); + assert_eq!(entries.len(), 0); + + service.index(); + + let response = service.fetch_json::<_, Vec>(&request); + let entries = response.body(); + assert_eq!(entries.len(), 3); +} + +#[test] +fn test_trigger_index_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + let request = service.request_builder().trigger_index(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_trigger_index_requires_admin() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + let request = service.request_builder().trigger_index(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/src/service/test/auth.rs b/src/service/test/auth.rs new file mode 100644 index 0000000..d995499 --- /dev/null +++ b/src/service/test/auth.rs @@ -0,0 +1,94 @@ +use cookie::Cookie; +use headers::{self, HeaderMapExt}; +use http::{Response, StatusCode}; + +use crate::service::constants::*; +use crate::service::test::{constants::*, ServiceType, TestService}; +use crate::unique_db_name; + +fn validate_cookies(response: &Response) { + let cookies: Vec = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|c| Cookie::parse(c.to_str().unwrap()).unwrap()) + .collect(); + assert!(cookies.iter().any(|c| c.name() == COOKIE_SESSION)); + assert!(cookies.iter().any(|c| c.name() == COOKIE_USERNAME)); + assert!(cookies.iter().any(|c| c.name() == COOKIE_ADMIN)); +} + +#[test] +fn test_login_rejects_bad_username() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let request = service.request_builder().login("garbage", TEST_PASSWORD); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_login_rejects_bad_password() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let request = service.request_builder().login(TEST_USERNAME, "garbage"); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_login_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let request = service + .request_builder() + .login(TEST_USERNAME, TEST_PASSWORD); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + + validate_cookies(&response); +} + +#[test] +fn test_authentication_via_http_header_rejects_bad_username() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let mut request = service.request_builder().random(); + let basic = headers::Authorization::basic("garbage", TEST_PASSWORD); + request.headers_mut().typed_insert(basic); + + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_authentication_via_http_header_rejects_bad_password() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let mut request = service.request_builder().random(); + let basic = headers::Authorization::basic(TEST_PASSWORD, "garbage"); + request.headers_mut().typed_insert(basic); + + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_authentication_via_http_header_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let mut request = service.request_builder().random(); + let basic = headers::Authorization::basic(TEST_USERNAME, TEST_PASSWORD); + request.headers_mut().typed_insert(basic); + + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + + validate_cookies(&response); +} diff --git a/src/service/test/collection.rs b/src/service/test/collection.rs new file mode 100644 index 0000000..5aede1b --- /dev/null +++ b/src/service/test/collection.rs @@ -0,0 +1,194 @@ +use http::StatusCode; +use std::path::{Path, PathBuf}; + +use crate::index; +use crate::service::test::{constants::*, ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_browse_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().browse(&PathBuf::new()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_browse_root() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let request = service.request_builder().browse(&PathBuf::new()); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); + let entries = response.body(); + assert_eq!(entries.len(), 1); +} + +#[test] +fn test_browse_directory() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect(); + let request = service.request_builder().browse(&path); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); + let entries = response.body(); + assert_eq!(entries.len(), 5); +} + +#[test] +fn test_browse_bad_directory() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let path: PathBuf = ["not_my_collection"].iter().collect(); + let request = service.request_builder().browse(&path); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[test] +fn test_flatten_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().flatten(&PathBuf::new()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_flatten_root() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let request = service.request_builder().flatten(&PathBuf::new()); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); + let entries = response.body(); + assert_eq!(entries.len(), 13); +} + +#[test] +fn test_flatten_directory() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let request = service + .request_builder() + .flatten(Path::new(TEST_MOUNT_NAME)); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); + let entries = response.body(); + assert_eq!(entries.len(), 13); +} + +#[test] +fn test_flatten_bad_directory() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let path: PathBuf = ["not_my_collection"].iter().collect(); + let request = service.request_builder().flatten(&path); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[test] +fn test_random_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().random(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_random() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let request = service.request_builder().random(); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); + let entries = response.body(); + assert_eq!(entries.len(), 3); +} + +#[test] +fn test_recent_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().recent(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_recent() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let request = service.request_builder().recent(); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); + let entries = response.body(); + assert_eq!(entries.len(), 3); +} + +#[test] +fn test_search_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().search(""); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_search_without_query() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let request = service.request_builder().search(""); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_search_with_query() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let request = service.request_builder().search("door"); + let response = service.fetch_json::<_, Vec>(&request); + let results = response.body(); + assert_eq!(results.len(), 1); + match results[0] { + index::CollectionFile::Song(ref s) => { + assert_eq!(s.title, Some("Beyond The Door".into())) + } + _ => panic!(), + } +} diff --git a/src/service/test/constants.rs b/src/service/test/constants.rs new file mode 100644 index 0000000..5ff11c9 --- /dev/null +++ b/src/service/test/constants.rs @@ -0,0 +1,7 @@ +pub const TEST_USERNAME: &str = "test_user"; +pub const TEST_PASSWORD: &str = "test_password"; +pub const TEST_USERNAME_ADMIN: &str = "test_admin"; +pub const TEST_PASSWORD_ADMIN: &str = "test_password_admin"; +pub const TEST_MOUNT_NAME: &str = "collection"; +pub const TEST_MOUNT_SOURCE: &str = "test-data/small-collection"; +pub const TEST_PLAYLIST_NAME: &str = "my_playlist"; diff --git a/src/service/test/media.rs b/src/service/test/media.rs new file mode 100644 index 0000000..81c7a92 --- /dev/null +++ b/src/service/test/media.rs @@ -0,0 +1,123 @@ +use http::{header, HeaderValue, StatusCode}; +use std::path::PathBuf; + +use crate::service::test::{constants::*, ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_audio_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] + .iter() + .collect(); + + let request = service.request_builder().audio(&path); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_audio_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] + .iter() + .collect(); + + let request = service.request_builder().audio(&path); + let response = service.fetch_bytes(&request); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().len(), 24_142); +} + +#[test] +fn test_audio_partial_content() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "02 - Candlelight.mp3"] + .iter() + .collect(); + + let mut request = service.request_builder().audio(&path); + let headers = request.headers_mut(); + headers.append( + header::RANGE, + HeaderValue::from_str("bytes=100-299").unwrap(), + ); + + let response = service.fetch_bytes(&request); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.body().len(), 200); + assert_eq!( + response.headers().get(header::CONTENT_LENGTH).unwrap(), + "200" + ); +} + +#[test] +fn test_audio_bad_path_returns_not_found() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let path: PathBuf = ["not_my_collection"].iter().collect(); + + let request = service.request_builder().audio(&path); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[test] +fn test_thumbnail_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"] + .iter() + .collect(); + + let pad = None; + let request = service.request_builder().thumbnail(&path, pad); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_thumbnail_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + service.index(); + service.login(); + + let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted", "Folder.jpg"] + .iter() + .collect(); + + let pad = None; + let request = service.request_builder().thumbnail(&path, pad); + let response = service.fetch_bytes(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_thumbnail_bad_path_returns_not_found() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let path: PathBuf = ["not_my_collection"].iter().collect(); + + let pad = None; + let request = service.request_builder().thumbnail(&path, pad); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/src/service/test/mod.rs b/src/service/test/mod.rs new file mode 100644 index 0000000..615cead --- /dev/null +++ b/src/service/test/mod.rs @@ -0,0 +1,112 @@ +use http::{Request, Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::path::Path; +use std::time::Duration; + +pub mod constants; +pub mod protocol; + +mod admin; +mod auth; +mod collection; +mod media; +mod playlist; +mod preferences; +mod settings; +mod swagger; +mod web; + +use crate::service::test::constants::*; +use crate::{config, index, vfs}; + +#[cfg(feature = "service-rocket")] +pub use crate::service::rocket::test::ServiceType; + +#[macro_export] +macro_rules! unique_db_name { + () => {{ + let file_name = file!(); + let file_name = file_name.replace("/", "-"); + let file_name = file_name.replace("\\", "-"); + format!("{}-line-{}", file_name, line!()) + }}; +} + +pub trait TestService { + fn new(unique_db_name: &str) -> Self; + fn request_builder(&self) -> &protocol::RequestBuilder; + fn fetch(&mut self, request: &Request) -> Response<()>; + fn fetch_bytes(&mut self, request: &Request) -> Response>; + fn fetch_json( + &mut self, + request: &Request, + ) -> Response; + + fn complete_initial_setup(&mut self) { + let configuration = config::Config { + album_art_pattern: None, + reindex_every_n_seconds: None, + ydns: None, + users: Some(vec![ + config::ConfigUser { + name: TEST_USERNAME_ADMIN.into(), + password: TEST_PASSWORD_ADMIN.into(), + admin: true, + }, + config::ConfigUser { + name: TEST_USERNAME.into(), + password: TEST_PASSWORD.into(), + admin: false, + }, + ]), + mount_dirs: Some(vec![vfs::MountPoint { + name: TEST_MOUNT_NAME.into(), + source: TEST_MOUNT_SOURCE.into(), + }]), + }; + let request = self.request_builder().put_settings(configuration); + let response = self.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + } + + fn login_admin(&mut self) { + let request = self + .request_builder() + .login(TEST_USERNAME_ADMIN, TEST_PASSWORD_ADMIN); + let response = self.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + } + + fn login(&mut self) { + let request = self.request_builder().login(TEST_USERNAME, TEST_PASSWORD); + let response = self.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + } + + fn index(&mut self) { + let request = self.request_builder().trigger_index(); + let response = self.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + + loop { + let browse_request = self.request_builder().browse(Path::new("")); + let response = self.fetch_json::<(), Vec>(&browse_request); + let entries = response.body(); + if entries.len() > 0 { + break; + } + std::thread::sleep(Duration::from_secs(1)); + } + + loop { + let flatten_request = self.request_builder().flatten(Path::new("")); + let response = self.fetch_json::<_, Vec>(&flatten_request); + let entries = response.body(); + if entries.len() > 0 { + break; + } + std::thread::sleep(Duration::from_secs(1)); + } + } +} diff --git a/src/service/test/playlist.rs b/src/service/test/playlist.rs new file mode 100644 index 0000000..ef12d5a --- /dev/null +++ b/src/service/test/playlist.rs @@ -0,0 +1,133 @@ +use http::StatusCode; + +use crate::index; +use crate::service::dto; +use crate::service::test::{constants::*, ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_list_playlists_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().playlists(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_list_playlists_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + let request = service.request_builder().playlists(); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_save_playlist_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; + let request = service + .request_builder() + .save_playlist(TEST_PLAYLIST_NAME, my_playlist); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_save_playlist_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; + let request = service + .request_builder() + .save_playlist(TEST_PLAYLIST_NAME, my_playlist); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_get_playlist_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_get_playlist_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + { + let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; + let request = service + .request_builder() + .save_playlist(TEST_PLAYLIST_NAME, my_playlist); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + } + + let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME); + let response = service.fetch_json::<_, Vec>(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_get_playlist_bad_name_returns_not_found() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let request = service.request_builder().read_playlist(TEST_PLAYLIST_NAME); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[test] +fn test_delete_playlist_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service + .request_builder() + .delete_playlist(TEST_PLAYLIST_NAME); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_delete_playlist_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + { + let my_playlist = dto::SavePlaylistInput { tracks: Vec::new() }; + let request = service + .request_builder() + .save_playlist(TEST_PLAYLIST_NAME, my_playlist); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); + } + + let request = service + .request_builder() + .delete_playlist(TEST_PLAYLIST_NAME); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_delete_playlist_bad_name_returns_not_found() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let request = service + .request_builder() + .delete_playlist(TEST_PLAYLIST_NAME); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/src/service/test/preferences.rs b/src/service/test/preferences.rs new file mode 100644 index 0000000..fd0183a --- /dev/null +++ b/src/service/test/preferences.rs @@ -0,0 +1,47 @@ +use http::StatusCode; + +use crate::config; +use crate::service::test::{ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_get_preferences_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().get_preferences(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_get_preferences_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let request = service.request_builder().get_preferences(); + let response = service.fetch_json::<_, config::Preferences>(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_put_preferences_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service + .request_builder() + .put_preferences(config::Preferences::default()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_put_preferences_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let request = service + .request_builder() + .put_preferences(config::Preferences::default()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/src/service/test/protocol.rs b/src/service/test/protocol.rs new file mode 100644 index 0000000..edd2ee4 --- /dev/null +++ b/src/service/test/protocol.rs @@ -0,0 +1,214 @@ +use http::{method::Method, Request}; +use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; +use std::path::Path; + +use crate::config; +use crate::service::dto; + +pub struct RequestBuilder {} + +impl RequestBuilder { + pub fn new() -> Self { + Self {} + } + + pub fn web_index(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/") + .body(()) + .unwrap() + } + + pub fn swagger_index(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/swagger/") + .body(()) + .unwrap() + } + + pub fn version(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/version") + .body(()) + .unwrap() + } + + pub fn initial_setup(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/initial_setup") + .body(()) + .unwrap() + } + + pub fn login(&self, username: &str, password: &str) -> Request { + let credentials = dto::AuthCredentials { + username: username.into(), + password: password.into(), + }; + Request::builder() + .method(Method::POST) + .uri("/api/auth") + .body(credentials) + .unwrap() + } + + pub fn get_settings(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/settings") + .body(()) + .unwrap() + } + + pub fn put_settings(&self, configuration: config::Config) -> Request { + Request::builder() + .method(Method::PUT) + .uri("/api/settings") + .body(configuration) + .unwrap() + } + + pub fn get_preferences(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/preferences") + .body(()) + .unwrap() + } + + pub fn put_preferences( + &self, + preferences: config::Preferences, + ) -> Request { + Request::builder() + .method(Method::PUT) + .uri("/api/preferences") + .body(preferences) + .unwrap() + } + + pub fn trigger_index(&self) -> Request<()> { + Request::builder() + .method(Method::POST) + .uri("/api/trigger_index") + .body(()) + .unwrap() + } + + pub fn browse(&self, path: &Path) -> Request<()> { + let path = path.to_string_lossy(); + let uri = format!("/api/browse/{}", url_encode(path.as_ref())); + Request::builder() + .method(Method::GET) + .uri(uri) + .body(()) + .unwrap() + } + + pub fn flatten(&self, path: &Path) -> Request<()> { + let path = path.to_string_lossy(); + let uri = format!("/api/flatten/{}", url_encode(path.as_ref())); + Request::builder() + .method(Method::GET) + .uri(uri) + .body(()) + .unwrap() + } + + pub fn random(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/random") + .body(()) + .unwrap() + } + + pub fn recent(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/recent") + .body(()) + .unwrap() + } + + pub fn search(&self, query: &str) -> Request<()> { + let uri = format!("/api/search/{}", url_encode(query)); + Request::builder() + .method(Method::GET) + .uri(uri) + .body(()) + .unwrap() + } + + pub fn audio(&self, path: &Path) -> Request<()> { + let path = path.to_string_lossy(); + let uri = format!("/api/audio/{}", url_encode(path.as_ref())); + Request::builder() + .method(Method::GET) + .uri(uri) + .body(()) + .unwrap() + } + + pub fn thumbnail(&self, path: &Path, pad: Option) -> Request<()> { + let path = path.to_string_lossy(); + let mut uri = format!("/api/thumbnail/{}", url_encode(path.as_ref())); + match pad { + Some(true) => uri.push_str("?pad=true"), + Some(false) => uri.push_str("?pad=false"), + None => (), + }; + Request::builder() + .method(Method::GET) + .uri(uri) + .body(()) + .unwrap() + } + + pub fn playlists(&self) -> Request<()> { + Request::builder() + .method(Method::GET) + .uri("/api/playlists") + .body(()) + .unwrap() + } + + pub fn save_playlist( + &self, + name: &str, + playlist: dto::SavePlaylistInput, + ) -> Request { + let uri = format!("/api/playlist/{}", url_encode(name)); + Request::builder() + .method(Method::PUT) + .uri(uri) + .body(playlist) + .unwrap() + } + + pub fn read_playlist(&self, name: &str) -> Request<()> { + let uri = format!("/api/playlist/{}", url_encode(name)); + Request::builder() + .method(Method::GET) + .uri(uri) + .body(()) + .unwrap() + } + + pub fn delete_playlist(&self, name: &str) -> Request<()> { + let uri = format!("/api/playlist/{}", url_encode(name)); + Request::builder() + .method(Method::DELETE) + .uri(uri) + .body(()) + .unwrap() + } +} + +fn url_encode(input: &str) -> String { + percent_encode(input.as_bytes(), NON_ALPHANUMERIC).to_string() +} diff --git a/src/service/test/settings.rs b/src/service/test/settings.rs new file mode 100644 index 0000000..aaded6b --- /dev/null +++ b/src/service/test/settings.rs @@ -0,0 +1,90 @@ +use http::StatusCode; + +use crate::config; +use crate::service::test::{constants::*, ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_get_settings_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + + let request = service.request_builder().get_settings(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_get_settings_requires_admin() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + + let request = service.request_builder().get_settings(); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[test] +fn test_get_settings_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + + let request = service.request_builder().get_settings(); + let response = service.fetch_json::<_, config::Config>(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_put_settings_requires_auth() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + let request = service + .request_builder() + .put_settings(config::Config::default()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn test_put_settings_requires_admin() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login(); + let request = service + .request_builder() + .put_settings(config::Config::default()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[test] +fn test_put_settings_golden_path() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + + let request = service + .request_builder() + .put_settings(config::Config::default()); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::OK); +} + +#[test] +fn test_put_settings_cannot_unadmin_self() { + let mut service = ServiceType::new(&unique_db_name!()); + service.complete_initial_setup(); + service.login_admin(); + + let mut configuration = config::Config::default(); + configuration.users = Some(vec![config::ConfigUser { + name: TEST_USERNAME_ADMIN.into(), + password: "".into(), + admin: false, + }]); + let request = service.request_builder().put_settings(configuration); + let response = service.fetch(&request); + assert_eq!(response.status(), StatusCode::CONFLICT); +} diff --git a/src/service/test/swagger.rs b/src/service/test/swagger.rs new file mode 100644 index 0000000..ef23126 --- /dev/null +++ b/src/service/test/swagger.rs @@ -0,0 +1,12 @@ +use http::StatusCode; + +use crate::service::test::{ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_swagger_can_get_index() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().swagger_index(); + let response = service.fetch_bytes(&request); + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/src/service/test/web.rs b/src/service/test/web.rs new file mode 100644 index 0000000..e8ac9fe --- /dev/null +++ b/src/service/test/web.rs @@ -0,0 +1,9 @@ +use crate::service::test::{ServiceType, TestService}; +use crate::unique_db_name; + +#[test] +fn test_web_can_get_index() { + let mut service = ServiceType::new(&unique_db_name!()); + let request = service.request_builder().web_index(); + let _response = service.fetch_bytes(&request); +}