267 changes: 265 additions & 2 deletions backend/Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions backend/Cargo.toml
Expand Up @@ -50,3 +50,8 @@ tracing-subscriber = { version = "0.3", features = [
'tracing-log',
] }
uuid = { version = "1", features = ["v4", "serde"] }

[dev-dependencies]
fake = "2.6"
mockall = "0.11"
reqwest = { version = "0.11", features = ["json", "cookies", "multipart"] }
20 changes: 20 additions & 0 deletions backend/README.md
Expand Up @@ -20,4 +20,24 @@ And then:
~/rust-auth/backend$ cargo watch -x 'run -- --release'
```

For the tests, do:

```bash
~/rust-auth/backend$ cargo test
```

If you get an error that looks like this, the `"Too many open files"` error:

```shell
---- users::register::test_register_user_failure_email stdout ----
thread 'actix-server worker 7' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 24, kind: Uncategorized, message: "Too many open files" }', /Users/joidogun/.cargo/registry/src/github.com-1ecc6299db9ec823/actix-server-2.2.0/src/worker.rs:400:34
```

It has nothing to do with the tests failing. You can find explanation and fix [here][2] or temporarily increase the system-wide maximum number of file handles (I used `50000` here):

```shell
ulimit -n 50000
```

[1]: https://crates.io/crates/cargo-watch "Cargo watch"
[2]: https://www.phind.com/search?cache=9d09941e-bee6-4892-a3a9-7f0c76d5aea9 "Too many open files error"
2 changes: 1 addition & 1 deletion backend/src/routes/users/register.rs
Expand Up @@ -70,7 +70,7 @@ pub async fn register_user(
error: "Error inserting user into the database".to_string(),
}
};
return actix_web::HttpResponse::InternalServerError().json(error_message);
return actix_web::HttpResponse::BadRequest().json(error_message);
}
};

Expand Down
4 changes: 2 additions & 2 deletions backend/src/types/general.rs
@@ -1,9 +1,9 @@
#[derive(serde::Serialize)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ErrorResponse {
pub error: String,
}

#[derive(serde::Serialize)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SuccessResponse {
pub message: String,
}
Expand Down
119 changes: 119 additions & 0 deletions backend/tests/api/helpers.rs
@@ -0,0 +1,119 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use once_cell::sync::Lazy;
use sqlx::Row;

static TRACING: Lazy<()> = Lazy::new(|| {
let subscriber = backend::telemetry::get_subscriber(false);
backend::telemetry::init_subscriber(subscriber);
});

pub struct TestApp {
pub address: String,
pub test_user: TestUser,
pub api_client: reqwest::Client,
}

impl TestApp {
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(&format!("{}/users/login/", &self.address))
.json(body)
.send()
.await
.expect("Failed to execute request.")
}
}

pub async fn spawn_app(pool: sqlx::postgres::PgPool) -> TestApp {
dotenv::from_filename(".env.test").ok();
Lazy::force(&TRACING);

let settings = {
let mut s = backend::settings::get_settings().expect("Failed to read settings.");
// Use a random OS port
s.application.port = 0;
s
};

let application = backend::startup::Application::build(settings.clone(), Some(pool.clone()))
.await
.expect("Failed to build application.");
let address = format!("http://127.0.0.1:{}", application.port());

let _ = tokio::spawn(application.run_until_stopped());

let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.cookie_store(true)
.build()
.unwrap();

let test_app = TestApp {
address,
test_user: TestUser::generate().await,
api_client: client,
};

test_app.test_user.store(&pool).await;

test_app
}

pub struct TestUser {
pub email: String,
pub password: String,
first_name: String,
last_name: String,
}

impl TestUser {
pub async fn generate() -> Self {
Self {
email: uuid::Uuid::new_v4().to_string(),
password: uuid::Uuid::new_v4().to_string(),
first_name: uuid::Uuid::new_v4().to_string(),
last_name: uuid::Uuid::new_v4().to_string(),
}
}

async fn store(&self, pool: &sqlx::postgres::PgPool) {
let salt = SaltString::generate(&mut OsRng);

let password_hash = Argon2::default()
.hash_password(self.password.as_bytes(), &salt)
.expect("Unable to hash password.")
.to_string();

let user_id = sqlx::query(
"INSERT INTO users (email, password, first_name, last_name, is_active, is_staff, is_superuser)
VALUES ($1, $2, $3, $4, true, true, true) RETURNING id"
)
.bind(&self.email)
.bind(password_hash)
.bind(&self.first_name)
.bind(&self.last_name)
.map(|row: sqlx::postgres::PgRow| -> uuid::Uuid{
row.get("id")
})
.fetch_one(pool)
.await
.expect("Failed to store test user.");

sqlx::query(
"INSERT INTO user_profile (user_id)
VALUES ($1)
ON CONFLICT (user_id)
DO NOTHING",
)
.bind(user_id)
.execute(pool)
.await
.expect("Cannot store user_profile to the DB");
}
}
2 changes: 2 additions & 0 deletions backend/tests/api/main.rs
@@ -0,0 +1,2 @@
mod helpers;
mod users;
62 changes: 62 additions & 0 deletions backend/tests/api/users/current_user.rs
@@ -0,0 +1,62 @@
use crate::helpers::spawn_app;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}

#[sqlx::test]
async fn test_get_current_user_failure(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// Then get current user
let get_user_response = app
.api_client
.get(&format!("{}/users/current-user/", &app.address))
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = get_user_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(
response.error,
"You are not logged in. Kindly ensure you are logged in and try again"
);
}

#[sqlx::test]
async fn test_get_current_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// First login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());

// Then get current user
let get_user_response = app
.api_client
.get(&format!("{}/users/current-user/", &app.address))
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = get_user_response
.json::<backend::types::UserVisible>()
.await
.expect("Cannot get user response");

assert_eq!(response.email, app.test_user.email);
assert!(response.is_active);
assert_eq!(response.id, response.profile.user_id);
}
76 changes: 76 additions & 0 deletions backend/tests/api/users/login.rs
@@ -0,0 +1,76 @@
use crate::helpers::spawn_app;
use fake::Fake;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}

#[sqlx::test]
async fn test_login_user_failure_bad_request(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// Act - Part 1 - Login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: fake::faker::name::en::NameWithTitle().fake(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_client_error());

let error_response = login_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(error_response.error, "Email and password do not match");
}

#[sqlx::test]
async fn test_login_user_failure_notfound(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// Act - Part 1 - Login
let login_body = LoginUser {
email: fake::faker::internet::en::SafeEmail().fake(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_client_error());

let error_response = login_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(error_response.error, "A user with these details does not exist. If you registered with these details, ensure you activate your account by clicking on the link sent to your e-mail address");
}

#[sqlx::test]
async fn test_login_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());

// Check that there is cookie present
let headers = login_response.headers();
assert!(headers.get("set-cookie").is_some());
let cookie_str = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie_str.contains("sessionid="));

// Check response
let response = login_response
.json::<backend::types::UserVisible>()
.await
.expect("Cannot get user response");

assert_eq!(response.email, app.test_user.email);
assert!(response.is_active);
assert_eq!(response.id, response.profile.user_id);
}
66 changes: 66 additions & 0 deletions backend/tests/api/users/logout.rs
@@ -0,0 +1,66 @@
use crate::helpers::spawn_app;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}

#[sqlx::test]
async fn test_logout_failure(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// Test logout user
let logout_response = app
.api_client
.post(&format!("{}/users/logout/", &app.address))
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = logout_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(
response.error,
"We currently have some issues. Kindly try again and ensure you are logged in"
);
}

#[sqlx::test]
async fn test_logout_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// First login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());

// Check that there is cookie present
let headers = login_response.headers();
assert!(headers.get("set-cookie").is_some());
let cookie_str = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie_str.contains("sessionid="));

// Then logout user
let logout_response = app
.api_client
.post(&format!("{}/users/logout/", &app.address))
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = logout_response
.json::<backend::types::SuccessResponse>()
.await
.expect("Cannot get user response");

assert_eq!(response.message, "You have successfully logged out");
}
6 changes: 6 additions & 0 deletions backend/tests/api/users/mod.rs
@@ -0,0 +1,6 @@
mod current_user;
mod login;
mod logout;
mod regenerate_token;
mod register;
mod update_users;
95 changes: 95 additions & 0 deletions backend/tests/api/users/regenerate_token.rs
@@ -0,0 +1,95 @@
use crate::helpers::spawn_app;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}
#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct UserEmail {
email: String,
}

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct NewUser<'a> {
email: &'a str,
password: String,
first_name: String,
last_name: String,
}

#[sqlx::test]
async fn test_regenerate_token_failure(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// First login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());

let user_email = UserEmail {
email: app.test_user.email.clone(),
};

// Then get current user
let res = app
.api_client
.post(&format!("{}/users/regenerate-token/", &app.address))
.json(&user_email)
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = res
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(
response.error,
"A user with this e-mail address does not exist. If you registered with this email, ensure you haven't activated it yet. You can check by logging in"
);
}

#[sqlx::test]
async fn test_regenerate_token_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// Request data
sqlx::query(
"INSERT INTO users (email, password, first_name, last_name, is_active, is_staff, is_superuser)
VALUES ($1, $2, $3, $4, false, true, true)"
)
.bind("email@example.com")
.bind("password_hash")
.bind("first_name")
.bind("last_name")
.execute(&pool)
.await
.expect("Failed to store test user.");

let user_email = UserEmail {
email: "email@example.com".to_string(),
};

// Then get current user
let get_user_response = app
.api_client
.post(&format!("{}/users/regenerate-token/", &app.address))
.json(&user_email)
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = get_user_response
.json::<backend::types::SuccessResponse>()
.await
.expect("Cannot get user response");

assert_eq!(response.message, "Account activation link has been sent to your email address. Kindly take action before its expiration");
}
157 changes: 157 additions & 0 deletions backend/tests/api/users/register.rs
@@ -0,0 +1,157 @@
use crate::helpers::spawn_app;
use fake::faker::{
internet::en::SafeEmail,
name::en::{FirstName, LastName, NameWithTitle},
};
use fake::Fake;
use sqlx::Row;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct NewUser<'a> {
email: &'a str,
password: String,
first_name: String,
last_name: String,
}

#[sqlx::test]
async fn test_register_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// Request data
let email: String = SafeEmail().fake();
let first_name: String = FirstName().fake();
let last_name: String = LastName().fake();
let password = NameWithTitle().fake();
let new_user = NewUser {
email: &email,
password,
first_name,
last_name,
};

let response = app
.api_client
.post(&format!("{}/users/register/", &app.address))
.json(&new_user)
.header("Content-Type", "application/json")
.send()
.await
.expect("Failed to execute request.");

assert!(response.status().is_success());

let saved_user = sqlx::query(
"SELECT
u.id AS u_id,
u.email AS u_email,
u.password AS u_password,
u.first_name AS u_first_name,
u.last_name AS u_last_name,
u.is_active AS u_is_active,
u.is_staff AS u_is_staff,
u.is_superuser AS u_is_superuser,
u.thumbnail AS u_thumbnail,
u.date_joined AS u_date_joined,
p.id AS p_id,
p.user_id AS p_user_id,
p.phone_number AS p_phone_number,
p.birth_date AS p_birth_date,
p.github_link AS p_github_link
FROM
users u
LEFT JOIN user_profile p ON p.user_id = u.id
WHERE
u.is_active=false AND u.email=$1
",
)
.bind(&email)
.map(|row: sqlx::postgres::PgRow| backend::types::User {
id: row.get("u_id"),
email: row.get("u_email"),
first_name: row.get("u_first_name"),
password: row.get("u_password"),
last_name: row.get("u_last_name"),
is_active: row.get("u_is_active"),
is_staff: row.get("u_is_staff"),
is_superuser: row.get("u_is_superuser"),
thumbnail: row.get("u_thumbnail"),
date_joined: row.get("u_date_joined"),
profile: backend::types::UserProfile {
id: row.get("p_id"),
user_id: row.get("p_user_id"),
phone_number: row.get("p_phone_number"),
birth_date: row.get("p_birth_date"),
github_link: row.get("p_github_link"),
},
})
.fetch_one(&pool)
.await
.expect("msg");

assert_eq!(saved_user.is_active, false);
assert_eq!(saved_user.email, email);
assert_eq!(saved_user.thumbnail, None);
assert_eq!(saved_user.profile.user_id, saved_user.id);
assert_eq!(saved_user.profile.phone_number, None)
}
#[sqlx::test]
async fn test_register_user_failure_email(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// First request data
let email = "backend@api.com".to_string();
let first_name: String = FirstName().fake();
let last_name: String = LastName().fake();
let password = NameWithTitle().fake();
let new_user_one = NewUser {
email: &email,
password,
first_name,
last_name,
};

let response_one = app
.api_client
.post(&format!("{}/users/register/", &app.address))
.json(&new_user_one)
.header("Content-Type", "application/json")
.send()
.await
.expect("Failed to execute request.");

assert!(response_one.status().is_success());

// First request data
let email = "backend@api.com".to_string();
let first_name: String = FirstName().fake();
let last_name: String = LastName().fake();
let password = NameWithTitle().fake();
let new_user_two = NewUser {
email: &email,
password,
first_name,
last_name,
};

let response_two = app
.api_client
.post(&format!("{}/users/register/", &app.address))
.json(&new_user_two)
.header("Content-Type", "application/json")
.send()
.await
.expect("Failed to execute request.");

assert!(response_two.status().is_client_error());

let error_response = response_two
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(
error_response.error,
"A user with that email address already exists"
);
}
86 changes: 86 additions & 0 deletions backend/tests/api/users/update_users.rs
@@ -0,0 +1,86 @@
use crate::helpers::spawn_app;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}

#[sqlx::test]
async fn test_update_user_failure_not_logged_in(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// multipart form
let form = reqwest::multipart::Form::new()
.text("github_link", "https://github.com/Sirneij")
.text("phone_number", "+2348135459073");

let update_user_response = app
.api_client
.patch(&format!("{}/users/update-user/", &app.address))
.multipart(form)
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = update_user_response
.json::<backend::types::ErrorResponse>()
.await
.expect("Cannot get user response");

assert_eq!(
response.error,
"You are not logged in. Kindly ensure you are logged in and try again"
);
}

#[sqlx::test]
async fn test_update_user_success(pool: sqlx::postgres::PgPool) {
let app = spawn_app(pool.clone()).await;

// First login
let login_body = LoginUser {
email: app.test_user.email.clone(),
password: app.test_user.password.clone(),
};
let login_response = app.post_login(&login_body).await;
assert!(login_response.status().is_success());

// Check that there is cookie present
let headers = login_response.headers();
assert!(headers.get("set-cookie").is_some());
let cookie_str = headers.get("set-cookie").unwrap().to_str().unwrap();
assert!(cookie_str.contains("sessionid="));

// multipart form
let form = reqwest::multipart::Form::new()
.text("github_link", "https://github.com/Sirneij")
.text("phone_number", "+2348135459073");

let update_user_response = app
.api_client
.patch(&format!("{}/users/update-user/", &app.address))
.multipart(form)
.send()
.await
.expect("Failed to execute request.");

// Check response
let response = update_user_response
.json::<backend::types::UserVisible>()
.await
.expect("Cannot get user response");

assert_eq!(response.email, app.test_user.email);
assert!(response.is_active);
assert_eq!(response.id, response.profile.user_id);
assert_eq!(
response.profile.github_link,
Some("https://github.com/Sirneij".to_string())
);
assert_eq!(
response.profile.phone_number,
Some("+2348135459073".to_string())
);
}