Большинство тест-сьютов API-аутентификации проверяют одно: валидные учётные данные дают доступ. Три состояния которые реально ломаются в продакшне: неаутентифицированные запросы возвращают не 401, запросы с правильным токеном но недостаточными правами возвращают не 403, и кешированные токены истекают посреди прогона создавая запутанные ошибки вместо чётких auth-ошибок. Здесь разобрано тестирование всех трёх случаев для API-ключей, Bearer-токенов, JWT, Basic auth и OAuth2, плюс переиспользуемая фикстура Playwright с автоматическим обновлением токенов.
Три вопроса которые должен отвечать каждый auth-тест
Прежде чем писать первый ассёрт, каждый API-тест с аутентификацией должен отвечать на три вопроса.
Кто я? Идентификация устанавливается через учётные данные: API-ключ, пару логин/пароль, client ID и secret. Тест должен уметь предоставлять эти данные без хардкода. Есть ли у меня права? Аутентификация (подтверждение личности) и авторизация (что разрешено делать) это разные системы. Валидный токен обычного пользователя не должен давать доступ к эндпоинту только для администраторов. Тесты которые проверяют только что аутентифицированные пользователи могут получить доступ к ресурсам, пропускают половину того что реально ломается в продакшне. Валидны ли мои учётные данные? Токены истекают. API-ключи ротируются. Тесты которые кешируют токен при старте и часами работают с короткоживущим токеном будут падать непонятно как: не «ошибка аутентификации», а «запрос не выполнен» или запутанный 401 в середине сьюта который начался нормально.Каждый раздел этой статьи относится к одному или нескольким из этих вопросов.
Аутентификация по API-ключу
API-ключи это простейший тип аутентификации: статическая секретная строка которую клиент отправляет с каждым запросом. Передаются двумя способами.
Ключ в заголовке более распространён и более безопасен. Название заголовка варьируется:X-API-Key, Authorization, Api-Key, и другие варианты.
curl -H "X-API-Key: sk_live_abc123" https://api.example.com/v1/dataimport { test, expect } from '@playwright/test';
test('GET /data with API key in header', async ({ request }) => {
const response = await request.get('https://api.example.com/v1/data', {
headers: {
'X-API-Key': process.env.API_KEY ?? ''
}
});
expect(response.status()).toBe(200);
});curl "https://api.example.com/v1/data?api_key=sk_live_abc123"test('GET /data with API key in query param', async ({ request }) => {
const response = await request.get('https://api.example.com/v1/data', {
params: {
api_key: process.env.API_KEY ?? ''
}
});
expect(response.status()).toBe(200);
});Если один API-ключ применяется ко всем тестам в сьюте, настрой его один раз в playwright.config.ts вместо повторения в каждом тесте:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'X-API-Key': process.env.API_KEY ?? ''
}
}
});Каждый запрос фикстуры request будет автоматически включать этот заголовок. Убираешь шум на уровне тестов, сохраняя возможность переопределить заголовок в конкретных тестах с другими учётными данными.
.env файл и секреты репозитория в CI. Ключ закоммиченный в систему контроля версий нужно считать скомпрометированным немедленно.Bearer-токены и JWT
Аутентификация по Bearer-токену это двушаговый процесс: получить токен, затем использовать его. JSON Web Tokens (JWT) это наиболее распространённый формат токенов: структуры JSON закодированные в base64, содержащие claims об идентификации и правах пользователя плюс временную метку истечения.
Получение токена выглядит как стандартный POST-логин:
import { test, expect } from '@playwright/test';
test('fetch a Bearer token and call a protected endpoint', async ({ request }) => {
// Шаг 1: аутентифицируемся и получаем токен
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
expect(loginRes.status()).toBe(200);
const { access_token } = await loginRes.json();
// Шаг 2: используем его в каждом защищённом запросе
const profileRes = await request.get('/api/user/profile', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
expect(profileRes.status()).toBe(200);
const profile = await profileRes.json();
expect(profile).toHaveProperty('id');
expect(profile).toHaveProperty('email');
});Для сьюта с множеством тестов под одним пользователем выноси логин в beforeAll. Токен получается один раз и переиспользуется:
import { test, expect } from '@playwright/test';
let accessToken: string;
test.beforeAll(async ({ request }) => {
const res = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
const body = await res.json();
accessToken = body.access_token;
});
test('list orders as authenticated user', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});
test('get order details', async ({ request }) => {
const response = await request.get('/api/orders/123', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()). Это полезно когда нужно проверить что токен содержит ожидаемые роли или время истечения без вызова дополнительного API-эндпоинта.Basic auth
HTTP Basic authentication кодирует логин и пароль как base64(username:password) и отправляет их в заголовке Authorization. Старый способ, встречается в основном во внутренних инструментах, legacy API и тестовых окружениях.
Playwright берёт кодирование на себя через опцию httpCredentials:
test('Basic auth via httpCredentials', async ({ playwright }) => {
const context = await playwright.request.newContext({
baseURL: 'https://api.example.com',
httpCredentials: {
username: process.env.BASIC_USER ?? '',
password: process.env.BASIC_PASS ?? ''
}
});
const response = await context.get('/protected/resource');
expect(response.status()).toBe(200);
await context.dispose();
});Можно также задать заголовок вручную:
test('Basic auth via Authorization header', async ({ request }) => {
const credentials = Buffer.from(
`${process.env.BASIC_USER}:${process.env.BASIC_PASS}`
).toString('base64');
const response = await request.get('/protected/resource', {
headers: {
Authorization: `Basic ${credentials}`
}
});
expect(response.status()).toBe(200);
});httpCredentials чище для целого контекста. Ручной заголовок полезен для тестирования edge cases: некорректных учётных данных или когда разные запросы в одном тесте нуждаются в разных учётных данных.
OAuth2 client credentials для сервис-сервис тестов
OAuth2 client credentials это вариант OAuth2 для взаимодействия сервисов без участия пользователя. Сервис аутентифицируется с client ID и secret, получает access-токен и использует его для вызова API другого сервиса. Распространено в микросервисных архитектурах и интеграциях с третьими сторонами.
Флоу:
curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=myapp&client_secret=secret123&scope=read:orders"В Playwright:
import { test, expect } from '@playwright/test';
async function getClientCredentialsToken(request: any): Promise<string> {
const tokenRes = await request.post('https://auth.example.com/oauth/token', {
form: {
grant_type: 'client_credentials',
client_id: process.env.OAUTH_CLIENT_ID ?? '',
client_secret: process.env.OAUTH_CLIENT_SECRET ?? '',
scope: 'read:orders write:orders'
}
});
expect(tokenRes.status()).toBe(200);
const { access_token } = await tokenRes.json();
return access_token;
}
test('call orders API with OAuth2 client credentials token', async ({ request }) => {
const token = await getClientCredentialsToken(request);
const response = await request.get('https://api.example.com/v1/orders', {
headers: {
Authorization: `Bearer ${token}`
}
});
expect(response.status()).toBe(200);
});Токены client credentials тоже истекают. Для сьюта с множеством тестов применяется та же стратегия обновления что для Bearer-токенов: получаешь токен один раз в beforeAll, отслеживаешь время истечения и обновляешь до использования.
Поле scope важно для тестирования авторизации. Запрос read:orders должен разрешать GET но не POST. Запрос scope который не был предоставлен должен возвращать 403, не 401. Это стоит тестировать явно.
Тестирование состояний auth-ошибок: 401 vs 403
Разница между 401 и 403 не косметическая. 401 означает что в запросе не было валидных учётных данных: сервер не знает кто это. 403 означает что сервер знает кто это, но не позволяет делать запрошенное. Возврат неверного кода это баг, и стоит тестировать оба.
import { test, expect } from '@playwright/test';
test('401 when no credentials are sent', async ({ request }) => {
// Без заголовка Authorization: сервер должен отклонить как неаутентифицированный
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
test('403 when authenticated but lacking permission', async ({ request }) => {
// Логинимся как обычный пользователь (не администратор)
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.REGULAR_USER_EMAIL,
password: process.env.REGULAR_USER_PASSWORD
}
});
const { access_token } = await loginRes.json();
// Пытаемся получить доступ к эндпоинту только для администраторов
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(response.status()).toBe(403);
});
test('401 when token is expired', async ({ request }) => {
// Заведомо истёкший токен (хардкод только для этого теста)
const expiredToken = process.env.EXPIRED_JWT_TOKEN ?? '';
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${expiredToken}` }
});
expect(response.status()).toBe(401);
});
test('401 when token is malformed', async ({ request }) => {
const response = await request.get('/api/user/profile', {
headers: { Authorization: 'Bearer not.a.valid.jwt' }
});
expect(response.status()).toBe(401);
});Тестирование истёкших токенов непросто потому что время обычно не под контролем. Практические подходы: держи хардкодный истёкший токен в переменной окружения тестового окружения (он никогда не должен работать, только быть истёкшим), или запускай тесты против сервера с настраиваемым смещением времени, или вызывай эндпоинт который явно истекает токен.
Также стоит тестировать: работает ли токен из одного окружения в другом? Позволяет ли токен пользователя A получить доступ к ресурсам пользователя B? Второй вопрос это категория BOLA (Broken Object Level Authorization): разбираем в следующем разделе.
Переиспользуемая auth-фикстура с автоматическим обновлением токена
Повторять флоу логина в каждом тесте создаёт шум. Хуже того, токен закешированный в beforeAll на длительность долгого сьюта тихо истечёт посреди прогона. Решение: фикстура которая делает обе вещи. Переиспользует токен между тестами и отслеживает когда нужно обновление.
// fixtures/auth.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
interface AuthContext {
getToken: () => Promise<string>;
asUser: (role: 'admin' | 'user' | 'readonly') => Promise<string>;
}
interface TokenCache {
token: string;
expiresAt: number; // Unix timestamp в мс
}
const tokenCache = new Map<string, TokenCache>();
async function fetchToken(
request: APIRequestContext,
email: string,
password: string
): Promise<string> {
const cacheKey = email;
const cached = tokenCache.get(cacheKey);
// Возвращаем кешированный токен если он действителен ещё более 30 секунд
if (cached && cached.expiresAt - Date.now() > 30_000) {
return cached.token;
}
const res = await request.post('/api/auth/login', {
data: { email, password }
});
const body = await res.json();
const { access_token, expires_in } = body;
tokenCache.set(cacheKey, {
token: access_token,
expiresAt: Date.now() + expires_in * 1000
});
return access_token;
}
type AuthFixtures = { auth: AuthContext };
export const test = base.extend<AuthFixtures>({
auth: async ({ request }, use) => {
const authContext: AuthContext = {
getToken: () =>
fetchToken(
request,
process.env.TEST_USER_EMAIL ?? '',
process.env.TEST_USER_PASSWORD ?? ''
),
asUser: (role) => {
const credentials: Record<string, { email: string; password: string }> = {
admin: {
email: process.env.ADMIN_EMAIL ?? '',
password: process.env.ADMIN_PASSWORD ?? ''
},
user: {
email: process.env.TEST_USER_EMAIL ?? '',
password: process.env.TEST_USER_PASSWORD ?? ''
},
readonly: {
email: process.env.READONLY_EMAIL ?? '',
password: process.env.READONLY_PASSWORD ?? ''
}
};
const creds = credentials[role];
return fetchToken(request, creds.email, creds.password);
}
};
await use(authContext);
}
});
export { expect } from '@playwright/test';Тесты использующие эту фикстуру чистые и явно указывают под какой ролью работают:
import { test, expect } from '../fixtures/auth.fixture';
test('admin can list all users', async ({ request, auth }) => {
const token = await auth.asUser('admin');
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` }
});
expect(response.status()).toBe(200);
});
test('readonly user cannot delete a record', async ({ request, auth }) => {
const token = await auth.asUser('readonly');
const response = await request.delete('/api/records/123', {
headers: { Authorization: `Bearer ${token}` }
});
expect(response.status()).toBe(403);
});Кеш на уровне модуля (Map) сохраняется между тестами в одном воркер-процессе. Буфер 30 секунд до истечения означает что токен обновляется до того как истечёт, а не после появления ошибки.
Базы безопасности: какие auth-баги искать
Auth-тесты проверяют что механика работает. Тесты безопасности проверяют что механику нельзя обойти. Это разные вещи, и вторые содержат интересные баги.
Broken Object Level Authorization (BOLA) самая распространённая уязвимость API. Пользователь A логинится и получает свой профиль по/api/users/42. Вопрос для тестирования: может ли пользователь A также получить /api/users/43? ID угадываемый, и многие API забывают проверять что запрашивающий пользователь является владельцем запрашиваемого ресурса.
test('BOLA: user A cannot read user B profile', async ({ request }) => {
// Аутентифицируемся как пользователь A
const loginA = await request.post('/api/auth/login', {
data: { email: process.env.USER_A_EMAIL, password: process.env.USER_A_PASSWORD }
});
const { access_token: tokenA } = await loginA.json();
// Получаем собственный профиль пользователя A чтобы найти его ID
const profileA = await request.get('/api/user/me', {
headers: { Authorization: `Bearer ${tokenA}` }
});
const { id: idA } = await profileA.json();
// ID пользователя B (из другого тестового аккаунта под нашим контролем)
const idB = process.env.USER_B_ID ?? '';
// Пытаемся прочитать профиль пользователя B с токеном пользователя A
const attemptedAccess = await request.get(`/api/users/${idB}`, {
headers: { Authorization: `Bearer ${tokenA}` }
});
// Должен вернуть 403, не 200
expect(attemptedAccess.status()).toBe(403);
});Другие паттерны для проверки:
Отсутствие auth на недокументированных эндпоинтах. Старые части API, внутренние эндпоинты или недавно добавленные маршруты иногда лишены auth-middleware. Систематический запрос эндпоинтов без учётных данных с проверкой что ни один не возвращает 200 это полезный sweep-тест. Эскалация привилегий. Если API принимает поле роли в теле запроса (например{ "role": "admin" }), обычный пользователь отправляющий это поле должен быть проигнорирован или отклонён, но не повышен в правах.
Повторное использование токена после выхода. После вызова /api/auth/logout access-токен должен быть инвалидирован на стороне сервера. Использование его снова должно возвращать 401.
test('token is invalidated after logout', async ({ request }) => {
const loginRes = await request.post('/api/auth/login', {
data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD }
});
const { access_token } = await loginRes.json();
// Подтверждаем что токен работает
const beforeLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(beforeLogout.status()).toBe(200);
// Выходим из системы
await request.post('/api/auth/logout', {
headers: { Authorization: `Bearer ${access_token}` }
});
// Токен больше не должен приниматься
const afterLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(afterLogout.status()).toBe(401);
});Эти тесты не требуют сканера безопасности. Просто API-вызовы с намеренно некорректными данными или несовпадающими учётными данными. Инвестиции небольшие, а ценность в том чтобы поймать уязвимости до пентеста или, хуже, реального инцидента.
FAQ
Использовать одни учётные данные для всего сьюта или уникальные для каждого теста
Один набор на роль обычно достаточен. Риск интерференции тестов из-за общих учётных данных низок пока тесты убирают за собой. Больший риск в параллельном выполнении: если параллельные воркеры логинятся как один пользователь, а сервер строго отслеживает сессии, возможны неожиданные auth-ошибки. Отдельные учётные данные на воркер решают эту проблему.
Как правильно хранить несколько наборов тестовых учётных данных для разных ролей
Используй переменные окружения с префиксами: ADMIN_EMAIL, ADMIN_PASSWORD, READONLY_EMAIL и так далее. Маппи их на имена ролей в фикстуре как показано выше. Никогда не храни пароли в файлах фикстур или тест-хелперах, только в переменных окружения или менеджере секретов.
API использует refresh-токены. Как с ними работать в тестах
Если access-токен истекает во время прогона, нужно обменять refresh-токен на новый. Подход с кешем токенов из раздела фикстур решает это: когда кешированный токен в пределах 30 секунд от истечения, происходит повторная аутентификация. Для OAuth2 флоу с явными refresh-токенами храни в кеше оба токена и пробуй обмен grant_type=refresh_token прежде чем откатываться к полному повторному логину.
Как тестировать authorization code флоу OAuth2? Там же нужен редирект браузера
Не нужно на уровне API. Authorization code флоу это пользовательский браузерный флоу. Тестируй его фикстурой page Playwright с браузером. Для API-тестов используй флоу без взаимодействия пользователя: client credentials для сервис-сервис и password grant (если сервер поддерживает) для имперсонации пользователей в тестах.
Что делать если API возвращает 200 с ошибкой в теле вместо правильного кода статуса
Это проблема дизайна на стороне сервера, но приходится с ней работать. Проверяй и код статуса и тело в ассёрте:
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${token}` }
});
// Некоторые API возвращают 200 с ошибкой в теле вместо 401
const body = await response.json();
expect(body.error).toBeUndefined();
expect(response.status()).toBe(200);Можно ли тестировать аутентификацию параллельно
Да, но осторожно с общим состоянием. Если тесты пишут данные одного пользователя, параллельное выполнение создаёт гонки состояний. Выполняй операции записи с разными пользователями или сериализуй их. Read-only auth-тесты параллелятся чисто потому что не изменяют состояние.
→ See also: API-тестирование с Playwright APIRequestContext (без Postman) | Авторизация в Playwright через storageState (без логина в каждом тесте) | Продвинутое API тестирование с Playwright: паттерны для реальных проектов