From 71b97a30c130d3d6332dada340f6d243390f6bc2 Mon Sep 17 00:00:00 2001 From: Pavel Kirilin <win10@list.ru> Date: Tue, 8 Feb 2022 14:38:43 +0400 Subject: [PATCH] Added tests. Signed-off-by: Pavel Kirilin <win10@list.ru> --- .github/workflows/lint.yml | 37 -- .github/workflows/test.yml | 70 +++ .gitignore | 4 +- Cargo.lock | 143 ++++-- Cargo.toml | 19 +- README.md | 8 +- src/config.rs | 53 +-- src/errors.rs | 2 + src/info_storages/file_info_storage.rs | 92 +++- .../models/available_info_storages.rs | 3 +- src/main.rs | 23 +- src/notifiers/dir_notifier.rs | 72 ++- src/notifiers/file_notifier.rs | 89 +++- src/notifiers/http_notifier.rs | 92 ++++ src/notifiers/mod.rs | 2 - src/notifiers/models/notification_manager.rs | 4 - src/protocol/core/get_info.rs | 263 +++++++++++ src/protocol/core/mod.rs | 24 +- src/protocol/core/routes.rs | 187 -------- src/protocol/core/server_info.rs | 56 +++ src/protocol/core/write_bytes.rs | 411 ++++++++++++++++++ src/protocol/creation/mod.rs | 8 +- src/protocol/creation/routes.rs | 313 ++++++++++++- src/protocol/getting/mod.rs | 7 +- src/protocol/getting/routes.rs | 71 ++- src/protocol/mod.rs | 9 +- src/protocol/termination/mod.rs | 7 +- src/protocol/termination/routes.rs | 69 ++- src/routes.rs | 1 + src/server.rs | 20 + src/state.rs | 56 +++ src/storages/file_storage.rs | 173 +++++++- src/storages/models/available_stores.rs | 6 +- src/utils/dir_struct.rs | 50 +++ src/utils/enums.rs | 29 ++ src/utils/headers.rs | 49 +++ src/utils/mod.rs | 1 + 37 files changed, 2102 insertions(+), 421 deletions(-) delete mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 src/protocol/core/get_info.rs delete mode 100644 src/protocol/core/routes.rs create mode 100644 src/protocol/core/server_info.rs create mode 100644 src/protocol/core/write_bytes.rs create mode 100644 src/server.rs create mode 100644 src/utils/dir_struct.rs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index d5d0e0b..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,37 +0,0 @@ -on: - - push - - pull_request - -name: Lint check -jobs: - pre_job: - # continue-on-error: true # Uncomment once integration is finished - runs-on: ubuntu-latest - # Map a step output to a job output - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@master - with: - # All of these options are optional, so you can remove them if you are happy with the defaults - concurrent_skipping: 'same_content' - skip_after_successful_duplicate: 'true' - paths_ignore: '["**/README.md"]' - - clippy_check: - needs: pre_job - if: ${{ needs.pre_job.outputs.should_skip != 'true' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - run: rustup component add clippy - - uses: actions-rs/clippy-check@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --all-features -p rustus -- -W clippy::all -W clippy::pedantic -D warnings - fmt_check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - run: cargo fmt -- --check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c8b52b1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,70 @@ +on: + - pull_request + +name: Lint check +jobs: + pre_job: + # continue-on-error: true # Uncomment once integration is finished + runs-on: ubuntu-latest + # Map a step output to a job output + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@master + with: + # All of these options are optional, so you can remove them if you are happy with the defaults + concurrent_skipping: 'same_content' + skip_after_successful_duplicate: 'true' + paths_ignore: '["**/README.md"]' + + fmt_check: + needs: pre_job + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Adding component + run: rustup component add rustfmt + - name: Checking code format + run: cargo fmt -- --check + + code_check: + needs: pre_job + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - run: rustup component add clippy + - uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-features -p rustus -- -W clippy::all -W clippy::pedantic -D warnings + + tests: + needs: pre_job + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install nightly toolchain + run: rustup toolchain install nightly --component llvm-tools-preview + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: Generate code coverage + run: cargo llvm-cov --features=all --lcov --output-path lcov.info + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: lcov.info \ No newline at end of file diff --git a/.gitignore b/.gitignore index 155c481..31bd9be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ /target -data/ \ No newline at end of file +data/ +tarpaulin-report.html +lcov.info \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 333bba1..101c5bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,7 +73,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.8.4", "sha-1 0.10.0", "smallvec", "zstd", @@ -391,23 +391,6 @@ dependencies = [ "event-listener", ] -[[package]] -name = "async-process" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83137067e3a2a6a06d67168e49e68a0957d215410473a740cea95a2425c0b7c6" -dependencies = [ - "async-io", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "libc", - "once_cell", - "signal-hook", - "winapi", -] - [[package]] name = "async-std" version = "1.10.0" @@ -609,6 +592,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", +] + [[package]] name = "bumpalo" version = "3.9.1" @@ -847,7 +841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03" dependencies = [ "generic-array 0.14.5", - "rand_core", + "rand_core 0.6.3", "subtle", ] @@ -1090,6 +1084,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "futures" version = "0.3.19" @@ -1380,6 +1380,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "httptest" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f25cfb6def593d43fae1ead24861f217e93bc70768a45cc149a69b5f049df4" +dependencies = [ + "bstr", + "bytes", + "crossbeam-channel", + "form_urlencoded", + "futures", + "http", + "hyper", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + [[package]] name = "hyper" version = "0.14.16" @@ -1795,7 +1817,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.4", "smallvec", "zeroize", ] @@ -2169,6 +2191,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + [[package]] name = "rand" version = "0.8.4" @@ -2177,7 +2212,7 @@ checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.3", "rand_hc", ] @@ -2188,9 +2223,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", ] +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + [[package]] name = "rand_core" version = "0.6.3" @@ -2206,7 +2256,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" dependencies = [ - "rand_core", + "rand_core 0.6.3", ] [[package]] @@ -2222,7 +2272,7 @@ dependencies = [ "hex", "log", "once_cell", - "rand", + "rand 0.8.4", "rbatis-core", "rbatis-macro-driver", "rbatis_sql", @@ -2309,13 +2359,22 @@ dependencies = [ "hex", "indexmap", "lazy_static", - "rand", + "rand 0.8.4", "serde", "serde_bytes", "serde_json", "uuid 0.8.2", ] +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "redis" version = "0.19.0" @@ -2366,6 +2425,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + [[package]] name = "regex-syntax" version = "0.6.25" @@ -2458,7 +2523,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand", + "rand 0.8.4", "subtle", "zeroize", ] @@ -2510,8 +2575,8 @@ name = "rustus" version = "0.4.1" dependencies = [ "actix-files", + "actix-rt", "actix-web", - "async-process", "async-std", "async-trait", "base64", @@ -2519,6 +2584,7 @@ dependencies = [ "derive_more", "fern", "futures", + "httptest", "lapin", "lazy_static", "log", @@ -2533,6 +2599,7 @@ dependencies = [ "strfmt", "structopt", "strum", + "tempdir", "thiserror", "tokio", "tokio-amqp", @@ -2739,16 +2806,6 @@ dependencies = [ "opaque-debug 0.3.0", ] -[[package]] -name = "signal-hook" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -2859,7 +2916,7 @@ dependencies = [ "once_cell", "parking_lot", "percent-encoding", - "rand", + "rand 0.8.4", "regex", "rsa", "rust_decimal", @@ -3061,6 +3118,16 @@ dependencies = [ "pem", ] +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand 0.4.6", + "remove_dir_all", +] + [[package]] name = "tempfile" version = "3.3.0" diff --git a/Cargo.toml b/Cargo.toml index 0dfcee7..579f2fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,15 @@ async-trait = "0.1.52" base64 = "^0.13.0" lazy_static = "1.4.0" log = "^0.4.14" -serde = "1" serde_json = "1" strfmt = "^0.1.6" thiserror = "^1.0" url = "2.2.2" +[dependencies.serde] +version = "1" +features = ["derive"] + [dependencies.openssl] version = "0.10.38" features = ["vendored"] @@ -71,10 +74,6 @@ version = "^3.0" optional = true version = "2.0" -[dependencies.async-process] -version = "1.3.0" -optional = true - [dependencies.reqwest] features = ["json"] optional = true @@ -88,7 +87,7 @@ features = ["derive"] version = "0.23" [dependencies.tokio] -features = ["time"] +features = ["time", "process"] version = "1.4.0" [dependencies.tokio-amqp] @@ -100,13 +99,17 @@ features = ["v4"] version = "^1.0.0-alpha.1" [features] -all = ["redis_info_storage", "db_info_storage", "http_notifier", "amqp_notifier", "file_notifiers"] +all = ["redis_info_storage", "db_info_storage", "http_notifier", "amqp_notifier"] amqp_notifier = ["lapin", "tokio-amqp", "mobc-lapin"] db_info_storage = ["rbatis", "rbson"] default = [] http_notifier = ["reqwest"] redis_info_storage = ["mobc-redis"] -file_notifiers = ["async-process"] + +[dev-dependencies] +tempdir = "0.3.7" +actix-rt = "2.6.0" +httptest = "0.15.4" [profile] [profile.release] diff --git a/README.md b/README.md index 7cb97ee..ea1bb82 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,8 @@ Example of a single file hook: # Hook name would be "pre-create", "post-create" and so on. HOOK_NAME="$1" -MEME="$(cat /dev/stdin | jq ".upload .metadata .meme" | xargs)" +HOOK_INFO="$2" +MEME="$(echo "$HOOK_INFO" | jq ".upload .metadata .meme" | xargs)" # Here we check if name in metadata is equal to pepe. if [[ $MEME = "pepe" ]]; then @@ -271,7 +272,10 @@ rustus --hooks-dir "hooks" In this case rustus will append a hook name to the directory you pointed at and call it as an executable. -Information about hook can be found in stdin. +Information about hook is passed as a first parameter, as if you call script by running: +```bash +./hooks/pre-create '{"id": "someid", ...}' +``` ### Http Hooks diff --git a/src/config.rs b/src/config.rs index 2adf14d..32e44d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,6 @@ -use std::collections::HashMap; -use std::env; +use std::ffi::OsString; use std::path::PathBuf; -use chrono::{Datelike, Timelike}; -use lazy_static::lazy_static; -use log::error; use structopt::StructOpt; use crate::info_storages::AvailableInfoStores; @@ -13,17 +9,6 @@ use crate::protocol::extensions::Extensions; use crate::storages::AvailableStores; -lazy_static! { - /// Freezing ENVS on startup. - static ref ENV_MAP: HashMap<String, String> = { - let mut m = HashMap::new(); - for (key, value) in env::vars() { - m.insert(format!("env[{}]", key), value); - } - m - }; -} - #[derive(StructOpt, Debug, Clone)] pub struct StorageOptions { /// Rustus storage type. @@ -124,11 +109,9 @@ pub struct NotificationsOptions { #[structopt(long, env = "RUSTUS_HOOKS_AMQP_EXCHANGE", default_value = "rustus")] pub hooks_amqp_exchange: String, - #[cfg(feature = "file_notifiers")] #[structopt(long, env = "RUSTUS_HOOKS_DIR")] pub hooks_dir: Option<PathBuf>, - #[cfg(feature = "file_notifiers")] #[structopt(long, env = "RUSTUS_HOOKS_FILE")] pub hooks_file: Option<String>, } @@ -200,6 +183,7 @@ pub struct RustusConf { pub notification_opts: NotificationsOptions, } +#[cfg_attr(coverage, no_coverage)] impl RustusConf { /// Function to parse CLI parametes. /// @@ -209,6 +193,14 @@ impl RustusConf { <RustusConf as StructOpt>::from_args() } + pub fn from_iter<I>(iter: I) -> RustusConf + where + I: IntoIterator, + I::Item: Into<OsString> + Clone, + { + <RustusConf as StructOpt>::from_iter(iter) + } + /// Base API url. pub fn base_url(&self) -> String { format!( @@ -219,14 +211,16 @@ impl RustusConf { ) } - /// URL for a particular file. - pub fn file_url(&self) -> String { + /// Helper for generating URI for test files. + #[cfg(test)] + pub fn file_url(&self, file_id: &str) -> String { let base_url = self.base_url(); format!( - "{}/{{file_id}}", + "{}/{}", base_url .strip_suffix('/') - .unwrap_or_else(|| base_url.as_str()) + .unwrap_or_else(|| base_url.as_str()), + file_id ) } @@ -235,21 +229,6 @@ impl RustusConf { self.notification_opts.hooks.contains(&hook) } - /// Generate directory name with user template. - pub fn dir_struct(&self) -> String { - let now = chrono::Utc::now(); - let mut vars: HashMap<String, String> = ENV_MAP.clone(); - vars.insert("day".into(), now.day().to_string()); - vars.insert("month".into(), now.month().to_string()); - vars.insert("year".into(), now.year().to_string()); - vars.insert("hour".into(), now.hour().to_string()); - vars.insert("minute".into(), now.minute().to_string()); - strfmt::strfmt(self.storage_opts.dir_structure.as_str(), &vars).unwrap_or_else(|err| { - error!("{}", err); - "".into() - }) - } - /// List of extensions. /// /// This function will parse list of extensions from CLI diff --git a/src/errors.rs b/src/errors.rs index 19be406..942fe39 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -61,6 +61,7 @@ pub enum RustusError { } /// This conversion allows us to use `RustusError` in the `main` function. +#[cfg_attr(coverage, no_coverage)] impl From<RustusError> for Error { fn from(err: RustusError) -> Self { Error::new(ErrorKind::Other, err) @@ -68,6 +69,7 @@ impl From<RustusError> for Error { } /// Trait to convert errors to http-responses. +#[cfg_attr(coverage, no_coverage)] impl ResponseError for RustusError { fn error_response(&self) -> HttpResponse { error!("{}", self); diff --git a/src/info_storages/file_info_storage.rs b/src/info_storages/file_info_storage.rs index 338ac68..65cd3ca 100644 --- a/src/info_storages/file_info_storage.rs +++ b/src/info_storages/file_info_storage.rs @@ -7,31 +7,27 @@ use log::error; use crate::errors::{RustusError, RustusResult}; use crate::info_storages::{FileInfo, InfoStorage}; -use crate::RustusConf; pub struct FileInfoStorage { - app_conf: RustusConf, + info_dir: PathBuf, } impl FileInfoStorage { - pub fn new(app_conf: RustusConf) -> Self { - Self { app_conf } + pub fn new(info_dir: PathBuf) -> Self { + Self { info_dir } } pub fn info_file_path(&self, file_id: &str) -> PathBuf { - self.app_conf - .info_storage_opts - .info_dir - .join(format!("{}.info", file_id)) + self.info_dir.join(format!("{}.info", file_id)) } } #[async_trait] impl InfoStorage for FileInfoStorage { async fn prepare(&mut self) -> RustusResult<()> { - if !self.app_conf.info_storage_opts.info_dir.exists() { + if !self.info_dir.exists() { DirBuilder::new() - .create(self.app_conf.info_storage_opts.info_dir.as_path()) + .create(self.info_dir.as_path()) .await .map_err(|err| RustusError::UnableToPrepareInfoStorage(err.to_string()))?; } @@ -42,6 +38,7 @@ impl InfoStorage for FileInfoStorage { let mut file = OpenOptions::new() .write(true) .create(create) + .truncate(true) .open(self.info_file_path(file_info.id.as_str()).as_path()) .await .map_err(|err| { @@ -86,3 +83,78 @@ impl InfoStorage for FileInfoStorage { }) } } + +#[cfg(test)] +mod tests { + use super::FileInfoStorage; + use crate::info_storages::FileInfo; + use crate::InfoStorage; + use std::collections::HashMap; + use std::fs::File; + use std::io::{Read, Write}; + + #[actix_rt::test] + async fn preparation() { + let dir = tempdir::TempDir::new("file_info").unwrap(); + let target_path = dir.into_path().join("not_exist"); + let mut storage = FileInfoStorage::new(target_path.clone()); + assert!(!target_path.exists()); + storage.prepare().await.unwrap(); + assert!(target_path.exists()); + } + + #[actix_rt::test] + async fn setting_info() { + let dir = tempdir::TempDir::new("file_info").unwrap(); + let storage = FileInfoStorage::new(dir.into_path()); + let file_info = FileInfo::new( + uuid::Uuid::new_v4().to_string().as_str(), + Some(10), + Some("random_path".into()), + "random_storage".into(), + None, + ); + storage.set_info(&file_info, true).await.unwrap(); + let info_path = storage.info_file_path(file_info.id.as_str()); + let mut buffer = String::new(); + File::open(info_path) + .unwrap() + .read_to_string(&mut buffer) + .unwrap(); + assert!(buffer.len() > 0); + } + + #[actix_rt::test] + async fn set_get_info() { + let dir = tempdir::TempDir::new("file_info").unwrap(); + let storage = FileInfoStorage::new(dir.into_path()); + let file_info = FileInfo::new( + uuid::Uuid::new_v4().to_string().as_str(), + Some(10), + Some("random_path".into()), + "random_storage".into(), + { + let mut a = HashMap::new(); + a.insert("test".into(), "pest".into()); + Some(a) + }, + ); + storage.set_info(&file_info, true).await.unwrap(); + let read_info = storage.get_info(file_info.id.as_str()).await.unwrap(); + assert_eq!(read_info.id, read_info.id); + assert_eq!(read_info.length, read_info.length); + assert_eq!(read_info.path, read_info.path); + assert_eq!(read_info.metadata, read_info.metadata); + } + + #[actix_rt::test] + async fn get_broken_info() { + let dir = tempdir::TempDir::new("file_info").unwrap(); + let storage = FileInfoStorage::new(dir.into_path()); + let file_id = "random_file"; + let mut file = File::create(storage.info_file_path(file_id)).unwrap(); + file.write_all("{not a json}".as_bytes()).unwrap(); + let read_info = storage.get_info(file_id).await; + assert!(read_info.is_err()); + } +} diff --git a/src/info_storages/models/available_info_storages.rs b/src/info_storages/models/available_info_storages.rs index 66a84cd..befef8d 100644 --- a/src/info_storages/models/available_info_storages.rs +++ b/src/info_storages/models/available_info_storages.rs @@ -32,13 +32,14 @@ impl AvailableInfoStores { /// # Params /// `config` - Rustus configuration. /// + #[cfg_attr(coverage, no_coverage)] pub async fn get( &self, config: &RustusConf, ) -> RustusResult<Box<dyn InfoStorage + Sync + Send>> { match self { Self::Files => Ok(Box::new(file_info_storage::FileInfoStorage::new( - config.clone(), + config.info_storage_opts.info_dir.clone(), ))), #[cfg(feature = "db_info_storage")] Self::DB => Ok(Box::new( diff --git a/src/main.rs b/src/main.rs index b05a51b..93aacc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +#![cfg_attr(coverage, feature(no_coverage))] use std::str::FromStr; use std::sync::Arc; @@ -10,12 +11,13 @@ use fern::colors::{Color, ColoredLevelConfig}; use fern::Dispatch; use log::LevelFilter; +use config::RustusConf; + use crate::errors::RustusResult; use crate::info_storages::InfoStorage; use crate::notifiers::models::notification_manager::NotificationManager; +use crate::server::rustus_service; use crate::state::State; -use config::RustusConf; - use crate::storages::Storage; mod config; @@ -24,10 +26,12 @@ mod info_storages; mod notifiers; mod protocol; mod routes; +mod server; mod state; mod storages; mod utils; +#[cfg_attr(coverage, no_coverage)] fn greeting(app_conf: &RustusConf) { let extensions = app_conf .extensions_vec() @@ -66,24 +70,15 @@ fn greeting(app_conf: &RustusConf) { /// This function may throw an error /// if the server can't be bound to the /// given address. +#[cfg_attr(coverage, no_coverage)] pub fn create_server(state: State) -> Result<Server, std::io::Error> { let host = state.config.host.clone(); let port = state.config.port; - let config = state.config.clone(); let workers = state.config.workers; let state_data: web::Data<State> = web::Data::from(Arc::new(state)); let mut server = HttpServer::new(move || { App::new() - .app_data(state_data.clone()) - // Adds all routes. - .configure(protocol::setup(config.clone())) - // Main middleware that appends TUS headers. - .wrap( - middleware::DefaultHeaders::new() - .add(("Tus-Resumable", "1.0.0")) - .add(("Tus-Max-Size", config.max_body_size.to_string())) - .add(("Tus-Version", "1.0.0")), - ) + .configure(rustus_service(state_data.clone())) .wrap(middleware::Logger::new("\"%r\" \"-\" \"%s\" \"%a\" \"%D\"")) // Middleware that overrides method of a request if // "X-HTTP-Method-Override" header is provided. @@ -111,6 +106,7 @@ pub fn create_server(state: State) -> Result<Server, std::io::Error> { Ok(server.run()) } +#[cfg_attr(coverage, no_coverage)] fn setup_logging(app_config: &RustusConf) -> RustusResult<()> { let colors = ColoredLevelConfig::new() // use builder methods @@ -137,6 +133,7 @@ fn setup_logging(app_config: &RustusConf) -> RustusResult<()> { } /// Main program entrypoint. +#[cfg_attr(coverage, no_coverage)] #[actix_web::main] async fn main() -> std::io::Result<()> { let app_conf = RustusConf::from_args(); diff --git a/src/notifiers/dir_notifier.rs b/src/notifiers/dir_notifier.rs index 670d1e1..008d79c 100644 --- a/src/notifiers/dir_notifier.rs +++ b/src/notifiers/dir_notifier.rs @@ -2,11 +2,10 @@ use crate::errors::RustusError; use crate::notifiers::{Hook, Notifier}; use crate::RustusResult; use actix_web::http::header::HeaderMap; -use async_process::{Command, Stdio}; use async_trait::async_trait; -use futures::AsyncWriteExt; use log::debug; use std::path::PathBuf; +use tokio::process::Command; pub struct DirNotifier { pub dir: PathBuf, @@ -31,18 +30,71 @@ impl Notifier for DirNotifier { _headers_map: &HeaderMap, ) -> RustusResult<()> { let hook_path = self.dir.join(hook.to_string()); + if !hook_path.exists() { + debug!("Hook {} not found.", hook.to_string()); + return Err(RustusError::HookError(format!( + "Hook file {} not found.", + hook + ))); + } debug!("Running hook: {}", hook_path.as_path().display()); - let mut command = Command::new(hook_path).stdin(Stdio::piped()).spawn()?; - command - .stdin - .as_mut() - .unwrap() - .write_all(message.as_bytes()) - .await?; - let stat = command.status().await?; + let mut command = Command::new(hook_path).arg(message).spawn()?; + let stat = command.wait().await?; if !stat.success() { return Err(RustusError::HookError("Returned wrong status code".into())); } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::DirNotifier; + use crate::notifiers::{Hook, Notifier}; + use actix_web::http::header::HeaderMap; + use std::fs::File; + use std::io::{Read, Write}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tempdir::TempDir; + + #[actix_rt::test] + async fn no_such_hook_file() { + let hook_dir = TempDir::new("dir_notifier").unwrap().into_path(); + let notifier = DirNotifier::new(hook_dir); + let res = notifier + .send_message("test".into(), Hook::PostCreate, &HeaderMap::new()) + .await; + assert!(res.is_err()); + } + + #[cfg(unix)] + #[actix_rt::test] + async fn success() { + let hook = Hook::PostCreate; + let dir = tempdir::TempDir::new("dir_notifier").unwrap().into_path(); + let hook_path = dir.join(hook.to_string()); + { + let mut file = File::create(hook_path.clone()).unwrap(); + let mut permissions = file.metadata().unwrap().permissions(); + permissions.set_mode(0o755); + file.set_permissions(permissions).unwrap(); + let script = r#"#!/bin/sh + echo "$1" > "$(dirname $0)/output""#; + file.write_all(script.as_bytes()).unwrap(); + file.sync_all().unwrap(); + } + let notifier = DirNotifier::new(dir.to_path_buf()); + let test_message = uuid::Uuid::new_v4().to_string(); + notifier + .send_message(test_message.clone(), hook.clone(), &HeaderMap::new()) + .await + .unwrap(); + let output_path = dir.join("output"); + assert!(output_path.exists()); + let mut buffer = String::new(); + let mut out_file = File::open(output_path).unwrap(); + out_file.read_to_string(&mut buffer).unwrap(); + assert_eq!(buffer, format!("{}\n", test_message)); + } +} diff --git a/src/notifiers/file_notifier.rs b/src/notifiers/file_notifier.rs index a502592..5ac7e31 100644 --- a/src/notifiers/file_notifier.rs +++ b/src/notifiers/file_notifier.rs @@ -2,10 +2,9 @@ use crate::errors::RustusError; use crate::notifiers::{Hook, Notifier}; use crate::RustusResult; use actix_web::http::header::HeaderMap; -use async_process::{Command, Stdio}; use async_trait::async_trait; -use futures::AsyncWriteExt; use log::debug; +use tokio::process::Command; pub struct FileNotifier { pub command: String, @@ -19,6 +18,7 @@ impl FileNotifier { #[async_trait] impl Notifier for FileNotifier { + #[cfg_attr(coverage, no_coverage)] async fn prepare(&mut self) -> RustusResult<()> { Ok(()) } @@ -32,18 +32,87 @@ impl Notifier for FileNotifier { debug!("Running command: {}", self.command.as_str()); let mut command = Command::new(self.command.as_str()) .arg(hook.to_string()) - .stdin(Stdio::piped()) + .arg(message) .spawn()?; - command - .stdin - .as_mut() - .unwrap() - .write_all(message.as_bytes()) - .await?; - let stat = command.status().await?; + let stat = command.wait().await?; if !stat.success() { return Err(RustusError::HookError("Returned wrong status code".into())); } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::FileNotifier; + use crate::notifiers::{Hook, Notifier}; + use actix_web::http::header::HeaderMap; + use std::fs::File; + use std::io::{Read, Write}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[cfg(unix)] + #[actix_rt::test] + async fn success() { + let dir = tempdir::TempDir::new("file_notifier").unwrap().into_path(); + let hook_path = dir.join("executable.sh"); + { + let mut file = File::create(hook_path.clone()).unwrap(); + let mut permissions = file.metadata().unwrap().permissions(); + permissions.set_mode(0o755); + file.set_permissions(permissions).unwrap(); + let script = r#"#!/bin/sh + HOOK_NAME="$1"; + MESSAGE="$2"; + echo "$HOOK_NAME $MESSAGE" > "$(dirname $0)/output""#; + file.write_all(script.as_bytes()).unwrap(); + file.sync_all().unwrap(); + } + let notifier = FileNotifier::new(hook_path.display().to_string()); + let hook = Hook::PostCreate; + let test_message = uuid::Uuid::new_v4().to_string(); + notifier + .send_message(test_message.clone(), hook.clone(), &HeaderMap::new()) + .await + .unwrap(); + let output_path = dir.join("output"); + assert!(output_path.exists()); + let mut buffer = String::new(); + let mut out_file = File::open(output_path).unwrap(); + out_file.read_to_string(&mut buffer).unwrap(); + assert_eq!(buffer, format!("{} {}\n", hook.to_string(), test_message)); + } + + #[cfg(unix)] + #[actix_rt::test] + async fn error_status() { + let dir = tempdir::TempDir::new("file_notifier").unwrap().into_path(); + let hook_path = dir.join("error_executable.sh"); + { + let mut file = File::create(hook_path.clone()).unwrap(); + let mut permissions = file.metadata().unwrap().permissions(); + permissions.set_mode(0o755); + file.set_permissions(permissions).unwrap(); + let script = r#"#!/bin/sh + read -t 0.1 MESSAGE + exit 1"#; + file.write_all(script.as_bytes()).unwrap(); + file.sync_all().unwrap(); + } + let notifier = FileNotifier::new(hook_path.display().to_string()); + let res = notifier + .send_message("test".into(), Hook::PostCreate, &HeaderMap::new()) + .await; + assert!(res.is_err()); + } + + #[actix_rt::test] + async fn no_such_file() { + let notifier = FileNotifier::new(format!("/{}.sh", uuid::Uuid::new_v4())); + let res = notifier + .send_message("test".into(), Hook::PreCreate, &HeaderMap::new()) + .await; + assert!(res.is_err()); + } +} diff --git a/src/notifiers/http_notifier.rs b/src/notifiers/http_notifier.rs index d8ea9ac..2e71004 100644 --- a/src/notifiers/http_notifier.rs +++ b/src/notifiers/http_notifier.rs @@ -28,6 +28,7 @@ impl HttpNotifier { #[async_trait] impl Notifier for HttpNotifier { + #[cfg_attr(coverage, no_coverage)] async fn prepare(&mut self) -> RustusResult<()> { Ok(()) } @@ -62,3 +63,94 @@ impl Notifier for HttpNotifier { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::HttpNotifier; + use crate::notifiers::{Hook, Notifier}; + use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue}; + use httptest::matchers::contains; + use httptest::responders::status_code; + use std::str::FromStr; + use std::time::Duration; + + #[actix_rt::test] + async fn success_request() { + let server = httptest::Server::run(); + server.expect( + httptest::Expectation::matching(httptest::matchers::request::method_path( + "POST", "/hook", + )) + .respond_with(httptest::responders::status_code(200)), + ); + let hook_url = server.url_str("/hook"); + + let notifier = HttpNotifier::new(vec![hook_url], vec![]); + notifier + .send_message("test_message".into(), Hook::PostCreate, &HeaderMap::new()) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn timeout_request() { + let server = httptest::Server::run(); + server.expect( + httptest::Expectation::matching(httptest::matchers::request::method_path( + "POST", "/hook", + )) + .respond_with(httptest::responders::delay_and_then( + Duration::from_secs(3), + status_code(200), + )), + ); + let hook_url = server.url_str("/hook"); + + let notifier = HttpNotifier::new(vec![hook_url], vec![]); + let result = notifier + .send_message("test_message".into(), Hook::PostCreate, &HeaderMap::new()) + .await; + assert!(result.is_err()); + } + + #[actix_rt::test] + async fn unknown_url() { + let server = httptest::Server::run(); + server.expect( + httptest::Expectation::matching(httptest::matchers::request::method_path( + "POST", "/hook", + )) + .respond_with(httptest::responders::status_code(404)), + ); + let hook_url = server.url_str("/hook"); + + let notifier = HttpNotifier::new(vec![hook_url], vec![]); + let result = notifier + .send_message("test_message".into(), Hook::PostCreate, &HeaderMap::new()) + .await; + assert!(result.is_err()); + } + + #[actix_rt::test] + async fn forwarded_header() { + let server = httptest::Server::run(); + server.expect( + httptest::Expectation::matching(httptest::matchers::all_of![ + httptest::matchers::request::method_path("POST", "/hook",), + httptest::matchers::request::headers(contains(("x-test-header", "meme-value"))) + ]) + .respond_with(httptest::responders::status_code(200)), + ); + let hook_url = server.url_str("/hook"); + let notifier = HttpNotifier::new(vec![hook_url], vec!["X-TEST-HEADER".into()]); + let mut header_map = HeaderMap::new(); + header_map.insert( + HeaderName::from_str("X-TEST-HEADER").unwrap(), + HeaderValue::from_str("meme-value").unwrap(), + ); + notifier + .send_message("test_message".into(), Hook::PostCreate, &header_map) + .await + .unwrap(); + } +} diff --git a/src/notifiers/mod.rs b/src/notifiers/mod.rs index 86ac9c6..0359510 100644 --- a/src/notifiers/mod.rs +++ b/src/notifiers/mod.rs @@ -1,8 +1,6 @@ #[cfg(feature = "amqp_notifier")] pub mod amqp_notifier; -#[cfg(feature = "file_notifiers")] pub mod dir_notifier; -#[cfg(feature = "file_notifiers")] mod file_notifier; #[cfg(feature = "http_notifier")] pub mod http_notifier; diff --git a/src/notifiers/models/notification_manager.rs b/src/notifiers/models/notification_manager.rs index 84509da..b01cc72 100644 --- a/src/notifiers/models/notification_manager.rs +++ b/src/notifiers/models/notification_manager.rs @@ -1,9 +1,7 @@ use crate::errors::RustusResult; #[cfg(feature = "amqp_notifier")] use crate::notifiers::amqp_notifier; -#[cfg(feature = "file_notifiers")] use crate::notifiers::dir_notifier::DirNotifier; -#[cfg(feature = "file_notifiers")] use crate::notifiers::file_notifier::FileNotifier; #[cfg(feature = "http_notifier")] use crate::notifiers::http_notifier; @@ -22,14 +20,12 @@ impl NotificationManager { notifiers: Vec::new(), }; debug!("Initializing notification manager."); - #[cfg(feature = "file_notifiers")] if tus_config.notification_opts.hooks_file.is_some() { debug!("Found hooks file"); manager.notifiers.push(Box::new(FileNotifier::new( tus_config.notification_opts.hooks_file.clone().unwrap(), ))); } - #[cfg(feature = "file_notifiers")] if tus_config.notification_opts.hooks_dir.is_some() { debug!("Found hooks directory"); manager.notifiers.push(Box::new(DirNotifier::new( diff --git a/src/protocol/core/get_info.rs b/src/protocol/core/get_info.rs new file mode 100644 index 0000000..bc8cc8a --- /dev/null +++ b/src/protocol/core/get_info.rs @@ -0,0 +1,263 @@ +use actix_web::{web, HttpRequest, HttpResponse}; + +use crate::errors::RustusError; + +use crate::{RustusResult, State}; + +pub async fn get_file_info( + state: web::Data<State>, + request: HttpRequest, +) -> RustusResult<HttpResponse> { + // Getting file id from URL. + if request.match_info().get("file_id").is_none() { + return Err(RustusError::FileNotFound); + } + let file_id = request.match_info().get("file_id").unwrap(); + + // Getting file info from info_storage. + let file_info = state.info_storage.get_info(file_id).await?; + if file_info.storage != state.data_storage.to_string() { + return Err(RustusError::FileNotFound); + } + let mut builder = HttpResponse::Ok(); + if file_info.is_partial { + builder.insert_header(("Upload-Concat", "partial")); + } + if file_info.is_final && file_info.parts.is_some() { + #[allow(clippy::or_fun_call)] + let parts = file_info + .parts + .clone() + .unwrap() + .iter() + .map(|file| { + format!( + "{}/{}", + state + .config + .base_url() + .strip_suffix('/') + .unwrap_or(state.config.base_url().as_str()), + file.as_str() + ) + }) + .collect::<Vec<String>>() + .join(" "); + builder.insert_header(("Upload-Concat", format!("final; {}", parts))); + } + builder + .no_chunking(file_info.offset as u64) + .insert_header(("Upload-Offset", file_info.offset.to_string())) + .insert_header(("Content-Length", file_info.offset.to_string())); + // Upload length is known. + if let Some(upload_len) = file_info.length { + builder.insert_header(("Upload-Length", upload_len.to_string())); + } else { + builder.insert_header(("Upload-Defer-Length", "1")); + } + if let Some(meta) = file_info.get_metadata_string() { + builder.insert_header(("Upload-Metadata", meta)); + } + Ok(builder.finish()) +} + +#[cfg(test)] +mod tests { + use actix_web::http::{Method, StatusCode}; + + use crate::{rustus_service, State}; + use actix_web::test::{call_service, init_service, TestRequest}; + use actix_web::{web, App}; + + #[actix_rt::test] + async fn success() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.offset = 100; + file_info.length = Some(100); + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::with_uri(state.config.file_url(file_info.id.as_str()).as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + let offset = response + .headers() + .get("Upload-Offset") + .unwrap() + .to_str() + .unwrap() + .parse::<usize>() + .unwrap(); + assert_eq!(file_info.offset, offset) + } + + #[actix_rt::test] + async fn success_metadata() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.offset = 100; + file_info.length = Some(100); + file_info.metadata.insert("test".into(), "value".into()); + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::with_uri(state.config.file_url(file_info.id.as_str()).as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + let metadata = response + .headers() + .get("Upload-Metadata") + .unwrap() + .to_str() + .unwrap(); + assert_eq!( + String::from(metadata), + format!("{} {}", "test", base64::encode("value")) + ) + } + + #[actix_rt::test] + async fn success_defer_len() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.deferred_size = true; + file_info.length = None; + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::with_uri(state.config.file_url(file_info.id.as_str()).as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!( + response + .headers() + .get("Upload-Defer-Length") + .unwrap() + .to_str() + .unwrap(), + "1" + ); + } + + #[actix_rt::test] + async fn test_get_file_info_partial() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.is_partial = true; + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::with_uri(state.config.file_url(file_info.id.as_str()).as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!( + response + .headers() + .get("Upload-Concat") + .unwrap() + .to_str() + .unwrap(), + "partial" + ); + } + + #[actix_rt::test] + async fn success_final() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.is_partial = false; + file_info.is_final = true; + file_info.parts = Some(vec!["test1".into(), "test2".into()]); + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::with_uri(state.config.file_url(file_info.id.as_str()).as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!( + response + .headers() + .get("Upload-Concat") + .unwrap() + .to_str() + .unwrap(), + format!( + "final; {} {}", + state.config.file_url("test1"), + state.config.file_url("test2") + ) + .as_str() + ); + } + + #[actix_rt::test] + async fn no_file() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::with_uri(state.config.file_url("unknknown").as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn test_get_file_info_wrong_storage() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.storage = String::from("unknown"); + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::with_uri(state.config.file_url(file_info.id.as_str()).as_str()) + .method(Method::HEAD) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/protocol/core/mod.rs b/src/protocol/core/mod.rs index df00d5c..dc29f98 100644 --- a/src/protocol/core/mod.rs +++ b/src/protocol/core/mod.rs @@ -1,9 +1,8 @@ -use actix_web::web::PayloadConfig; use actix_web::{guard, middleware, web}; -use crate::RustusConf; - -mod routes; +mod get_info; +mod server_info; +mod write_bytes; /// Add core TUS protocol endpoints. /// @@ -13,34 +12,33 @@ mod routes; /// OPTIONS /api - to get info about the app. /// HEAD /api/file - to get info about the file. /// PATCH /api/file - to add bytes to file. -pub fn add_extension(web_app: &mut web::ServiceConfig, app_conf: &RustusConf) { +#[cfg_attr(coverage, no_coverage)] +pub fn add_extension(web_app: &mut web::ServiceConfig) { web_app .service( // PATCH /base/{file_id} // Main URL for uploading files. - web::resource(app_conf.base_url().as_str()) + web::resource("") .name("core:server_info") .guard(guard::Options()) - .to(routes::server_info), + .to(server_info::server_info), ) .service( // PATCH /base/{file_id} // Main URL for uploading files. - web::resource(app_conf.file_url().as_str()) - // 10 MB chunks - .app_data(PayloadConfig::new(app_conf.max_body_size)) + web::resource("{file_id}") .name("core:write_bytes") .guard(guard::Patch()) - .to(routes::write_bytes), + .to(write_bytes::write_bytes), ) .service( // HEAD /base/{file_id} // Main URL for getting info about files. - web::resource(app_conf.file_url().as_str()) + web::resource("{file_id}") .name("core:file_info") .guard(guard::Head()) // Header to prevent the client and/or proxies from caching the response. .wrap(middleware::DefaultHeaders::new().add(("Cache-Control", "no-store"))) - .to(routes::get_file_info), + .to(get_info::get_file_info), ); } diff --git a/src/protocol/core/routes.rs b/src/protocol/core/routes.rs deleted file mode 100644 index d910f67..0000000 --- a/src/protocol/core/routes.rs +++ /dev/null @@ -1,187 +0,0 @@ -use actix_web::{web, web::Bytes, HttpRequest, HttpResponse}; - -use crate::errors::RustusError; -use crate::notifiers::Hook; -use crate::protocol::extensions::Extensions; -use crate::utils::headers::{check_header, parse_header}; -use crate::{RustusConf, State}; - -#[allow(clippy::needless_pass_by_value)] -pub fn server_info(app_conf: web::Data<RustusConf>) -> HttpResponse { - let ext_str = app_conf - .extensions_vec() - .into_iter() - .map(|x| x.to_string()) - .collect::<Vec<String>>() - .join(","); - HttpResponse::Ok() - .insert_header(("Tus-Extension", ext_str.as_str())) - .finish() -} - -pub async fn get_file_info( - state: web::Data<State>, - request: HttpRequest, -) -> actix_web::Result<HttpResponse> { - // Getting file id from URL. - if request.match_info().get("file_id").is_none() { - return Ok(HttpResponse::NotFound().body("No file id provided.")); - } - let file_id = request.match_info().get("file_id").unwrap(); - - // Getting file info from info_storage. - let file_info = state.info_storage.get_info(file_id).await?; - if file_info.storage != state.data_storage.to_string() { - return Ok(HttpResponse::NotFound().body("File not found.")); - } - let mut builder = HttpResponse::Ok(); - if file_info.is_partial { - builder.insert_header(("Upload-Concat", "partial")); - } - if file_info.is_final && file_info.parts.is_some() { - #[allow(clippy::or_fun_call)] - let parts = file_info - .parts - .clone() - .unwrap() - .iter() - .map(|file| { - format!( - "{}/{}", - state - .config - .base_url() - .strip_suffix('/') - .unwrap_or(state.config.base_url().as_str()), - file.as_str() - ) - }) - .collect::<Vec<String>>() - .join(" "); - builder.insert_header(("Upload-Concat", format!("final; {}", parts))); - } - builder - .no_chunking(file_info.offset as u64) - .insert_header(("Upload-Offset", file_info.offset.to_string())) - .insert_header(("Content-Length", file_info.offset.to_string())); - // Upload length is known. - if let Some(upload_len) = file_info.length { - builder.insert_header(("Upload-Length", upload_len.to_string())); - } else { - builder.insert_header(("Upload-Defer-Length", "1")); - } - if let Some(meta) = file_info.get_metadata_string() { - builder.insert_header(("Upload-Metadata", meta)); - } - Ok(builder.finish()) -} - -pub async fn write_bytes( - request: HttpRequest, - bytes: Bytes, - state: web::Data<State>, -) -> actix_web::Result<HttpResponse> { - // Checking if request has required headers. - let check_content_type = |val: &str| val == "application/offset+octet-stream"; - if !check_header(&request, "Content-Type", check_content_type) { - return Ok(HttpResponse::UnsupportedMediaType().body("Unknown content-type.")); - } - // Getting current offset. - let offset: Option<usize> = parse_header(&request, "Upload-Offset"); - - if offset.is_none() { - return Ok(HttpResponse::UnsupportedMediaType().body("No offset provided.")); - } - - if request.match_info().get("file_id").is_none() { - return Ok(HttpResponse::NotFound().body("No file id provided.")); - } - - // New upload length. - // Parses header `Upload-Length` only if the creation-defer-length extension is enabled. - let updated_len = if state - .config - .extensions_vec() - .contains(&Extensions::CreationDeferLength) - { - parse_header(&request, "Upload-Length") - } else { - None - }; - - let file_id = request.match_info().get("file_id").unwrap(); - // Getting file info. - let mut file_info = state.info_storage.get_info(file_id).await?; - - // According to TUS protocol you can't update final uploads. - if file_info.is_final { - return Ok(HttpResponse::Forbidden().finish()); - } - - // Checking if file was stored in the same storage. - if file_info.storage != state.data_storage.to_string() { - return Ok(HttpResponse::NotFound().finish()); - } - // Checking if offset from request is the same as the real offset. - if offset.unwrap() != file_info.offset { - return Ok(HttpResponse::Conflict().finish()); - } - - // If someone want to update file length. - // This required by Upload-Defer-Length extension. - if let Some(new_len) = updated_len { - // Whoop, someone gave us total file length - // less that he had already uploaded. - if new_len < file_info.offset { - return Err(RustusError::WrongOffset.into()); - } - // We already know the exact size of a file. - // Someone want to update it. - // Anyway, it's not allowed, heh. - if file_info.length.is_some() { - return Err(RustusError::SizeAlreadyKnown.into()); - } - - // All checks are ok. Now our file will have exact size. - file_info.deferred_size = false; - file_info.length = Some(new_len); - } - - // Checking if the size of the upload is already equals - // to calculated offset. It means that all bytes were already written. - if Some(file_info.offset) == file_info.length { - return Err(RustusError::FrozenFile.into()); - } - - // Appending bytes to file. - state - .data_storage - .add_bytes(&file_info, bytes.as_ref()) - .await?; - // Updating offset. - file_info.offset += bytes.len(); - // Saving info to info storage. - state.info_storage.set_info(&file_info, false).await?; - - let mut hook = Hook::PostReceive; - if file_info.length == Some(file_info.offset) { - hook = Hook::PostFinish; - } - if state.config.hook_is_active(hook) { - let message = state - .config - .notification_opts - .hooks_format - .format(&request, &file_info)?; - let headers = request.headers().clone(); - tokio::spawn(async move { - state - .notification_manager - .send_message(message, hook, &headers) - .await - }); - } - Ok(HttpResponse::NoContent() - .insert_header(("Upload-Offset", file_info.offset.to_string())) - .finish()) -} diff --git a/src/protocol/core/server_info.rs b/src/protocol/core/server_info.rs new file mode 100644 index 0000000..f5217cd --- /dev/null +++ b/src/protocol/core/server_info.rs @@ -0,0 +1,56 @@ +use actix_web::{web, HttpResponse}; + +use crate::State; + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::unused_async)] +pub async fn server_info(state: web::Data<State>) -> HttpResponse { + let ext_str = state + .config + .extensions_vec() + .into_iter() + .map(|x| x.to_string()) + .collect::<Vec<String>>() + .join(","); + HttpResponse::Ok() + .insert_header(("Tus-Extension", ext_str.as_str())) + .finish() +} + +#[cfg(test)] +mod tests { + use crate::protocol::extensions::Extensions; + use crate::{rustus_service, State}; + use actix_web::test::{call_service, init_service, TestRequest}; + + use actix_web::http::Method; + use actix_web::{web, App}; + + #[actix_rt::test] + async fn test_server_info() { + let mut state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + state.config.tus_extensions = vec![ + Extensions::Creation, + Extensions::Concatenation, + Extensions::Termination, + ]; + let request = TestRequest::with_uri(state.config.base_url().as_str()) + .method(Method::OPTIONS) + .to_request(); + let response = call_service(&mut rustus, request).await; + let extensions = response + .headers() + .get("Tus-Extension") + .unwrap() + .to_str() + .unwrap() + .clone(); + assert!(extensions.contains(Extensions::Creation.to_string().as_str())); + assert!(extensions.contains(Extensions::Concatenation.to_string().as_str())); + assert!(extensions.contains(Extensions::Termination.to_string().as_str())); + } +} diff --git a/src/protocol/core/write_bytes.rs b/src/protocol/core/write_bytes.rs new file mode 100644 index 0000000..322b219 --- /dev/null +++ b/src/protocol/core/write_bytes.rs @@ -0,0 +1,411 @@ +use actix_web::{web, web::Bytes, HttpRequest, HttpResponse}; + +use crate::errors::RustusError; +use crate::notifiers::Hook; +use crate::protocol::extensions::Extensions; +use crate::utils::headers::{check_header, parse_header}; +use crate::{RustusResult, State}; + +pub async fn write_bytes( + request: HttpRequest, + bytes: Bytes, + state: web::Data<State>, +) -> RustusResult<HttpResponse> { + // Checking if request has required headers. + let check_content_type = |val: &str| val == "application/offset+octet-stream"; + if !check_header(&request, "Content-Type", check_content_type) { + return Ok(HttpResponse::UnsupportedMediaType().body("Unknown content-type.")); + } + // Getting current offset. + let offset: Option<usize> = parse_header(&request, "Upload-Offset"); + + if offset.is_none() { + return Ok(HttpResponse::UnsupportedMediaType().body("No offset provided.")); + } + + if request.match_info().get("file_id").is_none() { + return Err(RustusError::FileNotFound); + } + + // New upload length. + // Parses header `Upload-Length` only if the creation-defer-length extension is enabled. + let updated_len = if state + .config + .extensions_vec() + .contains(&Extensions::CreationDeferLength) + { + parse_header(&request, "Upload-Length") + } else { + None + }; + + let file_id = request.match_info().get("file_id").unwrap(); + // Getting file info. + let mut file_info = state.info_storage.get_info(file_id).await?; + + // According to TUS protocol you can't update final uploads. + if file_info.is_final { + return Ok(HttpResponse::Forbidden().finish()); + } + + // Checking if file was stored in the same storage. + if file_info.storage != state.data_storage.to_string() { + return Err(RustusError::FileNotFound); + } + // Checking if offset from request is the same as the real offset. + if offset.unwrap() != file_info.offset { + return Ok(HttpResponse::Conflict().finish()); + } + + // If someone want to update file length. + // This required by Upload-Defer-Length extension. + if let Some(new_len) = updated_len { + // Whoop, someone gave us total file length + // less that he had already uploaded. + if new_len < file_info.offset { + return Err(RustusError::WrongOffset); + } + // We already know the exact size of a file. + // Someone want to update it. + // Anyway, it's not allowed, heh. + if file_info.length.is_some() { + return Err(RustusError::SizeAlreadyKnown); + } + + // All checks are ok. Now our file will have exact size. + file_info.deferred_size = false; + file_info.length = Some(new_len); + } + + // Checking if the size of the upload is already equals + // to calculated offset. It means that all bytes were already written. + if Some(file_info.offset) == file_info.length { + return Err(RustusError::FrozenFile); + } + + // Appending bytes to file. + state + .data_storage + .add_bytes(&file_info, bytes.as_ref()) + .await?; + // Updating offset. + file_info.offset += bytes.len(); + // Saving info to info storage. + state.info_storage.set_info(&file_info, false).await?; + + let mut hook = Hook::PostReceive; + if file_info.length == Some(file_info.offset) { + hook = Hook::PostFinish; + } + if state.config.hook_is_active(hook) { + let message = state + .config + .notification_opts + .hooks_format + .format(&request, &file_info)?; + let headers = request.headers().clone(); + tokio::spawn(async move { + state + .notification_manager + .send_message(message, hook, &headers) + .await + }); + } + Ok(HttpResponse::NoContent() + .insert_header(("Upload-Offset", file_info.offset.to_string())) + .finish()) +} + +#[cfg(test)] +mod tests { + use crate::{rustus_service, State}; + use actix_web::http::StatusCode; + use actix_web::test::{call_service, init_service, TestRequest}; + use actix_web::{web, App}; + + #[actix_rt::test] + /// Success test for writing bytes. + /// + /// This test creates file and writes bytes to it. + async fn success() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = Some(100); + file.offset = 0; + state.info_storage.set_info(&file, false).await.unwrap(); + let test_data = "memes"; + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .insert_header(("Upload-Offset", file.offset)) + .set_payload(test_data) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert_eq!( + resp.headers() + .get("Upload-Offset") + .unwrap() + .to_str() + .unwrap(), + test_data.len().to_string().as_str() + ); + let new_info = state + .info_storage + .get_info(file.id.clone().as_str()) + .await + .unwrap(); + assert_eq!(new_info.offset, test_data.len()); + } + + #[actix_rt::test] + /// Testing defer-length extension. + /// + /// During this test we'll try to update + /// file's length while writing bytes to it. + async fn success_update_file_length() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = None; + file.deferred_size = true; + file.offset = 0; + state.info_storage.set_info(&file, false).await.unwrap(); + let test_data = "memes"; + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .param("file_id", file.id.clone()) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .insert_header(("Upload-Offset", file.offset)) + .insert_header(("Upload-Length", "20")) + .set_payload(test_data) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + assert_eq!( + resp.headers() + .get("Upload-Offset") + .unwrap() + .to_str() + .unwrap(), + test_data.len().to_string().as_str() + ); + let new_info = state + .info_storage + .get_info(file.id.clone().as_str()) + .await + .unwrap(); + assert_eq!(new_info.offset, test_data.len()); + assert_eq!(new_info.deferred_size, false); + assert_eq!(new_info.length, Some(20)); + } + + #[actix_rt::test] + /// Tests that if new file length + /// is less than current offset, error is thrown. + async fn new_file_length_lt_offset() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = None; + file.deferred_size = true; + file.offset = 30; + state.info_storage.set_info(&file, false).await.unwrap(); + let test_data = "memes"; + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .insert_header(("Upload-Offset", file.offset)) + .insert_header(("Upload-Length", "20")) + .set_payload(test_data) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + } + + #[actix_rt::test] + /// Tests if user tries to update + /// file length with known length, + /// error is thrown. + async fn new_file_length_size_already_known() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = Some(100); + file.deferred_size = false; + file.offset = 0; + state.info_storage.set_info(&file, false).await.unwrap(); + let test_data = "memes"; + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .insert_header(("Upload-Offset", file.offset)) + .insert_header(("Upload-Length", "120")) + .set_payload(test_data) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + /// Checks that if Content-Type header missing, + /// wrong status code is returned. + async fn no_content_header() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = Some(100); + file.offset = 0; + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Upload-Offset", "0")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + #[actix_rt::test] + /// Tests that method will return error if no offset header specified. + async fn no_offset_header() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = Some(100); + file.offset = 0; + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + #[actix_rt::test] + /// Tests that method will return error if wrong offset is passed. + async fn wrong_offset_header() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.length = Some(100); + file.offset = 0; + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Upload-Offset", "1")) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + } + + #[actix_rt::test] + /// Tests that method would return error if file was already uploaded. + async fn final_upload() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.is_final = true; + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Upload-Offset", file.offset)) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[actix_rt::test] + /// Tests that method would return 404 if file was saved in other storage. + async fn wrong_storage() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.storage = "unknown".into(); + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Upload-Offset", file.offset)) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + /// Tests that method won't allow you to update + /// file if it's offset already equal to length. + async fn frozen_file() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file = state.create_test_file().await; + file.offset = 10; + file.length = Some(10); + state.info_storage.set_info(&file, false).await.unwrap(); + let request = TestRequest::patch() + .uri(state.config.file_url(file.id.as_str()).as_str()) + .insert_header(("Upload-Offset", file.offset)) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + /// Tests that method will return 404 if + /// unknown file_id is passed. + async fn unknown_file_id() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::patch() + .uri(state.config.file_url("unknown").as_str()) + .insert_header(("Upload-Offset", "0")) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload("memes") + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/protocol/creation/mod.rs b/src/protocol/creation/mod.rs index 2ecaf12..eede35d 100644 --- a/src/protocol/creation/mod.rs +++ b/src/protocol/creation/mod.rs @@ -1,18 +1,16 @@ use actix_web::{guard, web}; - -use crate::RustusConf; - mod routes; /// Add creation extensions. /// /// This extension allows you /// to create file before sending data. -pub fn add_extension(web_app: &mut web::ServiceConfig, app_conf: &RustusConf) { +#[cfg_attr(coverage, no_coverage)] +pub fn add_extension(web_app: &mut web::ServiceConfig) { web_app.service( // Post /base // URL for creating files. - web::resource(app_conf.base_url().as_str()) + web::resource("") .name("creation:create_file") .guard(guard::Post()) .to(routes::create_file), diff --git a/src/protocol/creation/routes.rs b/src/protocol/creation/routes.rs index 21c710d..20df718 100644 --- a/src/protocol/creation/routes.rs +++ b/src/protocol/creation/routes.rs @@ -30,7 +30,7 @@ fn get_metadata(request: &HttpRequest) -> Option<HashMap<String, String>> { .map(|header_string| { let mut meta_map = HashMap::new(); for meta_pair in header_string.split(',') { - let mut split = meta_pair.split(' '); + let mut split = meta_pair.trim().split(' '); let key = split.next(); let b64val = split.next(); if key.is_none() || b64val.is_none() { @@ -55,7 +55,7 @@ fn get_upload_parts(request: &HttpRequest) -> Vec<String> { let urls = header_str.strip_prefix("final;").unwrap(); urls.split(' ') - .filter_map(|val: &str| val.split('/').last().map(String::from)) + .filter_map(|val: &str| val.trim().split('/').last().map(String::from)) .filter(|val| val.trim() != "") .collect() } @@ -171,9 +171,6 @@ pub async fn create_file( } } - // Create upload URL for this file. - let upload_url = request.url_for("core:write_bytes", &[file_info.id.clone()])?; - // Checking if creation-with-upload extension is enabled. let with_upload = state .config @@ -181,15 +178,14 @@ pub async fn create_file( .contains(&Extensions::CreationWithUpload); if with_upload && !bytes.is_empty() && !(concat_ext && is_final) { let octet_stream = |val: &str| val == "application/offset+octet-stream"; - if !check_header(&request, "Content-Type", octet_stream) { - return Ok(HttpResponse::BadRequest().finish()); + if check_header(&request, "Content-Type", octet_stream) { + // Writing first bytes. + state + .data_storage + .add_bytes(&file_info, bytes.as_ref()) + .await?; + file_info.offset += bytes.len(); } - // Writing first bytes. - state - .data_storage - .add_bytes(&file_info, bytes.as_ref()) - .await?; - file_info.offset += bytes.len(); } state.info_storage.set_info(&file_info, true).await?; @@ -211,8 +207,299 @@ pub async fn create_file( }); } + // Create upload URL for this file. + let upload_url = request.url_for("core:write_bytes", &[file_info.id.clone()])?; + Ok(HttpResponse::Created() .insert_header(("Location", upload_url.as_str())) .insert_header(("Upload-Offset", file_info.offset.to_string())) .finish()) } + +#[cfg(test)] +mod tests { + use crate::server::rustus_service; + use crate::State; + use actix_web::http::StatusCode; + use actix_web::test::{call_service, init_service, TestRequest}; + use actix_web::{web, App}; + + #[actix_rt::test] + async fn success() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(100)); + assert_eq!(file_info.offset, 0); + } + + #[actix_rt::test] + async fn success_with_bytes() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let test_data = "memes"; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .insert_header(("Content-Type", "application/offset+octet-stream")) + .set_payload(web::Bytes::from(test_data)) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(100)); + assert_eq!(file_info.offset, test_data.len()); + } + + #[actix_rt::test] + async fn with_bytes_wrong_content_type() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let test_data = "memes"; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .insert_header(("Content-Type", "random")) + .set_payload(web::Bytes::from(test_data)) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(100)); + assert_eq!(file_info.offset, 0); + } + + #[actix_rt::test] + async fn success_defer_size() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Defer-Length", "1")) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, None); + assert!(file_info.deferred_size); + } + + #[actix_rt::test] + async fn success_partial_upload() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .insert_header(("Upload-Concat", "partial")) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(100)); + assert!(file_info.is_partial); + assert_eq!(file_info.is_final, false); + } + + #[actix_rt::test] + async fn success_final_upload() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut part1 = state.create_test_file().await; + let mut part2 = state.create_test_file().await; + part1.is_partial = true; + part1.length = Some(100); + part1.offset = 100; + + part2.is_partial = true; + part2.length = Some(100); + part2.offset = 100; + + state.info_storage.set_info(&part1, false).await.unwrap(); + state.info_storage.set_info(&part2, false).await.unwrap(); + + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .insert_header(( + "Upload-Concat", + format!("final;/files/{} /files/{}", part1.id, part2.id), + )) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(200)); + assert!(file_info.is_final); + } + + #[actix_rt::test] + async fn success_with_metadata() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .insert_header(( + "Upload-Metadata", + format!( + "test {}, pest {}", + base64::encode("data1"), + base64::encode("data2") + ), + )) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(100)); + assert_eq!(file_info.metadata.get("test").unwrap(), "data1"); + assert_eq!(file_info.metadata.get("pest").unwrap(), "data2"); + assert_eq!(file_info.offset, 0); + } + + #[actix_rt::test] + async fn success_with_metadata_wrong_encoding() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .insert_header(("Upload-Length", 100)) + .insert_header(( + "Upload-Metadata", + format!("test data1, pest {}", base64::encode("data")), + )) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::CREATED); + // Getting file from location header. + let item_id = resp + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap() + .split('/') + .last() + .unwrap(); + let file_info = state.info_storage.get_info(item_id).await.unwrap(); + assert_eq!(file_info.length, Some(100)); + assert!(file_info.metadata.get("test").is_none()); + assert_eq!(file_info.metadata.get("pest").unwrap(), "data"); + assert_eq!(file_info.offset, 0); + } + + #[actix_rt::test] + async fn no_length_header() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::post() + .uri(state.config.base_url().as_str()) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/src/protocol/getting/mod.rs b/src/protocol/getting/mod.rs index e80e448..7b91847 100644 --- a/src/protocol/getting/mod.rs +++ b/src/protocol/getting/mod.rs @@ -1,7 +1,5 @@ use actix_web::{guard, web}; -use crate::RustusConf; - mod routes; /// Add getting extension. @@ -10,10 +8,11 @@ mod routes; /// to get uploaded file. /// /// This is unofficial extension. -pub fn add_extension(web_app: &mut web::ServiceConfig, app_conf: &RustusConf) { +#[cfg_attr(coverage, no_coverage)] +pub fn add_extension(web_app: &mut web::ServiceConfig) { web_app.service( // GET /base/file - web::resource(app_conf.file_url().as_str()) + web::resource("{file_id}") .name("getting:get") .guard(guard::Get()) .to(routes::get_file), diff --git a/src/protocol/getting/routes.rs b/src/protocol/getting/routes.rs index 97af31a..e3e7c0a 100644 --- a/src/protocol/getting/routes.rs +++ b/src/protocol/getting/routes.rs @@ -1,12 +1,13 @@ -use actix_web::{web, HttpRequest, Responder}; +use actix_files::NamedFile; +use actix_web::{web, HttpRequest}; use crate::errors::RustusError; -use crate::State; +use crate::{RustusResult, State}; /// Retrieve actual file. /// /// This method allows you to download files directly from storage. -pub async fn get_file(request: HttpRequest, state: web::Data<State>) -> impl Responder { +pub async fn get_file(request: HttpRequest, state: web::Data<State>) -> RustusResult<NamedFile> { let file_id_opt = request.match_info().get("file_id").map(String::from); if let Some(file_id) = file_id_opt { let file_info = state.info_storage.get_info(file_id.as_str()).await?; @@ -18,3 +19,67 @@ pub async fn get_file(request: HttpRequest, state: web::Data<State>) -> impl Res Err(RustusError::FileNotFound) } } + +#[cfg(test)] +#[cfg_attr(coverage, no_coverage)] +mod test { + use crate::{rustus_service, State}; + use actix_web::http::StatusCode; + use actix_web::test::{call_service, init_service, TestRequest}; + use actix_web::{web, App}; + + #[actix_rt::test] + async fn success() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let file_info = state.create_test_file().await; + state + .data_storage + .add_bytes(&file_info, "data".as_bytes()) + .await + .unwrap(); + let request = TestRequest::get() + .uri(state.config.file_url(file_info.id.as_str()).as_str()) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert!(resp.status().is_success()); + } + + #[actix_rt::test] + async fn unknown_file_id() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::get() + .uri(state.config.file_url("random_str").as_str()) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn unknown_storage() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.storage = "unknown_storage".into(); + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::get() + .uri(state.config.file_url(file_info.id.as_str()).as_str()) + .to_request(); + let resp = call_service(&mut rustus, request).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index ecd6dba..6ac9a7b 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -12,20 +12,21 @@ mod termination; /// /// This function resolves all protocol extensions /// provided by CLI into services and adds it to the application. +#[cfg_attr(coverage, no_coverage)] pub fn setup(app_conf: RustusConf) -> Box<dyn Fn(&mut web::ServiceConfig)> { Box::new(move |web_app| { for extension in app_conf.extensions_vec() { match extension { - extensions::Extensions::Creation => creation::add_extension(web_app, &app_conf), + extensions::Extensions::Creation => creation::add_extension(web_app), extensions::Extensions::Termination => { - termination::add_extension(web_app, &app_conf); + termination::add_extension(web_app); } extensions::Extensions::Getting => { - getting::add_extension(web_app, &app_conf); + getting::add_extension(web_app); } _ => {} } } - core::add_extension(web_app, &app_conf); + core::add_extension(web_app); }) } diff --git a/src/protocol/termination/mod.rs b/src/protocol/termination/mod.rs index 3774fff..bc3e23b 100644 --- a/src/protocol/termination/mod.rs +++ b/src/protocol/termination/mod.rs @@ -1,17 +1,16 @@ use actix_web::{guard, web}; -use crate::RustusConf; - mod routes; /// Add termination extension. /// /// This extension allows you /// to terminate file upload. -pub fn add_extension(web_app: &mut web::ServiceConfig, app_conf: &RustusConf) { +#[cfg_attr(coverage, no_coverage)] +pub fn add_extension(web_app: &mut web::ServiceConfig) { web_app.service( // DELETE /base/file - web::resource(app_conf.file_url().as_str()) + web::resource("{file_id}") .name("termination:terminate") .guard(guard::Delete()) .to(routes::terminate), diff --git a/src/protocol/termination/routes.rs b/src/protocol/termination/routes.rs index 8e70f9d..82ae88e 100644 --- a/src/protocol/termination/routes.rs +++ b/src/protocol/termination/routes.rs @@ -1,6 +1,6 @@ use actix_web::{web, HttpRequest, HttpResponse}; -use crate::errors::RustusResult; +use crate::errors::{RustusError, RustusResult}; use crate::notifiers::Hook; use crate::State; @@ -16,7 +16,7 @@ pub async fn terminate( if let Some(file_id) = file_id_opt { let file_info = state.info_storage.get_info(file_id.as_str()).await?; if file_info.storage != state.data_storage.to_string() { - return Ok(HttpResponse::NotFound().finish()); + return Err(RustusError::FileNotFound); } state.info_storage.remove_info(file_id.as_str()).await?; state.data_storage.remove_file(&file_info).await?; @@ -37,3 +37,68 @@ pub async fn terminate( } Ok(HttpResponse::NoContent().finish()) } + +#[cfg(test)] +mod tests { + use crate::{rustus_service, State}; + use actix_web::http::StatusCode; + use actix_web::test::{call_service, init_service, TestRequest}; + use actix_web::{web, App}; + use std::path::PathBuf; + + #[actix_rt::test] + async fn success() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let file_info = state.create_test_file().await; + let request = TestRequest::delete() + .uri(state.config.file_url(file_info.id.as_str()).as_str()) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(state + .info_storage + .get_info(file_info.id.as_str()) + .await + .is_err()); + assert!(!PathBuf::from(file_info.path.unwrap()).exists()); + } + + #[actix_rt::test] + async fn unknown_file_id() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let request = TestRequest::delete() + .param("file_id", "not_exists") + .to_request(); + let result = call_service(&mut rustus, request).await; + assert_eq!(result.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn wrong_storage() { + let state = State::test_new().await; + let mut rustus = init_service( + App::new().configure(rustus_service(web::Data::new(state.test_clone().await))), + ) + .await; + let mut file_info = state.create_test_file().await; + file_info.storage = "unknown_storage".into(); + state + .info_storage + .set_info(&file_info, false) + .await + .unwrap(); + let request = TestRequest::delete() + .uri(state.config.file_url(file_info.id.as_str()).as_str()) + .to_request(); + let response = call_service(&mut rustus, request).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/routes.rs b/src/routes.rs index d731095..9dca6f6 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -6,6 +6,7 @@ use crate::errors::{RustusError, RustusResult}; /// All protocol urls can be found /// at `crate::protocol::*`. #[allow(clippy::unused_async)] +#[cfg_attr(coverage, no_coverage)] pub async fn not_found() -> RustusResult<HttpResponse> { Err(RustusError::FileNotFound) } diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..b688e88 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,20 @@ +use crate::{protocol, State}; +use actix_web::web::PayloadConfig; +use actix_web::{middleware, web}; + +pub fn rustus_service(state: web::Data<State>) -> Box<dyn Fn(&mut web::ServiceConfig)> { + Box::new(move |web_app| { + web_app.service( + web::scope(state.config.base_url().as_str()) + .app_data(state.clone()) + .app_data(PayloadConfig::new(state.config.max_body_size)) + // Main middleware that appends TUS headers. + .wrap( + middleware::DefaultHeaders::new() + .add(("Tus-Resumable", "1.0.0")) + .add(("Tus-Version", "1.0.0")), + ) + .configure(protocol::setup(state.config.clone())), + ); + }) +} diff --git a/src/state.rs b/src/state.rs index 16bc4f2..07de073 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use crate::info_storages::FileInfo; use crate::{InfoStorage, NotificationManager, RustusConf, Storage}; pub struct State { @@ -21,4 +23,58 @@ impl State { notification_manager, } } + + #[cfg(test)] + pub async fn from_config(config: RustusConf) -> Self { + Self { + config: config.clone(), + data_storage: Box::new(crate::storages::file_storage::FileStorage::new( + config.storage_opts.data_dir.clone(), + config.storage_opts.dir_structure.clone(), + )), + info_storage: Box::new( + crate::info_storages::file_info_storage::FileInfoStorage::new( + config.info_storage_opts.info_dir.clone(), + ), + ), + notification_manager: NotificationManager::new(&config).await.unwrap(), + } + } + + #[cfg(test)] + pub async fn test_new() -> Self { + let data_dir = tempdir::TempDir::new("data_dir").unwrap(); + let info_dir = tempdir::TempDir::new("info_dir").unwrap(); + let config = RustusConf::from_iter( + vec![ + "rustus", + "--data-dir", + data_dir.into_path().to_str().unwrap(), + "--info-dir", + info_dir.into_path().to_str().unwrap(), + ] + .into_iter(), + ); + Self::from_config(config).await + } + + #[cfg(test)] + pub async fn test_clone(&self) -> Self { + let config = self.config.clone(); + Self::from_config(config).await + } + + #[cfg(test)] + pub async fn create_test_file(&self) -> FileInfo { + let mut new_file = FileInfo::new( + uuid::Uuid::new_v4().to_string().as_str(), + Some(10), + None, + self.data_storage.to_string(), + None, + ); + new_file.path = Some(self.data_storage.create_file(&new_file).await.unwrap()); + self.info_storage.set_info(&new_file, true).await.unwrap(); + new_file + } } diff --git a/src/storages/file_storage.rs b/src/storages/file_storage.rs index 12d8b15..ccc699c 100644 --- a/src/storages/file_storage.rs +++ b/src/storages/file_storage.rs @@ -10,24 +10,26 @@ use log::error; use crate::errors::{RustusError, RustusResult}; use crate::info_storages::FileInfo; use crate::storages::Storage; -use crate::RustusConf; +use crate::utils::dir_struct::dir_struct; use derive_more::Display; #[derive(Display)] #[display(fmt = "file_storage")] pub struct FileStorage { - app_conf: RustusConf, + data_dir: PathBuf, + dir_struct: String, } impl FileStorage { - pub fn new(app_conf: RustusConf) -> FileStorage { - FileStorage { app_conf } + pub fn new(data_dir: PathBuf, dir_struct: String) -> FileStorage { + FileStorage { + data_dir, + dir_struct, + } } pub async fn data_file_path(&self, file_id: &str) -> RustusResult<PathBuf> { let dir = self - .app_conf - .storage_opts .data_dir // We're working wit absolute paths, because tus.io says so. .canonicalize() @@ -35,7 +37,7 @@ impl FileStorage { error!("{}", err); RustusError::UnableToWrite(err.to_string()) })? - .join(self.app_conf.dir_struct().as_str()); + .join(dir_struct(self.dir_struct.as_str())); DirBuilder::new() .recursive(true) .create(dir.as_path()) @@ -44,7 +46,7 @@ impl FileStorage { error!("{}", err); RustusError::UnableToWrite(err.to_string()) })?; - Ok(dir.join(file_id.to_string())) + Ok(dir.join(file_id)) } } @@ -53,10 +55,10 @@ impl Storage for FileStorage { async fn prepare(&mut self) -> RustusResult<()> { // We're creating directory for new files // if it doesn't already exist. - if !self.app_conf.storage_opts.data_dir.exists() { + if !self.data_dir.exists() { DirBuilder::new() .recursive(true) - .create(self.app_conf.storage_opts.data_dir.as_path()) + .create(self.data_dir.as_path()) .await .map_err(|err| RustusError::UnableToPrepareStorage(err.to_string()))?; } @@ -137,8 +139,7 @@ impl Storage for FileStorage { let mut file = OpenOptions::new() .write(true) .append(true) - .create(false) - .create_new(false) + .create(true) .open(file_info.path.as_ref().unwrap().clone()) .await .map_err(|err| { @@ -169,3 +170,151 @@ impl Storage for FileStorage { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::FileStorage; + use crate::info_storages::FileInfo; + use crate::Storage; + use std::fs::File; + use std::io::{Read, Write}; + use std::path::PathBuf; + + #[actix_rt::test] + async fn preparation() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let target_path = dir.into_path().join("not_exist"); + let mut storage = FileStorage::new(target_path.clone(), "".into()); + assert_eq!(target_path.exists(), false); + storage.prepare().await.unwrap(); + assert_eq!(target_path.exists(), true); + } + + #[actix_rt::test] + async fn create_file() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let storage = FileStorage::new(dir.into_path().clone(), "".into()); + let file_info = FileInfo::new("test_id", Some(5), None, storage.to_string(), None); + let new_path = storage.create_file(&file_info).await.unwrap(); + assert!(PathBuf::from(new_path).exists()); + } + + #[actix_rt::test] + async fn create_file_but_it_exists() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let base_path = dir.into_path().clone(); + let storage = FileStorage::new(base_path.clone(), "".into()); + let file_info = FileInfo::new("test_id", Some(5), None, storage.to_string(), None); + File::create(base_path.join("test_id")).unwrap(); + let result = storage.create_file(&file_info).await; + assert!(result.is_err()); + } + + #[actix_rt::test] + async fn adding_bytes() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let storage = FileStorage::new(dir.into_path().clone(), "".into()); + let mut file_info = FileInfo::new("test_id", Some(5), None, storage.to_string(), None); + let new_path = storage.create_file(&file_info).await.unwrap(); + let test_data = "MyTestData"; + file_info.path = Some(new_path.clone()); + storage + .add_bytes(&file_info, test_data.as_bytes()) + .await + .unwrap(); + let mut file = File::open(new_path).unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, String::from(test_data)) + } + + #[actix_rt::test] + async fn adding_bytes_to_unknown_file() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let storage = FileStorage::new(dir.into_path().clone(), "".into()); + let file_info = FileInfo::new( + "test_id", + Some(5), + Some(String::from("some_file")), + storage.to_string(), + None, + ); + let test_data = "MyTestData"; + let result = storage.add_bytes(&file_info, test_data.as_bytes()).await; + assert!(result.is_err()) + } + + #[actix_rt::test] + async fn get_contents_of_unknown_file() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let storage = FileStorage::new(dir.into_path().clone(), "".into()); + let file_info = FileInfo::new( + "test_id", + Some(5), + Some(storage.data_dir.join("unknown").display().to_string()), + storage.to_string(), + None, + ); + let file_info = storage.get_contents(&file_info).await; + assert!(file_info.is_err()); + } + + #[actix_rt::test] + async fn remove_unknown_file() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let storage = FileStorage::new(dir.into_path().clone(), "".into()); + let file_info = FileInfo::new( + "test_id", + Some(5), + Some(storage.data_dir.join("unknown").display().to_string()), + storage.to_string(), + None, + ); + let file_info = storage.remove_file(&file_info).await; + assert!(file_info.is_err()); + } + + #[actix_rt::test] + async fn success_concatenation() { + let dir = tempdir::TempDir::new("file_storage").unwrap(); + let storage = FileStorage::new(dir.into_path().clone(), "".into()); + + let mut parts = Vec::new(); + let part1_path = storage.data_dir.as_path().join("part1"); + let mut part1 = File::create(part1_path.clone()).unwrap(); + let size1 = part1.write("hello ".as_bytes()).unwrap(); + + parts.push(FileInfo::new( + "part_id1", + Some(size1), + Some(part1_path.display().to_string()), + storage.to_string(), + None, + )); + + let part2_path = storage.data_dir.as_path().join("part2"); + let mut part2 = File::create(part2_path.clone()).unwrap(); + let size2 = part2.write("world".as_bytes()).unwrap(); + parts.push(FileInfo::new( + "part_id2", + Some(size2), + Some(part2_path.display().to_string()), + storage.to_string(), + None, + )); + + let final_info = FileInfo::new( + "final_id", + None, + Some(storage.data_dir.join("final_info").display().to_string()), + storage.to_string(), + None, + ); + storage.concat_files(&final_info, parts).await.unwrap(); + let mut final_file = File::open(final_info.path.unwrap()).unwrap(); + let mut buffer = String::new(); + final_file.read_to_string(&mut buffer).unwrap(); + + assert_eq!(buffer.as_str(), "hello world"); + } +} diff --git a/src/storages/models/available_stores.rs b/src/storages/models/available_stores.rs index c4f8d1b..7bde098 100644 --- a/src/storages/models/available_stores.rs +++ b/src/storages/models/available_stores.rs @@ -19,10 +19,14 @@ impl AvailableStores { /// `config` - Rustus configuration. /// `info_storage` - Storage for information about files. /// + #[cfg_attr(coverage, no_coverage)] pub fn get(&self, config: &RustusConf) -> Box<dyn Storage + Send + Sync> { #[allow(clippy::single_match)] match self { - Self::FileStorage => Box::new(file_storage::FileStorage::new(config.clone())), + Self::FileStorage => Box::new(file_storage::FileStorage::new( + config.storage_opts.data_dir.clone(), + config.storage_opts.dir_structure.clone(), + )), } } } diff --git a/src/utils/dir_struct.rs b/src/utils/dir_struct.rs new file mode 100644 index 0000000..9c18757 --- /dev/null +++ b/src/utils/dir_struct.rs @@ -0,0 +1,50 @@ +use chrono::{Datelike, Timelike}; +use lazy_static::lazy_static; +use log::error; +use std::collections::HashMap; +use std::env; + +lazy_static! { + /// Freezing ENVS on startup. + static ref ENV_MAP: HashMap<String, String> = { + let mut m = HashMap::new(); + for (key, value) in env::vars() { + m.insert(format!("env[{}]", key), value); + } + m + }; +} + +/// Generate directory name with user template. +pub fn dir_struct(dir_structure: &str) -> String { + let now = chrono::Utc::now(); + let mut vars: HashMap<String, String> = ENV_MAP.clone(); + vars.insert("day".into(), now.day().to_string()); + vars.insert("month".into(), now.month().to_string()); + vars.insert("year".into(), now.year().to_string()); + vars.insert("hour".into(), now.hour().to_string()); + vars.insert("minute".into(), now.minute().to_string()); + strfmt::strfmt(dir_structure, &vars).unwrap_or_else(|err| { + error!("{}", err); + "".into() + }) +} + +#[cfg(test)] +mod tests { + use super::dir_struct; + use chrono::Datelike; + + #[test] + pub fn test_time() { + let now = chrono::Utc::now(); + let dir = dir_struct("{day}/{month}"); + assert_eq!(dir, format!("{}/{}", now.day(), now.month())); + } + + #[test] + pub fn test_unknown_var() { + let dir = dir_struct("test/{quake}"); + assert_eq!(dir, String::from("")); + } +} diff --git a/src/utils/enums.rs b/src/utils/enums.rs index 5109aee..4d90861 100644 --- a/src/utils/enums.rs +++ b/src/utils/enums.rs @@ -27,3 +27,32 @@ macro_rules! from_str { } }; } + +#[cfg(test)] +mod tests { + use crate::from_str; + use derive_more::{Display, From}; + use strum::EnumIter; + + #[derive(PartialEq, Debug, Display, EnumIter, From, Clone, Eq)] + pub enum TestEnum { + #[display(fmt = "test-val-1")] + TestVal1, + #[display(fmt = "test-val-2")] + TestVal2, + } + + from_str!(TestEnum, "test-vals"); + + #[test] + fn test_from_str_unknown_val() { + let result = TestEnum::from_str("unknown"); + assert!(result.is_err()) + } + + #[test] + fn test_from_str() { + let result = TestEnum::from_str("test-val-1"); + assert_eq!(result.unwrap(), TestEnum::TestVal1) + } +} diff --git a/src/utils/headers.rs b/src/utils/headers.rs index f2b0c42..493b09a 100644 --- a/src/utils/headers.rs +++ b/src/utils/headers.rs @@ -41,3 +41,52 @@ pub fn check_header(request: &HttpRequest, header_name: &str, expr: fn(&str) -> }) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::{check_header, parse_header}; + use actix_web::test::TestRequest; + + #[actix_rt::test] + async fn test_parse_header_unknown_header() { + let request = TestRequest::get().to_http_request(); + let header = parse_header::<String>(&request, "unknown"); + assert!(header.is_none()); + } + + #[actix_rt::test] + async fn test_parse_header_wrong_type() { + let request = TestRequest::get() + .insert_header(("test_header", String::from("test").as_bytes())) + .to_http_request(); + let header = parse_header::<i32>(&request, "test_header"); + assert!(header.is_none()); + } + + #[actix_rt::test] + async fn test_parse_header() { + let request = TestRequest::get() + .insert_header(("test_header", String::from("123").as_bytes())) + .to_http_request(); + let header = parse_header::<usize>(&request, "test_header"); + assert_eq!(header.unwrap(), 123); + } + + #[actix_rt::test] + async fn test_check_header_unknown_header() { + let request = TestRequest::get().to_http_request(); + let check = check_header(&request, "unknown", |value| value == "1"); + assert_eq!(check, false); + } + + #[actix_rt::test] + async fn test_check_header() { + let request = TestRequest::get() + .insert_header(("test_header", "1")) + .to_http_request(); + let check = check_header(&request, "test_header", |value| value == "1"); + assert!(check); + let check = check_header(&request, "test_header", |value| value == "2"); + assert!(!check); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 0f6b300..0bef5e2 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,3 @@ +pub mod dir_struct; pub mod enums; pub mod headers; -- GitLab