diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 04aabd8..ecfb1dc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,8 @@ on: push: branches: [main] + pull_request: + branches: [main] name: Docker env: REGISTRY: ghcr.io diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a28e00d..8af49dd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,6 +1,8 @@ on: push: branches: [main] + pull_request: + branches: [main] name: Server jobs: fmt: @@ -65,7 +67,7 @@ jobs: with: key: coverage - name: Generate code coverage - run: cargo llvm-cov --locked --lcov --output-path lcov.info + run: cargo llvm-cov --locked --workspace --exclude entity --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/.github/workflows/svelte.yml b/.github/workflows/svelte.yml index 9512dff..fdf69ed 100644 --- a/.github/workflows/svelte.yml +++ b/.github/workflows/svelte.yml @@ -1,6 +1,8 @@ on: push: branches: [main] + pull_request: + branches: [main] name: Client env: NODE_VERSION: 18 diff --git a/Cargo.lock b/Cargo.lock index a7e09a9..29daa45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-test-helper" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298f62fa902c2515c169ab0bfb56c593229f33faa01131215d58e3d4898e3aa9" +dependencies = [ + "axum", + "bytes", + "http", + "http-body", + "hyper", + "reqwest", + "serde", + "tokio", + "tower", + "tower-service", +] + [[package]] name = "bae" version = "0.1.7" @@ -666,6 +684,7 @@ dependencies = [ "argon2", "axum", "axum-server", + "axum-test-helper", "chrono", "deezer-rs", "displaydoc", @@ -859,6 +878,17 @@ dependencies = [ "waker-fn", ] +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", +] + [[package]] name = "futures-sink" version = "0.3.28" @@ -880,6 +910,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1308,6 +1339,7 @@ dependencies = [ "async-std", "sea-orm", "sea-orm-migration", + "tokio", ] [[package]] @@ -1765,6 +1797,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "once_cell", "percent-encoding", "pin-project-lite", @@ -1775,10 +1808,12 @@ dependencies = [ "serde_urlencoded", "tokio", "tokio-rustls 0.24.1", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", "winreg", @@ -2984,6 +3019,19 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +[[package]] +name = "wasm-streams" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bbae3363c08332cadccd13b67db371814cd214c2524020932f0804b8cf7c078" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.64" diff --git a/Cargo.toml b/Cargo.toml index e5c1d52..ec9e519 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ license-file = "LICENSE" build = "src/build.rs" [workspace] -members = [".", "entity", "migration", "deezer-rs", "api-macro"] +members = [".", "entity", "migration", "api-macro"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -52,6 +52,8 @@ https = ["dep:axum-server"] [dev-dependencies] serde_test = "1" +axum-test-helper = "0.3" +tokio = { version = "1", features = ["test-util"] } # TODO: fix caching with cargo-chef when optimizing # or not cache for docker build diff --git a/api-macro/src/lib.rs b/api-macro/src/lib.rs index 3d5938b..52549bf 100644 --- a/api-macro/src/lib.rs +++ b/api-macro/src/lib.rs @@ -198,7 +198,7 @@ impl ErrorVariant { fn to_variant(&self) -> syn::Variant { match self { ErrorVariant::InternalError => parse_quote!( - #[doc = "Internal error"] + #[doc = "Internal error: {0}"] #[status(axum::http::status::StatusCode::INTERNAL_SERVER_ERROR)] InternalError( #[from] diff --git a/migration/Cargo.toml b/migration/Cargo.toml index 98f4631..802c8fb 100644 --- a/migration/Cargo.toml +++ b/migration/Cargo.toml @@ -21,3 +21,6 @@ features = [ "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature "sqlx-sqlite", # `DATABASE_DRIVER` feature ] + +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index f906d6c..c52a88a 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -12,3 +12,24 @@ impl MigratorTrait for Migrator { vec![Box::new(m20220101_000001_create_table::Migration)] } } + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::Database; + + #[tokio::test] + async fn test_migrations() { + let db = Database::connect("sqlite::memory:") + .await + .expect("Failed to connect to database"); + + Migrator::up(&db, None) + .await + .expect("Failed to migrate database"); + + Migrator::down(&db, None) + .await + .expect("Failed to migrate database"); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index aa5b6f2..1d592a5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -14,6 +14,8 @@ use self::state::ApiState; mod admin; mod room; mod search; +#[cfg(test)] +mod test; mod websocket; pub mod state; diff --git a/src/api/test.rs b/src/api/test.rs new file mode 100644 index 0000000..b427272 --- /dev/null +++ b/src/api/test.rs @@ -0,0 +1,105 @@ +use axum::http::StatusCode; +use axum_test_helper::TestClient; +use chrono::{Duration, Utc}; +use migration::MigratorTrait; +use sea_orm::Database; +use serde_json::json; + +use crate::utils::jwt; + +use super::{router, state::ApiState}; + +#[tokio::test] +async fn test_admin_login() { + jwt::set_jwt_secret("secret"); + let db = Database::connect("sqlite::memory:").await.unwrap(); + let username = "admin"; + // password: admin + let password = "$argon2id$v=19$m=19456,t=2,p=1$bjFCSXBGR3pJclBraDFOSA$Aiqx8jvWC8UT8Xj9K37DqA"; + let state = ApiState::new(db, username.to_string(), password.to_string()); + let api = router(state); + + let client = TestClient::new(api); + + // Successful login + + let body = json!( { + "username": "admin", + "password": "admin" + }); + + let res = client.post("/admin/login").json(&body).send().await; + assert_eq!(res.status(), StatusCode::OK, "Successful login"); + + // Wrong password + + let body = json!( { + "username": "admin", + "password": "wrong" + }); + let res = client.post("/admin/login").json(&body).send().await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Wrong password"); + + // Wrong username + + let body = json!( { + "username": "wrong", + "password": "admin" + }); + let res = client.post("/admin/login").json(&body).send().await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Wrong username"); + + // Missing username + + let body = json!( { + "password": "admin" + }); + let res = client.post("/admin/login").json(&body).send().await; + assert_ne!(res.status(), StatusCode::OK, "Missing username"); +} + +#[tokio::test] +async fn test_room_create_and_join() { + let admin_token = jwt::admin_token(); + + let db = Database::connect("sqlite::memory:").await.unwrap(); + migration::Migrator::up(&db, None).await.unwrap(); + let username = "admin"; + // password: admin + let password = "$argon2id$v=19$m=19456,t=2,p=1$bjFCSXBGR3pJclBraDFOSA$Aiqx8jvWC8UT8Xj9K37DqA"; + let state = ApiState::new(db, username.to_string(), password.to_string()); + let client = TestClient::new(router(state)); + + // Create room + + let res = client + .post("/room") + .json(&json!({ + "id": "AAAAAA", + "expiration": Utc::now() + Duration::hours(5) + })) + .header("Authorization", admin_token) + .send() + .await; + assert_eq!(res.status(), StatusCode::OK, "Create rooom"); + + // Join the room + + let res = client.get("/room/AAAAAA/join").send().await; + let status = res.status(); + eprintln!("{}", res.text().await); + assert_eq!(status, StatusCode::OK, "Join room"); + + // Join error + + let res = client.get("/room/ZZZZZZ/join").send().await; + assert_eq!(res.status(), StatusCode::NOT_FOUND, "Room does not exist"); + + // Room id error + + let res = client.get("/room/ZZZZ/join").send().await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Id to short"); + + let res = client.get("/room/ZZZZZZZZZ/join").send().await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Id to short"); +} diff --git a/src/utils/cors.rs b/src/utils/cors.rs index e50c819..0e9978a 100644 --- a/src/utils/cors.rs +++ b/src/utils/cors.rs @@ -39,3 +39,17 @@ fn parse_allow_origin(var: &str) -> AllowOrigin { origins.into() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_allow_origin() { + let var = "http://localhost:3000,http://localhost:3001"; + let _ = parse_allow_origin(var); + + let var = "*"; + let _ = parse_allow_origin(var); + } +} diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index cd43c88..5188b3d 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -7,6 +7,7 @@ use axum::{ use chrono::{DateTime, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; +use thiserror::Error; use tokio::sync::OnceCell; use uuid::Uuid; @@ -80,10 +81,10 @@ impl FromRequestParts for User { } } -#[derive(Debug, displaydoc::Display)] +#[derive(Debug, PartialEq, Eq, displaydoc::Display, Error)] pub enum JwtVerifyError { /// The JWT is invalid: {0} - InvalidJwt(String), + InvalidJwt(#[from] jsonwebtoken::errors::Error), /// JWT Token not valid yet NotValidYet, /// JWT Token expired @@ -121,10 +122,7 @@ pub fn sign(user: User, exp: DateTime) -> String { pub fn verify(token: &str) -> Result { let token_decode = jsonwebtoken::decode(token, get_jwt_decoder(), &Validation::default()); - let claim: Claims = match token_decode { - Ok(token_data) => token_data.claims, - Err(e) => return Err(JwtVerifyError::InvalidJwt(e.to_string())), - }; + let claim: Claims = token_decode?.claims; let now = Utc::now().timestamp(); if claim.iat > now { return Err(JwtVerifyError::NotValidYet); @@ -161,12 +159,13 @@ pub fn set_jwt_secret(secret: &str) { // This is safe because JWT_SECRET is only initialized once at the start of the program - let res = JWT_SECRET.set(JwtKeyPair { + let _res = JWT_SECRET.set(JwtKeyPair { encoder: EncodingKey::from_secret(secret_bytes), decoder: DecodingKey::from_secret(secret_bytes), }); - if res.is_err() { + #[cfg(not(test))] + if _res.is_err() { panic!("JWT_SECRET is already set"); } } @@ -190,3 +189,55 @@ fn get_jwt_decoder() -> &'static DecodingKey { // This is safe because JWT_SECRET is only initialized once at the start of the program &JWT_SECRET.get().expect("JWT secret not set").decoder } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + + #[test] + fn test_jwt() { + set_jwt_secret("secret"); + let user = User::new_user(RoomID::new(0)); + let exp = Utc::now() + Duration::hours(1); + let token = sign(user, exp); + let user = verify(&token).unwrap(); + assert_eq!( + user.role, + Role::User { + room_id: RoomID::new(0) + } + ); + + let user = User::new_user(RoomID::new(0)); + let exp = Utc::now() - Duration::hours(1); + let token = sign(user, exp); + let user = verify(&token); + assert!(user.is_err()); + + let user = User::new_user(RoomID::new(0)); + let exp = Utc::now() + Duration::hours(2); + let token = jsonwebtoken::encode( + &Header::default(), + &Claims { + user, + iat: (Utc::now() + Duration::hours(1)).timestamp(), + exp: exp.timestamp(), + }, + get_jwt_encoder(), + ) + .unwrap(); + let user = verify(&token); + assert!(user.is_err()); + assert_eq!(user.unwrap_err(), JwtVerifyError::NotValidYet); + } +} + +#[cfg(test)] +pub(crate) fn admin_token() -> String { + use chrono::Duration; + + let exp = Utc::now() + Duration::hours(5); + let token = User::new_admin().into_token(exp).access_token; + format!("Bearer {token}") +}