Testing Authenticated Routes in AdonisJS
Tags: adonisjs, node, testing, typescript
Published: 2021-08-12 00:22:59

---
title: Testing Authenticated Routes in AdonisJS
published: true
date: 2021-08-11 00:00:00 UTC
tags: [AdonisJS, NodeJS, Testing, TypeScript]
canonical_url: https://warrenwong.org/posts/testing-authenticated-routes-in-adonisjs
---

> If you haven't created an AdonisJS 5.0 app yet, you can check out my [previous post](https://warrenwong.org/posts/posts/setting-up-vscode-for-adonisjs) or follow the docs [here](https://preview.adonisjs.com/guides/quick-start).

> We'll be testing authenticated routes, so if you haven't added authentication to your AdonisJS project, take a look at **[Add Authentication to Your AdonisJS Project](https://warrenwong.org/posts/posts/add-authentication-to-adonisjs)**. For some background on the libraries used, check out [this post](https://docs.adonisjs.com/cookbooks/testing-adonisjs-apps#sidenav-open) [Aman Virk](https://twitter.com/AmanVirk1) wrote.

## Set Up the Test Runner

So it's time to add tests to your brand new AdonisJS project, but what to do? AdonisJS doesn't come with a test-runner out-of-the-box at the moment. Well, for the most part, it's fairly simple if you just follow these simple steps.

First, install the dependencies:

```bash
# npm
npm i -D japa execa get-port supertest @types/supertest jsdom @types/jsdom

# yarn
yarn add -D japa execa get-port supertest @types/supertest jsdom @types/jsdom
```

Now, just copy `japaFile.ts` from the [article here](https://docs.adonisjs.com/cookbooks/testing-adonisjs-apps#interacting-with-the-database). We'll need to interact with the database so just copy it verbatim and place it at the base directory of the project:

```ts
import { HttpServer } from "@adonisjs/core/build/src/Ignitor/HttpServer";
import execa from "execa";
import getPort from "get-port";
import { configure } from "japa";
import { join } from "path";
import "reflect-metadata";
import sourceMapSupport from "source-map-support";

process.env.NODE_ENV = "testing";
process.env.ADONIS_ACE_CWD = join(__dirname);
sourceMapSupport.install({ handleUncaughtExceptions: false });

export let app: HttpServer;

async function runMigrations() {
  await execa.node("ace", ["migration:run"], {
    stdio: "inherit",
  });
}

async function rollbackMigrations() {
  await execa.node("ace", ["migration:rollback"], {
    stdio: "inherit",
  });
}

async function startHttpServer() {
  const { Ignitor } = await import("@adonisjs/core/build/src/Ignitor");
  process.env.PORT = String(await getPort());
  app = new Ignitor(__dirname).httpServer();
  await app.start();
}

async function stopHttpServer() {
  await app.close();
}

configure({
  files: ["test/**/*.spec.ts"],
  before: [runMigrations, startHttpServer],
  after: [stopHttpServer, rollbackMigrations],
});
```

To run the test, we'll create a test script in our `package.json` file:

```json
{
  "scripts": {
    "test": "node -r @adonisjs/assembler/build/register japaFile.ts"
  }
}
```

When working locally, I like to have a different database for `dev` and `testing`. AdonisJS can read the `.env.testing` file when `NODE_ENV=testing`, which was set in the `japaFile.ts` file. The easiest thing to do is to copy the `.env` file and rename it to `.env.testing`. Then go and add `_test` to the end of the current database name you have for your dev environment.

```bash
...
PG_DB_NAME=todos_test
```

Since we configured our test runner to look in the `test` directory for any file with the `.spec.ts` extension, we can just place any file matching that pattern in the test directory, and we will run it with the `npm test` command.

## Set Up the Authentication Secured Routes (To-dos)

As with any tutorial, we want to have a simple, but practical, example. Let's just use a Tt-do list app as an example. Let's go over what we want to do with our To-dos.

I want a user to be signed-in in order to create and/or update a todo. What good are todos if no one can see them? So let's allow anyone to look at the list of todos, as well as look at each individual todo. I don't think I want anyone to delete a todo, maybe just to change the status (Open, Completed, or Closed).

Let's leverage the generators to create the model, controller, and migration.

### Let's `make:migration`

```bash
node ace make:migration todos
```

Let's add a `name`, a `description`, and a foreign key of `user_id` to our new table:

```ts
import BaseSchema from "@ioc:Adonis/Lucid/Schema";

export default class Todos extends BaseSchema {
  protected tableName = "todos";

  public async up() {
    this.schema.createTable(this.tableName, table => {
      table.increments("id");
      table.string("name").notNullable();
      table.text("description");

      table.integer("user_id").notNullable();

      /**
       * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp("created_at", { useTz: true });
      table.timestamp("updated_at", { useTz: true });

      table.foreign("user_id").references("users_id");
    });
  }

  public async down() {
    this.schema.dropTable(this.tableName);
  }
}
```

Run the migration:

```bash
node ace migration:run
```

### Let's `make:model`

```bash
node ace make:model Todo
```

We'll want to add the same 3 fields we added to our migration, but we'll also want to add a `belongsTo` relationship to our model linking the `User` through the `creator` property:

```ts
import { BaseModel, BelongsTo, belongsTo, column } from "@ioc:Adonis/Lucid/Orm";
import { DateTime } from "luxon";
import User from "App/Models/User";

export default class Todo extends BaseModel {
  @column({ isPrimary: true })
  public id: number;

  @column()
  public userId: number;

  @column()
  public name: string;

  @column()
  public description: string;

  @belongsTo(() => User)
  public creator: BelongsTo<typeof User>;

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime;

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime;
}
```

Add the corresponding `hasMany` relationship to the `User` model now:

```ts
...
import Todo from "App/Models/Todo";

export default class User extends BaseModel {
  ...
  @hasMany(() => Todo)
  public todos: HasMany<typeof Todo>;
  ...
}
```

### Let's `make:controller`

```bash
node ace make:controller Todo
```

Now let's add our new `/todos` path to the `routes.ts` file:

```ts
...
Route.resource("todos", "TodosController").except(["destroy"]).middleware({
  create: "auth",
  edit: "auth",
  store: "auth",
  update: "auth",
});
```

Here, we want a RESTful resource, except `destroy`. I also want the request to run through the "auth" middleware for the `create`, `edit`, `store`, and `update` resources. Basically, anyone can view `index` and `show`, but anything else will require authentication.

We can see a list of our new routes with the `node ace list:routes` command. It's handy that it show which routes require authentication. It also lists the route names (handy for redirecting the linking).

```
┌────────────┬────────────────────────────────────┬────────────────────────────┬────────────┬────────────────────────┐
│ Method     │ Route                              │ Handler                    │ Middleware │ Name                   │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /                                  │ Closure                    │            │ home                   │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /login                             │ SessionsController.create  │            │ login                  │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /login                             │ SessionsController.store   │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /logout                            │ SessionsController.destroy │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /register                          │ UsersController.create     │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /register                          │ UsersController.store      │            │                        │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /users/:id                         │ UsersController.show       │            │ users.show             │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos                             │ TodosController.index      │            │ todos.index            │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos/create                      │ TodosController.create     │ auth       │ todos.create           │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ POST       │ /todos                             │ TodosController.store      │ auth       │ todos.store            │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos/:id                         │ TodosController.show       │            │ todos.show             │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ HEAD, GET  │ /todos/:id/edit                    │ TodosController.edit       │ auth       │ todos.edit             │
├────────────┼────────────────────────────────────┼────────────────────────────┼────────────┼────────────────────────┤
│ PUT, PATCH │ /todos/:id                         │ TodosController.update     │ auth       │ todos.update           │
└────────────┴────────────────────────────────────┴────────────────────────────┴────────────┴────────────────────────┘
```

## Back to Our Tests

Let's create a new test file called `test/functional/todos.spec.ts`. While I normally just start writing tests as I they come to my head, that's probably not idea. For just a high level overview, I know I'd like to test the To-do features. So far, it's just creating, saving, editing, and updating. Also, I'd want to make sure I test that anyone can access the `index` and `show` routes, but only an authenticated user can see the others.

### Testing "To-dos"
- Todo list shows up at the `index` route.
- Individual todo shows up a the `show` route.
- Create a todo and check the `show` route to see if it exists.
- Edit a todo and check the `show` route to see if the data is updated.
- Navigate to the `create` route without logging in to test if we get redirected to the sign-in page.
- Navigate to the `edit` route without loggin in to test if we get redirected to the sign-in page.

This should cover it for now. As always, feel free to add more if you feel like it.

## Write the tests

### Testing the `index` Route

Anyone should be able to view the list of todos. A good question to ask is what should someone see if there are no todos to see (the null state). Well, there should at least be a link to the `create` route to create a new todo. If there are todos, we should show them.

First, let's start off testing for a page to load when we go to the `index` route, `/todos`. I have an inkling that I will massively refactor this later, but let's just start out simple. No point in premature optimization, expecially if it turns out we need less tests than we think.

```ts
import supertest from "supertest";
import test from "japa";

const baseUrl = `http://${process.env.HOST}:${process.env.PORT}`;

test.group("Todos", () => {
  test("'index' should show a link to create a new todo", async assert => {
    await supertest(baseUrl).get("/todos").expect(200);
  });
});
```

Here we use the [supertest](https://github.com/visionmedia/supertest) library to see if we get a status of 200 back when we navigate to `/todos`. After running the test with `npm test`, it looks like we forgot to even open up our controller file.

```
Missing method "index" on "TodosController"
...
  ✖ 'index' should show a link to create a new todo
    Error: expected 200 "OK", got 500 "Internal Server Error"
```

Let's go a create that `index` method and the Edge template that goes along with it:

```ts
import { HttpContextContract } from "@ioc:Adonis/Core/HttpContext";

export default class TodosController {
  public async index({ view }: HttpContextContract) {
    return await view.render("todos/index");
  }
}
```

```bash
node ace make:view todos/index
```

```handlebars
@layout('layouts/default')

@section('body')
<a href="{{ route('todos.create') }}">Create Todo</a>
@endsection
```

Looks like we're passing the tests after adding this little bit of code. Red-green-refactor FTW!

Let's add some more to our test. I want to test for that link.

```ts
  test("'index' should show a link to create a new todo", async assert => {
    const { text } = await supertest(baseUrl).get("/todos").expect(200);
    const { document } = new JSDOM(text).window;
    const createTodosLink = document.querySelector("#create-todo");

    assert.exists(createTodosLink);
  });
```

Here I want to query the document for an element with the `create-todos` `id`. Once I put the `id` on my "Create Todo" link, I should be green again.

```handlebars
<a href="{{ route('todos.create') }}" id="create-todo">Create Todo</a>
```

Now comes time to actually persist some `Todo`s in the database and test to see if we can see them on `/todos`. Let's just simply create 2 new todos and test for their existence on the page.

```ts
  test("'index' should show all todos created", async assert => {
    const items = ["Have lunch", "Grocery shopping"];
    items.forEach(async name => await Todo.create({ name }));

    const { text } = await supertest(baseUrl).get("/todos");

    assert.include(text, items[0]);
    assert.include(text, items[1]);
  });
```

This looks simple enough. Let's create 2 `Todo`s, "Have lunch" and "Grocery shopping". Once these are saved, I should be able to navigate to `/todos` and see both. Since we're doing red-green-refactor, let's run our tests first to get our "red" before we try to turn it "green" by implementing our solution.

```
"uncaughtException" detected. Process will shutdown
    error: insert into "todos" ("created_at", "name", "updated_at") values ($1, $2, $3) returning "id" - null value in column "user_id" of relation "todos" violates not-null constraint
```

Oops, looks like we forgot to add a `user_id` to our `Todo`. Let's create a user first, then add these `Todo`s as "related" to the `User`.

```ts
  test("'index' should show all todos created", async assert => {
    const items = ["Have lunch", "Grocery shopping"];

    const user = await User.create({ email: "alice@email.com", password: "password" });
    await user.related("todos").createMany([{ name: items[0] }, { name: items[1] }]);
    
    const { text } = await supertest(baseUrl).get("/todos");

    assert.include(text, items[0]);
    assert.include(text, items[1]);
  });
```

Okay, now we're still not passing, but we don't have that knarly "uncaughtException" anymore. Now let's render out our list of todos. To do that, we'll need to query for the list of all todos in the controller, and then pass it to our view.

```ts
import Todo from "App/Models/Todo";

export default class TodosController {
  public async index({ view }: HttpContextContract) {

    const todos = await Todo.all();
    
    return await view.render("todos/index", { todos });
  }
}
```

```handlebars
@section('body')

<ul>
  @each(todo in todos)
  <li>{{ todo.name }}</li>
  @endeach
</ul>

<a href="{{ route('todos.create') }}" id="create-todo">Create Todo</a>
@endsection
```

Awesome. Back to "green".

Now let's work on the `show` route. We should be able to navigate there once the todo has been created.

```ts
test.group("Todos", () => {
  ...
  test("'show' should show the todo details", async assert => {
    const user = await User.create({ email: "alice@email.com", password: "password" });
    const todo = await user
      .related("todos")
      .create({ name: "Buy shoes", description: "Air Jordan 1" });
    const { text } = await supertest(baseUrl).get(`/todos/${todo.id}`);

    assert.include(text, todo.name);
    assert.include(text, todo.description);
  });
});
```

We're flying now. Our tests seem to have a lot of similar setup code. Possible refactor candidate. I'll note that for later.

```ts
export default class TodosController {
  ...
  public async show({ params, view }: HttpContextContract) {
    const id = params["id"];
    const todo = await Todo.findOrFail(id);
    return await view.render("todos/show", { todo });
  }
}
```

As with the `index` route, we'll need to create the view for our `show` route:

```bash
node ace make:view todos/show
```

```handlebars
@layout('layouts/default')

@section('body')
<h1>{{ todo.name }}</h1>
<p>{{ todo.description }}</p>
@endsection
```

Great, let's run the tests to see where we're at.

```
  ✖ 'show' should show the todo details
    error: insert into "users" ("created_at", "email", "password", "updated_at") values ($1, $2, $3, $4) returning "id" - duplicate key value violates unique constraint "users_email_unique"
```

Okay, you might have already thought, why's this guy creating another `User` with the same email? Well, what if I created this user in a test that's at the bottom of the file separated by hundreds of lines? What if the user was created for a test in another file? It would be really hard if we had to depend on some database state created who knows where.

Let's make sure we start each test, as if the database were brand new. Let's add some setup and teardown code:

```ts
test.group("Todos", group => {
  group.beforeEach(async () => {
    await Database.beginGlobalTransaction();
  });

  group.afterEach(async () => {
    await Database.rollbackGlobalTransaction();
  });
  ...
});
```

Alright! Back to green. So far, we've knocked off 2 tests from our "Testing todos" list we wrote before we started all the testing work.

Now it's time to tackle the `create` and `update` tests. Let's start it off like we started the others, with a test. Let's turn our "green" tests back to "red".

```ts
  test("'create' should 'store' a new `Todo` in the database", async assert => {
    const { text } = await supertest(baseUrl).get("/todos/create").expect(200);
    const { document } = new JSDOM(text).window;
    const createTodoForm = document.querySelector("#create-todo-form");

    assert.exists(createTodoForm);
  });
```

```
  ✖ 'create' should 'store' a new `Todo` in the database
    Error: expected 200 "OK", got 302 "Found"
```

Ahh, there we go. Our first issue with authentication. We need to be signed in to view this route, but how can we do that? After some Googling, looks like the `supertest` library has our solution. `supertest` allows you to access [`superagent`](https://github.com/visionmedia/superagent), which will retain the session cookies between requests, so we'll just need to "register" a new user prior to visiting the `store` route.

```ts
  test("'create' should 'store' a new `Todo` in the database", async assert => {
    const agent = supertest.agent(baseUrl);
    await User.create({ email: "alice@email.com", password: "password" });
    await agent
      .post("/login")
      .field("email", "alice@email.com")
      .field("password", "password");
    const { text } = await agent.get("/todos/create").expect(200);
    const { document } = new JSDOM(text).window;
    const createTodoForm = document.querySelector("#create-todo-form");

    assert.exists(createTodoForm);
  });
```

```ts
export default class TodosController {
  ...
  public async create({ view }: HttpContextContract) {
    return await view.render("todos/create");
  }
}
```

```bash
node ace make:view todos/create
```

```handlebars
@layout('layouts/default')

@section('body')
<form action="{{ route('todos.store') }}" method="post" id="create-todo-form">
  <div>
    <label for="name"></label>
    <input type="text" name="name" id="name">
  </div>
  <div>
    <label for="description"></label>
    <textarea name="description" id="description" cols="30" rows="10"></textarea>
  </div>
</form>
@endsection
```

We really are flying now. By adding the form with the `id` of `create-todo-form`, we're passing our tests again. We've checked that the form is there, but does it work? That's the real question. And from the experience of signing the user in with `supertest.agent`, we know that we just need to post to the `store` route with fields of `name` and `description`.

```ts
  test("'create' should 'store' a new `Todo` in the database", async assert => {
    ...
    await agent
      .post("/todos")
      .field("name", "Clean room")
      .field("description", "It's filthy!");
    const todo = await Todo.findBy("name", "Clean room");
    assert.exists(todo);
  });
```

Okay, back to "red" with a missing `store` method on `TodosController`. By now, you don't even need to read the error message and you'll know what to do. But still, it's nice to run the tests at every step so you only work on the smallest bits to get your tests to turn back "green".

```ts
import Todo, { todoSchema } from "App/Models/Todo";
...
export default class TodosController {
  ...
  public async store({
    auth,
    request,
    response,
    session,
  }: HttpContextContract) {
    const { user } = auth;
    if (user) {
      const payload = await request.validate({ schema: todoSchema });
      const todo = await user.related("todos").create(payload);
      response.redirect().toRoute("todos.show", { id: todo.id });
    } else {
      session.flash({ warning: "Something went wrong." });
      response.redirect().toRoute("login");
    }
  }
}
```

```ts
import { schema } from "@ioc:Adonis/Core/Validator";
...
export const todoSchema = schema.create({
  name: schema.string({ trim: true }),
  description: schema.string(),
});
```

We're doing a little more with this one. First off, the signed in user is already exists in the context of the application and is accessible through the `auth` property. I created a schema called `todoSchema` which is used to validate the data passed from the form. This does 2 things that I don't have to worry about explicitly, if there are any errors, those errors will be available from `flashMessages` upon the next view render (which will be the `create` form). The resulting `payload` can be used directly to create the new `Todo`.

If, for some reason, I don't find the signed in user from `auth`, I can flash a warning message and redirect the user back to the login screen.

Now let's test our `edit` route. Since I had to sign for this test as well, I extracted that functionality to a helper function called `loginUser`. `agent` retains the session cookies and the `User` is returned to use to associate the newly created `Todo`. I update the `name` and `description` of the `Todo` then navigate to the `show` route and make sure the updated values exist on the page.

```ts
test.group("Todos", group => {
  ...
  test("'edit' should 'update' an existing `Todo` in the database", async assert => {
    const user = await loginUser(agent);
    const todo = await user.related("todos").create({
      name: "See dentist",
      description: "Root canal",
    });
    await agent.get(`/todos/${todo.id}/edit`).expect(200);
    await agent
      .put(`/todos/${todo.id}`)
      .field("name", "See movie")
      .field("name", "Horror flick!");
    const { text } = await agent.get(`/todos/${todo.id}`).expect(200);
    assert.include(text, "See movie");
    assert.include(text, "Horror flick!");
  });
});

async function loginUser(agent: supertest.SuperAgentTest) {
  const user = await User.create({
    email: "alice@email.com",
    password: "password",
  });
  await agent
    .post("/login")
    .field("email", "alice@email.com")
    .field("password", "password");
  return user;
}
```

As with the `create` test, the `edit` should show a form, but prepopulated with the current values. For now, let's just copy the `todos/create` view template for `todos/edit`. We'll need to update the values of the input and textarea elements with the current values.

```ts
export default class TodosController {
  ...
  public async edit({ params, view }: HttpContextContract) {
    const id = params["id"];
    const todo = Todo.findOrFail(id);
    return await view.render("todos/edit", { todo });
  }
}
```

```bash
node ace make:view todos/edit
```

```handlebars
@layout('layouts/default')

@section('body')
<form action="{{ route('todos.update', {id: todo.id}, {qs: {_method: 'put'}}) }}" method="post" id="edit-todo-form">
  <div>
    <label for="name"></label>
    <input type="text" name="name" id="name" value="{{ flashMessages.get('name') || todo.name }}">
  </div>
  <div>
    <label for="description"></label>
    <textarea name="description" id="description" cols="30" rows="10">
      {{ flashMessages.get('description') || todo.description }}
    </textarea>
  </div>
  <div>
    <input type="submit" value="Create">
  </div>
</form>
@endsection
```

Here we need to do some [method spoofing](https://docs.adonisjs.com/cookbooks/validating-server-rendered-forms#form-method-spoofing), thus you see the strange action. This is just a way for AdonisJS spoof `PUT`, since HTTP only has `GET` and `POST`. You'll have to go to the `app.ts` file and set `allowMethodSpoofing` to `true`.

```ts
export const http: ServerConfig = {
  ...
  allowMethodSpoofing: true,
  ...
}
```

```ts
  public async update({ params, request, response }: HttpContextContract) {
    const id = params["id"];
    const payload = await request.validate({ schema: todoSchema });
    const todo = await Todo.updateOrCreate({ id }, payload);
    response.redirect().toRoute("todos.show", { id: todo.id });
  }
```

The last 2 tests we need to write are to check that going to `create` or `edit` redirects us to the sign-in page. There isn't any implementation since these are already done, but the negative case test is nice to have in case something breaks in the future.

```ts
  test("unauthenticated user to 'create' should redirect to signin", async assert => {
    const response = await agent.get("/todos/create").expect(302);
    assert.equal(response.headers.location, "/login");
  });

  test("unauthenticated user to 'edit' should redirect to signin", async assert => {
    const user = await User.create({
      email: "bob@email.com",
      password: "password",
    });
    const todo = await user.related("todos").create({ name: "Go hiking" });
    const response = await agent.get(`/todos/${todo.id}/edit`).expect(302);
    assert.equal(response.headers.location, "/login");
  });
```

These should both pass immediately. And now we're "green". We hit all the test cases we initially wanted to write, but our job is far from over. There's a fair bit of refactoring that needs to be done, not in the production code, but in the tests. If you see your tests as "documentation of intent", then there is definitely more editing to make things more clear.

While we're not done, this is a good place to stop. We've completed a feature. We have completed the tests we initially set out to write. We cycled between "red" and "green" several times. Now it's your turn. Are there any more tests you think you'd need to write. How about some refactoring?