Skip to content

Commit

Permalink
Merge #112: E2E tests for API user routes
Browse files Browse the repository at this point in the history
2b58923 tests: [#111] E2E test for user routes (Jose Celano)
f257692 feat: [#111] add cargo dependency rand (Jose Celano)
73a26ae tests: [#109] E2E test for category routes (Jose Celano)
652f50b refactor: [#109] extract structs and functions (Jose Celano)

Pull request description:

  E2E tests for API `user` routes.

ACKs for top commit:
  josecelano:
    ACK 2b58923
  da2ce7:
    ACK 2b58923

Tree-SHA512: 974a0393e0e2910c7855b8b9d7eb8bb04b57b6025b836ec27d2f1eb2a6438a7eeec731b344883fbe74c79f6be50fb4f21d813fd00465913be4c2454a77c13a12
  • Loading branch information
josecelano committed Apr 25, 2023
2 parents 80ad41e + 2b58923 commit a2aba45
Show file tree
Hide file tree
Showing 23 changed files with 580 additions and 76 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ pbkdf2 = "0.11.0"
text-colorizer = "1.0.0"
log = "0.4.17"
fern = "0.6.2"

[dev-dependencies]
rand = "0.8.5"
10 changes: 10 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ services:
- ~/.cargo:/home/appuser/.cargo
depends_on:
- tracker
- mailcatcher
- mysql

tracker:
image: torrust/tracker:develop
Expand Down Expand Up @@ -62,6 +64,14 @@ services:
depends_on:
- mysql

mailcatcher:
image: dockage/mailcatcher:0.8.2
networks:
- server_side
ports:
- 1080:1080
- 1025:1025

mysql:
image: mysql:8.0
command: '--default-authentication-plugin=mysql_native_password'
Expand Down
6 changes: 3 additions & 3 deletions config-idx-back.toml.local
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ max_password_length = 64
secret_key = "MaxVerstappenWC2021"

[database]
connect_url = "sqlite://storage/database/data.db?mode=rwc" # SQLite
connect_url = "sqlite://storage/database/torrust_index_backend_e2e_testing.db?mode=rwc" # SQLite
#connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_backend" # MySQL
torrent_info_update_interval = 3600

Expand All @@ -28,5 +28,5 @@ from = "example@email.com"
reply_to = "noreply@email.com"
username = ""
password = ""
server = ""
port = 25
server = "mailcatcher"
port = 1025
2 changes: 1 addition & 1 deletion config-tracker.toml.local
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
log_level = "info"
mode = "public"
db_driver = "Sqlite3"
db_path = "./storage/database/tracker.db"
db_path = "./storage/database/torrust_tracker_e2e_testing.db"
announce_interval = 120
min_announce_interval = 120
max_peer_timeout = 900
Expand Down
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Leechers
LEECHERS
lettre
luckythelab
mailcatcher
nanos
NCCA
nilm
Expand Down
22 changes: 15 additions & 7 deletions src/mailer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,21 @@ impl MailerService {
async fn get_mailer(cfg: &Configuration) -> Mailer {
let settings = cfg.settings.read().await;

let creds = Credentials::new(settings.mail.username.to_owned(), settings.mail.password.to_owned());

AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&settings.mail.server)
.port(settings.mail.port)
.credentials(creds)
.authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain])
.build()
if !settings.mail.username.is_empty() && !settings.mail.password.is_empty() {
// SMTP authentication
let creds = Credentials::new(settings.mail.username.clone(), settings.mail.password.clone());

AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&settings.mail.server)
.port(settings.mail.port)
.credentials(creds)
.authentication(vec![Mechanism::Login, Mechanism::Xoauth2, Mechanism::Plain])
.build()
} else {
// SMTP without authentication
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&settings.mail.server)
.port(settings.mail.port)
.build()
}
}

pub async fn send_verification_mail(
Expand Down
8 changes: 8 additions & 0 deletions src/routes/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder};
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use log::{debug, info};
use pbkdf2::Pbkdf2;
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
Expand All @@ -20,6 +21,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) {
web::scope("/user")
.service(web::resource("/register").route(web::post().to(register)))
.service(web::resource("/login").route(web::post().to(login)))
// code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user.
.service(web::resource("/ban/{user}").route(web::delete().to(ban_user)))
.service(web::resource("/token/verify").route(web::post().to(verify_token)))
.service(web::resource("/token/renew").route(web::post().to(renew_token)))
Expand Down Expand Up @@ -47,6 +49,8 @@ pub struct Token {
}

pub async fn register(req: HttpRequest, mut payload: web::Json<Register>, app_data: WebAppData) -> ServiceResult<impl Responder> {
info!("registering user: {}", payload.username);

let settings = app_data.cfg.settings.read().await;

match settings.auth.email_on_signup {
Expand Down Expand Up @@ -253,6 +257,8 @@ pub async fn verify_email(req: HttpRequest, app_data: WebAppData) -> String {

// TODO: add reason and date_expiry parameters to request
pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult<impl Responder> {
debug!("banning user");

let user = app_data.auth.get_user_compact_from_request(&req).await?;

// check if user is administrator
Expand All @@ -262,6 +268,8 @@ pub async fn ban_user(req: HttpRequest, app_data: WebAppData) -> ServiceResult<i

let to_be_banned_username = req.match_info().get("user").unwrap();

debug!("user to be banned: {}", to_be_banned_username);

let user_profile = app_data
.database
.get_user_profile_from_username(to_be_banned_username)
Expand Down
26 changes: 23 additions & 3 deletions tests/e2e/asserts.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
use crate::e2e::response::Response;

// Text responses

pub fn assert_response_title(response: &Response, title: &str) {
let title_element = format!("<title>{title}</title>");

assert!(
response.body.contains(&title),
response.body.contains(title),
":\n response does not contain the title element: `\"{title_element}\"`."
);
}

pub fn assert_ok(response: &Response) {
pub fn assert_text_ok(response: &Response) {
assert_eq!(response.status, 200);
if let Some(content_type) = &response.content_type {
assert_eq!(content_type, "text/html; charset=utf-8");
}
}

pub fn _assert_text_bad_request(response: &Response) {
assert_eq!(response.status, 400);
if let Some(content_type) = &response.content_type {
assert_eq!(content_type, "text/plain; charset=utf-8");
}
}

// JSON responses

pub fn assert_json_ok(response: &Response) {
assert_eq!(response.status, 200);
assert_eq!(response.content_type, "text/html; charset=utf-8");
if let Some(content_type) = &response.content_type {
assert_eq!(content_type, "application/json");
}
}
94 changes: 81 additions & 13 deletions tests/e2e/client.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,75 @@
use reqwest::Response as ReqwestResponse;
use serde::Serialize;

use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username};
use crate::e2e::connection_info::ConnectionInfo;
use crate::e2e::http::{Query, ReqwestQuery};
use crate::e2e::response::Response;

/// API Client
pub struct Client {
http_client: Http,
}

impl Client {
pub fn new(connection_info: ConnectionInfo) -> Self {
Self {
http_client: Http::new(connection_info),
}
}

// Context: about

pub async fn about(&self) -> Response {
self.http_client.get("about", Query::empty()).await
}

pub async fn license(&self) -> Response {
self.http_client.get("about/license", Query::empty()).await
}

// Context: category

pub async fn get_categories(&self) -> Response {
self.http_client.get("category", Query::empty()).await
}

// Context: root

pub async fn root(&self) -> Response {
self.http_client.get("", Query::empty()).await
}

// Context: user

pub async fn register_user(&self, registration_form: RegistrationForm) -> Response {
self.http_client.post("user/register", &registration_form).await
}

pub async fn login_user(&self, registration_form: LoginForm) -> Response {
self.http_client.post("user/login", &registration_form).await
}

pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Response {
self.http_client.post("user/token/verify", &token_verification_form).await
}

pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Response {
self.http_client.post("user/token/renew", &token_verification_form).await
}

pub async fn ban_user(&self, username: Username) -> Response {
self.http_client.delete(&format!("user/ban/{}", &username.value)).await
}
}

/// Generic HTTP Client
struct Http {
connection_info: ConnectionInfo,
base_path: String,
}

impl Client {
impl Http {
pub fn new(connection_info: ConnectionInfo) -> Self {
Self {
connection_info,
Expand All @@ -22,23 +81,32 @@ impl Client {
self.get_request_with_query(path, params).await
}

/*
pub async fn post(&self, path: &str) -> Response {
reqwest::Client::new().post(self.base_url(path).clone()).send().await.unwrap()
}
async fn delete(&self, path: &str) -> Response {
reqwest::Client::new()
.delete(self.base_url(path).clone())
pub async fn post<T: Serialize + ?Sized>(&self, path: &str, form: &T) -> Response {
let response = reqwest::Client::new()
.post(self.base_url(path).clone())
.json(&form)
.send()
.await
.unwrap()
.unwrap();
Response::from(response).await
}

pub async fn get_request(&self, path: &str) -> Response {
get(&self.base_url(path), None).await
async fn delete(&self, path: &str) -> Response {
let response = match &self.connection_info.token {
Some(token) => reqwest::Client::new()
.delete(self.base_url(path).clone())
.bearer_auth(token)
.send()
.await
.unwrap(),
None => reqwest::Client::new()
.delete(self.base_url(path).clone())
.send()
.await
.unwrap(),
};
Response::from(response).await
}
*/

pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response {
get(&self.base_url(path), Some(params)).await
Expand Down
15 changes: 14 additions & 1 deletion tests/e2e/connection_info.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
pub fn connection_with_no_token(bind_address: &str) -> ConnectionInfo {
pub fn anonymous_connection(bind_address: &str) -> ConnectionInfo {
ConnectionInfo::anonymous(bind_address)
}

pub fn authenticated_connection(bind_address: &str, token: &str) -> ConnectionInfo {
ConnectionInfo::new(bind_address, token)
}

#[derive(Clone)]
pub struct ConnectionInfo {
pub bind_address: String,
pub token: Option<String>,
}

impl ConnectionInfo {
pub fn new(bind_address: &str, token: &str) -> Self {
Self {
bind_address: bind_address.to_string(),
token: Some(token.to_string()),
}
}

pub fn anonymous(bind_address: &str) -> Self {
Self {
bind_address: bind_address.to_string(),
token: None,
}
}
}
24 changes: 24 additions & 0 deletions tests/e2e/contexts/about.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use crate::e2e::asserts::{assert_response_title, assert_text_ok};
use crate::e2e::environment::TestEnv;

#[tokio::test]
#[cfg_attr(not(feature = "e2e-tests"), ignore)]
async fn it_should_load_the_about_page_with_information_about_the_api() {
let client = TestEnv::default().unauthenticated_client();

let response = client.about().await;

assert_text_ok(&response);
assert_response_title(&response, "About");
}

#[tokio::test]
#[cfg_attr(not(feature = "e2e-tests"), ignore)]
async fn it_should_load_the_license_page_at_the_api_entrypoint() {
let client = TestEnv::default().unauthenticated_client();

let response = client.license().await;

assert_text_ok(&response);
assert_response_title(&response, "Licensing");
}
20 changes: 20 additions & 0 deletions tests/e2e/contexts/category.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use crate::e2e::asserts::assert_json_ok;
use crate::e2e::environment::TestEnv;

#[tokio::test]
#[cfg_attr(not(feature = "e2e-tests"), ignore)]
async fn it_should_return_an_empty_category_list_when_there_are_no_categories() {
let client = TestEnv::default().unauthenticated_client();

let response = client.get_categories().await;

assert_json_ok(&response);
}

/* todo:
- it_should_not_allow_adding_a_new_category_to_unauthenticated_clients
- it should allow adding a new category to authenticated clients
- it should not allow adding a new category with an empty name
- it should not allow adding a new category with an empty icon
- ...
*/
4 changes: 4 additions & 0 deletions tests/e2e/contexts/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod about;
pub mod category;
pub mod root;
pub mod user;
Loading

0 comments on commit a2aba45

Please sign in to comment.