diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 07db8b6..08d72cd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -20,6 +20,22 @@ jobs: test: name: Test Suite runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_DB: testaustime + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v2 - uses: actions-rs/toolchain@v1 @@ -27,9 +43,17 @@ jobs: profile: minimal toolchain: nightly override: true - - uses: actions-rs/cargo@v1 - with: - command: test + - name: Install Diesel + run: cargo install diesel_cli --features=postgres + - name: Create Test DB + env: + DATABASE_URL: postgres://postgres:postgres@localhost/testaustime + run: diesel migration run + + - name: Run tests + env: + TEST_DATABASE: postgres://postgres:postgres@localhost/testaustime + run: cargo test fmt: name: Rustfmt diff --git a/Cargo.lock b/Cargo.lock index df9b6c5..ca52fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1368,17 +1368,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - [[package]] name = "rand" version = "0.8.5" @@ -1546,15 +1535,6 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - [[package]] name = "scoped-futures" version = "0.1.3" @@ -1772,7 +1752,6 @@ dependencies = [ "http", "itertools", "log", - "r2d2", "rand", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 68bb9c4..c60cb88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,6 @@ regex = "1.5" tracing = "0.1.37" tracing-actix-web = "0.6.2" -r2d2 = "0.8" - log = "0.4" env_logger = "0.9" thiserror = "1.0" diff --git a/src/api/leaderboards.rs b/src/api/leaderboards.rs index 4f3083c..733ab4c 100644 --- a/src/api/leaderboards.rs +++ b/src/api/leaderboards.rs @@ -5,7 +5,7 @@ use actix_web::{ }; use dashmap::DashMap; use diesel::result::DatabaseErrorKind; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::{ api::auth::SecuredUserIdentity, @@ -14,12 +14,12 @@ use crate::{ models::{PrivateLeaderboard, UserId}, }; -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct LeaderboardName { pub name: String, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct LeaderboardInvite { pub invite: String, } diff --git a/src/error.rs b/src/error.rs index d05e852..812e0b3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,8 +7,6 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum TimeError { - #[error("Failed to connect to database connection pool")] - R2d2Error(#[from] r2d2::Error), #[error("Failed to connect to database connection pool")] DeadpoolError(#[from] diesel_async::pooled_connection::deadpool::PoolError), #[error("Diesel transaction failed `{0}`")] diff --git a/src/main.rs b/src/main.rs index 0a17dbb..827895c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,9 @@ mod requests; mod schema; mod utils; +#[cfg(test)] +mod tests; + use std::{num::NonZeroU32, sync::Arc}; use actix_cors::Cors; diff --git a/src/models.rs b/src/models.rs index 028be3d..a2ffb34 100644 --- a/src/models.rs +++ b/src/models.rs @@ -59,7 +59,7 @@ pub struct NewTestaustimeUser { pub identity: i32, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct SelfUser { pub id: i32, pub auth_token: String, @@ -107,7 +107,7 @@ pub struct TestausIdUser { use crate::schema::user_identities; -#[derive(Insertable, Serialize, Clone)] +#[derive(Insertable, Serialize, Clone, Deserialize)] #[diesel(table_name = user_identities)] pub struct NewUserIdentity { pub auth_token: String, @@ -202,7 +202,7 @@ pub struct NewLeaderboardMember { pub admin: bool, } -#[derive(Serialize, Clone, Debug)] +#[derive(Serialize, Clone, Debug, Deserialize)] pub struct PrivateLeaderboardMember { pub id: i32, pub username: String, @@ -210,7 +210,7 @@ pub struct PrivateLeaderboardMember { pub time_coded: i32, } -#[derive(Serialize, Clone, Debug)] +#[derive(Serialize, Clone, Debug, Deserialize)] pub struct PrivateLeaderboard { pub name: String, pub invite: String, diff --git a/src/requests.rs b/src/requests.rs index dabe4b1..7ddcddb 100644 --- a/src/requests.rs +++ b/src/requests.rs @@ -39,7 +39,7 @@ pub struct DataRequest { pub project_name: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub struct RegisterRequest { pub username: String, pub password: String, diff --git a/src/tests/account.rs b/src/tests/account.rs new file mode 100644 index 0000000..e8d90ea --- /dev/null +++ b/src/tests/account.rs @@ -0,0 +1,67 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use actix_web::test::{self, TestRequest}; +use serde_json::json; + +use super::{macros::*, *}; +use crate::models::{NewUserIdentity, SecuredAccessTokenResponse}; + +#[actix_web::test] +async fn public_accounts() { + let app = test::init_service(App::new().configure(init_test_services)).await; + + let body = json!({"username": "celebrity", "password": "password"}); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + let resp = request!(app, addr, post, "/auth/register", body); + assert!(resp.status().is_success(), "Creating user failed"); + + let user: NewUserIdentity = test::read_body_json(resp).await; + let resp = request_auth!(app, addr, get, "/users/@me", user.auth_token); + + assert!(resp.status().is_success(), "Getting profile failed"); + + let profile: serde_json::Value = test::read_body_json(resp).await; + assert!( + !profile["is_public"].as_bool().unwrap(), + "New account should be private" + ); + + let resp = request!(app, addr, get, "/users/celebrity/activity/data"); + assert!( + resp.status().is_client_error(), + "Data should be private for private accounts" + ); + + let resp = request!(app, addr, post, "/auth/securedaccess", body); + assert!( + resp.status().is_success(), + "Getting secured access token failed" + ); + let sat: SecuredAccessTokenResponse = test::read_body_json(resp).await; + + let change = json!({"public_profile": true}); + let resp = request_auth!(app, addr, post, "/account/settings", sat.token, change); + + assert!(resp.status().is_success(), "Changing settings failed"); + + let resp = request_auth!(app, addr, get, "/users/@me", user.auth_token); + + assert!(resp.status().is_success(), "Getting profile failed"); + + let profile: serde_json::Value = test::read_body_json(resp).await; + assert!( + profile["is_public"].as_bool().unwrap(), + "Setting account public failed" + ); + + let resp = request!(app, addr, get, "/users/celebrity/activity/data"); + assert!( + resp.status().is_success(), + "Data should be public for public accounts" + ); + + let resp = request!(app, addr, delete, "/users/@me/delete", body); + assert!(resp.status().is_success(), "Failed to delete user"); +} + +// TODO: add test for searching public accounts diff --git a/src/tests/activity.rs b/src/tests/activity.rs new file mode 100644 index 0000000..12975e5 --- /dev/null +++ b/src/tests/activity.rs @@ -0,0 +1,173 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use actix_web::test::{self, TestRequest}; +use serde_json::json; + +use super::{macros::*, *}; +use crate::{ + models::{CurrentActivity, NewUserIdentity}, + requests::HeartBeat, +}; + +#[actix_web::test] +async fn updating_activity_works() { + let app = test::init_service(App::new().configure(init_test_services)).await; + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + + let body = json!({"username": "activeuser", "password": "password"}); + let resp = request!(app, addr, post, "/auth/register", body); + let user: NewUserIdentity = test::read_body_json(resp).await; + + let heartbeat = HeartBeat { + hostname: Some(String::from("hostname")), + project_name: Some(String::from("cool project")), + language: Some(String::from("rust")), + editor_name: Some(String::from("nvim")), + }; + + let resp = request_auth!( + app, + addr, + post, + "/activity/update", + user.auth_token, + heartbeat + ); + assert!( + resp.status().is_success(), + "Sending heartbeat should succeed" + ); + + let resp = request_auth!( + app, + addr, + get, + "/users/@me/activity/current", + user.auth_token + ); + assert!( + resp.status().is_success(), + "Getting current activity should work" + ); + + let current: CurrentActivity = test::read_body_json(resp).await; + + assert_eq!( + heartbeat, current.heartbeat, + "Active session should match the sent heartbeat" + ); + + // NOTE: adding duration to the session + actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await; + + let resp = request_auth!( + app, + addr, + post, + "/activity/update", + user.auth_token, + heartbeat + ); + assert!(resp.status().is_success(), "Extending session should work"); + + let resp = request_auth!( + app, + addr, + get, + "/users/@me/activity/current", + user.auth_token + ); + let current: CurrentActivity = test::read_body_json(resp).await; + assert!( + current.duration >= 1, + "Duration should be at least 1 second" + ); + + let new_heartbeat = HeartBeat { + hostname: Some(String::from("hostname")), + project_name: Some(String::from("another project")), + language: Some(String::from("rust")), + editor_name: Some(String::from("nvim")), + }; + let resp = request_auth!( + app, + addr, + post, + "/activity/update", + user.auth_token, + new_heartbeat + ); + assert!( + resp.status().is_success(), + "Sending heartbeat should succeed" + ); + + let resp = request_auth!( + app, + addr, + get, + "/users/@me/activity/current", + user.auth_token + ); + let current: CurrentActivity = test::read_body_json(resp).await; + assert!( + current.heartbeat == new_heartbeat, + "Mismatch should start new session" + ); + + let resp = request_auth!(app, addr, get, "/users/@me/activity/data", user.auth_token); + let data: Vec = test::read_body_json(resp).await; + + assert!(!data.is_empty(), "Old session is stored in the database"); + + let resp = request!(app, addr, delete, "/users/@me/delete", body); + assert!(resp.status().is_success(), "Failed to delete user"); +} + +#[actix_web::test] +async fn flushing_works() { + let app = test::init_service(App::new().configure(init_test_services)).await; + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + + let body = json!({"username": "activeuser2", "password": "password"}); + let resp = request!(app, addr, post, "/auth/register", body); + let user: NewUserIdentity = test::read_body_json(resp).await; + + let heartbeat = HeartBeat { + hostname: Some(String::from("hostname")), + project_name: Some(String::from("cool project")), + language: Some(String::from("rust")), + editor_name: Some(String::from("nvim")), + }; + + let resp = request_auth!( + app, + addr, + post, + "/activity/update", + user.auth_token, + heartbeat + ); + assert!( + resp.status().is_success(), + "Sending heartbeat should succeed" + ); + + let resp = request_auth!(app, addr, get, "/users/@me/activity/data", user.auth_token); + let data: Vec = test::read_body_json(resp).await; + + assert!(data.is_empty(), "No session should exist"); + + let resp = request_auth!(app, addr, post, "/activity/flush", user.auth_token); + assert!(resp.status().is_success(), "Flushing should work"); + + let resp = request_auth!(app, addr, get, "/users/@me/activity/data", user.auth_token); + let data: Vec = test::read_body_json(resp).await; + + assert!(!data.is_empty(), "Session should be saved after a flush"); + + let resp = request!(app, addr, delete, "/users/@me/delete", body); + assert!(resp.status().is_success(), "Failed to delete user"); +} + +// TODO: write tests for /activity/delete and /activity/rename diff --git a/src/tests/auth.rs b/src/tests/auth.rs new file mode 100644 index 0000000..bdf7a20 --- /dev/null +++ b/src/tests/auth.rs @@ -0,0 +1,156 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use actix_web::test::{self, TestRequest}; +use serde_json::json; + +use super::{macros::*, *}; +use crate::models::{NewUserIdentity, SecuredAccessTokenResponse, SelfUser}; + +#[actix_web::test] +async fn register_and_delete() { + let app = test::init_service(App::new().configure(init_test_services)).await; + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + let body = json!({"username": "testuser", "password": "password"}); + + let resp = request!(app, addr, post, "/auth/register", body); + assert!(resp.status().is_success(), "Failed to create user"); + let user: NewUserIdentity = test::read_body_json(resp).await; + + let resp = request!(app, addr, post, "/auth/register", body); + assert!(resp.status().is_client_error(), "Usernames must be unique"); + + let resp = request_auth!(app, addr, get, "/users/@me", user.auth_token); + assert!( + resp.status().is_success(), + "Authentication token should work" + ); + + let resp = request!(app, addr, delete, "/users/@me/delete", body); + assert!(resp.status().is_success(), "Failed to delete user"); + + let resp = request!(app, addr, post, "/auth/login", body); + assert!(resp.status().is_client_error(), "User should be deleted") +} + +#[actix_web::test] +async fn login_change_username_and_password() { + let app = test::init_service(App::new().configure(init_test_services)).await; + + let body = json!({"username": "testuser2", "password": "password"}); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + + let resp = request!(app, addr, post, "/auth/register", body); + assert!(resp.status().is_success(), "Failed to create user"); + let user: NewUserIdentity = test::read_body_json(resp).await; + + let resp = request!(app, addr, post, "/auth/login", body); + assert!(resp.status().is_success(), "Login failed"); + let login_user: SelfUser = test::read_body_json(resp).await; + + assert_eq!( + user.auth_token, login_user.auth_token, + "Auth tokens should be equal" + ); + + let resp = request!(app, addr, post, "/auth/securedaccess", body); + assert!( + resp.status().is_success(), + "Getting secured access token failed" + ); + + let sat: SecuredAccessTokenResponse = test::read_body_json(resp).await; + + let change_request = json!({ + "new": "testuser3" + }); + + let resp = request_auth!( + app, + addr, + post, + "/auth/changeusername", + user.auth_token, + change_request + ); + assert!( + resp.status().is_client_error(), + "Auth token is not secured access token" + ); + + let resp = request_auth!( + app, + addr, + post, + "/auth/changeusername", + sat.token, + change_request + ); + assert!(resp.status().is_success(), "Username change failed"); + + let resp = request_auth!(app, addr, get, "/users/@me", user.auth_token); + let renamed_user: serde_json::Value = test::read_body_json(resp).await; + assert_eq!( + renamed_user["username"], change_request["new"], + "Username not changed" + ); + + let pw_request = json!({ + "old": "password", + "new": "password1", + }); + + let resp = request_auth!( + app, + addr, + post, + "/auth/changepassword", + user.auth_token, + pw_request + ); + assert!(resp.status().is_success(), "Password change failed"); + + let new_body = json!({"username": "testuser3", "password": "password1"}); + + let resp = request!(app, addr, post, "/auth/login", new_body); + assert!(resp.status().is_success(), "Password not changed"); + + let resp = request!(app, addr, delete, "/users/@me/delete", new_body); + assert!(resp.status().is_success(), "Failed to delete user"); +} + +#[actix_web::test] +async fn invalid_usernames_and_passwords_are_rejected() { + let app = test::init_service(App::new().configure(init_test_services)).await; + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + + let body = json!({"username": "invalid[$$]", "password": "password"}); + let resp = request!(app, addr, post, "/auth/register", body); + assert!( + resp.status().is_client_error(), + "Invalid username should fail" + ); + let resp_body: serde_json::Value = test::read_body_json(resp).await; + + assert!(resp_body["error"] + .as_str() + .unwrap() + .to_ascii_lowercase() + .contains("username")); + + let body = json!({"username": "validusername", "password": "short"}); + let resp = request!(app, addr, post, "/auth/register", body); + assert!( + resp.status().is_client_error(), + "Too short password should fail" + ); + let resp_body: serde_json::Value = test::read_body_json(resp).await; + + assert!(resp_body["error"] + .as_str() + .unwrap() + .to_ascii_lowercase() + .contains("password")); +} + +// TODO: test ratelimits diff --git a/src/tests/friends.rs b/src/tests/friends.rs new file mode 100644 index 0000000..db83f5f --- /dev/null +++ b/src/tests/friends.rs @@ -0,0 +1,81 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use actix_web::test::{self, TestRequest}; +use serde_json::json; + +use super::{macros::*, *}; +use crate::models::NewUserIdentity; + +#[actix_web::test] +async fn adding_friends_works() { + let app = test::init_service(App::new().configure(init_test_services)).await; + + let f1_body = json!({"username": "friend1", "password": "password"}); + let f2_body = json!({"username": "friend2", "password": "password"}); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + let other_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 80u16); + + let resp = request!(app, addr, post, "/auth/register", f1_body); + assert!(resp.status().is_success(), "Failed to create user"); + let f1: NewUserIdentity = test::read_body_json(resp).await; + + let resp = request!(app, other_addr, post, "/auth/register", f2_body); + assert!(resp.status().is_success(), "Failed to create user"); + let f2: NewUserIdentity = test::read_body_json(resp).await; + + let resp = TestRequest::post() + .peer_addr(addr) + .uri("/friends/add") + .insert_header(("authorization", "Bearer ".to_owned() + &f1.auth_token)) + .set_payload(f2.friend_code.clone()) + .send_request(&app) + .await; + + assert!(resp.status().is_success(), "Adding friend works"); + + let resp = TestRequest::post() + .peer_addr(addr) + .uri("/friends/add") + .insert_header(("authorization", "Bearer ".to_owned() + &f1.auth_token)) + .set_payload(f2.friend_code.clone()) + .send_request(&app) + .await; + + assert!(resp.status().is_client_error(), "Re-adding friend fails"); + + let resp = TestRequest::post() + .peer_addr(addr) + .uri("/friends/add") + .insert_header(("authorization", "Bearer ".to_owned() + &f1.auth_token)) + .set_payload(f1.friend_code.clone()) + .send_request(&app) + .await; + + assert!(resp.status().is_client_error(), "Adding self fails"); + + let resp = request_auth!( + app, + addr, + get, + &format!("/users/{}/activity/data", &f1.username), + f2.auth_token + ); + assert!( + resp.status().is_success(), + "Friends can see eachothers data" + ); + + let resp = request_auth!(app, addr, get, "/friends/list", f2.auth_token); + assert!(resp.status().is_success(), "Getting friends-list works"); + + let friends: Vec = test::read_body_json(resp).await; + assert_eq!(friends.len(), 1, "Friend appears in friends-list"); + + let resp = request!(app, addr, delete, "/users/@me/delete", f1_body); + assert!(resp.status().is_success(), "Failed to delete user"); + + let resp = request!(app, addr, delete, "/users/@me/delete", f2_body); + assert!(resp.status().is_success(), "Failed to delete user"); +} + +// TODO: write tests for /friends/regenerate and /friends/remove diff --git a/src/tests/leaderboards.rs b/src/tests/leaderboards.rs new file mode 100644 index 0000000..099c7dd --- /dev/null +++ b/src/tests/leaderboards.rs @@ -0,0 +1,126 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use actix_web::test::{self, TestRequest}; +use serde_json::json; + +use super::{macros::*, *}; +use crate::{ + api::leaderboards::{LeaderboardInvite, LeaderboardName}, + models::{NewUserIdentity, PrivateLeaderboard, SecuredAccessTokenResponse}, +}; + +#[actix_web::test] +async fn creation_joining_and_deletion() { + let app = test::init_service(App::new().configure(init_test_services)).await; + + let owner_body = json!({"username": "leaderboardowner", "password": "password"}); + let member_body = json!({"username": "leaderboardmember", "password": "password"}); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 80u16); + let addr2 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 80u16); + + let resp = request!(app, addr, post, "/auth/register", owner_body); + assert!(resp.status().is_success(), "Creating user failed"); + + let owner: NewUserIdentity = test::read_body_json(resp).await; + + let resp = request!(app, addr2, post, "/auth/register", member_body); + assert!(resp.status().is_success(), "Creating user failed"); + + let member: NewUserIdentity = test::read_body_json(resp).await; + + let create = LeaderboardName { + name: "board".to_string(), + }; + + let resp = request_auth!( + app, + addr, + post, + "/leaderboards/create", + owner.auth_token, + create + ); + + assert!(resp.status().is_success(), "Leaderboard creation failed"); + + let created: serde_json::Value = test::read_body_json(resp).await; + + let resp = request_auth!( + app, + addr, + post, + "/leaderboards/create", + owner.auth_token, + create + ); + + assert!( + resp.status().is_client_error(), + "Duplicate leaderboards cannot exist" + ); + + let invite = LeaderboardInvite { + invite: created["invite_code"].as_str().unwrap().to_string(), + }; + + let resp = request_auth!( + app, + addr, + post, + "/leaderboards/join", + member.auth_token, + invite + ); + + assert!(resp.status().is_success(), "Joining leaderboard failed"); + + let resp = request_auth!( + app, + addr, + post, + "/leaderboards/join", + owner.auth_token, + invite + ); + + assert!( + resp.status().is_client_error(), + "Trying to re-join a leaderboard should fail" + ); + + let resp = request_auth!(app, addr, get, "/leaderboards/board", member.auth_token); + + assert!(resp.status().is_success(), "Getting leaderboard failed"); + + let board: PrivateLeaderboard = test::read_body_json(resp).await; + assert_eq!( + board.members.len(), + 2, + "Leaderboard member count should be 2" + ); + + let resp = request!(app, addr, post, "/auth/securedaccess", owner_body); + assert!( + resp.status().is_success(), + "Getting secured access token failed" + ); + let sat: SecuredAccessTokenResponse = test::read_body_json(resp).await; + + let resp = request_auth!(app, addr, delete, "/leaderboards/board", sat.token); + assert!(resp.status().is_success(), "Leaderboards deletion failed"); + + let resp = request_auth!(app, addr, get, "/leaderboards/board", member.auth_token); + + assert!( + resp.status().is_client_error(), + "Leaderboard should be deleted" + ); + + let resp = request!(app, addr, delete, "/users/@me/delete", owner_body); + assert!(resp.status().is_success(), "Failed to delete user"); + + let resp = request!(app, addr, delete, "/users/@me/delete", member_body); + assert!(resp.status().is_success(), "Failed to delete user"); +} + +// TODO: add tests for all the leaderboards endpoints diff --git a/src/tests/macros.rs b/src/tests/macros.rs new file mode 100644 index 0000000..5ec0209 --- /dev/null +++ b/src/tests/macros.rs @@ -0,0 +1,40 @@ +macro_rules! request { + ($app:expr, $addr:expr, $method:tt, $uri:expr) => { + TestRequest::$method() + .peer_addr($addr) + .uri($uri) + .send_request(&$app) + .await + }; + ($app:expr, $addr:expr, $method:tt, $uri:expr, $body:expr) => { + TestRequest::$method() + .peer_addr($addr) + .uri($uri) + .set_json(&$body) + .send_request(&$app) + .await + }; +} + +macro_rules! request_auth { + ($app:expr, $addr:expr, $method:tt, $uri:expr, $token:expr) => { + TestRequest::$method() + .peer_addr($addr) + .uri($uri) + .insert_header(("authorization", "Bearer ".to_owned() + &$token)) + .send_request(&$app) + .await + }; + ($app:expr, $addr:expr, $method:tt, $uri:expr, $token:expr, $body:expr) => { + TestRequest::$method() + .peer_addr($addr) + .uri($uri) + .set_json(&$body) + .insert_header(("authorization", "Bearer ".to_owned() + &$token)) + .send_request(&$app) + .await + }; +} + +pub(crate) use request; +pub(crate) use request_auth; diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..fa53262 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,132 @@ +// TODO add tests for oauth and improve test coverage +mod account; +mod activity; +mod auth; +mod friends; +mod leaderboards; +mod macros; + +use std::{num::NonZeroU32, sync::Arc}; + +use actix_web::{ + http::StatusCode, + test, web, + web::{Data, ServiceConfig}, + App, +}; +use governor::{Quota, RateLimiter}; + +// NOTE: We would like to use diesels Connection::begin_test_transaction +// But cannot use them because our database uses transactions to implement +// some of the routes and there cannot exists transactions within transactions :'( +use crate::database::Database; + +// FIXME: There is quite a lot of duplicate code from main +// in this function, perhaps these functions could be unified somehow. +fn init_test_services(cfg: &mut ServiceConfig) { + let db_url = + std::env::var("TEST_DATABASE").expect("TEST_DATABASE not set, refusing to run tests"); + + let ratelimiter = Arc::new( + RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(500_u32).unwrap())).with_middleware(), + ); + + let heartbeat_store = Data::new(crate::api::activity::HeartBeatMemoryStore::new()); + let leaderboard_cache = Data::new(crate::api::leaderboards::LeaderboardCache::new()); + + let secured_access_token_storage = Data::new(crate::SecuredAccessTokenStorage::new()); + + #[cfg(feature = "testausid")] + let client = crate::Client::new(); + + let cors = crate::Cors::default() + .allow_any_origin() + .allowed_methods(vec!["GET", "POST", "DELETE"]) + .allowed_headers(vec![ + http::header::AUTHORIZATION, + http::header::ACCEPT, + http::header::CONTENT_TYPE, + ]) + .max_age(3600); + let query_config = crate::QueryConfig::default().error_handler(|err, _| match err { + crate::QueryPayloadError::Deserialize(e) => { + crate::ErrorBadRequest(json!({ "error": e.to_string() })) + } + _ => unreachable!(), + }); + + cfg.service( + web::scope("") + .app_data(Data::new(crate::RegisterLimiter { + limit_by_peer_ip: false, + storage: crate::DashMap::new(), + })) + .app_data(Data::new(Database::new(db_url))) + .app_data(query_config) + .app_data(Data::clone(&secured_access_token_storage)) + .wrap(cors) + .service(crate::api::health) + .service(crate::api::auth::register) + .service({ + let scope = web::scope("") + .wrap(crate::AuthMiddleware) + .wrap(crate::TestaustimeRateLimiter { + limiter: Arc::clone(&ratelimiter), + use_peer_addr: false, + bypass_token: String::from("balls"), + }) + .service({ + web::scope("/activity") + .service(crate::api::activity::update) + .service(crate::api::activity::delete) + .service(crate::api::activity::flush) + .service(crate::api::activity::rename_project) + }) + .service(crate::api::auth::login) + .service(crate::api::auth::regenerate) + .service(crate::api::auth::changeusername) + .service(crate::api::auth::changepassword) + .service(crate::api::auth::get_secured_access_token) + .service(crate::api::account::change_settings) + .service(crate::api::friends::add_friend) + .service(crate::api::friends::get_friends) + .service(crate::api::friends::regenerate_friend_code) + .service(crate::api::friends::remove) + .service(crate::api::users::my_profile) + .service(crate::api::users::get_activities) + .service(crate::api::users::get_current_activity) + .service(crate::api::users::delete_user) + .service(crate::api::users::my_leaderboards) + .service(crate::api::users::get_activity_summary) + .service(crate::api::leaderboards::create_leaderboard) + .service(crate::api::leaderboards::get_leaderboard) + .service(crate::api::leaderboards::join_leaderboard) + .service(crate::api::leaderboards::leave_leaderboard) + .service(crate::api::leaderboards::delete_leaderboard) + .service(crate::api::leaderboards::promote_member) + .service(crate::api::leaderboards::demote_member) + .service(crate::api::leaderboards::kick_member) + .service(crate::api::leaderboards::regenerate_invite) + .service(crate::api::search::search_public_users) + .service(crate::api::stats::stats); + #[cfg(feature = "testausid")] + { + scope.service(crate::api::oauth::callback) + } + }), + ) + .app_data(Data::clone(&heartbeat_store)) + .app_data(Data::clone(&leaderboard_cache)); + #[cfg(feature = "testausid")] + { + cfg.app_data(Data::new(client)); + } +} + +#[actix_web::test] +async fn health() { + let app = test::init_service(App::new().configure(init_test_services)).await; + let req = test::TestRequest::with_uri("/health").to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK) +}