RizTech Academy logo
RizTech Academy
API AutomationLesson 3 of 430 min

Auth, and arranging state through the API

Most endpoints need you to be logged in and often need something to already exist before you can test them — you cannot read a transaction that was never created. Handling authentication cleanly, and arranging the state a test depends on, is much of the craft of API automation. Done well, tests stay independent and fast; done badly, they leak state and flake. This lesson is auth and arranging state through the API, with AgentPay's suite as the model.

Authenticating in tests

AgentPay (like most real APIs) uses token auth: you log in once, get a token, and send it as Authorization: Bearer <token> on every subsequent request. In tests you automate exactly that — get a token, pass it along:

const token = await tokenFor('admin@agentpay.test', 'admin123');
const res = await app.inject({
  method: 'GET',
  url: '/transactions',
  headers: { authorization: `Bearer ${token}` },
});

The tokenFor helper (from the automating lesson) hides the login so no test repeats it. A few practical points:

  • Log in via the API, not the UI. Even when testing a web app, authenticate through the API call — it is fast and sturdy. Driving the login form to get a token is slow and brittle (the arrange-through-the-API principle from the fixtures lesson).
  • Different users for different tests. AgentPay logs in as admin for admin tests and as the asha operator for scoping tests — because who you are is exactly what several tests are about.
  • Keep credentials as test constants, not scattered literals: AgentPay exports ADMIN and OPERATOR from the test helper.

Testing auth itself

Auth is not just a hurdle to get past — it is behaviour you must test directly, because auth bugs are serious (the api-test-design lesson). Automate the auth cases:

it('rejects a request with no token (401)', async () => {
  const res = await app.inject({ method: 'GET', url: '/transactions' });
  expect(res.statusCode).toBe(401);
});

it('rejects an invalid token (401)', async () => {
  const res = await app.inject({ method: 'GET', url: '/transactions', headers: { authorization: 'Bearer nonsense' } });
  expect(res.statusCode).toBe(401);
});

And — the important one — permission (authorization, not just authentication): a valid token for the wrong user must be refused. AgentPay automates the id-swap access test that the UI cannot easily reach:

it("forbids an operator reading another agent's transaction by id (403), not 404", async () => {
  const adminToken = await tokenFor('admin@agentpay.test', 'admin123');
  const all = (await app.inject({ url: '/transactions?limit=100', headers: { authorization: `Bearer ${adminToken}` } })).json().data;
  const other = all.find((t) => t.agentId !== 1);          // a transaction NOT owned by agent 1
  const operatorToken = await tokenFor('asha@agentpay.test', 'operator123'); // asha is agent 1
  const res = await app.inject({ url: `/transactions/${other.id}`, headers: { authorization: `Bearer ${operatorToken}` } });
  expect(res.statusCode).toBe(403);                         // blocked — the access bug is caught
});

This is one of the most valuable automated tests in the whole suite: it proves the API enforces access control, a bug class that is serious, common, and invisible from the UI. Automate a permission test for every protected resource.

Arranging state through the API

Many tests need data to exist first. The rule: create what you need through the API in the test's setup, do not assume it is there. AgentPay does this two ways, both valid:

  • Reset to a known seed. beforeEach(reset) gives every test the same 60 transactions and known agents, so read/filter/pagination tests have predictable data without creating it. Simple and deterministic.
  • Create per test. When a test needs a specific record, create it via the API first, then act on it — for example, POST a transaction, capture its id from the response, then GET that id. The test owns the record it depends on, so it cannot be broken by other tests.

The anti-pattern to avoid: a test that assumes "transaction 42 exists" because a previous test created it, or because someone seeded it by hand. That couples tests to each other and to the environment, and it is the shared-state flakiness the automation-strategy module warned about. Each test arranges its own state.

Idempotency and money: state that must not double

AgentPay's most safety-critical automated test arranges state to prove a money-flow guarantee — sending the same request twice must not create two transactions:

it('is idempotent: the same Idempotency-Key never creates a duplicate', async () => {
  const headers = { authorization: `Bearer ${token}`, 'idempotency-key': 'abc-123' };
  const first = await app.inject({ method: 'POST', url: '/transactions', headers, payload: good });
  const second = await app.inject({ method: 'POST', url: '/transactions', headers, payload: good });
  expect(first.statusCode).toBe(201);          // created
  expect(second.statusCode).toBe(200);          // returned the existing one
  expect(second.json().id).toBe(first.json().id); // SAME record — no duplicate
});

This is the money-and-transaction-flows concern (double-charge on a retry) turned into an automated guarantee. Because the test arranges the exact scenario (same key, twice) and asserts the exact outcome (same id), it locks in a property that would be disastrous to break — precisely the kind of high-value test the automation-strategy module said to prioritise.

Check your work

Authenticate via the API — tokenFor gets a token, sent as Authorization: Bearer; log in through the API even for UI tests (fast, sturdy); use different users where identity is the point; keep credentials as test constants.

Test auth directly — no token → 401, invalid token → 401, and permission: a valid token for the wrong user → 403 (AgentPay's id-swap test proves access control the UI cannot reach). Automate a permission test per protected resource — a serious, common, UI-invisible bug class.

Arrange state through the API — create what you need in setup (reset to a known seed, or POST a record and capture its id), never assume data exists or that a prior test left it. Each test owns its state — the cure for shared-state flakiness.

Idempotency/money — arrange the exact risky scenario (same Idempotency-Key twice) and assert the exact safe outcome (same id, no duplicate). High-value: it locks in a guarantee that would be disastrous to break.

Practice

  1. Write a tokenFor helper and use it to authenticate a GET request; confirm it works.
  2. Automate the three auth cases: no token → 401, bad token → 401, and a wrong-user access → 403.
  3. Reproduce AgentPay's id-swap test: as an operator, try to read another agent's transaction and assert 403.
  4. Write a test that creates a transaction via POST, captures its id, then GETs it and checks the body.
  5. Automate the idempotency test: POST the same request twice with one key and assert the same id comes back.
  6. Take a test that assumes a record already exists and rewrite it to arrange its own state; explain why.

Official documentation

Next: testing at the right layer — API vs UI.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship