vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    Кукі, сесії та сховище браузера

    Зміст

    HTTP — протокол без памʼяті (stateless). Кожен запит сервер отримує так, ніби бачить клієнта вперше: у самому запиті немає нічого, що звʼязувало б його з попереднім. Але вебзастосунки живуть інакше — ви логінитесь один раз, а далі десятки сторінок «памʼятають», що це саме ви. Ця ілюзія памʼяті будується поверх безпамʼятного протоколу, і будівельний матеріал тут — кукі (cookies), серверні сесії та сховища браузера.

    Для AQA це не абстрактна теорія. Саме тут ховається чи не найбільше причин флаку (flakiness): тест «бачить» користувача розлогіненим, стан протікає з одного тесту в інший, збережена сесія не підхоплюється. Хто розуміє, де фізично лежить стан і як він потрапляє на сервер, той діагностує такі падіння за хвилини, а не перебором.

    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
    СерверБраузерСерверБраузерБраузер зберігає кукіGET /login (кукі ще немає)200 OK, Set-Cookie: session_id=abc123GET /dashboard, Cookie: session_id=abc123200 OK (сервер «згадав» вас)СерверБраузерСерверБраузерБраузер зберігає кукіGET /login (кукі ще немає)200 OK, Set-Cookie: session_id=abc123GET /dashboard, Cookie: session_id=abc123200 OK (сервер «згадав» вас)

    Кожна кука — окремий заголовок 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»).

    Атрибути — це правила, за якими браузер вирішує долю кукі: кому її показувати, доки зберігати, чи давати доступ скриптам. Помилка в атрибуті часто виглядає як «нічого не працює», хоча кука фізично є.

    АтрибутЩо задаєНавіщо / наслідки
    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 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

    Кука зазвичай не містить самих даних користувача — вона містить ключ. Класична схема серверної сесії:

    1. Ви логінитесь. Сервер створює запис сесії у себе (в памʼяті, Redis, БД) з усім станом: хто ви, ролі, кошик тощо.
    2. Сервер генерує ідентифікатор сесії (session id) — довгий випадковий рядок — і віддає його кукою: Set-Cookie: session_id=....
    3. На кожен наступний запит браузер надсилає цю кукі. Сервер бере session id, знаходить свій запис і «згадує» вас.
    Сховище сесійСерверБраузерСховище сесійСерверБраузерPOST /login (логін + пароль)Створити запис сесії (хто ви, ролі, кошик)Set-Cookie: session_id=... (HttpOnly, Secure)GET /page, Cookie: session_id=...Знайти запис за session idСтан користувача200 OK (сервер «згадав» вас)Сховище сесійСерверБраузерСховище сесійСерверБраузерPOST /login (логін + пароль)Створити запис сесії (хто ви, ролі, кошик)Set-Cookie: session_id=... (HttpOnly, Secure)GET /page, Cookie: session_id=...Знайти запис за session idСтан користувача200 OK (сервер «згадав» вас)

    Тобто у браузері лежить лише непрозорий ключ, а стан — на сервері. Тому 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
    ХарактеристикаlocalStoragesessionStorage
    Час життяПостійно, доки не стерлиДоки живе вкладка (її сесія)
    Область (scope)На origin (схема + хост + порт)На origin + конкретну вкладку
    Спільність між вкладкамиТак, спільний для всіх вкладок originНі, кожна вкладка має свій
    ОбсягЗазвичай близько 5 МБ (залежить від браузера)Приблизно стільки ж
    Тип данихТільки рядкиТільки рядки
    Відправка на серверНіколи автоматичноНіколи автоматично
    Доступ із JSЗавжди (немає аналога HttpOnly)Завжди

    Нюанс. За MDN, браузери дають близько 5 МіБ localStorage і ще 5 МіБ sessionStorage на origin; точний облік (символи UTF-16 чи байти) відрізняється між браузерами — для критичних сценаріїв перевір на цільовому.

    Оскільки все — рядки, обʼєкти зберігають через JSON.stringify/JSON.parse. Важливий для тестів момент про sessionStorage: він привʼязаний до вкладки, тож новий контекст чи нова вкладка його не успадковують — це часта причина, чому «залогінена» в одному місці сесія не працює в іншому, якщо застосунок тримає токен саме там.

    Хоч і те, й те «зберігає дані в браузері», відмінності визначають, для чого кожне придатне.

    КритерійCookielocalStorage
    Обсяг~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+), інакше теж ні. Те, що не зберігається, доводиться відновлювати вручну (наприклад, класти sessionStorage init-скриптом на кожній сторінці через 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.

    Ні

    Так

    Так

    Ні

    Кука сесії є в контексті?

    Проблема storage state
    або сховища vs cookie

    Кука поїхала в заголовку Cookie?

    Причина поза кукою:
    згасла сесія або бекенд

    Звірити Domain / Path / Secure / SameSite
    з хостом і шляхом запиту

    Ні

    Так

    Так

    Ні

    Кука сесії є в контексті?

    Проблема storage state
    або сховища vs cookie

    Кука поїхала в заголовку 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 кукі з хостом і шляхом того запиту, що має її нести. Розбіжність — і є діагноз. Це швидша перевірка, ніж будь-які здогади про згаслі сесії чи баги бекенду, і починати варто саме з неї.