DEV Community

Thiago Valentim
Thiago Valentim

Posted on

Unit Testing Auth0 Custom Database Scripts

Introduction

Welcome, fellow developers! Today I want to present you a step-by-step technique on how to test Auth0’s custom actions and databases in Javascript. For those of you who don’t know Auth0, it’s an identity management platform that you can connect to your existing or new applications, and configure it to easily provide authentication and authorization mechanisms. It’s one of the easiest solutions for IAM nowadays.

You can check the reference repository if you just want to see the final result, but I encourage you to keep reading if you also want to understand the thought process. Here's what we'll learn:

  • How to use auth0-deploy-cli to keep you tenant's configuration, code, and settings in a local repo
  • How to create tests for your custom database
  • How to apply TDD to create the tests above and implement the scripts
  • How to use Spies instead of Mocks to test network calls to external services
  • How to inject dependencies in database scripts
  • How to use globally-defined objects for testing
  • How to properly handle errors in Auth0

Without further ado, let's get into it!

Auth0 Custom Database Scripts

So, what are Auth0’s custom database scripts? To be concise, these are scripts used to connect to an existing application’s database to perform the following actions:

  • Create - This script will create a user in your database when someone signs up via Auth0
  • Login - This script authenticates a user against the credentials stored in your database.
  • Delete - Once a user is deleted via Auth0’s dashboard or Management API, this script ensures the user is also deleted in your database.
  • Get User - This script determines whether a user with a given email exists. It’s executed when users change their password to determine if they exist.
  • Verify - This script marks the current user’s email address as verified in your database
  • Change Password - This script should change the password stored for the current user in your database.

Diagram illustrating how each database script interacts with Auth0 and your database

These actions must be highly maintainable and reliable — after all, your application’s authorization and authentication flows will go through them. Creating unit tests for these custom database scripts is extremely valuable to ensure everything is working as expected while also serving as live documentation for IAM. In the next section, we’ll see what we need to start creating tests for our Auth0 Tenant scripts.

Pulling the tenant data

Before creating tests for our scripts, we need our tenant’s data and configuration in our local machines. To do so, we must install auth0-deploy-cli. This tool can download your tenant’s configuration and schema to local folders. Follow the steps below to configure it and pull your tenant’s data:

The steps below assume you already have an Auth0 tenant created.

  1. Create a new Database authentication method. We’ll use this as our example external database that Auth0 needs to connect to and synchronize with. Go to Authentication > Database > Create DB Connection to create it.
    1. Go to the Custom Database tab and toggle the Use my own Database switch. This will enable all the custom database action scripts.
  2. Install the CLI tool using your preferred package manager: yarn add -D auth0-deploy-cli
  3. Create an Auth0 Application to represent the CLI tool access. To do so, go to the dashboard, Applications > Applications > Create Application > Machine to Machine.
    1. Select the Auth0 Management API as the authorized party. Then, you can select all the permissions for the purpose of this exercise - ideally, you’d go with the least-privileges strategy.
  4. Create a file named config.json in the root of your project, and put the following data into it:
{
  "AUTH0_DOMAIN": "auth0-domain", // You can check this in the application you just created, under the `settings` tab
  "AUTH0_CLIENT_ID": "auth0-deploy-application-client-id",
  "AUTH0_CLIENT_SECRET": "auth0-deploy-application-client-secret",
  "AUTH0_ALLOW_DELETE": false
}
// These values are used by the CLI to connect to your Auth0 tenant and potentially pull / push data.
Enter fullscreen mode Exit fullscreen mode
  1. Create a script in your package.json file to pull changes from the tenant to your local file system:
{
  "scripts": {
      "a0deploy export -c config.json --format=yaml --output_folder=tenant"
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Execute the script with your package manager. Then, your directory structure will look like the following:
📦tenant
 ┣ 📂databases
 ┃ ┗ 📂Database // This folder represents the Database Authentication we've created
 ┃ ┃ ┣ 📜change_password.js
 ┃ ┃ ┣ 📜create.js
 ┃ ┃ ┣ 📜delete.js
 ┃ ┃ ┣ 📜get_user.js
 ┃ ┃ ┣ 📜login.js
 ┃ ┃ ┗ 📜verify.js
 ┣ 📂emailTemplates
 ┣ 📂hooks
 ┣ 📂pages
 ┣ 📂rules
 ┗ 📜tenant.yaml // This file stores all the tenant's settings and data
Enter fullscreen mode Exit fullscreen mode

At this point, we’re ready to start creating tests for our database scripts. We’ll see how to do that in the next section.

Creating tests for database scripts

Finally, we have (almost) everything set up to start writing our tests - but before we do so, let’s make it clear that we need an example definition of an API that is a port to our external database. You can check out all the requirements in the Readme file, but let’s take care of a single endpoint for now, the signup:

POST /signup - used to create users
- Body:
  - `email`: The user's email
  - `password`: The user's password
  - `passwordConfirmation`: The user's password confirmation
- Response:
  -**Success**:
    - **status**: 201
    - **body**: 
      - `id`: The user's id
  -**Error**: 
    - **status**: 400 | 500
    - **body**: 
      - `error`: The error message
Enter fullscreen mode Exit fullscreen mode

With that information, we’ll start defining which tests we need before implementing anything. Create a new file under /tenant/test/databases/create.test.js . There, we’ll determine what needs to be done:

const { test, describe } = require('node:test');
const assert = require('node:assert/strict');

describe('Create user script', () => {
  test.todo('successfully creates a user');
  test.todo('calls axios post with correct parameters');
  test.todo('throws validation error when user already exists');
  test.todo('throws error with descriptive message when something goes wrong');
});
Enter fullscreen mode Exit fullscreen mode

Notice that we’re using node’s native test module - the idea is to use the least number of dependencies, and node’s test runner has all features we need here - while also being way faster than jest.

To execute test files with node’s test runner, we need to add another script to our package.json file:

{
    "scripts": {
      "test": "node --test"
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, you can simply execute it like yarn test and see the results. Remember, you’ll need Node’s LTS version to make it work!

So, we must pick one test case above and define its body. Then, we’ll implement the least amount of code that we need to make it pass - this is how we apply the famous Test-Driven Development (TDD). Let’s start with the first test, namely, “successfully creates a user”. This is the easiest one to begin with - if you look at the generated comments for the create.js script, the expected result when the user is successfully created is that it should call the callback function with null:

// There are three ways this script can finish:
  // 1. A user was successfully created
  //     callback(null);
  // 2. This user already exists in your database
  //     callback(new ValidationError("user_exists", "my error message"));
  // 3. Something went wrong while trying to reach your database
  //     callback(new Error("my error message"));
Enter fullscreen mode Exit fullscreen mode

Considering that requirement now, let’s define the test body as follows:

const { test, describe, mock } = require('node:test');
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
const { create } = require('../../databases/Database/create');

describe('Create user script', () => {
  test('successfully creates a user', async () => {
    // Arrange
    const user = makeUserData();
    const callback = mock.fn();

    // Act
    await create(user, callback);

    // Assert
    assert.equal(callback.mock.callCount(), 1);
    assert.deepEqual(callback.mock.calls[0].arguments, [null]);
  });
  test.todo('calls axios post with correct parameters');
  test.todo('throws validation error when user already exists');
  test.todo('throws error with descriptive message when something goes wrong');
});

function makeUserData() {
  return {
    email: 'user@mail.com',
    password: randomUUID(),
    tenant: 'test-tenant',
    client_id: 'test-client-id',
    connection: 'Database',
  };
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the only expected behavior here is that our callback function, created using node’s mock utility, was called with null argument. the makeUserData() utility function returns an object with the interface the create script expects.

Now, after executing this test and seeing it failing, we’ll change the source code to make it pass:

// You have to manually  change the code below to explicitly export the `create` function,
// otherwise you can't import that function into the test file. It still works in Auth0, so no need to worry :)
module.exports.create = async function create(user, callback) {
  // Auto-generated docs redacted
  return callback(null);
};
Enter fullscreen mode Exit fullscreen mode

The first test should pass now. Great! That's our first step towards implementing this create custom script. Before we move to the next ones, we can perform a slight improvement to our "Assert" block in our test code, which reads a bit weird now:

// Assert
assert.equal(callback.mock.callCount(), 1);
assert.deepEqual(callback.mock.calls[0].arguments, [null]);
Enter fullscreen mode Exit fullscreen mode

We can improve the readability of this type of test by creating a new utility:

// mock-extended.js file
const { mock } = require('node:test');
const assert = require('node:assert/strict');

function mockFn() {
  const mockedFunction = mock.fn();
  mockedFunction.shouldHaveBeenCalledOnceWith = (expectedArgument) => {
    assert.equal(mockedFunction.mock.callCount(), 1);
    assert.deepEqual(
      mockedFunction.mock.calls[0].arguments[0],
      expectedArgument
    );
  };
  return mockedFunction;
}

module.exports = {
  mockFn,
};
Enter fullscreen mode Exit fullscreen mode

The mock-extended file above exposes a factory to create an enhanced node's native mock object. This object has an additional shouldHaveBeenCalledOnceWith(argument) method to increase the test readability. Here's how we can rewrite the test:

test('successfully creates a user', async () => {
    // Arrange
    const user = makeUserData();
    const callback = mockFn();

    // Act
    await create(user, callback);

    // Assert
    callback.shouldHaveBeenCalledOnceWith(null);
  });
Enter fullscreen mode Exit fullscreen mode

Way more expressive, don't you agree? The next step is to implement another todo test, test.todo('calls axios post with correct parameters');. This test will require more sophistication, so we'll dive into it in the next section.

Creating and injecting Spies to communicate with external services

Mocking is a common practice when testing your application's behavior when communicating with external services. Mocks are a particular type of Test Double, an object used to replace the production one for testing purposes. However, mocks in Javascript are not so great because they don't provide a nice API for stubbing returned values or checking for called values (as you can notice with the callback mock example, we had to create a new utility).

A more helpful approach when we want to override communication with external services is to create a Spy. A Spy is an object that follows the same API as the production one but returns canned data and stores what was called internally. This is useful to make the test code cleaner and readable while also encapsulating the details of how the stubbing process works underneath it. Moreover, you can reutilize the Spy definition across different tests.

So, let's get back to what we needed for the new test:

test.todo('calls axios post with correct parameters');
Enter fullscreen mode Exit fullscreen mode

This test aims to ensure we called axios.post with the URL and body parameters as defined in the requirements:

  • URL: POST /signup
  • Body: email, password, and passwordConfirmation

We'll use an AxiosSpy class to test this with a nice fluent interface. However, to use this spy, we also need the create script to accept an instance of axios as an additional parameter, otherwise we can't inject our spy:

module.exports.create = async function create(user, callback, axios = require('axios') {
  // Auto-generated docs redacted
  return callback(null);
};
Enter fullscreen mode Exit fullscreen mode

The default value of this axios instance will be used in the production environment. Now, let's get back to the test:

const { test, describe } = require('node:test');
const { mockFn } = require('../utils/mock-extended');
const { AxiosSpy, HttpMethod } = require('../utils/axios.spy');
const { randomUUID } = require('node:crypto');
const { create } = require('../../databases/Database/create');

describe('Create user script', () => {
  test('successfully creates a user', async () => {
    // Arrange
    const user = makeUserData();
    const callback = mockFn();
    const axiosSpy = new AxiosSpy();

    // Act
    await create(user, callback, axiosSpy);

    // Assert
    callback.shouldHaveBeenCalledOnceWith(null);
  });
  test('calls axios post with correct parameters', async () => {
    // Arrange
    const user = makeUserData();
    const callback = mockFn();
    const axiosSpy = new AxiosSpy();

    // Act
    await create(user, callback, axiosSpy);

    // Assert
    axiosSpy
      .shouldHaveSentNumberOfRequests(1)
      .withUrl(new URL('https://backend/signup'))
      .withMethod(HttpMethod.Post)
      .withBody({
        email: user.email,
        password: user.password,
        passwordConfirmation: user.password,
      });
  });
  test.todo('throws validation error when user already exists');
  test.todo('throws error with descriptive message when something goes wrong');
});

function makeUserData() {
  return {
    email: 'user@mail.com',
    password: randomUUID(),
    tenant: 'test-tenant',
    client_id: 'test-client-id',
    connection: 'Database',
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice that we also had to change the first test case to inject the AxiosSpy instance, otherwise, it would try to use the actual axios object. We execute the test to see it failing, and then we can add the missing implementation:

module.exports.create = async function create(
  user,
  callback,
  axios = require('axios')
) {
  const { email, password } = user;

  await axios.post(new URL('https://backend/signup'), {
    email,
    password,
    passwordConfirmation: password,
  });

  return callback(null);
};
Enter fullscreen mode Exit fullscreen mode

This should suffice to make the test pass, so we'll dive into the next step: using global objects.

Using global references in tests

Checking the next test we need to implement you'll notice it requires a globally defined object:

test.todo('throws validation error when user already exists');
Enter fullscreen mode Exit fullscreen mode

This ValidationError is an internal error created by Auth0's runtime that you can use without importing it. The create function template comes with the following comment:

// 2. This user already exists in your database
  //     callback(new ValidationError("user_exists", "my error message"));
Enter fullscreen mode Exit fullscreen mode

So, how do we make it available for our tests? Simple enough, just define a ValidationError class in the test file global context:

global.ValidationError = class ValidationError extends Error {
  constructor(code, message) {
    super(message);
    this.code = code;
  }
};
Enter fullscreen mode Exit fullscreen mode

Now, we can write down the test specification:

test('throws validation error when user already exists', async () => {
    // Arrange
    const user = makeUserData();
    const callback = mockFn();
    const axiosSpy = new AxiosSpy();
    axiosSpy.stubResponseFor(
      HttpMethod.Post,
      new URL('https://backend/signup'),
      {
        status: 404,
        body: {
          error: 'user already exists',
        },
      }
    );

    // Act
    await create(user, callback, axiosSpy);

    // Assert
    callback.shouldHaveBeenCalledOnceWith(
      new ValidationError('user_exists', 'User already exists')
    );
  });
Enter fullscreen mode Exit fullscreen mode

We can execute this test to guarantee it's failing for the right reason now: the callback function wasn't called with that validation error. Here's what we need to change to make it pass:

module.exports.create = async function create(
  user,
  callback,
  axios = require('axios')
) {
const { email, password } = user;

try {
  await axios.post(new URL('https://backend/signup'), {
    email,
    password,
    passwordConfirmation: password,
  });
} catch (error) {
  return callback(new ValidationError('user_exists', 'User already exists'));
}
  return callback(null);
};

Enter fullscreen mode Exit fullscreen mode

Now the test works again ✅. We can use this same strategy for other globally-defined objects in Auth0, such as the configuration object used to store sensitive information.

The final step now is to make sure the last test also passes. It should be straightforward, as we don't need any additional constructs:

test('throws error with descriptive message when something goes wrong', async () => {
    // Arrange
    const user = makeUserData();
    const callback = mockFn();
    const axiosSpy = new AxiosSpy();
    axiosSpy.stubUnexpectedErrorFor(
      HttpMethod.Post,
      new URL('https://backend/signup'),
      new Error('Network error')
    );

    // Act
    await create(user, callback, axiosSpy);

    // Assert
    callback.shouldHaveBeenCalledOnceWith(
      new Error(
        'Something went wrong while trying to sign up user (Network error)'
      )
    );
  });
Enter fullscreen mode Exit fullscreen mode

This test ensures we handle unexpected errors properly, wrapping their message into our own. Here's the implementation to make it pass:

module.exports.create = async function create(
  user,
  callback,
  axios = require('axios')
) {
const { email, password } = user;

try {
  await axios.post(new URL('https://backend/signup'), {
    email,
    password,
    passwordConfirmation: password,
  });
} catch (error) {
  if (error.response?.status === 404)
    return callback(new ValidationError('user_exists', 'User already exists'));

  return callback(
    new Error(
      `Something went wrong while trying to sign up user (${error.message})`
    )
  );
}
  return callback(null);
};

Enter fullscreen mode Exit fullscreen mode

Now all unit tests are passing, and we've completed the create script.

Conclusion

Automated tests are undeniably essential to ensure our code's maintainability. Without proper tests, we can't trust any code changes and have to rely on expensive manual tests. Auth0's custom scripts aren't an exception—in fact, since they play such an important role in our system, they should be thoroughly tested. This article explained how to do it while also teaching a few tips and tricks.

If readers show enough interest, the next article in this series will discuss how to perform integration tests and use them in your CI. I hope this one has helped you start testing Auth0 scripts.

See you in the next one!

Top comments (0)