Testing Security
openapi-backend checks the security requirements
of every operation by calling your security handlers, and rejects requests
that don't satisfy them with your unauthorizedHandler, or in strict mode.
Security tests make sure protected operations stay protected.
Anonymous requests
Send a request without credentials, and check that it was rejected:
import { test, expect } from 'vitest';
import { client } from './client';
test('creating pets requires authentication', async () => {
const res = await client.createPet({ example: true });
expect(res.status).toBe(401);
expect(res.context.security.authorized).toBe(false);
});
Every protected operation
Your spec says which operations need authentication, so you can test all of them with one test:
import { test, expect } from 'vitest';
import { api, client } from './client';
// operations that require authentication
const operationIds = api
.getOperations()
.filter(({ security }) => security.length > 0)
// an empty requirement {} makes authentication optional
.filter(({ security }) =>
security.every((requirement) => Object.keys(requirement).length > 0),
)
.map(({ operationId }) => operationId);
test.each(operationIds)('%s requires authentication', async (operationId) => {
const res = await client.call(operationId, { example: true });
expect(res.status).toBe(401);
});
example: true fills in path parameters and request bodies from your spec, so the same test works for every
operation. Operations you add later are tested automatically.
Test tables built from api.getOperations() need the API to be initialized when the test file loads. The shared
test client module does that with a top-level await api.init().
Authenticated requests
To test your security handlers, send real credentials. Create a client for each user with
client.with():
import { signToken } from '../src/auth';
const token = signToken({ sub: 'alice' });
const alice = client.with({ headers: { authorization: `Bearer ${token}` } });
test('alice can create pets', async () => {
const res = await alice.createPet({ example: true });
expect(res.status).toBe(201);
});
test('rejects expired tokens', async () => {
const expired = signToken({ sub: 'alice' }, { expiresIn: -1 });
const res = await client.createPet({
example: true,
headers: { authorization: `Bearer ${expired}` },
});
expect(res.status).toBe(401);
});
Stubbing security handlers
To test what your operation handlers do with a user, like checking roles or who owns a pet, you don't need real
credentials. The security option provides the result of a security handler, and the handler isn't called:
const user = client.with({ security: { jwt: { role: 'user' } } });
const admin = client.with({ security: { jwt: { role: 'admin' } } });
test('only admins can delete pets', async () => {
expect((await user.deletePetById({ params: { id: 1 } })).status).toBe(403);
expect((await admin.deletePetById({ params: { id: 1 } })).status).toBe(204);
});
The keys are the names of your security schemes, and the values are what your security handler would have returned.
Your handlers find them in c.security.jwt, as usual.
Stubs only replace security handlers. Security requirements are still checked the usual way: a stubbed jwt result
doesn't let a request into an operation that requires apiKey, and a falsy result, or an object with an error
property, fails the requirement.
Stubs skip your security handlers. Keep some tests that send real credentials, so the handlers themselves are tested too.
Stubs need the request to be handled in the same process as your tests. They don't work against a running server.
Inspecting security results
res.context.security has the authorization result and the result of each security handler:
const res = await alice.createPet({ example: true });
expect(res.context.security.authorized).toBe(true);
expect(res.context.security.jwt).toMatchObject({ sub: 'alice' });
Check the status, too
Security handlers don't reject requests on their own. Without an unauthorizedHandler or strict: true, the
operation handler still runs for unauthorized requests, and res.context.security.authorized is false either way.
Assert on res.status, like the tests above do, and a protected operation that lets anonymous requests through fails
the test. See Security Best Practices.
For the rest of the best practices, use openapi-backend-codeql.
Signed-in users with Vitest fixtures
When signing in takes setup, like creating a user in your database, provide clients with Vitest fixtures:
import { test as base } from 'vitest';
import type { TestClient } from 'openapi-backend/testing';
import { client } from './client';
import { createUser, deleteUser, signToken } from './users';
export const test = base.extend<{ user: TestClient }>({
user: async ({}, use) => {
const account = await createUser();
const token = signToken({ sub: account.id });
await use(client.with({ headers: { authorization: `Bearer ${token}` } }));
await deleteUser(account.id);
},
});
import { expect } from 'vitest';
import { test } from './fixtures';
test('users can create pets', async ({ user }) => {
const res = await user.createPet({ example: true });
expect(res.status).toBe(201);
});