Authentication system using rust (actix-web) and sveltekit - Automated testing
Tags: testing, rust, playwright, webdev
Published: 2023-05-05 23:21:02

## Introduction

We have come thus far. From a skeletal project to a feature-complete authentication system. Boldly, we can pat ourselves on the back for a job well done. But wait, we haven't completed the development phase of Software Engineering. Though we have manually tested our backend service and frontend application, automated tests are still missing. Not everyone has the luxury to manually browse through your application to test whether or not something is broken. We can assist those persons by providing some tests which can easily be run to confirm the correctness of our development efforts. This article will try to lay some foundations for testing our current system. You are at liberty to include stuff such as mocking of function calls and the rest. The project's repository awaits your PRs!

## Source code

The source code for this series is hosted on GitHub via:

{% github Sirneij/rust-auth %}

## Implementation

### Step 1: Automated backend testing

> You can get the overview of the code for this section on [github][1].

When starting out, we made some design decisions at the backend. The decision will allow us to independently test the service without interfering with the real application using a term called integration testing. We'll utilize two "dev" packages: [reqwest][3] and [fake][4]. Dev dependencies only get introduced into your application in development or during testing. In production, they are not included:

```shell
~/rust-auth/backend$ cargo add --dev reqwest --features json,cookies,rustls-tls

~/rust-auth/backend$ cargo add --dev fake
```

For [reqwest][3], we activated `json`, `cookies`, and `rustls-tls` in addition to its default features. `json` provides serialization and deserialization for JSON bodies; `cookies` allows cookie session support; and `rustls-tls` enables TLS functionality provided by `rustls`. Now to the test proper.

Create an `api` subfolder in the `tests` folder. Then create a `helper.rs` file which has the following content:

```rust
// backend/tests/api/helpers.rs
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");
    }
}
```

By now, long snippets shouldn't faze you again. What is needed is some skimming and you will definitely get an idea of what it does. As for this snippet, we first imported a couple of things. Then we lazily initiated our `telemetry` module which helps provide request/response tracing functionality. Next, a `TestApp` struct that holds some important defaults of our test application was defined. A `post_login` method was then defined for this struct so that with the app's instance in scope, we can easily make a login request. `spawn_app` was where we built our application; fed it with a database pool &mdash; will be supplied by SQLx &mdash; started the app with any available port; initialized our reqwest client with some defaults such as enabling cookies for all requests; gave `TestApp` fields the values we wanted; created a default application test user and then returned the `TestApp`. `TestUser` is the struct that houses our test user and it has some pretty basic methods to help perform its job. All these are done to ease our testing experience.

In `backend/tests/api/main.rs`, we made our test module:

```rust
// backend/tests/api/main.rs
mod helpers;
mod users;
```

This module expects that a `backend/tests/api/users/mod.rs` file has been created:

```rust
// backend/tests/api/users/mod.rs
mod current_user;
mod login;
mod logout;
mod regenerate_token;
mod register;
mod update_users;
```

The repo currently has those tests. I want collaborators to come up with a more encompassing test suite. For this article, we will only discuss the `login`, `register` and `update_users` tests. Other ones are very identical.

To the `register` test suite:

```rust
// backend/tests/api/users/register.rs
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"
    );
}
```

We used [fake][4] to generate names, emails and passwords. Notice the [`#[sqlx::test]`][5] macro. That's an incredibly nifty macro that automatically creates test databases, connects to them, applies migrations, and deletes databases. An automatic test database management suit. Under the hood, it uses either `#[tokio::test]` or `#[async_std::test]` depending on your choice of async runtime. To successfully use it, you must activate SQLx `migrate` feature and all test functions that need to use the pool it creates need to pass the `pool: sqlx::postgres::PgPool` parameter. In `test_register_user_success`, we tested the success path of our `register` route. All its side effects were asserted. We also test its unhappy paths. A major drawback of these tests is the absence of mocking. You are very welcome to send a PR.

Next is `backend/tests/api/users/login.rs`:

```rust
// backend/tests/api/users/login.rs
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);
}
```

We first ensured that its unhappy paths were properly tested before proceeding to its happy path where we ensured that cookie is present in the response header using:

```rust
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="));
```

Thanks to reqwest's cookie feature.

Last is testing the api endpoint that expects a multipart form:

```rust
// backend/tests/api/users/update_users.rs
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())
    );
}
```

With reqwest, sending multipart forms is a breeze. You must activate the `multipart` feature though. In these tests, we didn't upload images because I hadn't found a good way to mock aws's s3 functionalities yet. If you do, kindly send a PR.

That's the bit about the tests. The full tests included can be found in the repo.

### Step 2: Automated frontend testing

> You can get the overview of the code for this section on [github][2].

The front-end already helped us include some awesome testing libraries: [vitest][6] for unit testing and [playwright][7] for end-to-end testing. When we started our app, we already chose to use these libraries and a `tests` folder was created for [playwright][7]. [vitest][6] uses any file found somewhere else that has `*.test.js|ts` as part of their filenames. For this article, we will only test the home page (`/` route) and login page (`/auth/login` route). PRs are welcome for other routes. I also included unit tests for some helper functions.

Let's start with the home page (`/` route):

```ts
// frontend/tests/index.test.ts
import { expect, test } from '@playwright/test';

test('index page has title', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveTitle('Written articles | Actix Web & SvelteKit');
});

test('index page has h1 content', async ({ page }) => {
    await page.goto('/');
    expect(await page.textContent('h1')).toContain(
        'Authentication system using Actix Web and Sveltekit'
    );
});

test('index page has img alt content', async ({ page }) => {
    await page.goto('/');
    const isVisible = await page.getByAltText('Rust (actix-web) and Sveltekit').isVisible();
    expect(isVisible).toBe(true);
});

test('test some elements', async ({ page }) => {
    await page.goto('/');
    await page
        .getByRole('heading', { name: 'Authentication system using Actix Web and Sveltekit' })
        .click();
    await page.getByRole('link', { name: 'Login' }).click();

    await page.getByRole('button', { name: '+' }).click();
});
```

It is pretty simple to read through. Being a first-timer with JavaScript automated testing, I fell in love with it instantly. In the tests, we're trying to be sure that those strings are indeed on the page. Next is `frontend/tests/login.test.ts`:

```ts
// frontend/tests/login.test.ts
import { expect, test } from '@playwright/test';

test('login page has title, h1 and url', async ({ page }) => {
    await page.goto('/auth/login');
    await expect(page).toHaveTitle('Auth - Login | Actix Web & SvelteKit');
    await expect(page).toHaveURL('/auth/login');
    expect(await page.textContent('h1')).toBe('Login');
});

test('login page form element', async ({ page }) => {
    await page.goto('/auth/login');

    // Form element
    const formElement = page.locator('form');
    const formAction = await formElement.getAttribute('action');
    expect(formAction).toBe('?/login');

    // Email input
    const inputField = page.getByRole('textbox', { name: 'Email address' });
    await inputField.click();
    await inputField.type('jane.doe@example.com');
    await expect(inputField).toHaveValue('jane.doe@example.com');

    // Password input
    const passwordInput = page.getByRole('textbox', { name: 'Password' });
    await passwordInput.click();
    await passwordInput.type('mypassword');
    expect(await page.inputValue("input[type='password']")).toBe('mypassword');
});

test('login page has some links', async ({ page }) => {
    await page.goto('/auth/login');
    await page.getByRole('link', { name: 'Create an account.' }).click();
    await page.getByRole('link', { name: 'Forgot password?' }).click();
});
```

We took a step further to interact with the form element and its input elements. How cool is that?!

One of the unit tests included is:

```ts
// frontend/src/lib/utils/helpers/input.validation.test.ts

import { test, expect } from 'vitest';
import { isValidEmail, isValidPasswordMedium, isValidPasswordStrong } from './input.validation';

test('test isValidEmail', () => {
    let email = 'good@email.com';
    expect(isValidEmail(email)).toEqual(true);

    email = 'bad@email';
    expect(isValidEmail(email)).toEqual(false);
});

test('test isValidPasswordStrong', () => {
    let password = '123456Data@gmail.Com';
    expect(isValidPasswordStrong(password)).toEqual(true);

    password = 'badpassword';
    expect(isValidEmail(password)).toEqual(false);
});
test('test isValidPasswordMedium', () => {
    let password = '123456Data';
    expect(isValidPasswordMedium(password)).toEqual(true);

    password = 'badpassword';
    expect(isValidEmail(password)).toEqual(false);
});
```

These tests use the power of [vitest][6] to test the helper functions included.

That's it!!! It's finally time to draw the curtains after this long series. As stated, I welcome gigs, comments, criticisms (constructive), collaborations and all other nifty stuff... Bye for now.

## Outro

Enjoyed this article? Consider [contacting me for a job, something worthwhile or buying a coffee ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and [Twitter](https://twitter.com/Sirneij). It isn't bad if you help share this article for wider coverage. I will appreciate it...

[1]: https://github.com/Sirneij/rust-auth/pull/14/files "Backend automated testing"
[2]: https://github.com/Sirneij/rust-auth/pull/16/files "Frontend automated testing"
[3]: https://crates.io/crates/reqwest "higher level HTTP client library"
[4]: https://crates.io/crates/fake "An easy to use library for generating fake data like name, number, address, lorem, dates, etc."
[5]: https://docs.rs/sqlx/0.6.3/sqlx/attr.test.html "Attribute Macro sqlx::test"
[6]: https://vitest.dev/ "Vitest Blazing Fast Unit Test Framework"
[7]: https://playwright.dev/ "Playwright enables reliable end-to-end testing for modern web apps."