Автентифікація та авторизація
Зміст
Коли автотест «логіниться» в застосунок, за цим стоять два різні процеси, які легко сплутати, поки вони працюють, і дуже боляче плутати, коли вони ламаються. Цей розділ розбирає, як застосунок розуміє, хто ти і що тобі дозволено, у яких формах цей стан живе (cookie, токен, JWT), як він передається в кожному запиті — і чому для тестів майже завжди вигідніше отримати цей стан через API, а не клікати форму входу.
Автентифікація проти авторизації
Це дві окремі стадії, і плутанина між ними — джерело як багів у продукті, так і хибних діагнозів у тестах.
Автентифікація (authentication) — відповідь на питання «хто ти?». Користувач доводить свою особу: логін і пароль, токен, відбиток, код із застосунку-автентифікатора. Результат — застосунок упевнений, що по той бік саме той, за кого себе видають.
Авторизація (authorization) — відповідь на питання «що тобі можна?». Автентифікований користувач намагається виконати дію, а система вирішує, чи має він на це право: чи може читач редагувати проєкт, чи бачить менеджер чужі рахунки.
Порядок завжди такий: спершу автентифікація, потім авторизація. Не можна вирішити, що людині дозволено, поки не знаєш, хто вона.
Найчіткіше різницю видно у кодах відповіді HTTP:
| Код | Назва | Насправді означає | Коли |
|---|---|---|---|
401 | Unauthorized | «Я тебе не впізнав» — проблема автентифікації | Токен відсутній, протермінований, невалідний |
403 | Forbidden | «Я знаю, хто ти, але тобі сюди не можна» — проблема авторизації | Валідний користувач без потрібних прав |
Назва 401 Unauthorized історично невдала: за змістом це «unauthenticated». За специфікацією (RFC 9110, §15.5.2) відповідь 401 мусить нести заголовок WWW-Authenticate щонайменше з одним викликом (challenge), який підказує клієнту, яку схему автентифікації застосувати. Для AQA це практичне правило діагностики: 401 — копай у бік логіну (протух токен, не підставилась cookie); 403 — копай у бік ролей і прав. Плутати їх у перевірках не можна, бо це різні вимоги.
Cookie/session-based авторизація
Історично перший підхід у вебі. HTTP не має пам’яті — кожен запит незалежний. Щоб сервер «пам’ятав» залогіненого користувача між запитами, придумали сесії (sessions).
Механіка така:
- Користувач надсилає логін і пароль.
- Сервер перевіряє їх, створює запис сесії у себе (у пам’яті, Redis, базі) і генерує випадковий ідентифікатор сесії (session ID).
- Сервер повертає цей ID у заголовку
Set-Cookie. - Браузер зберігає cookie і автоматично додає її до кожного наступного запиту на цей домен.
POST /login HTTP/1.1
Host: app.example.com
Content-Type: application/json
{"email": "user@example.com", "password": "secret"}
HTTP/1.1 200 OK
Set-Cookie: session_id=8f2a...e91; HttpOnly; Secure; SameSite=Lax; Path=/
Далі браузер сам підставляє cookie:
GET /api/projects HTTP/1.1
Host: app.example.com
Cookie: session_id=8f2a...e91
Ключова ідея: сам ID нічого не означає, це просто випадковий ключ. Уся інформація про користувача лежить на сервері, тобто стан зберігається на сервері (stateful) — і це водночас сила і слабкість. Сила: сесію можна миттєво відкликати, просто видаливши запис. Слабкість: усі сервери в кластері мусять мати спільне сховище сесій, інакше користувача «викидатиме» щоразу, як його запит потрапить на іншу машину.
Атрибути cookie, які зобов’язаний розуміти AQA:
| Атрибут | Що робить | Чому важливо для тестів |
|---|---|---|
HttpOnly | Cookie недоступна для JavaScript (document.cookie її не бачить) | Захист від крадіжки через XSS; але й твій page.evaluate її не прочитає |
Secure | Надсилається лише через HTTPS (на реальних хостах) | На звичайному http:// cookie не встановиться; сучасні Chrome/Firefox роблять виняток для localhost як secure context — джерело «загадкових» падінь локально |
SameSite | Чи слати cookie в крос-сайтових запитах: Strict / Lax / None | Впливає на CSRF і на сценарії з iframe/редіректами (OAuth); None вимагає ще й Secure |
Domain / Path | На які домени й шляхи діє cookie | Cookie з піддомену може не підставитись на інший піддомен |
Expires / Max-Age | Час життя | Без них — сесійна cookie, зникає із закриттям браузера |
Нюанс. За MDN, https-вимогу атрибута
Secureбраузери ігнорують для localhost — томуSecure-кукі наhttp://localhostзазвичай працюють. А от поведінка префіксів__Host-/__Secure-наhttp://localhostі різниця міжlocalhostта127.0.0.1між браузерами відрізняються — для конкретного стенду перевір емпірично.
Важлива деталь про замовчування: браузери на базі Chromium (Chrome, Edge) за відсутності явного SameSite трактують cookie як SameSite=Lax — це діє з Chrome 80 (2020). Firefox і Safari до Lax-за-замовчуванням НЕ переходять: Firefox спирається на Total Cookie Protection (партиціювання сторонніх cookie), а Safari блокує більшість сторонніх cookie через Intelligent Tracking Prevention незалежно від SameSite. Тобто той самий сценарій на різних рушіях може поводитися по-різному, і це варто тримати в голові.
CSRF-токен
Автоматичне підставляння cookie, яке робить сесії такими зручними, породжує окремий клас атак — CSRF (Cross-Site Request Forgery, міжсайтова підробка запиту).
Уяви: користувач залогінений у bank.example.com, його сесійна cookie живе в браузері. Він відкриває сторонній сайт, на якому захована форма, що автоматично надсилає POST на bank.example.com/transfer. Браузер, як і завжди, сам чіпляє до цього запиту сесійну cookie — бо запит іде на банківський домен. Сервер бачить валідну сесію й виконує переказ. Користувач нічого не натискав свідомо; за нього все зробив чужий сайт його ж автентифікованим браузером.
Корінь проблеми: cookie доводить, що запит іде від браузера користувача, але не доводить, що його ініціювала справжня сторінка застосунку. Саме цей розрив закриває CSRF-токен (synchronizer token pattern):
- Сервер генерує непередбачуваний токен, прив’язаний до сесії, і вкладає його у видану сторінку — у прихованому полі форми або в
<meta>-тег. - Клієнт зобов’язаний повернути цей токен разом із небезпечною дією — у полі форми або в заголовку на кшталт
X-CSRF-Token. - Сервер звіряє надісланий токен зі збереженим для цієї сесії; не збіглося —
403.
Захист працює завдяки Same-Origin Policy: чужий сайт не може прочитати вміст сторінки застосунку, а отже не знає токена й не може його підставити. CSRF-токен доповнює SameSite-cookie, а не замінює її — це два різні рубежі.
Для AQA це часте джерело «непояснимих» 403. Коли тест б’є по ендпоінту з мутацією напряму (в обхід форми), він мусить спершу дістати CSRF-токен і передати його в заголовку:
// Витягуємо CSRF-токен зі сторінки й шлемо його в мутаційному запиті
const csrf = await page.getAttribute('meta[name="csrf-token"]', 'content');
await page.request.post('/api/projects', {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'New project' },
});
Важлива деталь: чисто token-based API (без cookie, з Authorization: Bearer) до CSRF зазвичай не вразливе, бо токен не підставляється автоматично — його треба свідомо додати в заголовок, а чужий сайт цього зробити не може. CSRF — насамперед проблема автентифікації через cookie.
Token-based авторизація
Альтернатива сесіям, що домінує в API та SPA. Замість тримати стан у себе, сервер запаковує потрібний контекст у токен і віддає клієнту. Клієнт зберігає токен сам і додає його до кожного запиту — зазвичай у заголовку Authorization, а не в cookie.
Головна відмінність від сесій — сервер не тримає стан (stateless). Отримавши токен, сервер перевіряє його підпис і читає вміст, нічого не шукаючи у себе в базі. Це чудово масштабується: будь-яка машина в кластері перевірить токен самостійно.
Зворотний бік тієї ж медалі: токен важко відкликати достроково. Поки він не протермінувався, він валідний — навіть якщо користувача вже заблокували. Це компенсують коротким часом життя токена (див. розділ про refresh/access нижче).
| Критерій | Session-based | Token-based |
|---|---|---|
| Де стан | На сервері | У токені (в клієнта) |
| Масштабування | Потрібне спільне сховище сесій | Кожен вузол самодостатній |
| Відкликання | Миттєве (видалити запис) | Складне (чекати закінчення / чорні списки) |
| Типове сховище в клієнта | Cookie (автоматично) | localStorage / пам’ять + заголовок вручну |
| Типове застосування | Класичні server-rendered застосунки | SPA, мобільні клієнти, API |
JWT і його три частини
Найпоширеніший формат токена — JWT (JSON Web Token), специфікований у RFC 7519. Це рядок із трьох частин, розділених крапками:
<header>.<payload>.<signature>
Кожну частину закодовано в base64url (варіант base64, де + і / замінено на - і _, а хвостові = відкинуто — щоб рядок був безпечний для URL). Класичний зразок токена з jwt.io:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1. Header (заголовок). Метадані токена — алгоритм підпису та тип:
{
"alg": "HS256",
"typ": "JWT"
}
2. Payload (корисне навантаження). Твердження (claims) про користувача. Частина полів стандартизована (registered claims):
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
| Claim | Значення |
|---|---|
iss | видавець (issuer) |
sub | суб’єкт — зазвичай ID користувача |
aud | аудиторія (audience) — для кого токен |
exp | час закінчення (expiration), Unix-час у секундах |
iat | час видачі (issued at) |
nbf | не дійсний раніше (not before) |
jti | унікальний ID токена |
3. Signature (підпис). Ось що робить токен захищеним. Підпис обчислюється над base64url(header) + "." + base64url(payload) за допомогою секрету (для HMAC, напр. HS256) або приватного ключа (для RSA/ECDSA, напр. RS256). Сервер перевіряє підпис і так переконується, що payload не підмінили.
Критично важлива річ для AQA: header і payload не зашифровані, а лише закодовані. Будь-хто, у кого є токен, прочитає payload звичайним base64url-декодером. JWT захищає від підробки (зміниш payload — зламається підпис), але не від читання. Тому в payload не кладуть паролі чи секрети. У тестах це навпаки зручно: з payload можна дістати exp і перевірити, чи не протух токен, ще до запиту.
// Декодуємо payload JWT без жодної бібліотеки
function decodeJwtPayload(token) {
const payload = token.split('.')[1];
const json = Buffer.from(payload, 'base64url').toString('utf8');
return JSON.parse(json);
}
const claims = decodeJwtPayload(token);
const expiresAt = new Date(claims.exp * 1000); // exp у секундах
Де зберігати JWT і які ризики
Класичне питання без ідеального рішення — кожен варіант має свій вектор атаки, і вибір впливає на те, як тест взагалі дістане токен.
| Сховище | Плюс | Ризик | Доступність із тесту |
|---|---|---|---|
localStorage | Просто, переживає перезавантаження | Вразливе до XSS: будь-який чужий скрипт прочитає токен | Легко читається/пишеться через page.evaluate |
sessionStorage | Те саме, але живе лише в межах вкладки | Той самий XSS | Так само доступне |
Cookie з HttpOnly | JS не має доступу — XSS не вкраде | Вразливе до CSRF (шлеться автоматично) | Недоступне для JS, лише через контекст браузера |
| Пам’ять (JS-змінна) | XSS-вікно вужче | Втрачається при F5, потрібен refresh-механізм | Не персистується — тест мусить логінитись щоразу |
Спрощено: localStorage вразливий до XSS, HttpOnly-cookie — до CSRF. XSS означає, що зловмисник виконав свій JS на твоїй сторінці й прочитав усе, до чого дотягнеться JavaScript, — а до HttpOnly-cookie він не дотягнеться. CSRF, як ми бачили вище, — навпаки: браузер сам чіпляє cookie до підробленого крос-сайтового запиту. Тому найзахищеніша схема часто така: короткий access-токен у пам’яті, довгий refresh-токен у HttpOnly-cookie.
Для автоматизації це прямо визначає підхід: токен у localStorage тест підставляє через page.evaluate/addInitScript; HttpOnly-cookie — лише через context.addCookies, бо зі сторінки JS до неї не має доступу.
Схема Bearer
Коли токен передають у заголовку, використовують схему Bearer (RFC 6750). «Bearer» — «пред’явник»: хто пред’явив токен, той і має доступ, жодного додаткового доказу не треба. Тому токен = ключ від квартири, і поводитися з ним слід відповідно.
GET /api/projects HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxw...
Формат простий: слово Bearer, пробіл, токен. У Playwright це задають один раз на весь контекст, щоб не дублювати в кожному запиті:
const context = await request.newContext({
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
},
});
Типова помилка, що дає 401: забути слово Bearer або поставити зайвий пробіл. Якщо API віддає 401 на явно валідному токені — перше, що перевіряють, це точний рядок заголовка.
Basic Auth і як формується заголовок
Basic Authentication (RFC 7617) — найпростіша схема HTTP-автентифікації. Логін і пароль об’єднують через двокрапку, кодують у base64 і кладуть у той самий заголовок Authorization зі схемою Basic:
Authorization: Basic base64(username + ":" + password)
Канонічний приклад із RFC — користувач Aladdin, пароль open sesame:
printf '%s' 'Aladdin:open sesame' | base64
# QWxhZGRpbjpvcGVuIHNlc2FtZQ==
GET /secret HTTP/1.1
Host: example.com
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
Найважливіше усвідомити: base64 — це кодування, а не шифрування. Його тривіально розкодувати назад. Пароль тут фактично передається відкритим текстом, просто в іншому вигляді, тому Basic Auth безпечний лише поверх HTTPS. Якщо доступ без заголовка, сервер відповідає 401 із WWW-Authenticate: Basic realm="...", і браузер показує рідний діалог логіну.
Для тестів це часом дуже зручно: закриті staging-стенди нерідко захищені саме Basic Auth. У Playwright креденшали передають на рівні контексту:
// Playwright: httpCredentials на рівні контексту
const context = await browser.newContext({
httpCredentials: { username: 'staging', password: 's3cret' },
});
API key проти session token
Обидва — рядки, які кладуть у запит, тому їх плутають, хоча призначення різне.
| Критерій | API key | Session/user token |
|---|---|---|
| Кого ідентифікує | Застосунок/інтеграцію/проєкт | Конкретного користувача |
| Час життя | Довгий, часто безстроковий | Короткий, прив’язаний до сесії |
| Права | Зазвичай широкі, фіксовані | Успадковують роль користувача |
| Де живе | У конфізі сервісу, CI-секретах | У браузері користувача |
| Як передається | Authorization, кастомний заголовок (напр. X-API-Key) або query-параметр | Authorization: Bearer або cookie |
API key відповідає на «який сервіс стукає», session token — на «який користувач». Для AQA це означає різні набори тестів: API-ключем зазвичай ганяють інтеграційні/бекенд-сценарії від імені проєкту, а user-токеном перевіряють поведінку під конкретною роллю (права, видимість даних). Плутати їх у тесті небезпечно: ключ проєкту може мати ширші права, ніж реальний користувач, і тест «позеленіє» там, де жива людина отримала б 403.
OAuth: вхід через Google/GitHub
OAuth 2.0 (RFC 6749) — протокол делегованого доступу. Він відповідає на питання: як дати застосунку A доступ до частини даних у сервісі B, не віддаючи A твій пароль від B. Саме на ньому побудований «Увійти через Google/GitHub».
Оглядово потік авторизаційного коду (authorization code flow) виглядає так:
- Користувач тисне «Увійти через Google» у застосунку (client).
- Застосунок перенаправляє браузер на сторінку Google (authorization server).
- Користувач логіниться в Google і погоджується поділитися даними (consent).
- Google перенаправляє браузер назад на застосунок із одноразовим кодом (authorization code) у URL.
- Бекенд застосунку обмінює цей код на access-токен, звертаючись до Google уже напряму (сервер-до-сервера); для конфіденційних клієнтів обмін підтверджують секретом клієнта.
- Застосунок отримує токен і/або дані профілю.
Ключова ідея: пароль від Google ніколи не потрапляє до застосунку. Застосунок бачить лише код, а потім токен з обмеженим набором дозволів (scopes) — наприклад, «читати email і ім’я».
Термінологічно варто розрізняти OAuth 2.0 (авторизація, доступ до ресурсів) і OpenID Connect — надбудову над OAuth для власне автентифікації (доведення особи). «Вхід через Google» технічно спирається на OpenID Connect.
Сучасні реалізації для публічних клієнтів (SPA, мобільні), у яких немає безпечного місця для секрету, додають PKCE (RFC 7636) — механізм, що захищає обмін коду від перехоплення. Для AQA важливо, що цей потік багатокроковий, з редіректами між доменами, зовнішнім UI Google/GitHub і залежністю від їхньої доступності. Це прямо веде до наступних двох тем.
Refresh token проти access token
Токен-based авторизація балансує між безпекою і зручністю через пару токенів.
Access token — короткоживучий (хвилини–години). Ним підписують кожен запит до API. Якщо його вкрадуть, вікно зловживання мале, бо він швидко протухне.
Refresh token — довгоживучий (дні–тижні). Ним нічого не запитують у ресурсів; його єдина роль — отримати новий access-токен, коли старий закінчився, без повторного вводу пароля.
POST /oauth/token HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"grant_type": "refresh_token", "refresh_token": "def50200a1b2..."}
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "def50200c3d4..."
}
Логіка розподілу така: access-токен часто «світиться» в заголовках, тож його роблять коротким; refresh-токен цінніший, тож його ховають надійніше (часто в HttpOnly-cookie) і використовують рідко. Це компроміс: не змушувати користувача логінитись щогодини, але й не давати одному вкраденому токену довгого життя.
Що робити, коли токен протермінувався під час прогону
Це один із найпідступніших джерел флаку (flakiness): тест стабільно зелений, а вночі в CI раптом падає з 401 — бо токен, згенерований на старті прогону, дожив своє посеред довгої сюїти.
Стратегії, від найгіршої до найкращої:
- Ігнорувати — сподіватись, що прогін коротший за час життя токена. Крихко: варто додати тестів або сповільнитись CI — і воно посиплеться.
- Логінитись у кожному тесті — надійно щодо токена, але повільно й навантажує сервер автентифікації.
- Перевіряти
expперед використанням — прочитати payload, і якщо до закінчення лишилось менше запасу (напр. 60 с), оновити токен наперед. Уникає гонки на межі. - Ловити
401і оновлювати з ретраєм — обгорнути транспортний шар так, щоб на401він тихо викликав refresh і повторив запит один раз.
async function apiRequest(path, ctx) {
let res = await fetch(path, { headers: authHeader(ctx.accessToken) });
if (res.status === 401) {
ctx.accessToken = await refresh(ctx.refreshToken); // оновили
res = await fetch(path, { headers: authHeader(ctx.accessToken) }); // один ретрай
}
return res;
}
Головне правило: зберігай токен централізовано, а не копіюй його по тестах. Тоді оновлення в одному місці бачать усі. І пильнуй межу експірації в довгих сюїтах — саме там ховається «плаваючий» 401.
Автоматизація OAuth-логіну і підводні камені
Прогнати повний OAuth-потік через реальний UI Google чи GitHub у тесті — погана ідея, і ось чому:
- Захист від ботів. Google/GitHub цілеспрямовано детектують автоматизацію: капчі, перевірка «підозрілого входу», блокування headless-браузерів. Твій тест — саме те, що вони блокують.
- Зовнішня залежність. Ти ставиш зелений колір своєї сюїти в залежність від доступності й незмінності чужого UI, на який не маєш впливу.
- Крос-доменні редіректи. Потік стрибає між доменами; cookie з
SameSiteі popup-вікна легко ламають сценарій. - 2FA на тестовому Google-акаунті. Реальний акаунт майже завжди тягне за собою другий фактор (див. нижче).
Практичні обхідні шляхи:
-
Не тестувати чужого провайдера. OAuth Google — це відповідальність Google. Тобі треба переконатися, що твій застосунок правильно обробляє повернутий код/токен. Тому провайдера мокають: перехоплюють редірект і підставляють заздалегідь відомий код або токен.
-
Мок на рівні мережі. У Playwright перехоплюють callback-запит і повертають підготовану відповідь:
await page.route('**/oauth/callback*', route => route.fulfill({ status: 200, body: JSON.stringify({ token: TEST_TOKEN }) }) ); -
Тестовий IdP. Підняти власний OpenID-провайдер у тестовому середовищі (напр. локальний mock-сервер), який видає передбачувані токени без капч і 2FA.
-
Обійти UI взагалі. Якщо мета — перевірити застосунок після входу, отримати сесію через бекенд-ендпоінт чи прямий обмін токена, а UI-логін лишити для одного окремого smoke-тесту.
2FA/MFA і чому ускладнює тести
Двофакторна / багатофакторна автентифікація (2FA/MFA) вимагає другого доказу особи, крім пароля: код із SMS, код із застосунку-автентифікатора (TOTP), пуш-підтвердження, апаратний ключ. Для безпеки — чудово; для автоматизації — стіна.
Проблеми випливають із самої суті другого фактора:
- SMS/пуш приходять на пристрій, якого в CI немає. Автоматизувати їх приймання дорого і крихко.
- Апаратні ключі (WebAuthn/FIDO) фізично неможливо емулювати штатним браузером без спеціальних віртуальних автентифікаторів.
- Одноразовість. Другий фактор навмисне непередбачуваний і короткоживучий — тобто протилежність тому, що потрібно стабільному тесту.
Практично найкраще піддається автоматизації TOTP (Time-based One-Time Password) — саме на ньому працюють Google Authenticator та подібні. Секрет (shared secret) відомий обом сторонам, а код — детермінована функція від секрету й поточного часу. Якщо тестовий акаунт налаштований на TOTP і секрет доступний тесту, код можна порахувати:
// otplib генерує той самий код, що й застосунок-автентифікатор
const { authenticator } = require('otplib');
const code = authenticator.generate(process.env.TOTP_SECRET);
TOTP стандартизовано в RFC 6238 (розширення HOTP, RFC 4226). За замовчуванням крок — 30 секунд, а код — 6 цифр. Конкретний застосунок, однак, може налаштувати інший крок, довжину коду чи геш-алгоритм — а згенерований код прямо залежить від цих параметрів, тож для точного збігу їх треба знати наперед.
Але навіть із TOTP найздоровіший підхід у більшості сюїт — вимкнути 2FA для тестових акаунтів або мати окремий шлях входу для тестового середовища. Другий фактор перевіряють кількома спеціальними тестами, а не тягнуть його крізь кожен сценарій, де він лише заважає.
Чому API-логін і підстановка стану надійніші за UI-логін
Логінитись через форму в кожному тесті — повільно, крихко й дублює перевірку, якій місце в одному тесті. Замість цього стан автентифікації здобувають один раз через API і підставляють у браузер напряму.
| Аспект | UI-логін у кожному тесті | API-логін + підстановка стану |
|---|---|---|
| Швидкість | Повільно (рендер, кліки, редіректи) | Швидко (один HTTP-запит) |
| Стабільність | Ламається зі зміною форми, локаторів, капчі | Не залежить від UI логіну |
| Ізоляція | Кожен тест повторює той самий шлях | Стан готується окремо, тести чисті |
| 2FA/OAuth | Впирається в другий фактор і провайдера | Обходиться на рівні API |
| Що перевіряє | Домішує «а чи працює логін» до всього | Перевіряє лише свою фічу |
Ключова причина глибша за швидкість: логін і цільова фіча — різні вимоги, і змішувати їх у перевірці не можна. Якщо тест кабінету щоразу проходить форму входу, то падіння логіну «фарбує в червоне» тест кабінету, хоча кабінет ні до чого. Один тест перевіряє одну вимогу; форму логіну перевіряє окремий тест логіну, решта отримує стан в обхід.
Технічно це роблять так. Спершу — один раз — отримують стан:
// global-setup: логін через API, збереження стану
const context = await request.newContext();
const res = await context.post('https://app.example.com/api/login', {
data: { email: USER, password: PASS },
});
const token = (await res.json()).token;
// зберегти cookie + localStorage контексту у файл
await context.storageState({ path: 'state.json' });
Далі кожен тест стартує вже залогіненим, підвантаживши цей стан:
// у кожному тесті — старт із готовим станом, без форми входу
const context = await browser.newContext({ storageState: 'state.json' });
Якщо токен живе в localStorage, його підставляють скриптом ще до завантаження сторінки:
await context.addInitScript(token => {
window.localStorage.setItem('access_token', token);
}, TOKEN);
Якщо ж стан — це HttpOnly-cookie, JS до неї не дотягнеться, тож підставляють на рівні контексту:
await context.addCookies([{
name: 'session_id', value: SESSION,
domain: 'app.example.com', path: '/', httpOnly: true, secure: true,
}]);
Саме тут сходиться все з попередніх підрозділів: щоб грамотно підставити стан, треба знати, у якій формі він живе (cookie чи токен), де саме (localStorage, HttpOnly-cookie, пам’ять) і як передається (Bearer, cookie, X-API-Key). А якщо застосунок захищений CSRF-токеном, підстановка сесії ще й потребує витягти цей токен зі сторінки перед першою мутацією — інакше замість зеленого тесту отримаєш 403. Розуміння моделі автентифікації тут не абстрактна теорія, а прямий інструмент, що робить сюїту швидкою, стабільною й чесно сфокусованою на тому, що кожен тест насправді має перевіряти.
Чим автентифікація відрізняється від авторизації?
Автентифікація відповідає на питання «хто ти?», авторизація — «що тобі можна?». Спершу користувач доводить особу — логіном і паролем, токеном, кодом з автентифікатора; потім система вирішує, чи має він право на конкретну дію: чи може читач редагувати проєкт, чи бачить менеджер чужі рахунки. Порядок завжди фіксований: спочатку автентифікація, потім авторизація — не можна вирішити, що людині дозволено, поки не знаєш, хто вона. Для тестувальника це дві різні вимоги з різними наборами тестів: одна перевіряє вхід, інша — ролі й права. Найшвидше різницю видно у кодах відповіді: 401 — проблема автентифікації, 403 — авторизації.
У чому різниця між 401 і 403?
401 Unauthorized означає «я тебе не впізнав» — токен відсутній, протермінований або невалідний; 403 Forbidden — «я знаю, хто ти, але тобі сюди не можна» — користувач валідний, але без потрібних прав. Назва 401 Unauthorized історично невдала: за змістом це «unauthenticated», а за RFC 9110 така відповідь мусить нести заголовок WWW-Authenticate з підказкою, яку схему автентифікації застосувати. Для AQA це готове правило діагностики: впіймав 401 — копай у бік логіну (протух токен, не підставилась cookie), впіймав 403 — копай у бік ролей і прав. В асертах ці коди плутати не можна, бо вони перевіряють різні вимоги: тест на «неавторизований доступ заборонено» з очікуванням 401 замість 403 пропустить реальний баг у моделі прав.
Як працює автентифікація на сесіях і cookie?
Це stateful-підхід: увесь стан про користувача живе на сервері, а клієнт носить лише випадковий ключ до нього. Користувач шле логін і пароль, сервер перевіряє їх, створює запис сесії у себе (пам'ять, Redis, база) і повертає згенерований session ID у заголовку Set-Cookie; браузер зберігає cookie й автоматично чіпляє її до кожного наступного запиту на цей домен. Сам ідентифікатор нічого не означає — це просто ключ, за яким сервер знаходить свій запис. Звідси сила підходу: сесію можна миттєво відкликати, видаливши запис; і слабкість: усі сервери кластера мусять мати спільне сховище сесій, інакше користувача «викидатиме» на іншій машині. Для автотестів автоматичне підставляння cookie означає, що достатньо покласти валідну сесійну cookie в контекст браузера — і всі запити підуть автентифікованими.
Які атрибути cookie треба знати тестувальнику і на що вони впливають?
П'ять ключових: HttpOnly ховає cookie від JavaScript — захист від крадіжки через XSS, але й page.evaluate її не прочитає; Secure дозволяє надсилання лише через HTTPS; SameSite (Strict/Lax/None) керує, чи їде cookie в крос-сайтових запитах, причому None вимагає ще й Secure; Domain/Path обмежують, куди cookie взагалі підставляється; Expires/Max-Age задають час життя, без них cookie сесійна і зникає із закриттям браузера. Практичний нюанс для локальних прогонів: сучасні Chrome/Firefox трактують localhost як secure context, тож Secure-cookie там зазвичай працює і на http:// — джерело «загадкових» розбіжностей між локалем і стендом. Ще одна пастка: Chromium з версії 80 за відсутності явного SameSite трактує cookie як Lax, а Firefox і Safari до такого замовчування не переходять — той самий сценарій на різних рушіях може поводитись по-різному. Типовий симптом на співбесіді: «сервер ставить cookie, а її немає» — найчастіше це SameSite=None без Secure або невідповідність домену/шляху.
Чим token-based авторизація відрізняється від session-based?
Головна відмінність — де живе стан: у сесій він на сервері (stateful), у токенів — усередині самого токена в клієнта (stateless). Сервер, отримавши токен, перевіряє підпис і читає вміст, нічого не шукаючи у своїй базі — тому будь-який вузол кластера самодостатній і підхід чудово масштабується без спільного сховища. Зворотний бік: токен важко відкликати достроково — поки не протермінувався, він валідний, навіть якщо користувача вже заблокували; це компенсують коротким часом життя і парою access/refresh. Відрізняється і транспорт: сесійну cookie браузер підставляє сам, а токен клієнт зберігає самостійно (localStorage, пам'ять) і вручну додає в заголовок Authorization. Типове застосування теж різне: сесії — класичні server-rendered застосунки, токени — SPA, мобільні клієнти та API. Для AQA це визначає спосіб підстановки стану в тест: cookie — через context.addCookies, токен — через заголовок або addInitScript.
Що таке JWT і з яких частин він складається?
JWT (JSON Web Token, RFC 7519) — найпоширеніший формат токена: рядок із трьох частин, розділених крапками, — header, payload і signature, кожна закодована в base64url. Header містить метадані — алгоритм підпису (alg, напр. HS256) і тип; payload — твердження (claims) про користувача, серед стандартних: sub (ID користувача), exp (час закінчення в Unix-секундах), iat (час видачі), iss (видавець), aud (аудиторія). Підпис обчислюється над закодованими header і payload за допомогою секрету (HMAC) або приватного ключа (RSA/ECDSA) — саме він гарантує, що payload не підмінили: зміниш хоч символ — підпис зламається. У тестах це зручно використовувати напряму: розпарсити payload звичайним base64url-декодером, дістати exp і перевірити, чи не протух токен, ще до запиту.
Payload JWT зашифрований? Чи можна класти туди пароль?
Ні: header і payload лише закодовані в base64url, а не зашифровані — будь-хто, у кого є токен, прочитає вміст звичайним декодером без жодного секрету. JWT захищає від підробки (підпис зламається при зміні payload), але не від читання — це принципово різні гарантії, і їх часто плутають. Тому в payload не можна класти паролі, секрети чи будь-які чутливі дані. Для тестувальника ця «відкритість» — робочий інструмент: із payload дістають exp, sub чи роль і будують на цьому перевірки, наприклад оновлення токена наперед, коли до експірації лишилося мало часу. Гарне питання-пастка на співбесіді: «токен же підписаний, отже дані в безпеці?» — підпис гарантує цілісність, а не конфіденційність.
Що таке схема Bearer і які типові помилки з нею в тестах?
Bearer (RFC 6750) — схема передачі токена в заголовку Authorization: слово Bearer, пробіл, токен. «Bearer» означає «пред'явник»: хто пред'явив токен, той і має доступ, жодного додаткового доказу не треба — тому токен слід берегти як ключ від квартири. Найчастіша причина «незрозумілого» 401 на явно валідному токені — зіпсований рядок заголовка: забуте слово Bearer, зайвий пробіл, обрізаний під час копіювання токен. Тож перше, що перевіряють при 401 в API-тесті, — точний вміст заголовка в реальному запиті, а не сам токен. У Playwright заголовок задають один раз на весь контекст через extraHTTPHeaders, щоб не дублювати в кожному запиті й мати єдину точку оновлення.
Як влаштований Basic Auth і чому base64 — це не захист?
Basic Auth (RFC 7617) — найпростіша схема HTTP-автентифікації: логін і пароль з'єднують двокрапкою, кодують у base64 і кладуть у заголовок Authorization зі схемою Basic. Ключове: base64 — це кодування, а не шифрування, його тривіально розкодувати назад — пароль фактично летить відкритим текстом, просто в іншому вигляді, тому Basic Auth безпечний лише поверх HTTPS. Якщо запит приходить без заголовка, сервер відповідає 401 із WWW-Authenticate: Basic realm="...", і браузер показує рідний діалог логіну. Для тестувальника це щоденна практика: закриті staging-стенди часто захищені саме Basic Auth, і в Playwright креденшали передають на рівні контексту через httpCredentials, а не через UI-діалог. Канонічний приклад із RFC — Aladdin:open sesame, який кодується в QWxhZGRpbjpvcGVuIHNlc2FtZQ==.
Де зберігати токен на клієнті та які ризики в кожного варіанта?
Ідеального місця немає — кожен варіант має свій вектор атаки. localStorage простий і переживає перезавантаження, але вразливий до XSS: будь-який чужий скрипт на сторінці прочитає токен; sessionStorage — те саме, лише в межах вкладки. Cookie з HttpOnly недоступна для JavaScript, тож XSS її не вкраде, — зате вона надсилається автоматично, а отже вразлива до CSRF. Пам'ять (JS-змінна) звужує вікно для XSS, але втрачається при F5 і вимагає refresh-механізму. Звідси типова захищена схема: короткий access-токен у пам'яті плюс довгий refresh-токен у HttpOnly-cookie. Для автоматизації сховище прямо визначає спосіб підстановки: токен у localStorage тест кладе через page.evaluate/addInitScript, а HttpOnly-cookie — лише через context.addCookies, бо зі сторінки JS до неї не дотягнеться.
Що таке CSRF і як CSRF-токен закриває цю атаку?
CSRF (cross-site request forgery, міжсайтова підробка запиту) експлуатує саме ту зручність, що робить сесії привабливими: браузер сам чіпляє cookie до кожного запиту на домен. Користувач залогінений у банку, відкриває чужий сайт із прихованою формою, яка шле POST на банківський домен, — браузер автоматично додає сесійну cookie, і сервер виконує дію від імені користувача, який нічого свідомо не натискав. Корінь проблеми: cookie доводить, що запит іде від браузера користувача, але не доводить, що його ініціювала справжня сторінка застосунку. CSRF-токен (synchronizer token pattern) закриває цей розрив: сервер вкладає непередбачуваний токен у видану сторінку, клієнт зобов'язаний повернути його з кожною небезпечною дією — у полі форми чи заголовку X-CSRF-Token, — а не збіглося — 403. Захист тримається на Same-Origin Policy: чужий сайт не може прочитати сторінку застосунку, тож токена не знає. Важливо: CSRF-токен доповнює SameSite-cookie, а не замінює — це два різні рубежі; а чисто token-based API з Authorization: Bearer до CSRF зазвичай не вразливе, бо токен не підставляється автоматично. Для AQA це класичне джерело «непояснимих» 403: тест, що б'є по мутаційному ендпоінту в обхід форми, мусить спершу дістати CSRF-токен зі сторінки й передати його в заголовку.
Навіщо потрібна пара access-токен + refresh-токен?
Це компроміс між безпекою і зручністю: не змушувати користувача логінитись щогодини, але й не давати одному вкраденому токену довгого життя. Access-токен короткоживучий (хвилини–години) і підписує кожен запит до API — якщо його вкрадуть, вікно зловживання мале. Refresh-токен довгоживучий (дні–тижні), ним нічого не запитують у ресурсів — його єдина роль дістати новий access-токен, коли старий закінчився, без повторного вводу пароля. Логіка розподілу: access часто «світиться» в заголовках, тож його роблять коротким; refresh цінніший, тож його ховають надійніше — часто в HttpOnly-cookie — і використовують рідко. Для тестувальника ця пара означає, що довгі прогони мусять уміти оновлювати access-токен, інакше посеред сюїти вилізе «плаваючий» 401.
Чим API key відрізняється від session/user-токена?
API key ідентифікує застосунок, інтеграцію чи проєкт — відповідає на «який сервіс стукає»; session token ідентифікує конкретного користувача — «хто саме працює». Звідси решта відмінностей: ключ живе довго (часто безстроково) в конфізі сервісу чи CI-секретах і має широкі фіксовані права; токен короткоживучий, прив'язаний до сесії та успадковує роль користувача. Передаються теж по-різному: ключ — через Authorization, кастомний заголовок на кшталт X-API-Key або query-параметр; токен — через Authorization: Bearer або cookie. Для AQA це різні набори тестів: API-ключем ганяють інтеграційні й бекенд-сценарії від імені проєкту, а user-токеном перевіряють поведінку під конкретною роллю — права, видимість даних. Плутати їх у тесті небезпечно: ключ проєкту може мати ширші права, ніж реальний користувач, і тест «позеленіє» там, де жива людина отримала б 403.
Як працює «Увійти через Google» (OAuth 2.0)?
OAuth 2.0 (RFC 6749) — протокол делегованого доступу: як дати застосунку A доступ до частини даних у сервісі B, не віддаючи A твій пароль від B. У потоці авторизаційного коду користувач тисне «Увійти через Google», застосунок редіректить браузер на сторінку Google, там користувач логіниться і дає згоду (consent), Google повертає браузер назад із одноразовим authorization code у URL, а бекенд застосунку обмінює цей код на access-токен уже напряму, сервер-до-сервера. Ключова ідея: пароль від Google ніколи не потрапляє до застосунку — той бачить лише код, а потім токен з обмеженим набором дозволів (scopes). Термінологічний нюанс, який люблять питати: OAuth 2.0 — про авторизацію (доступ до ресурсів), а власне автентифікацію («вхід через Google») забезпечує OpenID Connect — надбудова над OAuth. Для публічних клієнтів без безпечного місця для секрету (SPA, мобільні) додають PKCE (RFC 7636), що захищає обмін коду від перехоплення. Для AQA важливо, що потік багатокроковий, із крос-доменними редіректами й зовнішнім UI провайдера — саме тому його не ганяють через реальний Google у кожному тесті.
Як автоматизувати логін через OAuth-провайдера в e2e-тестах?
Коротка відповідь: не проганяти реальний UI Google/GitHub, а мокати провайдера. Причини: провайдери цілеспрямовано детектують автоматизацію (капчі, «підозрілий вхід», блокування headless), ти ставиш зелений колір сюїти в залежність від чужого UI, потік стрибає між доменами з усіма проблемами SameSite-cookie й popup-вікон, а реальний акаунт майже завжди тягне 2FA. Принципова думка: OAuth Google — відповідальність Google; твоя зона — перевірити, що твій застосунок правильно обробляє повернутий код чи токен. Практичні шляхи: перехопити callback на рівні мережі (у Playwright — page.route на URL колбека з підготованою відповіддю), підняти тестовий IdP, який видає передбачувані токени без капч і 2FA, або взагалі обійти UI — отримати сесію через бекенд-ендпоінт. UI-логін через провайдера лишають для одного окремого smoke-тесту, а не тягнуть крізь усю сюїту.
Як тестувати застосунок з увімкненою 2FA?
Другий фактор за задумом непередбачуваний і короткоживучий — тобто протилежність тому, що потрібно стабільному тесту, тому підхід залежить від типу фактора. SMS і пуш приходять на пристрій, якого в CI немає, — автоматизувати їх приймання дорого і крихко; апаратні ключі (WebAuthn/FIDO) штатним браузером без спеціальних віртуальних автентифікаторів не емулюються. Найкраще піддається автоматизації TOTP (RFC 6238): код — детермінована функція від спільного секрету й поточного часу, тож якщо секрет тестового акаунта доступний тесту, код рахується бібліотекою на кшталт otplib — тим самим, що згенерував би застосунок-автентифікатор. За замовчуванням крок — 30 секунд, код — 6 цифр, але конкретний застосунок може налаштувати інші параметри, і їх треба знати наперед. Утім найздоровіша стратегія для більшості сюїт — вимкнути 2FA для тестових акаунтів або мати окремий шлях входу в тестовому середовищі: другий фактор перевіряють кілька спеціальних тестів, а не кожен сценарій.
Тест стабільно зелений локально, але вночі в CI падає з 401 посеред сюїти. Що відбувається і як це лікувати?
Класичний «плаваючий» флак від експірації токена: access-токен, згенерований на старті прогону, доживає своє посеред довгої сюїти — локально прогін коротший за час життя токена, у нічному CI ні. Стратегії від найгіршої до найкращої: ігнорувати й сподіватися, що прогін короткий (посиплеться з ростом сюїти); логінитись у кожному тесті (надійно, але повільно й навантажує сервер автентифікації); перевіряти exp із payload перед використанням і оновлювати наперед, якщо до закінчення лишилося менше запасу, скажімо 60 секунд (уникає гонки на межі); обгорнути транспортний шар так, щоб на 401 він тихо викликав refresh і повторив запит рівно один раз. Головне правило — зберігати токен централізовано, а не копіювати по тестах: тоді оновлення в одному місці бачать усі. І пильнувати межу експірації саме в довгих сюїтах — там і живе цей флак.
Чому API-логін із підстановкою стану надійніший за UI-логін у кожному тесті?
Глибша причина — не швидкість, а ізоляція вимог: логін і цільова фіча — різні вимоги, і змішувати їх в одній перевірці не можна. Якщо тест кабінету щоразу проходить форму входу, падіння логіну «фарбує в червоне» тест кабінету, хоча кабінет ні до чого; один тест перевіряє одну вимогу, а форму логіну — окремий тест логіну. Додаткові виграші: один HTTP-запит замість рендера, кліків і редіректів; незалежність від змін форми, локаторів і капчі; обхід 2FA й OAuth на рівні API. Технічно стан здобувають один раз у global-setup — логін через API і збереження стану контексту через storageState у файл, з якого стартує кожен тест. Спосіб підстановки диктує форма стану: токен у localStorage кладуть через addInitScript ще до завантаження сторінки, HttpOnly-cookie — через context.addCookies, бо JS до неї не має доступу. І не забути про CSRF: якщо застосунок ним захищений, перед першою мутацією треба витягти токен зі сторінки — інакше замість зеленого тесту буде 403.
Три кейси, де модель автентифікації прямо вирішує, зелений тест чи флак: діагностика 401 проти 403 (і пастка забутого Bearer), звірка exp у payload JWT, щоб токен не протух посеред довгої сюїти, і API-логін з підстановкою стану — де ховаються неправильний domain кукі та 403 через незачеплений CSRF-токен. Скрізь — що дивитися і чому.
Кейс 1. 401 проти 403: різний діагноз, різний тест
Найчастіша плутанина в перевірках прав — трактувати «не пустило» як одне ціле. Це два різні коди про два різні рубежі: 401 — «я тебе не впізнав» (автентифікація), 403 — «впізнав, але тобі сюди не можна» (авторизація). Тест на права мусить розрізняти їх явно, інакше протермінований токен «позеленить» перевірку рольової моделі.
import { test, expect, request } from '@playwright/test';
test('без токена — 401, з токеном читача на адмін-ендпоінт — 403', async () => {
// 1) взагалі без креденшалів: сервер нас не впізнав
const anon = await request.newContext();
const noAuth = await anon.get('https://api.example.com/admin/users');
expect(noAuth.status(), 'без токена має бути 401, а не 403').toBe(401);
// 401 зобов'язана нести WWW-Authenticate (RFC 9110)
expect(noAuth.headers()['www-authenticate']).toBeTruthy();
// 2) валідний токен читача на адмінський ресурс: впізнав, але не дозволив
const reader = await request.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${READER_TOKEN}` },
});
const forbidden = await reader.get('https://api.example.com/admin/users');
expect(forbidden.status(), 'валідний юзер без прав має бути 403').toBe(403);
});
Що дивитися і чому:
401копають у бік логіну,403— у бік ролей. Якщо тест «без прав» отримав401замість403, річ не в правах — токен не підставився або протух. Асертити треба конкретний код, неexpect(status).toBeGreaterThan(400): це замітає різницю під килим.- Забутий
Bearerдає401на валідному токені. Схема Bearer вимагає точного рядка: словоBearer, один пробіл, токен. Зайвий пробіл чиAuthorization: ${token}без схеми — і сервер не впізнає креденшал. Коли API віддає401на явно живому токені, перше, що звіряють, — точний рядок заголовка. 401безWWW-Authenticate— сам собою баг сервера. За RFC 9110 відповідь401мусить нести цей заголовок із challenge. Його відсутність варто зафіксувати окремою асерцією.
Кейс 2. Токен протух посеред прогону: перевірити exp до запиту
Класичний нічний флак: сюїта стабільно зелена, а в CI раптом 401 — токен, згенерований на старті, дожив своє посеред довгої сюїти. Payload JWT не зашифрований, лише закодований у base64url, тож exp можна прочитати без жодної бібліотеки й оновити токен наперед, не чекаючи гонки на межі.
// Декодуємо payload JWT — він лише base64url-закодований, не шифрований
function decodeJwtPayload(token: string): { exp: number } {
const payload = token.split('.')[1];
const json = Buffer.from(payload, 'base64url').toString('utf8');
return JSON.parse(json);
}
// exp у СЕКУНДАХ; оновлюємо, якщо лишилось менше запасу
function isExpiringSoon(token: string, skewSeconds = 60): boolean {
const { exp } = decodeJwtPayload(token);
return exp * 1000 - Date.now() < skewSeconds * 1000;
}
Надійніший рубіж — обгорнути транспорт так, щоб на 401 він тихо оновив токен і повторив запит рівно один раз:
async function apiRequest(path: string, ctx: AuthCtx) {
if (isExpiringSoon(ctx.accessToken)) {
ctx.accessToken = await refresh(ctx.refreshToken); // оновили наперед
}
let res = await fetch(path, { headers: authHeader(ctx.accessToken) });
if (res.status === 401) {
ctx.accessToken = await refresh(ctx.refreshToken); // ловимо гонку на межі
res = await fetch(path, { headers: authHeader(ctx.accessToken) }); // один ретрай
}
return res;
}
Що дивитися і чому:
exp— Unix-час у СЕКУНДАХ, аDate.now()— у мілісекундах. Забути* 1000— типова помилка: різниця стає велетенським відʼємним числом, токен здається завжди простроченим, і рефреш запускається на кожній перевірці. Множення на 1000 обов'язкове.- Зберігай токен централізовано, не копіюй по тестах. Оновлення в одному місці мають бачити всі. Якщо кожен тест тримає власну копію, refresh в одному не рятує решту — і
401«плаває» непередбачувано. - Payload читається будь-ким — це не витік, а інструмент. JWT захищає від підробки (зміниш payload — зламається підпис), але не від читання. Саме тому в payload не кладуть секрети, і саме тому тест може дістати
expдо запиту. Не намагайся так «перевірити пароль» — його там і не має бути.
Кейс 3. API-логін + підстановка стану: пастки domain і CSRF-токена
Логінитись формою в кожному тесті — повільно, крихко й домішує «а чи працює логін» до перевірки цільової фічі. Стан здобувають один раз через API і підставляють у контекст напряму. Але як саме підставляти, залежить від того, у якій формі живе стан — і тут два тихі провали.
// global-setup: логін через API, збереження стану один раз на прогін
import { request } from '@playwright/test';
const ctx = await request.newContext();
const res = await ctx.post('https://app.example.com/api/login', {
data: { email: process.env.USER_EMAIL, password: process.env.USER_PASSWORD },
});
await ctx.storageState({ path: 'state.json' }); // cookie + localStorage контексту
Далі — залежно від того, де живе токен. Якщо в localStorage, його підставляють скриптом ДО завантаження сторінки:
await context.addInitScript((token) => {
window.localStorage.setItem('access_token', token);
}, TOKEN);
Якщо ж стан — це HttpOnly-cookie, JS до неї не дотягнеться, тож кладуть на рівні контексту, і domain мусить точно збігатися з хостом застосунку:
await context.addCookies([{
name: 'session_id', value: SESSION,
domain: 'app.example.com', path: '/', // саме хост, не www і не .example.com
httpOnly: true, secure: true, sameSite: 'Lax',
}]);
А коли застосунок захищений CSRF-токеном, пряма мутація в обхід форми впаде на 403, поки не витягнеш токен зі сторінки й не передаси його в заголовку:
// без цього — 403: cookie доводить «від браузера», але не «зі справжньої сторінки»
const csrf = await page.getAttribute('meta[name="csrf-token"]', 'content');
await page.request.post('/api/projects', {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'New project' },
});
Що дивитися і чому:
- Неправильний
domain= «розлогінений», хоча кука в сторі є. Вкажешwww.example.comзамістьapp.example.com— кука ляже в контекст, але браузер її не відправить на потрібний хост. Симптом ідентичний відсутності авторизації; звіряйdomain/pathкукі з хостом і шляхом запиту, який має її нести. localStorage→addInitScript,HttpOnly-cookie →addCookies. Це не взаємозамінні шляхи: токен уlocalStorageне покладеш кукою, аHttpOnly-cookie не запишеш скриптом сторінки (JS її не бачить — так і задумано проти XSS). Форма зберігання диктує метод підстановки.403на прямій мутації — майже завжди незачеплений CSRF-токен. Session-cookie підставляється браузером автоматично, але не доводить, що запит ішов зі справжньої сторінки; цей розрив і закриває synchronizer-token. ЧистоBearer-API (без cookie) до CSRF зазвичай не вразливе, тож там цього кроку не треба.- Стан готують один раз, тести стартують чистими. Так падіння логіну не «фарбує в червоне» тест кабінету: логін перевіряє окремий тест, решта отримує стан в обхід форми.
Автентифікація vs авторизація
- Розумію різницю: автентифікація — «хто ти?», авторизація — «що тобі можна?»; спершу перша, потім друга.
- Можу пояснити, чому
401— це проблема автентифікації («не впізнав»), а403— авторизації («впізнав, але не можна»), і куди копати в кожному разі. - Знаю, що назва
401 Unauthorizedісторично невдала (за змістом «unauthenticated») і що за RFC 9110 відповідь401несе заголовокWWW-Authenticateз challenge.
Session-based і cookie
- Розумію, чому сесії існують: HTTP stateless, тож сервер тримає стан у себе, а браузеру віддає лише випадковий session ID у
Set-Cookie. - Можу пояснити, що session-based підхід stateful (сесію легко відкликати, але потрібне спільне сховище на кластер), а сам ID нічого не означає.
- Знаю атрибути cookie й що вони дають тесту:
HttpOnly(JS не бачить, іpage.evaluateтеж),Secure(лише HTTPS, виняток для localhost),SameSite,Domain/Path,Expires/Max-Age. - Розумію, що Chromium за замовчуванням трактує cookie як
SameSite=Lax(з Chrome 80), а Firefox/Safari до цього дефолту не переходять — той самий сценарій поводиться по-різному на різних рушіях. - Можу пояснити, що таке CSRF і чому воно можливе: браузер сам чіпляє cookie до крос-сайтового запиту, тож cookie доводить «від браузера», але не «від справжньої сторінки».
- Знаю, що CSRF-токен (synchronizer token pattern) закриває цей розрив, спирається на Same-Origin Policy, доповнює (не замінює)
SameSite, і що прямий запит з мутацією мусить дістати токен зі сторінки та передати вX-CSRF-Token.
Token-based, JWT і сховище
- Можу пояснити, чому token-based підхід stateless (сервер перевіряє підпис, нічого не шукає в базі) і як це впливає на масштабування й складність відкликання.
- Знаю будову JWT:
header.payload.signature, кожна частина в base64url, і що підпис захищає від підробки, а не від читання. - Розумію, чому header і payload JWT лише закодовані, а не зашифровані: будь-хто прочитає payload base64url-декодером, тож секрети туди не кладуть, зате тест може дістати
expперед запитом. - Можу пояснити компроміс сховищ:
localStorageвразливий до XSS,HttpOnly-cookie — до CSRF, і чому надійна схема — короткий access у пам'яті + refresh уHttpOnly-cookie. - Знаю, як це визначає підстановку в тесті:
localStorage-токен — черезpage.evaluate/addInitScript, аHttpOnly-cookie — лише черезcontext.addCookies.
Схеми передачі та інтеграції
- Знаю схему Bearer (RFC 6750): формат
Authorization: Bearer <token>, і що забутийBearerчи зайвий пробіл дає401. - Розумію Basic Auth:
base64(логін:пароль)уAuthorization: Basic ...— це кодування, не шифрування, безпечне лише поверх HTTPS; у Playwright задається черезhttpCredentials. - Можу пояснити різницю API key vs session token: перший ідентифікує застосунок/інтеграцію (широкі фіксовані права, довге життя), другий — конкретного користувача, і чому їх плутати в тесті небезпечно.
- Розумію суть OAuth 2.0 authorization code flow: пароль від провайдера ніколи не потрапляє до застосунку, той бачить лише код, потім токен з обмеженими scopes; «вхід через Google» технічно спирається на OpenID Connect.
- Можу пояснити пару refresh/access token: access короткоживучий (підписує кожен запит), refresh довгий і лише міняється на новий access без повторного логіну.
Автоматизація й флак
- Розумію, чому протухлий під час прогону токен дає «плаваючий»
401, і знаю стратегії: перевірятиexpнаперед або ловити401з одним ретраєм; зберігати токен централізовано, а не копіювати по тестах. - Можу пояснити, чому реальний UI-логін через Google/GitHub — погана ідея (антибот-захист, зовнішня залежність, крос-доменні редіректи, 2FA) і як обійти: мокати провайдера, тестовий IdP, обхід UI через бекенд.
- Знаю, чому 2FA/MFA ускладнює тести і що з факторів найкраще автоматизується TOTP (RFC 6238, 30 с/6 цифр за замовчуванням); найздоровіше — вимкнути 2FA для тестових акаунтів.
- Можу пояснити, чому API-логін + підстановка стану надійніші за UI-логін: швидше, стабільніше, і головне — логін і цільова фіча різні вимоги, які не можна змішувати в одній перевірці.
Сервер повернув 401 Unauthorized. Куди копати?
Питання
Автентифікація проти авторизації — у чому різниця і який порядок?