diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2bbba0..699d257 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: docker build +name: docker build latest master on: push: diff --git a/.github/workflows/testable.yml b/.github/workflows/testable.yml index 2a350d3..8e244a6 100644 --- a/.github/workflows/testable.yml +++ b/.github/workflows/testable.yml @@ -1,4 +1,4 @@ -name: docker build testable +name: docker build latest testable on: push: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f23814d..696cd5a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,4 @@ - -name: Running Tests +name: Run tests on: [push] @@ -26,7 +25,7 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Build + - name: Build executable run: cargo build --verbose - name: Run tests run: cargo test --verbose -- --test-threads=1 diff --git a/.gitignore b/.gitignore index 96ef6c0..e5a09bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +[0-9a-zA-Z]*-[0-9a-zA-Z]*-[0-9a-zA-Z]*-[0-9a-zA-Z]*-[0-9a-zA-Z]* diff --git a/Cargo.toml b/Cargo.toml index 14eea42..b0b6586 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ sea-orm = { version = "0.12.3", features = ["sqlx-postgres", "runtime-tokio-rust tokio = { version = "1.32.0", features = ["full"] } tokio-postgres = "0.7.10" tracing = "0.1.37" -tracing-subscriber = { version = "0.3.17", features = ["env-filter", "fmt"] } +tracing-subscriber = { version = "0.3.17", features = ["env-filter", "fmt", "ansi", "std", "json"] } entity = { path = "entity" } migration = { path = "migration" } serde = { version = "1.0.188", features = ["derive"] } @@ -41,6 +41,9 @@ mime = "0.3.17" axum-test-helper = "0.3.0" hyper = "0.14.27" reqwest = { version = "0.11.22", features = ["json", "multipart"] } +oneshot = "0.1.6" +crossfire = "1.0.1" +http-body = "0.4.5" [workspace] members = [".", "entity", "migration"] diff --git a/assets/a.zip b/assets/a.zip index fa40545..e192629 100644 Binary files a/assets/a.zip and b/assets/a.zip differ diff --git a/src/extractors/mod.rs b/src/extractors/mod.rs index 5504af2..5b292e3 100644 --- a/src/extractors/mod.rs +++ b/src/extractors/mod.rs @@ -30,6 +30,7 @@ where { type Rejection = SeroError; + #[tracing::instrument(skip(parts, state))] async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = Arc::from_ref(state); @@ -60,6 +61,7 @@ where } impl Subdomain { + #[tracing::instrument(skip(headers))] pub fn from_headers(headers: &HeaderMap) -> Result { Ok(Self({ let header = headers @@ -106,6 +108,7 @@ where } impl SubdomainModel { + #[tracing::instrument(skip(headers, state))] pub async fn from_headers(headers: &HeaderMap, state: &S) -> Result where Arc: FromRef, diff --git a/src/handlers/cors.rs b/src/handlers/cors.rs index d336a61..08f57d4 100644 --- a/src/handlers/cors.rs +++ b/src/handlers/cors.rs @@ -14,9 +14,9 @@ use entity::prelude::*; use sea_orm::{prelude::*, Set}; use std::sync::Arc; -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct OriginForm { - origin: String, + pub origin: String, } #[tracing::instrument(skip(state))] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6da6b81 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,167 @@ +use std::time::Duration; + +use axum::{ + extract::DefaultBodyLimit, + http::{HeaderName, HeaderValue}, + routing::{get, post}, + Router, +}; +use extractors::SubdomainModel; +use hyper::{HeaderMap, StatusCode}; +use migration::{Migrator, MigratorTrait}; +use sea_orm::{ConnectOptions, Database}; +use services::cors::CorsService; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; +use tracing_subscriber::prelude::*; + +pub mod apperror; +pub mod config; +pub mod extractors; +pub mod handlers; +pub mod services; + +#[derive(Clone, Debug)] +pub struct AppState { + pub connection: sea_orm::DatabaseConnection, + pub config: config::Config, +} + +pub struct CorsTask { + origin: String, + subdomain: String, + sender: oneshot::Sender, +} + +pub async fn initialize_database( + config: &config::Config, + migrations: bool, +) -> sea_orm::DatabaseConnection { + let mut opt = ConnectOptions::new(&config.database_url); + opt.sqlx_logging(true); + + let connection = Database::connect(opt).await.unwrap(); + + connection.ping().await.unwrap(); + + if migrations { + Migrator::up(&connection, None).await.unwrap(); + } + connection +} + +pub async fn app() -> Router { + let config = config::Config::default(); + + let connection = initialize_database(&config, true).await; + let state = std::sync::Arc::new(AppState { + connection, + config: Default::default(), + }); + + let (cors_task_sender, cors_task_receiver) = crossfire::mpsc::unbounded_future::(); + + std::thread::spawn(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + + rt.block_on(async move { + let config = config::Config::default(); + + let connection = initialize_database(&config, false).await; + + let state = std::sync::Arc::new(AppState { connection, config }); + loop { + if let Ok(task) = cors_task_receiver.recv().await { + let mut map = HeaderMap::new(); + + map.insert( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(&task.subdomain).unwrap(), + ); + + let subdomain_model_extractor = SubdomainModel::from_headers(&map, &state) + .await + .map_err(|cause| { + tracing::error!(%cause, + "Failed to extract subdomain model from headers for cors!"); + }); + match subdomain_model_extractor.is_err() { + true => { + task.sender.send(false).unwrap(); + } + false => { + let res = CorsService::check( + subdomain_model_extractor.unwrap().0, + &task.origin, + &state.connection, + ) + .await + .unwrap(); + task.sender.send(res).unwrap(); + } + } + } + } + }) + }); + + let api_router = Router::new() + .route("/health", get(|| async { StatusCode::OK })) + .route("/login", post(handlers::auth::login)) + .route("/registration", post(handlers::auth::registration)) + .route("/upload", post(handlers::sites::upload)) + .route("/teardown", post(handlers::sites::teardown)) + .route("/download", post(handlers::sites::download)) + .route("/enable", post(handlers::sites::enable)) + .route("/disable", post(handlers::sites::disable)) + .route("/cors/add", post(handlers::cors::add_origin)) + .route("/cors/clear", post(handlers::cors::clear_all)); + + let cors_layer = CorsLayer::new() + .allow_methods(AllowMethods::any()) + .allow_headers(AllowHeaders::any()) + .allow_origin(AllowOrigin::predicate(move |origin, parts| { + let (tx, rx) = oneshot::channel(); + + let cloned_origin = origin.to_owned().to_str().unwrap_or_default().to_string(); + let cloned_subdomain: String = parts + .headers + .get("X-Subdomain") + .map(|header| header.to_str().unwrap_or_default().to_string()) + .unwrap(); + + cors_task_sender + .try_send(CorsTask { + origin: cloned_origin, + subdomain: cloned_subdomain, + sender: tx, + }) + .ok(); + + rx.recv_timeout(Duration::from_secs(3)).unwrap_or(false) + })); + + let mut app = Router::new() + .nest("/api", api_router) + .route("/*path", get(handlers::sites::file)) + .route("/", get(handlers::sites::index_redirect)) + .layer(cors_layer) + .with_state(state.clone()); + + if config.max_body_limit_size.is_some() { + app = app.layer(DefaultBodyLimit::max(config.max_body_limit_size.unwrap())); + } + + app +} + +pub async fn prepare() -> Router { + dotenv::dotenv().ok(); + + let stdout_log = tracing_subscriber::fmt::layer().pretty(); + let subscriber = tracing_subscriber::Registry::default() + .with(stdout_log) + .with(tracing_subscriber::EnvFilter::from_default_env()); + + tracing::subscriber::set_global_default(subscriber).ok(); + app().await +} diff --git a/src/main.rs b/src/main.rs index de8c158..bf6081e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,847 +1,15 @@ -use std::{fmt::Debug, net::SocketAddr, sync::mpsc}; +use std::net::SocketAddr; -use axum::{ - extract::DefaultBodyLimit, - http::StatusCode, - routing::{get, post}, - Router, -}; - -use extractors::SubdomainModel; -use services::cors::CorsService; -use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; - -use migration::{Migrator, MigratorTrait}; -use sea_orm::{ConnectOptions, Database}; - -mod apperror; -mod config; -mod extractors; -mod handlers; -mod services; - -#[derive(Clone, Debug)] -pub struct AppState { - connection: sea_orm::DatabaseConnection, - config: config::Config, -} - -async fn app() -> Router { - let config = config::Config::default(); - - let mut opt = ConnectOptions::new(&config.database_url); - opt.sqlx_logging(true); - let connection = Database::connect(opt).await.unwrap(); - connection.ping().await.unwrap(); - - Migrator::up(&connection, None).await.unwrap(); - - let api_router = Router::new() - .route("/health", get(|| async { StatusCode::OK })) - .route("/login", post(handlers::auth::login)) - .route("/registration", post(handlers::auth::registration)) - .route("/upload", post(handlers::sites::upload)) - .route("/teardown", post(handlers::sites::teardown)) - .route("/download", post(handlers::sites::download)) - .route("/enable", post(handlers::sites::enable)) - .route("/disable", post(handlers::sites::disable)) - .route("/cors/add", post(handlers::cors::add_origin)) - .route("/cors/clear", post(handlers::cors::clear_all)); - - let state = std::sync::Arc::new(AppState { - connection, - config: Default::default(), - }); - - let cloned_state = state.clone(); - - let _cors_layer = CorsLayer::new() - .allow_methods(AllowMethods::any()) - .allow_headers(AllowHeaders::any()) - .allow_origin(AllowOrigin::predicate(move |origin, parts| { - let cloned_headers = parts.headers.clone(); - let (tx, rx) = mpsc::channel(); - - let cloned_state = cloned_state.clone(); - let cloned_origin = origin - .to_owned() - .to_str() - .map(|s| s.to_string()) - .unwrap_or_default(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async move { - tracing::info!("Starting cors!"); - let subdomain_model_extractor = - SubdomainModel::from_headers(&cloned_headers, &cloned_state) - .await - .map_err(|cause| { - tracing::error!(%cause, - "Failed to extract subdomain model from headers for cors!"); - }); - if subdomain_model_extractor.is_err() { - tx.send(false).ok(); - return; - } - - let res = CorsService::check( - subdomain_model_extractor.unwrap().0, - &cloned_origin, - &cloned_state.connection, - ) - .await - .unwrap_or(false); - - tx.send(res).ok(); - }); - }); - - rx.recv().unwrap_or(false) - })); - - let mut app = Router::new() - .nest("/api", api_router) - .route("/*path", get(handlers::sites::file)) - .route("/", get(handlers::sites::index_redirect)) - .with_state(state.clone()); - - if config.max_body_limit_size.is_some() { - app = app.layer(DefaultBodyLimit::max(config.max_body_limit_size.unwrap())); - } - - app -} +use sero::{config, prepare}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + let app = prepare().await; let config = config::Config::default(); let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - axum::Server::bind(&addr) - .serve(app().await.into_make_service()) + .serve(app.into_make_service()) .await .unwrap(); } - -#[cfg(test)] -mod tests { - use crate::{handlers::auth::AuthToken, services::auth::AuthCredentials}; - - use super::*; - use axum::{ - body::Body, - http::{header, HeaderName, HeaderValue, Method, Request, StatusCode}, - }; - use axum_test_helper::TestClient; - use std::io::Read; - use tower::ServiceExt; // for `oneshot` and `ready` - - async fn prepare() -> Router { - dotenv::dotenv().ok(); - //tracing_subscriber::fmt() - // .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - // .try_init() - // .ok(); - app().await - } - - #[tokio::test] - async fn health_check() { - let app = prepare().await; - - let response = app - .oneshot( - Request::builder() - .uri("/api/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK) - } - - #[tokio::test] - async fn registration_check() { - let app = prepare().await; - let random_username = uuid::Uuid::new_v4(); - let random_password = uuid::Uuid::new_v4(); - let body = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: random_password.to_string(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); - - let body = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: String::default(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn login_check() { - let app = prepare().await; - let random_username = uuid::Uuid::new_v4(); - let random_password = uuid::Uuid::new_v4(); - let body = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: random_password.to_string(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - - assert!(serde_json::from_slice::(&body).is_ok()); - - let body = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: String::from("wrong"), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn upload_check() { - let app = prepare().await; - let random_username = uuid::Uuid::new_v4(); - let random_password = uuid::Uuid::new_v4(); - let first_user = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: random_password.to_string(), - }) - .unwrap(); - - let second_user = serde_urlencoded::to_string(AuthCredentials { - username: uuid::Uuid::new_v4().to_string(), - password: uuid::Uuid::new_v4().to_string(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(second_user.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(first_user.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(first_user.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - - let token = serde_json::from_slice::(&body).unwrap().token; - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(second_user.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - - let second_token = serde_json::from_slice::(&body).unwrap().token; - - let client = TestClient::new(app); - - let test_zip = std::path::PathBuf::from("./assets/a.zip"); - assert!(test_zip.exists()); - - let mut f = std::fs::File::open(&test_zip).expect("no file found"); - let metadata = std::fs::metadata(&test_zip).expect("unable to read metadata"); - let mut buffer = vec![0; metadata.len() as usize]; - f.read_exact(&mut buffer).expect("buffer overflow"); - - let form = reqwest::multipart::Form::new() - .part("archive", reqwest::multipart::Part::bytes(buffer.clone())); - - let random_subdoamain = uuid::Uuid::new_v4().to_string(); - let bearer = format!("Bearer {token}"); - let response = client - .post("/api/upload") - .multipart(form) - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let bearer = format!("Bearer {second_token}"); - let form = reqwest::multipart::Form::new() - .part("archive", reqwest::multipart::Part::bytes(buffer)); - let response = client - .post("/api/upload") - .multipart(form) - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn teardown_check() { - let app = prepare().await; - let random_username = uuid::Uuid::new_v4(); - let random_password = uuid::Uuid::new_v4(); - let body = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: random_password.to_string(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - - let token = serde_json::from_slice::(&body).unwrap().token; - - let client = TestClient::new(app); - - let test_zip = std::path::PathBuf::from("./assets/a.zip"); - assert!(test_zip.exists()); - - let mut f = std::fs::File::open(&test_zip).expect("no file found"); - let metadata = std::fs::metadata(&test_zip).expect("unable to read metadata"); - let mut buffer = vec![0; metadata.len() as usize]; - f.read_exact(&mut buffer).expect("buffer overflow"); - - let form = reqwest::multipart::Form::new() - .part("archive", reqwest::multipart::Part::bytes(buffer)); - - let random_subdoamain = uuid::Uuid::new_v4().to_string(); - let bearer = format!("Bearer {token}"); - let response = client - .post("/api/upload") - .multipart(form) - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .get("/a/index.html") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .post("/api/teardown") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .get("/a/index") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn disable_enable_check() { - let app = prepare().await; - let random_username = uuid::Uuid::new_v4(); - let random_password = uuid::Uuid::new_v4(); - let body = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: random_password.to_string(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - - let token = serde_json::from_slice::(&body).unwrap().token; - - let client = TestClient::new(app); - - let test_zip = std::path::PathBuf::from("./assets/a.zip"); - assert!(test_zip.exists()); - - let mut f = std::fs::File::open(&test_zip).expect("no file found"); - let metadata = std::fs::metadata(&test_zip).expect("unable to read metadata"); - let mut buffer = vec![0; metadata.len() as usize]; - f.read_exact(&mut buffer).expect("buffer overflow"); - - let form = reqwest::multipart::Form::new() - .part("archive", reqwest::multipart::Part::bytes(buffer)); - - let random_subdoamain = uuid::Uuid::new_v4().to_string(); - let bearer = format!("Bearer {token}"); - let response = client - .post("/api/upload") - .multipart(form) - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .get("/a/index.html") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .post("/api/disable") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .get("/a/index") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - - let response = client - .post("/api/enable") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - } - - #[tokio::test] - async fn download_check() { - let app = prepare().await; - let random_username = uuid::Uuid::new_v4(); - let random_password = uuid::Uuid::new_v4(); - let user = serde_urlencoded::to_string(AuthCredentials { - username: random_username.to_string(), - password: random_password.to_string(), - }) - .unwrap(); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/registration") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(user.clone())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/login") - .header( - header::CONTENT_TYPE, - mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), - ) - .body(Body::from(user.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - - let token = serde_json::from_slice::(&body).unwrap().token; - - let client = TestClient::new(app.clone()); - - let test_zip = std::path::PathBuf::from("./assets/a.zip"); - assert!(test_zip.exists()); - - let mut f = std::fs::File::open(&test_zip).expect("no file found"); - let metadata = std::fs::metadata(&test_zip).expect("unable to read metadata"); - let mut buffer = vec![0; metadata.len() as usize]; - f.read_exact(&mut buffer).expect("buffer overflow"); - - let form = reqwest::multipart::Form::new() - .part("archive", reqwest::multipart::Part::bytes(buffer.clone())); - - let random_subdoamain = uuid::Uuid::new_v4().to_string(); - let bearer = format!("Bearer {token}"); - let response = client - .post("/api/upload") - .multipart(form) - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let response = client - .get("/a/index.html") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .send() - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let bearer = format!("Bearer {token}"); - - let response = app - .clone() - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/download") - .header( - HeaderName::from_static("x-subdomain"), - HeaderValue::from_str(&random_subdoamain).unwrap(), - ) - .header( - header::AUTHORIZATION, - HeaderValue::from_str(&bearer).unwrap(), - ) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); - assert_eq!(body, buffer) - } -} diff --git a/src/services/auth.rs b/src/services/auth.rs index 0ca294e..341e3c4 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -20,6 +20,22 @@ pub struct AuthCredentials { pub password: String, } +impl AuthCredentials { + pub fn random() -> Self { + Self { + username: uuid::Uuid::new_v4().to_string(), + password: uuid::Uuid::new_v4().to_string(), + } + } + + pub fn random_unvalid() -> Self { + Self { + username: uuid::Uuid::new_v4().to_string(), + password: "".to_owned(), + } + } +} + impl AuthCredentials { pub fn valid(&self) -> bool { !self.username.is_empty() && !self.password.is_empty() diff --git a/src/services/cors.rs b/src/services/cors.rs index 3a995d0..65e65fb 100644 --- a/src/services/cors.rs +++ b/src/services/cors.rs @@ -15,7 +15,11 @@ impl CorsService { Ok(subdomain .find_related(CorsEntity) .all(connection) - .await? + .await + .map_err(|cause| { + tracing::error!(%cause, "Failed to retrieve origins!"); + cause + })? .iter() .any(|origin_model| origin_model.matches(origin))) } diff --git a/tests/all.rs b/tests/all.rs new file mode 100644 index 0000000..f2504d0 --- /dev/null +++ b/tests/all.rs @@ -0,0 +1,630 @@ +#[cfg(test)] +mod tests { + use tracing_subscriber::prelude::*; + + use axum::{ + body::Body, + http::{header, HeaderName, HeaderValue, Method, Request, StatusCode}, + Router, + }; + use axum_test_helper::TestClient; + use reqwest::multipart::{Form, Part}; + use sero::{ + app, + handlers::{auth::AuthToken, cors::OriginForm}, + services::auth::AuthCredentials, + }; + use std::io::Read; + use tower::ServiceExt; + + type TestResponse = + hyper::Response>; + + async fn registration(app: &Router, credentials: &AuthCredentials) -> TestResponse { + let body = serde_urlencoded::to_string(credentials).unwrap(); + let request = Request::builder() + .method(Method::POST) + .uri("/api/registration") + .header( + header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(Body::from(body)) + .unwrap(); + + app.clone().oneshot(request).await.unwrap() + } + + async fn login(app: &Router, credentials: &AuthCredentials) -> TestResponse { + let body = serde_urlencoded::to_string(credentials).unwrap(); + let request = Request::builder() + .method(Method::POST) + .uri("/api/login") + .header( + header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(Body::from(body)) + .unwrap(); + + app.clone().oneshot(request).await.unwrap() + } + + async fn teardown( + app: &Router, + token: &str, + subdomain: &str, + ) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + let bearer = format!("Bearer {token}"); + + client + .post("/api/teardown") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .send() + .await + } + + async fn enable(app: &Router, token: &str, subdomain: &str) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + let bearer = format!("Bearer {token}"); + + client + .post("/api/enable") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .send() + .await + } + + async fn disable(app: &Router, token: &str, subdomain: &str) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + let bearer = format!("Bearer {token}"); + + client + .post("/api/disable") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .send() + .await + } + + async fn download( + app: &Router, + token: &str, + subdomain: &str, + ) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + let bearer = format!("Bearer {token}"); + + client + .post("/api/download") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .send() + .await + } + + async fn add_origin( + app: &Router, + token: &str, + subdomain: &str, + origin: &str, + ) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + let bearer = format!("Bearer {token}"); + let origin = OriginForm { + origin: origin.to_owned(), + }; + client + .post("/api/cors/add") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .header( + header::CONTENT_TYPE, + mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(), + ) + .body(Body::from(serde_urlencoded::to_string(origin).unwrap())) + .send() + .await + } + + async fn clear_origins( + app: &Router, + token: &str, + subdomain: &str, + ) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + let bearer = format!("Bearer {token}"); + client + .post("/api/cors/clear") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .send() + .await + } + + async fn upload(app: &Router, subdomain: &str, token: &str) -> axum_test_helper::TestResponse { + let zip = std::path::PathBuf::from("./assets/a.zip"); + assert!(zip.exists()); + + let client = TestClient::new(app.clone()); + + let mut f = std::fs::File::open(&zip).unwrap(); + let metadata = std::fs::metadata(&zip).unwrap(); + let mut buffer = vec![0; metadata.len() as usize]; + + f.read_exact(&mut buffer).unwrap(); + + let form = Form::new().part("archive", Part::bytes(buffer.clone())); + + let bearer = format!("Bearer {token}"); + + client + .post("/api/upload") + .multipart(form) + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).unwrap(), + ) + .send() + .await + } + + async fn get_page(app: &Router, page: &str, subdomain: &str) -> axum_test_helper::TestResponse { + let client = TestClient::new(app.clone()); + + client + .get(page) + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(subdomain).unwrap(), + ) + .send() + .await + } + + async fn prepare() -> Router { + dotenv::dotenv().ok(); + + let stdout_log = tracing_subscriber::fmt::layer().pretty(); + let subscriber = tracing_subscriber::Registry::default() + .with(stdout_log) + .with(tracing_subscriber::EnvFilter::from_default_env()); + + tracing::subscriber::set_global_default(subscriber).ok(); + app().await + } + + #[tokio::test] + async fn health_check() { + let app = prepare().await; + + let response = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK) + } + + #[tokio::test] + async fn registration_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + let unvalid_credentials = AuthCredentials::random_unvalid(); + + let first_user_registration_response = registration(&app, &first_user).await; + assert!(first_user_registration_response.status().is_success()); + + let first_user_second_attempt_response = registration(&app, &first_user).await; + assert_eq!( + first_user_second_attempt_response.status(), + (StatusCode::CONFLICT) + ); + + let invalid_credentials_attempt_response = registration(&app, &unvalid_credentials).await; + assert_eq!( + invalid_credentials_attempt_response.status(), + (StatusCode::BAD_REQUEST) + ); + } + + #[tokio::test] + async fn login_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + let second_user = AuthCredentials::random(); + let invalid_credentials = AuthCredentials::random_unvalid(); + + let first_user_register_response = registration(&app, &first_user).await; + assert!(first_user_register_response.status().is_success()); + + let first_user_login_response = login(&app, &first_user).await; + assert!(first_user_login_response.status().is_success()); + + let body = hyper::body::to_bytes(first_user_login_response.into_body()) + .await + .unwrap(); + let token = serde_json::from_slice::(&body).unwrap().token; + + assert!(!token.is_empty()); + + let second_user_login_response = login(&app, &second_user).await; + assert_eq!( + second_user_login_response.status(), + StatusCode::UNAUTHORIZED + ); + + let invalid_user_login_response = login(&app, &invalid_credentials).await; + assert_eq!( + invalid_user_login_response.status(), + StatusCode::BAD_REQUEST + ); + } + + #[tokio::test] + async fn upload_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + let second_user = AuthCredentials::random(); + + let random_subdomain = uuid::Uuid::new_v4().to_string(); + + assert!(registration(&app, &first_user).await.status().is_success()); + assert!(registration(&app, &second_user).await.status().is_success()); + + let first_user_login_response = login(&app, &first_user).await; + let second_user_login_response = login(&app, &second_user).await; + + assert!(first_user_login_response.status().is_success()); + assert!(second_user_login_response.status().is_success()); + + let first_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(first_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + + let second_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(second_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + + assert_eq!( + upload(&app, &random_subdomain, &first_user_token) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + upload(&app, &random_subdomain, &second_user_token) + .await + .status(), + StatusCode::FORBIDDEN + ); + + let page_response = get_page(&app, "/a/index.html", &random_subdomain).await; + assert_eq!(page_response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn teardown_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + let second_user = AuthCredentials::random(); + + let random_subdomain = uuid::Uuid::new_v4().to_string(); + + assert!(registration(&app, &first_user).await.status().is_success()); + assert!(registration(&app, &second_user).await.status().is_success()); + + let first_user_login_response = login(&app, &first_user).await; + let second_user_login_response = login(&app, &second_user).await; + + assert!(first_user_login_response.status().is_success()); + assert!(second_user_login_response.status().is_success()); + + let first_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(first_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + let second_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(second_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + + assert!(upload(&app, &random_subdomain, &first_user_token) + .await + .status() + .is_success()); + assert_eq!( + upload(&app, &random_subdomain, &second_user_token) + .await + .status(), + StatusCode::FORBIDDEN + ); + + let page_response = get_page(&app, "/a/index.html", &random_subdomain).await; + assert_eq!(page_response.status(), StatusCode::OK); + + let teardown_response = teardown(&app, &first_user_token, &random_subdomain).await; + assert!(teardown_response.status().is_success()); + + let page_response = get_page(&app, "/a/index.html", &random_subdomain).await; + assert_eq!(page_response.status(), StatusCode::NOT_FOUND); + + assert!(upload(&app, &random_subdomain, &second_user_token) + .await + .status() + .is_success()); + + let page_response = get_page(&app, "/a/index.html", &random_subdomain).await; + assert_eq!(page_response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn disable_enable_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + + let random_subdomain = uuid::Uuid::new_v4().to_string(); + + assert!(registration(&app, &first_user).await.status().is_success()); + + let first_user_login_response = login(&app, &first_user).await; + + assert!(first_user_login_response.status().is_success()); + + let first_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(first_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + + assert!(upload(&app, &random_subdomain, &first_user_token) + .await + .status() + .is_success()); + + assert!(get_page(&app, "/a/index.html", &random_subdomain) + .await + .status() + .is_success()); + assert!(enable(&app, &first_user_token, &random_subdomain) + .await + .status() + .is_success()); + assert!(get_page(&app, "/a/index.html", &random_subdomain) + .await + .status() + .is_success()); + + assert!(disable(&app, &first_user_token, &random_subdomain) + .await + .status() + .is_success()); + assert_eq!( + get_page(&app, "/a/index.html", &random_subdomain) + .await + .status(), + StatusCode::SERVICE_UNAVAILABLE + ); + + assert!(enable(&app, &first_user_token, &random_subdomain) + .await + .status() + .is_success()); + assert!(get_page(&app, "/a/index.html", &random_subdomain) + .await + .status() + .is_success()); + } + + #[tokio::test] + async fn download_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + + let random_subdomain = uuid::Uuid::new_v4().to_string(); + + assert!(registration(&app, &first_user).await.status().is_success()); + + let first_user_login_response = login(&app, &first_user).await; + + assert!(first_user_login_response.status().is_success()); + + let first_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(first_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + + assert!(upload(&app, &random_subdomain, &first_user_token) + .await + .status() + .is_success()); + + assert!(get_page(&app, "/a/index.html", &random_subdomain) + .await + .status() + .is_success()); + + let zip = std::path::PathBuf::from("./assets/a.zip"); + assert!(zip.exists()); + + let mut f = std::fs::File::open(&zip).unwrap(); + let metadata = std::fs::metadata(&zip).unwrap(); + let mut buffer = vec![0; metadata.len() as usize]; + + f.read_exact(&mut buffer).unwrap(); + + let download_response = download(&app, &first_user_token, &random_subdomain).await; + assert!(download_response.status().is_success()); + let body = download_response.bytes().await; + assert_eq!(body, buffer) + } + + #[tokio::test] + async fn cors_check() { + let app = prepare().await; + + let first_user = AuthCredentials::random(); + + let random_subdomain = uuid::Uuid::new_v4().to_string(); + + assert!(registration(&app, &first_user).await.status().is_success()); + + let first_user_login_response = login(&app, &first_user).await; + + assert!(first_user_login_response.status().is_success()); + + let first_user_token = serde_json::from_slice::( + &hyper::body::to_bytes(first_user_login_response.into_body()) + .await + .unwrap(), + ) + .unwrap() + .token; + + assert!(upload(&app, &random_subdomain, &first_user_token) + .await + .status() + .is_success()); + + assert!(get_page(&app, "/a/index.html", &random_subdomain) + .await + .status() + .is_success()); + + assert!(clear_origins(&app, &first_user_token, &random_subdomain) + .await + .status() + .is_success()); + + assert!(clear_origins(&app, &first_user_token, &random_subdomain) + .await + .status() + .is_success()); + + assert!( + add_origin(&app, &first_user_token, &random_subdomain, "some") + .await + .status() + .is_success() + ); + + let preflight_response = preflight(&app, &random_subdomain, "some").await; + let preflight_response_allowed_origin = preflight_response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN); + assert!(preflight_response_allowed_origin.is_some()); + + let bad_preflight_response = preflight(&app, &random_subdomain, "wrong").await; + let bad_preflight_response_allowed_origin = bad_preflight_response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN); + assert!(bad_preflight_response_allowed_origin.is_none()); + + let wildcrd_add_origin_response = + add_origin(&app, &first_user_token, &random_subdomain, "*").await; + + assert!(wildcrd_add_origin_response.status().is_success()); + + let random_origin = uuid::Uuid::new_v4().to_string(); + + let preflight_response = preflight(&app, &random_subdomain, &random_origin).await; + let preflight_response_allowed_origin = preflight_response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN); + + assert!(preflight_response_allowed_origin.is_some()); + assert!(preflight_response_allowed_origin.unwrap() == &random_origin); + } + + async fn preflight(app: &Router, random_subdomain: &str, origin: &str) -> TestResponse { + app.clone() + .oneshot( + Request::builder() + .method(Method::OPTIONS) + .uri("/") + .header( + HeaderName::from_static("x-subdomain"), + HeaderValue::from_str(random_subdomain).unwrap(), + ) + .header(header::ORIGIN, HeaderValue::from_str(origin).unwrap()) + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "X-Subdomain") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } +}