Кукі, сесії та сховище браузера
Зміст
HTTP — протокол без памʼяті (stateless). Кожен запит сервер отримує так, ніби бачить клієнта вперше: у самому запиті немає нічого, що звʼязувало б його з попереднім. Але вебзастосунки живуть інакше — ви логінитесь один раз, а далі десятки сторінок «памʼятають», що це саме ви. Ця ілюзія памʼяті будується поверх безпамʼятного протоколу, і будівельний матеріал тут — кукі (cookies), серверні сесії та сховища браузера.
Для AQA це не абстрактна теорія. Саме тут ховається чи не найбільше причин флаку (flakiness): тест «бачить» користувача розлогіненим, стан протікає з одного тесту в інший, збережена сесія не підхоплюється. Хто розуміє, де фізично лежить стан і як він потрапляє на сервер, той діагностує такі падіння за хвилини, а не перебором.
Що таке cookie і як вона потрапляє в браузер
Cookie — це маленький іменований шматок даних (імʼя=значення), який браузер зберігає й автоматично додає до наступних запитів на той самий сайт. Розмір однієї кукі невеликий: за RFC 6265 браузери мають підтримувати щонайменше 4096 байтів на кукі, причому за формулюванням стандарту це сума довжин імені, значення й атрибутів (рахують браузери по-різному: Chromium враховує й атрибути, Firefox — переважно лише імʼя та значення). На практиці саме в цей ліміт варто вкладатися.
Ставить кукі сервер — через заголовок відповіді (response header) Set-Cookie. Браузер сам кукі не вигадує; класичний сценарій — сервер у відповіді каже «запамʼятай оце». Створити кукі може й JavaScript на сторінці через document.cookie (про доступ із коду — нижче), але це вже дії самого застосунку, а не браузера.
Обмін виглядає так. Сервер у відповіді надсилає:
HTTP/1.1 200 OK
Content-Type: text/html
Set-Cookie: session_id=abc123def456; Path=/; HttpOnly; Secure; SameSite=Lax
Set-Cookie: theme=dark; Path=/; Max-Age=31536000
Кожна кука — окремий заголовок Set-Cookie. Браузер їх зберігає й на кожен наступний відповідний запит додає заголовок запиту (request header) Cookie:
GET /dashboard HTTP/1.1
Host: app.example.com
Cookie: session_id=abc123def456; theme=dark
Зверніть увагу на асиметрію: сервер надсилає кукі з атрибутами (Path, Secure тощо), а браузер повертає лише пари імʼя=значення, злиті в один заголовок Cookie через ;. Атрибути назад не їдуть — вони потрібні тільки браузеру, щоб вирішити, коли й куди цю кукі відправляти.
Механізм описаний у RFC 6265 («HTTP State Management Mechanism»); поведінку SameSite та інші уточнення дає пізніший чернетковий документ (неформально — «6265bis»).
Атрибути cookie
Атрибути — це правила, за якими браузер вирішує долю кукі: кому її показувати, доки зберігати, чи давати доступ скриптам. Помилка в атрибуті часто виглядає як «нічого не працює», хоча кука фізично є.
| Атрибут | Що задає | Навіщо / наслідки |
|---|---|---|
Domain | Для якого домену кука дійсна | Не заданий — кука host-only (лише той хост, що її поставив). Заданий (Domain=example.com) — кука їде і на домен, і на всі його піддомени |
Path | Префікс шляху, для якого кука відправляється | За замовчуванням — «тека» запиту, що поставив кукі. Path=/admin → кука не піде на /dashboard |
Expires | Абсолютна дата/час згасання | HTTP-дата. Після неї браузер видаляє кукі |
Max-Age | Скільки секунд жити | Відносний час. Max-Age=0 (або відʼємне) — негайно видалити. Має пріоритет над Expires, якщо задані обидва |
HttpOnly | Заборонити доступ із JS | Кука не видна в document.cookie. Захищає токен сесії від крадіжки через XSS |
Secure | Слати лише через HTTPS | По HTTP кука не відправляється. Обовʼязковий разом із SameSite=None |
SameSite | Поведінка в міжсайтових запитах | Значення Strict / Lax / None — див. нижче |
Кілька важливих «чому»:
Domainта піддомени. Задати можна тільки свій або батьківський зареєстрований домен — не чужий. Без атрибута кука прикріплена рівно до хоста, що її поставив: кука зapp.example.comне поїде наexample.com. ЗDomain=example.com— поїде і наexample.com, і наapp.example.com, і наapi.example.com. Це вирішує, чи «бачить» вас сусідній піддомен.Path— це не про безпеку, а про доставку. Він лише обмежує, на які шляхи браузер додасть кукі, і легко обходиться.Path=/(найпоширеніше) — на весь сайт.HttpOnlyта тести. Якщо кука сесії маєHttpOnly, ви не дістанете її ні черезdocument.cookieу браузері, ні черезpage.evaluateв автотесті. Читати таку кукі треба на рівні контексту браузера (Playwright/Selenium API), а не зі сторінки.Secureна localhost. Браузери зазвичай трактуютьhttp://localhostяк «безпечний контекст», тожSecure-кукі там часто працюють; на іншому HTTP-хості — ні. Це типова пастка при тестах проти нелокального стенду без TLS.- Префікси імен. Кука з іменем на
__Host-мусить бутиSecure, поставлена з HTTPS, безDomainі зPath=/, а на__Secure-— простоSecureз HTTPS; інакше браузер її відкине. Якщо ви руками кладете таку кукі в тесті з «неправильними» атрибутами, вона мовчки не встановиться.
SameSite: Strict, Lax, None
SameSite керує тим, чи додається кука до запитів, які ініціює інший сайт. Це головний важіль проти CSRF (міжсайтова підробка запиту, cross-site request forgery).
| Значення | Коли кука додається | Практичний наслідок |
|---|---|---|
Strict | Тільки коли запит іде з того самого сайту | Найсуворіше. Перехід за посиланням з іншого сайту (лист, месенджер) кукі не несе — перше відкриття може виглядати як «розлогінений» |
Lax | З того самого сайту + верхньорівнева навігація безпечним методом (GET-перехід за посиланням) | Розумний баланс; у Chromium-браузерах — дефолт, коли атрибут не заданий |
None | Завжди, зокрема в міжсайтових запитах | Потрібен для крос-сайт сценаріїв (віджети, iframe, SSO). Обовʼязково разом із Secure, інакше браузер кукі відкине |
Ключові уточнення:
- «Той самий сайт» (same-site) рахується за зареєстрованим доменом (eTLD+1), а не за повним хостом. Запити між
app.example.comіapi.example.comвважаються same-site, а міжexample.comіexample.org— ні. Laxпропускає кукі при верхньорівневих GET-навігаціях (клік по посиланню), але не при крос-сайтPOST, при завантаженні в<iframe>, картинках чиfetchз іншого сайту.
Пастка
Якщо сервер віддає SameSite=None без Secure, сучасний браузер таку кукі проігнорує. У тестах це виглядає як «сервер її ставить, а її немає».
- Дефолт
Laxдля незаданогоSameSite— це поведінка саме Chromium-рушіїв (Chrome, Edge, Opera; діє починаючи з Chrome 80). Firefox і Safari цього дефолту не застосовують: там незадана кука фактично поводиться майже без обмежень (ближче до старої моделі, тобто доNone). Тому не покладайтеся на дефолт — задавайтеSameSiteявно на кожній куці.
Окремий пласт — блокування сторонніх (third-party) кукі. Safari (через ITP) за замовчуванням блокує сторонні кукі, а Firefox (через Total Cookie Protection) ізолює їх — кожен сайт отримує власну «банку» кукі, тож поставлена в контексті чужого сайту кука не шериться між сайтами. І те, і те діє незалежно від SameSite. Тож крос-сайт сценарій, що «працює в Chrome», може ламатися в іншому рушії — це варто памʼятати при кросбраузерному тестуванні SSO.
Для AQA SameSite важливий у двох місцях: тестування SSO/платіжних редіректів (кука має бути None; Secure, інакше сесія «губиться» на поверненні) і відтворення багів логіну «через зовнішнє посилання» (Strict дає розлогінений перший екран — і це не баг, а очікувана поведінка).
Session vs persistent cookie
Кукі бувають двох видів за тривалістю життя:
- Сесійна (session cookie) — без
Expiresі безMax-Age. Живе, доки відкрита «сесія браузера», і зникає після її завершення. Типова роль — ідентифікатор сесії. Застереження: функція відновлення вкладок («continue where you left off») у багатьох браузерах зберігає сесійні кукі між перезапусками, тож «закрив браузер → розлогінився» справджується не завжди. - Постійна (persistent cookie) — має
ExpiresабоMax-Age. Переживає перезапуск браузера й живе до вказаної дати. Так реалізують «запамʼятати мене».
Set-Cookie: session_id=abc123; Path=/; HttpOnly # сесійна
Set-Cookie: remember_me=xyz789; Path=/; Max-Age=2592000 # постійна, 30 днів
У тестах ця різниця визначає, що збережеться у storage state: постійні кукі відновляться в новому контексті надійно, а щодо сесійних треба бути уважним (див. розділ про storage state).
Серверна сесія і session id
Кука зазвичай не містить самих даних користувача — вона містить ключ. Класична схема серверної сесії:
- Ви логінитесь. Сервер створює запис сесії у себе (в памʼяті, Redis, БД) з усім станом: хто ви, ролі, кошик тощо.
- Сервер генерує ідентифікатор сесії (session id) — довгий випадковий рядок — і віддає його кукою:
Set-Cookie: session_id=.... - На кожен наступний запит браузер надсилає цю кукі. Сервер бере session id, знаходить свій запис і «згадує» вас.
Тобто у браузері лежить лише непрозорий ключ, а стан — на сервері. Тому session id роблять довгим і випадковим (щоб не вгадати), позначають HttpOnly (щоб не вкрав XSS) і Secure (щоб не перехопили по HTTP). Імена кукі часто підказують стек: JSESSIONID (Java), PHPSESSID (PHP), connect.sid (Express), sessionid (Django).
Альтернатива — токен (наприклад JWT), де стан «зашитий» у сам токен, і сервер не тримає запис сесії. Токен теж можуть класти в кукі, а можуть — у сховище браузера, і тоді він летить не автоматично кукою, а вручну в заголовку Authorization. Для тестів це принципова розвилка: у першому випадку сесія «переноситься» кукою, у другому — доведеться відтворювати заголовок або сховище.
Сховища браузера: localStorage і sessionStorage
Web Storage — це просте key-value сховище рядків, доступне з JavaScript. Два різновиди з однаковим API, але різним часом життя й областю видимості.
localStorage.setItem('token', 'eyJhbGciOiJI...');
localStorage.getItem('token'); // "eyJhbGciOiJI..."
localStorage.removeItem('token');
localStorage.clear(); // стерти все для цього origin
sessionStorage.setItem('step', '3'); // той самий API
| Характеристика | localStorage | sessionStorage |
|---|---|---|
| Час життя | Постійно, доки не стерли | Доки живе вкладка (її сесія) |
| Область (scope) | На origin (схема + хост + порт) | На origin + конкретну вкладку |
| Спільність між вкладками | Так, спільний для всіх вкладок origin | Ні, кожна вкладка має свій |
| Обсяг | Зазвичай близько 5 МБ (залежить від браузера) | Приблизно стільки ж |
| Тип даних | Тільки рядки | Тільки рядки |
| Відправка на сервер | Ніколи автоматично | Ніколи автоматично |
| Доступ із JS | Завжди (немає аналога HttpOnly) | Завжди |
Нюанс. За MDN, браузери дають близько 5 МіБ localStorage і ще 5 МіБ sessionStorage на origin; точний облік (символи UTF-16 чи байти) відрізняється між браузерами — для критичних сценаріїв перевір на цільовому.
Оскільки все — рядки, обʼєкти зберігають через JSON.stringify/JSON.parse. Важливий для тестів момент про sessionStorage: він привʼязаний до вкладки, тож новий контекст чи нова вкладка його не успадковують — це часта причина, чому «залогінена» в одному місці сесія не працює в іншому, якщо застосунок тримає токен саме там.
localStorage vs cookie
Хоч і те, й те «зберігає дані в браузері», відмінності визначають, для чого кожне придатне.
| Критерій | Cookie | localStorage |
|---|---|---|
| Обсяг | ~4 КБ на кукі | ~5 МБ на origin |
| Відправка на сервер | Автоматично, з кожним відповідним запитом | Ніколи автоматично |
| Доступ із JS | Так, якщо немає HttpOnly | Завжди |
| Захист від XSS-крадіжки | Можливий (HttpOnly) | Немає |
| Час життя | Керований (Expires/Max-Age) або сесійний | Постійний, доки не стерли вручну |
| Область | Домен + шлях, керується атрибутами | Тільки origin, без піддоменів і шляхів |
| Типове призначення | Session id, серверні прапорці | Токени клієнта, чернетки, UI-налаштування |
Головний практичний водорозділ — автовідправка. Кука сама їде на сервер, тому нею тримають серверну сесію. localStorage не їде нікуди, тому туди кладуть те, що потрібне лише клієнтському JS. І звідси ж — про безпеку: токен у localStorage завжди доступний будь-якому скрипту на сторінці (ризик при XSS), тоді як HttpOnly-кука скрипту недоступна.
Що автоматично відправляється на сервер
Коротко й чітко, бо на цьому будується половина мережевих тестів:
- Автоматично летить: кукі — ті, що збігаються за
Domain,Path, вимогоюSecureі правиламиSameSite. Браузер додає їх у заголовокCookieбез участі коду сторінки. - Не летить саме собою:
localStorage,sessionStorage,IndexedDB. Якщо ці дані мають дістатися сервера — застосунок явно кладе їх у тіло запиту або в заголовок (наприкладAuthorization: Bearer <token>).
Практичний наслідок для перехоплення мережі (network interception) в автотестах: якщо ви авторизуєтеся кукою, підміняти/додавати треба саме кукі контексту; якщо токеном зі сховища — доведеться і покласти токен у сховище, і, можливо, підправити вихідний заголовок запиту.
IndexedDB
Коли даних більше, ніж влазить у Web Storage, або потрібні структуровані записи з пошуком, застосунки беруть IndexedDB — вбудовану в браузер транзакційну обʼєктну базу.
Її відмінності від localStorage:
- Зберігає не лише рядки, а структуровані обʼєкти й бінарні дані (Blob).
- API асинхронний (на подіях/проміс-обгортках), тож не блокує потік — на відміну від синхронного localStorage.
- Обсяг значно більший (залежить від браузера й вільного місця).
- Так само привʼязана до origin і так само не відправляється на сервер автоматично.
Нюанс. Квоти IndexedDB рахуються від повного розміру диска, а не вільного місця: у Chromium-браузерах origin може зайняти до ~60% диска, у Firefox — до 10% диска (не більше 10 ГіБ на групу сайтів), у Safari — до ~60% для браузерних застосунків. Місця багато, але воно не безмежне, і система може його звільняти.
Для AQA IndexedDB важлива у двох аспектах. По-перше, офлайн-застосунки й PWA часто тримають там свій стан і кеш — і його треба вміти чистити між тестами. По-друге (і це пастка): інструменти захоплюють авторизаційний стан не завжди повністю. За замовчуванням storage state у Playwright захоплює кукі й localStorage, але не IndexedDB — тож якщо застосунок тримає сесію в IndexedDB, типово збережений storage state не «залогінить» вас. У свіжих версіях Playwright (від 1.51) зʼявилася опція indexedDB: true для storageState(...), яка додає IndexedDB у знімок; без неї IndexedDB у стан не потрапляє.
Усе це в автотестах
Storage state
Storage state — це знімок стану браузера (кукі + localStorage; за потреби — і IndexedDB), який можна зберегти у файл і згодом підняти в новому контексті. Так один раз пройдений логін перевикористовується без повторного проходу через UI.
У Playwright:
// зберегти поточний стан (наприклад, після логіну)
await context.storageState({ path: 'auth.json' });
// підняти новий контекст уже «залогіненим»
const context = await browser.newContext({ storageState: 'auth.json' });
Файл — звичайний JSON, який корисно вміти читати очима:
{
"cookies": [
{
"name": "session_id",
"value": "abc123def456",
"domain": "app.example.com",
"path": "/",
"expires": 1767225600,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://app.example.com",
"localStorage": [
{ "name": "token", "value": "eyJhbGciOiJI..." }
]
}
]
}
Що варто памʼятати про межі цього механізму:
- До storage state за замовчуванням потрапляють кукі й localStorage.
sessionStorageне потрапляє ніколи;IndexedDB— лише якщо явно передатиindexedDB: true(Playwright 1.51+), інакше теж ні. Те, що не зберігається, доводиться відновлювати вручну (наприклад, кластиsessionStorageinit-скриптом на кожній сторінці черезaddInitScript). - Кука з
expires: -1— сесійна. Її відновлення в новому контексті менш надійне, ніж постійної, тому для стабільної авторизації краще спиратися на постійні кукі/токени. - Файл містить живі секрети (session id, токени). У репозиторій його не комітять; генерують перед прогоном і тримають поза індексом.
Перевикористання сесії без UI-логіну
Проходити форму логіну в кожному тесті — повільно й крихко: будь-яка зміна на сторінці входу валить усі тести підряд. Тому логін виносять в один крок і перевикористовують результат.
Патерн «залогінитися раз» (Playwright, глобальний setup):
// global-setup: один прохід UI-логіну на весь прогін
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://app.example.com/login');
await page.fill('#email', process.env.USER_EMAIL);
await page.fill('#password', process.env.USER_PASSWORD);
await page.click('button[type=submit]');
await page.waitForURL('**/dashboard');
await context.storageState({ path: 'auth.json' });
Далі проєкт стартує вже з storageState: 'auth.json', і кожен тест починається залогіненим.
Ще швидший шлях — узагалі оминути UI й отримати сесію через API: сходити запитом на ендпойнт логіну, дістати кукі/токен і покласти їх у контекст.
// логін через API, без жодного кліку по сторінці
const res = await request.post('https://app.example.com/api/login', {
data: { email: process.env.USER_EMAIL, password: process.env.USER_PASSWORD }
});
await context.addCookies([
{ name: 'session_id', value: extractSessionId(res),
domain: 'app.example.com', path: '/' }
]);
Так само в Selenium стан переносять через driver.manage().addCookie(...). Важливе обмеження: кукі можна додати лише для домену, на якому браузер уже перебуває. Тому спершу роблять get() на потрібний домен (нехай навіть на 404-сторінку), потім додають кукі, і лише потім переходять углиб застосунку.
Навіщо чистити стан між тестами
Головна вимога до набору тестів — ізоляція: результат тесту не має залежати від того, що (і в якому порядку) виконувалося до нього. Кукі, localStorage і IndexedDB — це спільна памʼять, яка легко протікає між тестами й ламає ізоляцію.
Типові симптоми брудного стану:
- Тести зелені поодинці, але червоніють у наборі — або навпаки. Класична ознака залежності від порядку.
- Тест «логіну» проходить, бо попередній тест уже залишив кукі сесії.
- Тест «розлогіну» падає через раз, бо стартовий стан щоразу різний.
У Playwright ізоляцію значною мірою дає модель контекстів: кожен тест отримує свіжий BrowserContext зі своїми кукі й сховищами, тож між тестами стан за замовчуванням не тече. Небезпека зʼявляється, коли ви свідомо шерите стан (той самий storage state, спільний акаунт на бекенді, дані в БД, на які кукі лише «вказують»). Тоді чистити доводиться явно:
await context.clearCookies();
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
Окремо: чистка на клієнті не прибирає серверний стан. Якщо тест наплодив записів під спільним акаунтом, наступний тест їх побачить, навіть з ідеально чистими кукі. Ізоляція стану — це і клієнт, і сервер.
Діагностика: «наступний тест бачить користувача розлогіненим»
Це один із найчастіших симптомів. Розлогінений вигляд означає рівно одне: на запит не поїхала валідна кука сесії (або не пішов токен). Далі — чому саме, від найчастішого до рідшого.
| Причина | Як розпізнати | Що зробити |
|---|---|---|
| storage state не підхопився | У новому контексті кукі немає взагалі | Перевірити, що контекст стартує з storageState, а файл існує й не порожній |
Сесія жила в sessionStorage/IndexedDB | Логін є, але після нового контексту зникає; у storage state цих даних нема | Відновлювати ці сховища вручну, вмикати indexedDB: true або логінитися через API |
| Кука сесійна й не пережила рестарт | expires: -1, стан губиться між прогонами | Спиратися на постійну кукі «remember me» або на API-логін |
| Токен/сесія просто згасли | Валідні на старті, «відвалюються» посеред прогону | Оновлювати сесію рідше, ніж її TTL; логінитися ближче до тестів |
Невірний Domain/Path | Кука є у сторі, але не їде на потрібний запит | Див. наступний розділ |
| Спільний акаунт вбили в паралелі | Флак саме під навантаженням/паралеллю | Ізолювати акаунти між воркерами |
Алгоритм перевірки простий і завжди той самий: подивитися, які кукі реально є в контексті (context.cookies()), і що реально пішло в заголовку Cookie проблемного запиту (перехопленням мережі). Розбіжність між «кука у сторі є» і «в запиті її немає» майже завжди вказує на Domain, Path, Secure або SameSite.
Наслідки неправильних Domain/Path
Domain і Path вирішують, на які запити браузер додасть кукі. Помилка тут дає найпідступніший клас багів: кука фізично збережена, у DevTools вона видна — але на потрібний запит не летить, і застосунок поводиться так, ніби ви не авторизовані.
- Занадто вузький
Domain. Кука поставлена host-only наwww.example.com, а застосунок працює наapp.example.com— кука не поїде. У тестах: залогінилися на одному піддомені, перейшли на інший, і там «розлогінений». - Занадто широкий
Domain.Domain=example.comрозсилає кукі на всі піддомени, зокрема сторонні чи стейджингові — зайвий витік сесії й плутанина, чия саме кука поїхала. - Занадто вузький
Path.Path=/app— і на/dashboardкука не піде. Логін «спрацював», а перший же перехід поза/appвиглядає розлогіненим. - Ручне додавання кукі не на тому домені. У
addCookies/addCookieвказалиdomain, що не збігається з реальним хостом застосунку, — кука ляже, але браузер її не відправить. Симптом ідентичний «розлогінений», хоча в сторі кука є.
Правило перевірки: коли кука «є, але не працює», порівняйте Domain/Path кукі з хостом і шляхом того запиту, що має її нести. Розбіжність — і є діагноз. Це швидша перевірка, ніж будь-які здогади про згаслі сесії чи баги бекенду, і починати варто саме з неї.
Що таке cookie і навіщо вони потрібні?
Cookie — це маленький іменований шматок даних (імʼя=значення), який браузер зберігає й автоматично додає до наступних запитів на той самий сайт. Потрібні вони тому, що HTTP — протокол без памʼяті (stateless): кожен запит сервер отримує так, ніби бачить клієнта вперше, і в самому запиті немає нічого, що звʼязувало б його з попереднім. Кукі — будівельний матеріал «ілюзії памʼяті»: ви логінитесь один раз, а далі сервер упізнає вас за кукою в кожному запиті. Розмір однієї кукі невеликий — за RFC 6265 браузери мають підтримувати щонайменше 4096 байтів, тож у кукі кладуть ключі й прапорці, а не самі дані. Типовий вміст — ідентифікатор сесії, «запамʼятати мене», налаштування на кшталт теми.
Як cookie зʼявляється в браузері і як повертається на сервер?
Ставить кукі сервер — заголовком відповіді Set-Cookie, причому кожна кука — це окремий такий заголовок. Створити кукі може і JavaScript сторінки через document.cookie, але це вже дії самого застосунку, а не браузера. Далі браузер додає збережені кукі до кожного відповідного запиту одним заголовком Cookie — лише пари імʼя=значення, злиті через крапку з комою. Тут важлива асиметрія: атрибути (Path, Secure, HttpOnly тощо) їдуть тільки в Set-Cookie і назад не повертаються — вони потрібні браузеру, щоб вирішити, коли й куди кукі відправляти. Тому в перехопленому запиті атрибутів кукі не видно — дивитися їх треба у відповіді сервера або в DevTools.
Чим сесійна (session) cookie відрізняється від постійної (persistent)?
Сесійна кука не має ні Expires, ні Max-Age: вона живе, доки відкрита сесія браузера, і зникає після її завершення — типова роль для ідентифікатора сесії. Постійна кука має Expires або Max-Age, переживає перезапуск браузера й живе до вказаної дати — так реалізують «запамʼятати мене». Є застереження: функція відновлення вкладок («continue where you left off») у багатьох браузерах зберігає сесійні кукі між перезапусками, тож «закрив браузер — розлогінився» справджується не завжди. Для автотестів різниця принципова: постійні кукі надійно відновлюються зі збереженого storage state у новому контексті, а щодо сесійних (у файлі стану вони мають expires: -1) треба бути уважним — їх відновлення менш надійне.
Що таке серверна сесія і що насправді лежить у куці session_id?
У куці зазвичай лежить не стан користувача, а лише ключ до нього — ідентифікатор сесії (session id). Схема класична: після логіну сервер створює запис сесії у себе (в памʼяті, Redis, БД) з усім станом — хто ви, ролі, кошик — і віддає браузеру довгий випадковий рядок кукою Set-Cookie: session_id=.... На кожен наступний запит браузер надсилає цю кукі, сервер знаходить свій запис за ключем і «згадує» вас. Session id роблять довгим і випадковим (щоб не вгадати), позначають HttpOnly (щоб не вкрав XSS) і Secure (щоб не перехопили по HTTP). Імена таких кук часто видають стек: JSESSIONID — Java, PHPSESSID — PHP, connect.sid — Express, sessionid — Django. Альтернатива — токен (наприклад JWT), де стан зашитий у сам токен і сервер запис сесії не тримає.
Чим localStorage відрізняється від sessionStorage?
API в них однаковий (setItem/getItem, зберігають лише рядки), різниця — у часі життя й області видимості. localStorage живе постійно, доки його не стерли, і спільний для всіх вкладок одного origin (схема + хост + порт). sessionStorage живе, доки жива конкретна вкладка, і кожна вкладка має свій — нова вкладка чи новий контекст його не успадковують. Обсяг у обох — зазвичай близько 5 МБ на origin, і жодне з них ніколи не відправляється на сервер автоматично. Для автотестів привʼязка sessionStorage до вкладки — часта причина «зниклого» логіну: якщо застосунок тримає токен саме там, у новому контексті ви розлогінені, і storage state тут не допоможе — він sessionStorage не зберігає взагалі.
Чим cookie відрізняється від localStorage і що з цього автоматично відправляється на сервер?
Головний водорозділ — автовідправка: кука сама їде на сервер із кожним відповідним запитом у заголовку Cookie, а localStorage (як і sessionStorage та IndexedDB) не їде нікуди й ніколи — якщо дані мають дістатися сервера, застосунок явно кладе їх у тіло запиту або в заголовок на кшталт Authorization: Bearer .... Звідси й призначення: кукою тримають серверну сесію, у localStorage — те, що потрібне лише клієнтському JS (токени клієнта, чернетки, UI-налаштування). Інші відмінності: обсяг близько 4 КБ на кукі проти приблизно 5 МБ на origin; кука може бути захищена від JS через HttpOnly, а localStorage завжди доступний будь-якому скрипту на сторінці (ризик при XSS); час життя кукі керований атрибутами, localStorage живе, доки не стерли вручну. Для тестів це визначає стратегію авторизації: сесія на куці — переносимо кукі контексту, токен у сховищі — доведеться класти токен у сховище і, можливо, підправляти вихідний заголовок.
Що робить атрибут HttpOnly і як він впливає на автотести?
HttpOnly забороняє доступ до кукі з JavaScript: вона не видна в document.cookie, але браузер так само надсилає її на сервер. Мета — захистити токен сесії від крадіжки через XSS: навіть якщо на сторінці виконався чужий скрипт, кукі він не прочитає. Для автотестів наслідок прямий: HttpOnly-кукі не дістати ні через document.cookie, ні через page.evaluate — читати її треба на рівні контексту браузера, через API Playwright або Selenium (наприклад context.cookies()). Якщо тест «не бачить» кукі зі сторінки, хоча в DevTools вона є, — перше, що варто перевірити, це саме HttpOnly.
Навіщо атрибут Secure і яка з ним пастка на localhost?
Secure каже браузеру надсилати кукі лише через HTTPS — по HTTP вона не відправляється, що захищає від перехоплення в незашифрованому трафіку. Він обовʼязковий у парі з SameSite=None, інакше браузер кукі відкине. Пастка для тестувальника: браузери зазвичай трактують http://localhost як безпечний контекст, тож локально Secure-кукі працюють — а на іншому HTTP-хості без TLS та сама кука мовчки не поїде. Тому тест, зелений локально, може падати проти нелокального стенду без HTTPS, і виглядатиме це як «користувач розлогінений». Окремо варто памʼятати про префікси імен: кука на __Host- мусить бути Secure, поставлена з HTTPS, без Domain і з Path=/, а на __Secure- — просто Secure з HTTPS; якщо руками класти таку кукі в тесті з іншими атрибутами, браузер її мовчки не встановить.
Поясніть значення SameSite: Strict, Lax, None
SameSite керує тим, чи додається кука до запитів, які ініціює інший сайт, — це головний важіль проти CSRF (міжсайтова підробка запиту, cross-site request forgery). Strict — кука їде лише з того самого сайту: навіть перехід за посиланням з листа чи месенджера кукі не несе, тож перше відкриття може виглядати розлогіненим — і це очікувана поведінка, а не баг. Lax — те саме плюс верхньорівнева навігація безпечним методом (GET-перехід за посиланням), але не крос-сайт POST, не iframe, не картинки й не fetch з іншого сайту; у Chromium-браузерах це дефолт, коли атрибут не заданий. None — кука їде завжди, зокрема в міжсайтових запитах: потрібен для віджетів, iframe, SSO — і обовʼязково разом із Secure. Для AQA це критично у тестуванні SSO та платіжних редіректів: кука там має бути None; Secure, інакше сесія «губиться» на поверненні.
Кука має SameSite=None без Secure. Що побачить тестувальник?
Сучасний браузер таку кукі просто проігнорує — у тестах це виглядає як «сервер її ставить, а її немає». У відповіді заголовок Set-Cookie присутній, але в сховищі браузера кука не зʼявляється і в наступні запити не потрапляє. Це класична пастка формату «виглядає як баг бекенду, а насправді порушене правило браузера»: SameSite=None дозволений лише в парі з Secure. Діагностується швидко: порівняти Set-Cookie у відповіді з реальним вмістом сховища кукі — якщо кука «не долетіла», перевірити саме комбінацію атрибутів, а не логіку сервера.
Що означає «той самий сайт» для SameSite? Чи є піддомени same-site?
Same-site рахується за зареєстрованим доменом (eTLD+1), а не за повним хостом: запити між app.example.com і api.example.com — same-site, а між example.com і example.org — ні. Тому SameSite не заважає обміну кукі між власними піддоменами — за це відповідає атрибут Domain. Варто памʼятати й про різницю дефолтів: Lax для незаданого SameSite — поведінка саме Chromium-рушіїв (починаючи з Chrome 80), а Firefox і Safari цього дефолту не застосовують, там незадана кука поводиться майже без обмежень. Тож на дефолт покладатися не можна — SameSite задають явно на кожній куці. Плюс окремий пласт: Safari через ITP блокує сторонні кукі, а Firefox через Total Cookie Protection ізолює їх по «банках» на кожен сайт — незалежно від SameSite, тому крос-сайт сценарій, що працює в Chrome, може ламатися в іншому рушії.
Як атрибути Domain і Path впливають на відправку кукі та які типові баги з ними?
Domain і Path вирішують, на які запити браузер додасть кукі, — і помилка тут дає найпідступніший клас багів: кука фізично збережена, в DevTools видна, але на потрібний запит не летить. Без Domain кука host-only — прикріплена рівно до хоста, що її поставив: кука з app.example.com не поїде на example.com. З Domain=example.com вона їде і на домен, і на всі піддомени — а це вже ризик зайвого витоку сесії на стейджингові чи сторонні піддомени. Path обмежує шляхи: Path=/admin — і на /dashboard кука не піде; при цьому Path — не про безпеку, а лише про доставку, він легко обходиться. Типові сценарії в тестах: залогінилися на одному піддомені, перейшли на інший — «розлогінений»; або в addCookies вказали domain, що не збігається з реальним хостом, — кука ляже в стор, але браузер її не відправить. Правило діагностики: коли кука «є, але не працює», порівняйте її Domain/Path із хостом і шляхом запиту, який має її нести, — це швидше за будь-які здогади про згаслі сесії чи баги бекенду.
Що таке IndexedDB і коли її обирають замість localStorage?
IndexedDB — вбудована в браузер транзакційна обʼєктна база; її беруть, коли даних більше, ніж влазить у Web Storage, або потрібні структуровані записи з пошуком. Від localStorage вона відрізняється чотирма речами: зберігає не лише рядки, а структуровані обʼєкти й бінарні дані (Blob); API асинхронний і не блокує потік, на відміну від синхронного localStorage; обсяг значно більший (квоти рахуються від розміру диска й залежать від браузера); так само привʼязана до origin і так само не відправляється на сервер автоматично. Типові користувачі — офлайн-застосунки та PWA, які тримають там стан і кеш. Для AQA дві практичні речі: IndexedDB треба вміти чистити між тестами, і — пастка — за замовчуванням storage state у Playwright її не захоплює, тож якщо застосунок тримає сесію в IndexedDB, збережений стан вас не «залогінить»; від Playwright 1.51 є опція indexedDB: true для storageState(...), яка додає її у знімок.
Що таке storage state у Playwright, що в нього потрапляє, а що ні?
Storage state — знімок стану браузера, який зберігається у JSON-файл (context.storageState(...)) і піднімається в новому контексті через newContext зі storageState — так один раз пройдений логін перевикористовується без повторного проходу через UI. За замовчуванням туди потрапляють кукі й localStorage. Не потрапляють: sessionStorage — ніколи (його доводиться відновлювати вручну, наприклад init-скриптом через addInitScript), та IndexedDB — лише якщо явно передати indexedDB: true (Playwright 1.51+). Сесійні кукі у файлі мають expires: -1, і їх відновлення в новому контексті менш надійне, ніж постійних, — для стабільної авторизації краще спиратися на постійні кукі або токени. І про гігієну: файл містить живі секрети (session id, токени), тому в репозиторій його не комітять — генерують перед прогоном і тримають поза індексом.
Як організувати авторизацію в автотестах, щоб не проходити форму логіну в кожному тесті?
Проходити UI-логін у кожному тесті — повільно й крихко: будь-яка зміна сторінки входу валить усі тести підряд, тому логін виносять в один крок і перевикористовують результат. Перший патерн — «залогінитися раз»: у глобальному setup пройти UI-логін один раз на весь прогін, зберегти context.storageState({ path: 'auth.json' }), і далі кожен тест стартує вже залогіненим. Ще швидше — оминути UI взагалі: сходити API-запитом на ендпойнт логіну, дістати кукі чи токен і покласти їх у контекст через context.addCookies(...). У Selenium стан переносять через driver.manage().addCookie(...), але з важливим обмеженням: кукі можна додати лише для домену, на якому браузер уже перебуває, — тому спершу роблять get() на потрібний домен (нехай навіть на 404-сторінку), потім додають кукі, і лише потім ідуть углиб застосунку. Вибір між кукою і токеном визначається тим, де застосунок тримає сесію: куки переносяться контекстом, токен зі сховища доведеться класти в сховище й відтворювати заголовок.
Навіщо чистити стан між тестами і чому чистка на клієнті не гарантує ізоляцію?
Головна вимога до набору тестів — ізоляція: результат тесту не має залежати від того, що і в якому порядку виконувалося до нього, а кукі, localStorage і IndexedDB — спільна памʼять, яка легко протікає між тестами. Типові симптоми брудного стану: тести зелені поодинці, але червоніють у наборі (класична ознака залежності від порядку); тест логіну проходить, бо попередній уже залишив кукі сесії; тест розлогіну падає через раз, бо стартовий стан щоразу різний. У Playwright ізоляцію значною мірою дає модель контекстів — кожен тест отримує свіжий BrowserContext зі своїми кукі й сховищами; небезпека зʼявляється, коли стан шерять свідомо: той самий storage state, спільний акаунт, дані в БД. Тоді чистять явно: context.clearCookies() плюс localStorage.clear() і sessionStorage.clear() через page.evaluate. Але чистка на клієнті не прибирає серверний стан: якщо тест наплодив записів під спільним акаунтом, наступний їх побачить навіть з ідеально чистими кукі — ізоляція стану це і клієнт, і сервер.
Наступний тест бачить користувача розлогіненим. Як діагностувати?
Розлогінений вигляд означає рівно одне: на запит не поїхала валідна кука сесії або не пішов токен — далі питання лише «чому саме». Алгоритм завжди той самий: спершу подивитися, які кукі реально є в контексті (context.cookies()), потім — що реально пішло в заголовку Cookie проблемного запиту (перехопленням мережі). Якщо кукі немає взагалі — не підхопився storage state (контекст стартує без нього або файл порожній), або сесія жила в sessionStorage/IndexedDB, які у стан за замовчуванням не потрапляють, або кука була сесійною (expires: -1) і не пережила рестарт. Якщо кука у сторі є, але в запит не поїхала — майже завжди винні Domain, Path, Secure чи SameSite: звірити їх із хостом і шляхом запиту. Якщо кука і є, і поїхала — причина поза кукою: сесія згасла посеред прогону (логінитися ближче до тестів, оновлювати частіше за TTL) або спільний акаунт «вбили» паралельні воркери (ізолювати акаунти). Такий порядок перевірок дає діагноз за хвилини замість перебору здогадок.
Три кейси, де стан браузера вирішує, зелений тест чи флак: перевикористання логіну через storage state (і чому sessionStorage/IndexedDB туди не потрапляють), діагностика «наступний тест бачить користувача розлогіненим» через звірку кукі в сторі з реальним заголовком Cookie, і логін через API без UI з пасткою неправильного domain у addCookies. Скрізь — що дивитися і чому.
Кейс 1. Storage state: логін один раз, але не все переноситься
Проходити форму входу в кожному тесті повільно й крихко. Логін виносять в один прохід і зберігають знімок стану, а далі кожен контекст стартує вже залогіненим:
// global-setup: один прохід UI-логіну на весь прогін
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://app.example.com/login');
await page.fill('#email', process.env.USER_EMAIL);
await page.fill('#password', process.env.USER_PASSWORD);
await page.click('button[type=submit]');
await page.waitForURL('**/dashboard');
await context.storageState({ path: 'auth.json' });
Файл — звичайний JSON, який корисно читати очима, бо в ньому видно, що саме збереглося:
{
"cookies": [
{ "name": "session_id", "value": "abc123def456",
"domain": "app.example.com", "path": "/",
"expires": 1767225600, "httpOnly": true,
"secure": true, "sameSite": "Lax" }
],
"origins": [
{ "origin": "https://app.example.com",
"localStorage": [ { "name": "token", "value": "eyJhbGciOiJI..." } ] }
]
}
Що дивитися і чому:
- До знімка йдуть кукі й localStorage — і все.
sessionStorageне потрапляє ніколи, IndexedDB — лише зindexedDB: true(Playwright 1.51+). Якщо застосунок тримає сесію саме там, «залогінений» стан не підхопиться, і новий контекст покаже розлогінений екран. Те, що не зберігається, відновлюють вручну — наприклад,sessionStorageчерезaddInitScriptна кожній сторінці. "expires": -1— це сесійна кука. Її відновлення в новому контексті менш надійне, ніж постійної. Для стабільної авторизації спирайтеся на постійні кукі (remember me) або токен, а не на сесійну.- Файл — живий секрет. У ньому справжній session id і токен, тож
auth.jsonне комітять: генерують перед прогоном і тримають поза індексом.
Кейс 2. «Наступний тест бачить розлогіненим»: кука в сторі є, а в запит не поїхала
Найпідступніший клас багів: у DevTools кука видна, а застосунок поводиться так, ніби ви не авторизовані. Розлогінений вигляд означає рівно одне — на запит не поїхала валідна кука сесії. Алгоритм завжди той самий: звірити, які кукі реально є в контексті, з тим, що реально пішло в заголовку Cookie проблемного запиту.
import { test, expect } from '@playwright/test';
test('кука сесії реально їде на потрібний запит', async ({ page, context }) => {
// 1) що лежить у сторі
const stored = await context.cookies();
const session = stored.find((c) => c.name === 'session_id');
expect(session, 'session_id немає в контексті взагалі').toBeTruthy();
// 2) що реально пішло в заголовку Cookie
// важливо: синхронний req.headers() НЕ віддає cookie-заголовок —
// читати треба асинхронним allHeaders()/headerValue()
let sentCookie = '';
page.on('request', async (req) => {
if (req.url().includes('/api/me')) sentCookie = (await req.headerValue('cookie')) ?? '';
});
await page.goto('https://app.example.com/dashboard');
// розбіжність «у сторі є / у запиті нема» = діагноз
expect(sentCookie, 'кука у сторі є, а в запит не поїхала').toContain('session_id=');
});
Що дивитися і чому:
- Порожньо в сторі vs порожньо в запиті — це різні діагнози. Немає кукі в контексті взагалі → storage state не підхопився (перевір, що контекст стартує з
storageState, а файл не порожній). Кука є, але в заголовок не потрапила → майже завждиDomain,Path,SecureабоSameSite. - Занадто вузький
Domain/Path. Кука host-only наwww.example.comне поїде наapp.example.com;Path=/appне піде на/dashboard. Симптом ідентичний «розлогінений», хоча в DevTools кука на місці. Звіртеdomain/pathкукі з хостом і шляхом того запиту, що має її нести. - Сесія жила в
sessionStorage/IndexedDB. Логін є, але після нового контексту зникає, і в кукі шукати марно — вона там і не була. Ознака: у storage state цих даних немає. - Починайте саме зі звірки, а не з гіпотез. Розбіжність «є в сторі / немає в запиті» — швидша перевірка, ніж здогади про згаслі сесії чи баги бекенду.
Кейс 3. Логін через API без UI: пастка domain в addCookies і читання HttpOnly-кукі
Найшвидший логін оминає UI: сходити запитом на ендпойнт входу, дістати кукі й покласти в контекст. Але тут два місця, де воно тихо не працює.
import { test, expect } from '@playwright/test';
test('API-логін кладе кукі на правильний домен', async ({ context, request }) => {
const res = await request.post('https://app.example.com/api/login', {
data: { email: process.env.USER_EMAIL, password: process.env.USER_PASSWORD },
});
await context.addCookies([
{ name: 'session_id', value: extractSessionId(res),
domain: 'app.example.com', path: '/', // саме хост застосунку, не www і не .example.com
httpOnly: true, secure: true, sameSite: 'Lax' },
]);
const page = await context.newPage();
await page.goto('https://app.example.com/dashboard');
// HttpOnly-куку document.cookie НЕ побачить — це не баг, так задумано
const viaJs = await page.evaluate(() => document.cookie);
expect(viaJs).not.toContain('session_id=');
// читати таку куку треба через API контексту
const session = (await context.cookies()).find((c) => c.name === 'session_id');
expect(session?.httpOnly).toBe(true);
});
Що дивитися і чому:
domainвaddCookiesмусить збігатися з реальним хостом. Вкажетеwww.example.comзамістьapp.example.com— кука ляже, але браузер її не відправить. Симптом ідентичний «розлогінений», хоча в сторі кука є. У Selenium додатковий нюанс: кукі можна класти лише для домену, на якому браузер уже перебуває, — тож спершуget()на домен, потімaddCookie, і лише потім углиб застосунку.HttpOnly→ тількиcontext.cookies(). Черезdocument.cookieтокен сесії не прочитається ніколи, боHttpOnlyрозриває головний вектор XSS. АсертhttpOnly === trueзаразом фіксує, що атрибут безпеки на місці.SameSiteне лишайте «за дефолтом». Chromium вважає куку без атрибута заLax, а Firefox і Safari — ні (там вона поводиться ближче доNone). Для SSO/платіжних редіректів кука має бутиNone; Secure, інакше на поверненні із зовнішнього сайту сесія «губиться».NoneбезSecureбраузер відкине — виглядає як «сервер її ставить, а її немає».- Чистка
document.cookieне прибереHttpOnly-куку. Для ізоляції між тестами стартуйте з чистого контексту або кличтеcontext.clearCookies(); клієнтська чистка серверний стан теж не чіпає.
Cookie: механізм і атрибути
- Розумію, чому HTTP stateless і як cookie, серверні сесії та сховища будують ілюзію памʼяті поверх безпамʼятного протоколу.
- Знаю, що cookie ставить сервер заголовком
Set-Cookie(кожна кука — окремий заголовок), а браузер повертає лише париімʼя=значення, злиті в один заголовокCookie; атрибути назад не їдуть. - Можу пояснити, що робить кожен атрибут:
Domain,Path,Expires/Max-Age,HttpOnly,Secure,SameSite— і чому помилка в атрибуті виглядає як «нічого не працює», хоча кука фізично є. - Розумію різницю host-only vs
Domain=example.com: без атрибута кука прикріплена рівно до хоста, із заданим доменом — їде і на нього, і на всі піддомени. - Знаю, що
Path— про доставку, а не безпеку, аHttpOnly-куку не дістати ні черезdocument.cookie, ні черезpage.evaluate— тільки через API контексту браузера. - Можу пояснити пастку
Secureнаlocalhost(часто «безпечний контекст», тож працює) та префікси імен__Host-/__Secure-, які браузер мовчки відкине за «неправильних» атрибутів.
SameSite та сторонні cookie
- Можу пояснити
Strict/Lax/Noneі чомуNoneобовʼязково разом ізSecure(інакше «сервер її ставить, а її немає»). - Знаю, що same-site рахується за зареєстрованим доменом (eTLD+1):
app.example.comіapi.example.com— same-site, аexample.comіexample.org— ні. - Розумію, чому
Lax-дефолт — це поведінка лише Chromium, тож не покладаюся на дефолт і задаюSameSiteявно. - Знаю, що Safari (ITP) блокує, а Firefox (Total Cookie Protection) ізолює сторонні кукі незалежно від
SameSite— тож крос-сайт SSO «з Chrome» може ламатися в іншому рушії.
Сесії, токени і сховища браузера
- Розумію, чому cookie зазвичай містить не дані, а непрозорий session id, а стан лежить на сервері; знаю, чому session id роблять довгим,
HttpOnlyіSecure. - Можу пояснити розвилку «серверна сесія в кукі» vs «токен (JWT) у сховищі + заголовок
Authorization» і що вона змінює для тестів. - Знаю різницю session cookie vs persistent cookie і пастку відновлення вкладок («закрив браузер → розлогінився» справджується не завжди).
- Можу пояснити
localStoragevssessionStoragevs cookie: обсяг, час життя, область (origin / вкладка), доступ із JS і головний водорозділ — автовідправка на сервер. - Знаю, що автоматично на сервер летять лише відповідні кукі;
localStorage,sessionStorage,IndexedDBне їдуть самі — застосунок кладе їх у тіло чи заголовок явно. - Розумію, чим
IndexedDBвідрізняється (структуровані дані, асинхронний API, більший обсяг) і чому це пастка для storage state.
Стан в автотестах і діагностика
- Можу пояснити, що таке storage state, що в нього за замовчуванням потрапляє (кукі + localStorage), а що ні (
sessionStorage— ніколи;IndexedDB— лише зindexedDB: true, Playwright 1.51+). - Знаю патерни перевикористання сесії без UI-логіну: глобальний setup зі
storageStateта логін через API зaddCookies; памʼятаю обмеження Selenium — кукі додають лише на домені, де браузер уже перебуває. - Розумію, навіщо ізоляція й чистка стану між тестами, чому модель контекстів Playwright дає її «безкоштовно» і що клієнтська чистка не прибирає серверний стан.
- Можу діагностувати «наступний тест бачить користувача розлогіненим»: порівняти, які кукі реально є в контексті (
context.cookies()) і що пішло в заголовкуCookieзапиту; розбіжність майже завжди вказує наDomain,Path,SecureабоSameSite.
Що з переліченого браузер сам, без участі коду сторінки, додає до кожного відповідного запиту на сервер?
Питання
HTTP називають stateless. Що це означає і як застосунок тоді «пам'ятає», що ви залогінені?