Next.js
Updated
5 min read

Testing Next.js app router API routes

How do you test Next.js app router API route handlers? A guide to testing Next.js API routes and mocking next-auth to properly test authenticated routes.

Testing Next.js app router API routes

One of the fun things about software security is learning about unbelievably complex and sophisticated attack methodologies. A favorite of mine was the walkthrough by Google’s Project Zero of the Turing complete CPU that was implemented within a fake GIF inside the iOS PDF rendering component to develop a zero click exploit for iMessage. So elaborate!

Whilst interesting to read about, it’s highly unlikely any normal people will ever be affected by these vulnerabilities. In reality, most attacks are drive-by opportunistic attempts to exploit low-hanging fruit - things like default passwords, known issues in outdated software, or simple logic bugs in code.

The most common form of exploit from the OWASP Top 10 2021 is broken access control i.e. the ability to access data or accounts you shouldn’t have access to. These come about through easy-to-miss errors in permissions checking, missing access control for other HTTP methods like POST or DELETE, or accessing accounts owned by other people due to insecure direct object references or session bugs.

Mapping
How the OWASP Top 10 has changed to show the most common vulnerabilities in 2021 vs 2017.

These types of security problems are difficult to detect during development, but often come up during penetration testing. However, it should be relatively easy to write tests to verify the intended behavior and avoid regressions as the code evolves.

Although developers primarily use Arcjet through our security SDKs, we also have a web dashboard for reviewing decisions and request analytics. This is built with Next.js and contains both the frontend and the API the frontend calls (our low-latency gRPC API is implemented in Rust, Go & WebAssembly). We need to make sure we're able to test those endpoints.

This post is about writing tests for Next.js API app router route handlers, previously known as API routes in the pages router. Implementing some simple tests should be able to help protect against both A01:2021-Broken Access Control and A07:2021-Identification and Authentication Failures.

Challenges testing Next.js API route handlers

Unfortunately, Next.js does not make it easy to test API route handlers. The official guides only discuss testing frontend components and when you start digging into testing the API routes you find it’s not straightforward.

Next.js has moved towards using standard Request and Response types from TypeScript 5.2+, but there are still extended NextRequest and NextResponse types, Next.js patches the global fetch function, it uses custom APIs for accessing headers or cookies, has optional segment configurations and offers different runtimes.

Then you need to figure out how to mock your own libraries and start a server to be able to make the requests. Some have reported using node-mocks-http is an option, but I couldn’t get it to work. And what if you want to test for the Edge runtime?

How to test Next.js API route handlers

Thankfully, the next-test-api-route-handler package by Xunnamius solves most of these problems. It is test framework agnostic and allows you to emulate Next.js route handlers using the app router, pages router, and the edge runtime.

The package readme provides multiple examples, but assumes you already have a test harness set up. In our case, we followed the Next.js guide for testing with Jest but set the testEnvironment to node by installing jest-environment-node rather than jsdom.

This resulted in the following base files:

// /jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest.js";

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: "./",
});

// Add any custom config to be passed to Jest
const config: Config = {
  coverageProvider: "v8",
  testEnvironment: "node",
  moduleNameMapper: {
    // Uncomment to provides the Next.js cache function
    //react: "next/dist/compiled/react/cjs/react.development.js",
    "^@/(.*)$": "<rootDir>/$1",
  },
  setupFilesAfterEnv: ["./jest.setup.ts"],
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);
// /jest.setup.ts
import "@testing-library/jest-dom";
// /app/api/hello/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  return NextResponse.json({ hello: true }, { status: 200 });
}
// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import * as appHandler from "./route";

it("GET returns 200", async () => {
  await testApiHandler({
    appHandler,
    test: async ({ fetch }) => {
      const response = await fetch({ method: "GET" });
      const json = await response.json();
      expect(response.status).toBe(200);
      await expect(json).toStrictEqual({
        hello: true,
      });
    },
  });
});

Then running jest from the root of the project will execute the test.

Mocking next-auth and testing authenticated routes

Now we have a framework in place, we can extend the tests to mock our authentication library and test to ensure the correct responses are returned. This example uses the open source next-auth library, but next-test-api-route-handler has an example for mocking Clerk middleware which Arcjet also integrates with.

To get the next-auth session you need to call getServerSession. Using the simple example above, an example route handler would look like this:

// /app/api/hello/route.ts
import { authOptions } from "@/lib/auth"; // This is a custom helper as described in https://next-auth.js.org/configuration/nextjs
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const session = await getServerSession(authOptions);

  if (!session || !session.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  return NextResponse.json({ hello: true }, { status: 200 });
}

We therefore need to mock the call to getServerSession using Jest:

// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import type { Session } from "next-auth";
import * as appHandler from "./route";

let mockedSession: Session | null = null;

// This mocks our custom helper function to avoid passing authOptions around
jest.mock("@/lib/auth", () => ({
  authOptions: {
    adapter: {},
    providers: [],
    callbacks: {},
  },
}));

// This mocks calls to getServerSession
jest.mock("next-auth/next", () => ({
  getServerSession: jest.fn(() => Promise.resolve(mockedSession)),
}));

afterEach(() => {
  mockedSession = null;
});

Then in the same test file we can write a test to check that the GET returns a 403 when there is no session:

// /app/api/hello/route.test.ts
it("GET returns 403 unauthorized when not authenticated", async () => {
  mockedSession = null;

  await testApiHandler({
    appHandler,
    test: async ({ fetch }) => {
      const response = await fetch({ method: "GET" });
      const json = await response.json();
      expect(response.status).toBe(401);
      await expect(json).toStrictEqual({
        error: "Unauthorized",
      });
    },
  });
});

And to simulate a session we set mockedSession within another test. This gets reset automatically in afterEach:

it("GET returns 200 when authenticated", async () => {
  mockedSession = {
    expires: "expires",
    user: {
      id: "test",
    },
  };

  await testApiHandler({
    appHandler,
    test: async ({ fetch }) => {
      const response = await fetch({ method: "GET" });
      const json = await response.json();
      expect(response.status).toBe(200);
      await expect(json).toStrictEqual({
        hello: true,
      });
    },
  });
});

The full test file looks like this:

// /app/api/hello/route.test.ts
import { testApiHandler } from "next-test-api-route-handler"; // Must always be first
import type { Session } from "next-auth";
import * as appHandler from "./route";

let mockedSession: Session | null = null;

// This mocks our custom helper function to avoid passing authOptions around
jest.mock("@/lib/auth", () => ({
  authOptions: {
    adapter: {},
    providers: [],
    callbacks: {},
  },
}));

// This mocks calls to getServerSession
jest.mock("next-auth/next", () => ({
  getServerSession: jest.fn(() => Promise.resolve(mockedSession)),
}));

afterEach(() => {
  mockedSession = null;
});

it("GET returns 403 unauthorized when not authenticated", async () => {
  mockedSession = null;

  await testApiHandler({
    appHandler,
    test: async ({ fetch }) => {
      const response = await fetch({ method: "GET" });
      const json = await response.json();
      expect(response.status).toBe(401);
      await expect(json).toStrictEqual({
        error: "Unauthorized",
      });
    },
  });
});

it("GET returns 200 when authenticated", async () => {
  mockedSession = {
    expires: "expires",
    user: {
      id: "test",
    },
  };

  await testApiHandler({
    appHandler,
    test: async ({ fetch }) => {
      const response = await fetch({ method: "GET" });
      const json = await response.json();
      expect(response.status).toBe(200);
      await expect(json).toStrictEqual({
        hello: true,
      });
    },
  });
});

Conclusions

There’s a lot more you can do here, such as mocking database calls, splitting the mocks into separate files and testing authorization (does this user have access to the right data?), but this sets up the basics so you can ensure that your sensitive Next.js API routes are properly tested for authentication.

Related articles

Next.js server action security
Next.js
9 min read

Next.js server action security

Server actions are an elegant way to handle simple functions for common actions like form submissions, but they're a public API so you still need to consider security.

Subscribe by email

Get the full posts by email every week.