DEV Community

kadir
kadir

Posted on

Auth & Sessions for the API Layer (Playwright + TypeScript, Ch.12)

Chapter 11 tested reads, which
need no identity. Most of an API is gated behind auth — creating articles, reading
the current user, following people. Doing that by hand means logging in and
threading a token through every request. We'll hide all of it behind one fixture.

Code for this chapter is tagged ch-12 in the repo:
https://github.com/aktibaba/playwright-qa-course — see
src/fixtures/auth.fixture.ts and src/tests/api/user.spec.ts.

How Inkwell auth works

Log in, get a JWT:

POST /api/users/login  { "user": { "email": "...", "password": "..." } }
→ { "user": { "token": "eyJ…", "username": "playwright", ... } }
Enter fullscreen mode Exit fullscreen mode

Then send it on protected requests using the RealWorld scheme — Token <jwt>,
not Bearer:

GET /api/user
Authorization: Token eyJ…
Enter fullscreen mode Exit fullscreen mode

A chained authedApi fixture

Back in Chapter 9 we drew the line: merge across modules, chain within a
dependency line.
authedApi is the chain — it depends on api (to log in) and
testUser (who to log in as), so it's built on top of them with .extend:

// src/fixtures/auth.fixture.ts
import { mergeTests, request, type APIRequestContext } from "@playwright/test";
import { env } from "@utils/env";
import { test as apiTest } from "./api.fixture";
import { test as dataTest } from "./data.fixture";

export interface AuthFixtures {
  authedApi: APIRequestContext;
}

export const test = mergeTests(apiTest, dataTest).extend<AuthFixtures>({
  authedApi: async ({ api, testUser }, use) => {
    const res = await api.post("users/login", {
      data: { user: { email: testUser.email, password: testUser.password } },
    });
    const { user } = await res.json();

    const context = await request.newContext({
      baseURL: `${env.apiURL}/`,
      extraHTTPHeaders: { Authorization: `Token ${user.token}` },
    });
    await use(context);
    await context.dispose();
  },
});
Enter fullscreen mode Exit fullscreen mode

Two design points:

  • extraHTTPHeaders attaches the token to every request the context makes — so the test never repeats the header.
  • It's test-scoped, on purpose. It depends on the test-scoped testUser, and in Part 4 that user becomes unique per test — so each test logs in its own user. (A worker-scoped fixture couldn't depend on testUser anyway — Chapter 10's rule.)

The composition root just swaps the leaf modules for the auth module that now
carries them:

// src/fixtures/index.ts
export const test = mergeTests(authTest, pagesTest);
Enter fullscreen mode Exit fullscreen mode

Specs still import { test, expect } from "@fixtures" — unchanged.

Authenticated, and rejected

With the fixture in place, an authenticated call is a one-liner — and we assert the
negative case too, because "does it reject anonymous access?" is part of the
contract:

test("GET /user returns the current user", async ({ authedApi, testUser }) => {
  const res = await authedApi.get("user");
  expect(res.ok()).toBeTruthy();

  const { user } = await res.json();
  expect(user.username).toBe(testUser.username);
  expect(user.email).toBe(testUser.email);
});

test("GET /user without a token is rejected", async ({ api }) => {
  const res = await api.get("user");        // the anonymous context
  expect(res.status()).toBe(401);
  const body = await res.json();
  expect(body.errors.body[0]).toContain("login");
});
Enter fullscreen mode Exit fullscreen mode

Note we keep both clients available: api for anonymous calls, authedApi for
authenticated ones. Testing the boundary between them is where real auth bugs hide.

Next up

We can now read and authenticate. Chapter 13 — Building CRUD API suites: create,
read, update, and delete articles through authedApi, each test making and cleaning
up its own data. Tag: ch-13.

Following along? Star the repo
and tell me how you manage auth tokens in your API tests.

Top comments (0)