URL і кодування
Зміст
Один із найчастіших багів у житті тестувальника звучить приблизно так: у пошук ввели red & blue, а застосунок шукав тільки red. Або дзеркально: тест ?q=search results, а в адресному рядку стоїть ?q=search+results — асерт червоний, хоча продукт працює правильно. Обидва випадки не про пошук і не про асерт: URL лише виглядає як текстовий рядок, а насправді це структура з роздільниками, з частинами, які ніколи не залишають браузер, і з двома різними угодами про запис пробілу.
Загублений параметр після редиректу, 404 через регістр у шляху, «якийсь CORS» між двома портами локалхоста, картинка, якої немає лише на проді, — усе це читається просто з адреси, якщо знати, з чого вона складається. Це канонічна глава теми: тут повний виклад структури URL і кодування, решта глав розділу посилаються сюди.
URL — не рядок, а структура
Формально URL (Uniform Resource Locator) описує RFC 3986 як ієрархічну послідовність компонентів: URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ], де authority ділиться ще на userinfo, хост і порт. Специфікацій при цьому дві: браузери реалізують живий WHATWG URL Standard, який прямо ставить за мету узгодити RFC 3986 і RFC 3987 із реальними реалізаціями й замістити їх, а поділ на URI/IRI визнає зайвим.
| Компонент | Приклад | Що робить |
|---|---|---|
| Схема | https | Визначає тип URL і подальшу обробку; нечутлива до регістру, канонічна форма — малими літерами |
| userinfo | user:pass@ | Дані користувача перед @; у рядку запиту на сервер не їде — браузер зазвичай підставляє їх у заголовок Authorization |
| Хост | shop.example.com | IP-літерал у квадратних дужках, IPv4 у десятковій формі або зареєстроване імʼя; регістр не має значення |
| Порт | :8443 | Відсутнє значення або 16-бітове число |
| Шлях | /catalog/42 | Здебільшого ієрархічні дані; закінчується першим ?, # або кінцем URI |
| Запит | ?color=red&size=xl | Неієрархічні дані після першого ? до # або кінця |
| Фрагмент | #reviews | Непрямо ідентифікує вторинний ресурс; дереференсує його виключно клієнт |
Дві деталі звідси дають більше, ніж здається. По-перше, внутрішньої структури запиту специфікація не диктує взагалі: розбиття на пари ключ=значення, зʼєднані &, — домовленість, успадкована від HTML-форм, тож «а що буде, якщо той самий параметр передати двічі» — питання до конкретного бекенда. По-друге, для «особливих» схем (http, https, ws, wss, ftp) визначено типові порти, які в серіалізації опускають: https://example.com:443/ і https://example.com/ — той самий ресурс, а https://example.com:8443/ — уже інше . Рівність двох URL узагалі визначається збігом їхніх серіалізованих форм, причому фрагмент за бажанням із порівняння виключають.
Регістр — окрема пастка: схема й хост нечутливі до нього й нормалізуються в нижній, решта компонентів вважається чутливою, якщо схема не каже інакше. Фактична поведінка шляху залежить від сервера й файлової системи: на Linux регістр значущий, на Windows/IIS і в багатьох CDN — часто ні, а дехто просто редиректить /Catalog на /catalog.
Довжину зверху специфікація не обмежує, зате задає нижню межу підтримки: RECOMMENDED тримати URI щонайменше у 8000 октетів. Решту вирішують сервери, і цифри того самого порядку — Apache має LimitRequestLine з дефолтом 8190 байтів, nginx віддає 414 (Request-URI Too Large), коли стартовий рядок запиту не влазить у буфер large_client_header_buffers. Ліміт на тіло на порядки щедріший (LimitRequestBody — 1073741824 байти), звідси й правило «великі дані — тілом, а не параметрами в URL».
Що з URL їде на сервер, а що ні
Це найпрактичніше знання глави. У HTTP клієнт розбирає URI на компоненти, звертається до сервера authority й надсилає йому дані з authority, шляху та запиту. Фрагмент у цьому переліку не згаданий: він відокремлюється від решти URI ще до звернення до ресурсу, і дереференсує його виключно клієнт, незалежно від схеми.
Наслідки прямі. Фрагмента немає в логах сервера — ніколи, тож фільтрувати за ним на бекенді неможливо: зміна лише фрагмента серверного запиту не породжує. Дзеркально: userinfo у стартовому рядку запиту теж не зʼявляється — браузер зазвичай підставляє його в заголовок Authorization.
Наша практика (не канон). Наслідок для тесту специфікація не формулює, це наш вивід із неї: очікування мережевої відповіді після кліку по якірному посиланню зависне, бо запиту не буде — чекати треба на зміну DOM або адреси.
Абсолютні й відносні URL
Абсолютний URL несе схему й достатньо даних, щоб знайти ресурс без жодного контексту: https://shop.example.com/cart. Відносний без контексту сенсу не має — коректний рядок URL мусить бути або відносним, або абсолютним (за яким може йти фрагмент), і відносний набуває значення лише відносно базового URL: item.html, ../cart, ?page=2.
Правило резолвінгу має один неочевидний крок, і саме він дає більшість переходів «не туди». При зʼєднанні шляхів береться шлях бази, і від нього відкидається все після останнього / — «файлова» частина бази зникає, а на її місце стає відносний шлях. Звідси прямий наслідок: завершальний слеш у базі змінює результат.
| База | Відносний | Результат |
|---|---|---|
https://s.com/catalog/phones/ | item.html | https://s.com/catalog/phones/item.html |
https://s.com/catalog/phones | item.html | https://s.com/catalog/item.html |
У другому рядку phones для резолвера — «файл», і він його відкидає.
Баз при цьому дві, і плутають саме їх. Коли відносне посилання резолвить браузер, базою є URL документа. Коли відносний аргумент page.goto() резолвить Playwright, базою є baseURL з конфігурації, а не поточна адреса сторінки — тож перехід «не туди» трапляється навіть тоді, коли на екрані відкрито потрібну сторінку.
// playwright.config.ts
export default defineConfig({
use: { baseURL: 'https://staging.example.com/app/' }, // слеш наприкінці не декоративний
});
// у тесті
await page.goto('search?q=test'); // → https://staging.example.com/app/search?q=test
Наша практика (не канон). Далі — наші робочі конвенції; окремого стандарту під них немає. baseURL зі шляхом закінчуємо слешем, інакше останній сегмент відкидається при кожному відносному переході. У самих тестах беремо шляхи від кореня (/catalog/phones): вони не залежать ні від слеша, ні від поточної адреси. І якщо одразу після навігації в трасі стоїть несподіваний 301 чи 308, першими гіпотезами перевіряємо регістр і завершальний слеш у шляху, а не «сервер тупить».
Відсоткове кодування: що кодують і чому
Відсоткове кодування (percent-encoding, воно ж URL-encoding) — механізм подання одного байта трійкою символів: % і дві шістнадцяткові цифри його значення. Кодують тоді, коли символ виходить за дозволений набір або вживається як роздільник. Незарезервовані символи — латинські літери обох регістрів, цифри, дефіс, крапка, підкреслення й тильда — кодування не потребують. Зарезервовані ж розділяють компоненти, тож коли самі дані конфліктують із роллю роздільника, їх кодують перед складанням URL: пошук red & blue у параметрі має стати q=red%20%26%20blue, інакше & розірве його на два параметри — і ви отримаєте баг із першого абзацу глави.
Механізм двокроковий, і саме тут ламається інтуїція. Спершу текст перетворюється на байти (у вебі майже завжди UTF-8), і лише потім кожен байт окремо переписується як %XX: пробіл 0x20 дає %20, а одна українська літера «і» (U+0456) — аж дві трійки, %D1%96. Набір символів, що підлягають кодуванню, залежить від компонента: для фрагмента це керівні символи, пробіл і кілька спецсимволів, а в запиті додатково кодується #, бо він починає фрагмент. Самі шістнадцяткові цифри регістронезалежні — %2F і %2f кодують той самий байт, хоч при нормалізації їх приводять до верхнього регістру.
Побічний ефект, важливий для QA: те саме кодування в безпеці працює як спосіб пройти повз фільтр. Перелік схем, які OWASP вимагає перебрати при пошуку , починається з URL-кодування й подвійного URL-кодування (%2e%2e%2f і ..%2f — це той самий ../), а дзеркальна вимога до застосунку — декодувати й канонізувати ввід перед валідацією і не декодувати двічі. Повний розбір — у розділі про безпеку; тут важливо, що «символ закодований» не означає «символ знешкоджений».
Кодувати компонент чи цілий URL
Задачі дві, і вони не взаємозамінні: закодувати одне динамічне значення — або привести до ладу вже зібраний URL. MDN формулює вибір прямо: encodeURI() призначений «to encode a URL as a whole, assuming it is already well-formed», а для окремого значення беруть encodeURIComponent(), «to avoid URL syntax characters in unwanted places». Механіка різниці ще прозоріша: encodeURI() навмисно не чіпає роздільники ; / ? : @ & = + $ , #, бо в цілому URL вони частина синтаксису — і саме тому він непридатний для значення.
Третій інструмент — URLSearchParams, серіалізатор формату application/x-www-form-urlencoded. І тут зʼявляється друга угода про пробіл.
| Інструмент | Пробіл | Роздільники (&, =, #) |
|---|---|---|
encodeURIComponent() | %20 | кодує |
encodeURI() | %20 | лишає як є |
URLSearchParams | + | кодує |
У режимі application/x-www-form-urlencoded пробіл кодується не як %20, а як +; відповідно справжній плюс записується там як %2B, а в шляху + — це літеральний плюс. Сам формат кодує список пар «імʼя-значення», а стандарт відверто називає його історичною , збереженою заради сумісності. Той самий ефект видно в обʼєкті URL: для шляху він використовує %20, а searchParams серіалізує пробіл як +.
Наша практика (не канон). Угоду про + знає парсер form-urlencoded, і звідси наш вивід — прямого твердження джерела не мають: decodeURIComponent про неї не знає й лишає плюс плюсом, тому значення запиту читають через URLSearchParams.
Звідси два робочі правила для тесту: порівнюй декодовані значення, а не сирі рядки (?q=search+results і ?q=search%20results логічно однакові, але як рядки не збігаються), і матчинг у будуй по розібраному URL — інакше результат залежить від того, хто і як закодував значення (Перехоплення й мокання мережі).
// асерт по розібраному URL, а не по рядку
const url = new URL(page.url());
expect(url.searchParams.get('q')).toBe('red & blue');
// матчер маршруту теж по компонентах
await page.route(
(u) => u.pathname === '/api/search' && u.searchParams.get('q') === 'red & blue',
(route) => route.fulfill({ body: '{"items":[]}', contentType: 'application/json' }),
);
Не-ASCII: кирилиця у шляху, у параметрах і в хості
У шляху й запиті правило вже описане: UTF-8, потім побайтово. На дроті /статті виглядає як /%D1%81%D1%82%D0%B0%D1%82%D1%82%D1%96, а ?q=Київ — як ?q=%D0%9A%D0%B8%D1%97%D0%B2. Адресний рядок показує людську форму, у логах і в HAR лежить закодована — це та сама адреса, а не дві різні.
Хост — окремий випадок, і поширене «у доменних іменах кодування не буває» тут неточне: граматика reg-name відсоткові октети дозволяє, саме так подають не-ASCII зареєстровані імена. Обмеження інше — відсоткове кодування в хості не використовують, якщо воно не подає послідовність UTF-8. Модальності теж різні, і їх легко переплутати: перетворення інтернаціоналізованого імені в IDNA перед пошуком імені — вимога (must), а віддавати перевагу IDNA над відсотковим кодуванням у самому URI — лише рекомендація з умовою (should, «if they wish to maximize interoperability»). Практичний наслідок один: браузерне API повертає форму xn--…, тож асерт «поточний хост дорівнює приклад.укр» провалиться, і це не баг застосунку.
Ресурс і ендпоінт: що стоїть за шляхом
За шляхом стоїть не файл на диску, а ресурс (resource) — ключова абстракція REST: ресурсом може бути будь-що, чому можна дати назву (документ, зображення, послуга в часі на кшталт «сьогоднішня погода в Лос-Анджелесі», колекція інших ресурсів, невіртуальний обʼєкт). Головне тут — що ресурс є концептуальним відображенням на набір сутностей, а не самою сутністю в конкретний момент: вміст за тією самою адресою може змінюватися, лишаючись тим самим ресурсом, а дії виконуються над представленням (representation) — послідовністю байтів плюс метадані. Висновок для тесту прямий: асерт спирається на інваріанти, а не на «те, що там лежало вчора».
(endpoint) — конкретна URL-адреса, за якою до ресурсу або колекції звертаються. Оскільки шлях містить ієрархічні дані, які разом із неієрархічним запитом ідентифікують ресурс, звідси й звична конвенція: колекція — множиною, ідентифікатор — окремим сегментом шляху (/api/v1/users/42), а фільтри, сортування й — параметрами запиту. Дію задає HTTP-метод, а не URL (REST API та формати даних). І дрібниця, що коштує годин: схема й хост нечутливі до регістру, а шлях і запит — чутливі, тож тест, який складає URL із даних, ловить 404 на рівному місці саме тут. Як будувати запити в API-клієнті — тема розділу про API.
Редиректи: що переїжджає разом з адресою
Редирект (redirect) ініціює сервер: відповідь із кодом 3xx і заголовком Location, у якому стоїть цільова адреса; сам клас означає, що запит нормальний, але для завершення потрібна ще одна дія. Постійні редиректи (301, 308) кажуть, що стару адресу більше не слід використовувати, і MDN додає, що й читалки оновлюють її в себе; RFC 9110 обережніший — сервер це лише пропонує, і «this suggestion is usually ignored». Тимчасові (302, 303, 307) канонічною лишають початкову адресу.
Найпрактичніше розрізнення — що стається з методом і тілом.
| Код | Метод і тіло при переході |
|---|---|
301, 302 | GET лишається GET; інші методи клієнт може підмінити на GET, і тіло тоді втрачається |
303 | Метод навмисно змінюється на GET, тіло втрачається (типово — після обробки форми) |
307, 308 | Метод і тіло не змінюються |
Модальність специфікації тут різна, і сильні кандидати це знають: нормативну заборону змінювати метод RFC 9110 прописує лише для 307, у розділі про 308 такого речення немає взагалі (норма живе в RFC 7538), а про тіло RFC 9110 не говорить у жодному з цих місць — його збереження документує саме MDN. Сам 308 — молодший код (June 2014), і специфікація застерігає, що він «might not be recognized everywhere».
Тепер про параметри. Location несе цільовий URL, тож рядок запиту переїде рівно тоді, коли сервер його туди переніс; якщо ні — параметри зникають, а в тесті це виглядає як «фільтр сам скинувся» або «після логіну повернуло не туди». Побачити це з коду тесту можна лише з вимкненим автопрямуванням: браузери й більшість HTTP-клієнтів ідуть за перенаправленнями самі, тож у коді видно фінальний 200, і зламана ланка ланцюга проходить мовчки (у браузерному тесті той самий ланцюг лишається видимим у панелі Network). Помітний виняток — curl, який без прапорця -L за редиректом не піде.
Наостанок три межі. 304 Not Modified формально теж 3xx, але це не переїзд: клієнт уже має валідну збережену копію, і сервер відсилає його до неї (Кешування); 300 Multiple Choices — ручний вибір із переліку. Кожен редирект коштує зайвого HTTP-запиту, а петля зазвичай означає проблему конфігурації сервера, і той, якщо здатен її помітити, віддає 500. І є ще два способи перенаправлення, яких у ланцюзі 3xx не буде взагалі: meta-refresh у HTML і зміна адреси через DOM (HTTP статус-коди).
Хост і порт: у що резолвиться адреса
Хост в URL — це імʼя, яке ще треба перетворити на адресу, а порт — номер, за яким на машині знаходять конкретний сервіс. Канон теми — глава DNS, IP, порти та мережа; тут лише те, без чого не читається сам URL.
IP-адреса веде до машини: IPv4 — це 32 біти чотирма десятковими октетами, IPv6 — 128 біт вісьмома групами до чотирьох шістнадцяткових цифр, у яких провідні нулі опускають, а один ланцюжок нульових груп згортають до ::. Саме тому IPv6-літерал в URL беруть у квадратні дужки: інакше двокрапки не відрізнити від роздільника порту. Порт — 16-бітове число, яким TCP ідентифікує сервіси застосунків і веде кілька окремих потоків між тими самими хостами; адреса разом із портом називається сокетом, а зʼєднання визначає пара сокетів. IANA ділить діапазон на well-known (0–1023), registered (1024–49151) і dynamic/ephemeral (49152–65535); типові номери — 80 (HTTP), 443 (HTTPS), 22 (SSH), 53 (DNS), 3306 (MySQL), 5432 (PostgreSQL), 6379 (Redis), 27017 (MongoDB), 8080 (dev-сервери). Для стандартних схем порт можна не писати — мається на увазі типовий.
Окремо — loopback, бо на ньому спотикаються щодня. 127.0.0.1 і ::1 означають «ця сама машина», і пастка з localhost не в резолві імені: обидві адреси за цим імʼям доступні, а резолверам рекомендовано (should) завжди повертати loopback. Пастка — у виборі сімейства адрес: двостековий клієнт шле AAAA-запит першим і свідомо віддає перевагу IPv6. Якщо застосунок слухає лише 127.0.0.1, а клієнт пішов на ::1, зʼєднання «незрозуміло чому» не встановлюється.
Походження: схема, хост і порт разом
Походження (origin) — це трійка «схема + хост + порт»: дві URL належать до одного походження тоді й лише тоді, коли всі три складові збігаються. Що в трійку не входить — шлях, параметри запиту й фрагмент; відмінність лише у шляху зберігає однакове походження. Логіка проста: походження описує, з ким браузер розмовляє, а не що саме він просить, а фрагмент до того ж на сервер не їде взагалі — тож частиною цієї трійки він бути не може за побудовою.
Далі — три наслідки, які й дають більшість «дивних» помилок:
- Піддомен — це інший хост.
app.example.comіapi.example.com— різні походження, попри спільний домен другого рівня. - Інша схема — інше походження.
http://site.comіhttps://site.comрізні, бо різниться перший член трійки. - Порт має значення.
https://site.comіhttps://site.com:443— одне походження (типовий порт опускають), аhttps://site.com:8443— уже інше.
Останній пункт і є причиною доброї частки болю на локальних стендах: фронт на http://localhost:3000 і API на http://localhost:4000 — це два різні походження з усіма правилами, хоча «це ж один localhost». Що саме забороняє ця межа й як сервер її легально послаблює — канон глави CORS і політика одного походження.
Змішаний контент: схема в URL як безпекова межа
(mixed content) — це коли сторінка завантажена по HTTPS, а частину ресурсів тягне по HTTP: значок у браузері обіцяє захист, а частина трафіку йде відкрито, і такі ресурси можна прочитати або підмінити. Сучасний поділ у специфікаціях — «оновлюваний» (upgradable) і «блокований» (blockable) вміст: перший браузер має автоматично перевести з HTTP на HTTPS, другий — заблокувати. Оновлюваний запит браузер переписує зі схеми http на https сам, і якщо ресурсу там немає, запит просто зазнає невдачі — відкоту на HTTP не буде. Блокується все, що не є оновлюваним: <script src>, <link href> зі стилями, <iframe src>, а також fetch() і XMLHttpRequest.
Дві тонкості ловлять навіть досвідчених. По-перше, до оновлюваних належать не «зображення» взагалі, а <img> із джерелом у src; те саме зображення через srcset чи <picture> стоїть у переліку блокованих. По-друге, запити, які інакше були б оновлені, блокуються, якщо хост URL — IP-адреса, а не доменне імʼя.
Звідси головна пастка тестування: локальний стенд цього дефекту не показує, бо локальні ресурси вважаються такими, що з захищених походжень, — і file:-URL, і вміст із loopback-адрес на кшталт http://127.0.0.1/ чи http://localhost/. Перевіряти змішаний контент треба на адресі з доменним імʼям. Слід у є завжди, бо браузер попереджає і про оновлення, і про блокування, а в Playwright консоль читають через подію console — це швидший діагноз, ніж півгодини правити . Симптом при цьому не завжди виглядає як «немає картинки»: заблокований може бути fetch, і тоді сторінка лишається без даних (HTTPS, TLS і безпека).
SPA і MPA: роль фрагмента й History API
Односторінковий застосунок (SPA, single-page application) завантажує лише один документ і далі оновлює вміст його body через JavaScript-API, коли треба показати інший контент; переходи «між сторінками» документ не перезавантажують — змінюються тільки URL і вміст.
Змінити адресу без походу на сервер можна двома способами, і обидва вже описані вище. Перший — фрагмент: він на сервер не їде за визначенням, тож усе після # браузер обробляє сам. Другий — History API: history.pushState() і history.replaceState() міняють URL без запиту на сервер. Важлива деталь для автотестів: подія popstate реагує лише на переміщення по історії, а самі виклики pushState/replaceState її не генерують — слухач popstate клієнтської навігації не побачить.
Наслідок для очікувань прямий. У багатосторінковому застосунку дочекатися завершення переходу просто: є навігаційні події й подія load. У SPA ці сигнали на переходах не спрацьовують, і чекати доводиться на появу контенту. А оскільки pushState міняє адресу миттєво й нікого не питає, асерт лише на URL підтверджує тільки те, що відпрацював роутер (Асинхронне завантаження: SPA/MPA, CSR/SSR, Практичні сценарії AQA: флак і синхронізація).
Типові помилки
- «Тест бачить
q=search+resultsзамістьsearch results— застосунок псує дані». Насправді+— це угода форматуapplication/x-www-form-urlencodedпро пробіл; порівнювати треба декодовані значення. - «Фрагмента немає в логах сервера — логування зламане». Насправді фрагмент відокремлюється до звернення до ресурсу й на сервер не їде взагалі.
- «
encodeURI()закодував рядок — значення безпечно класти в параметр». Насправді ця функція навмисно лишає роздільники& = ? #недоторканими, тож значення з&розірве запит; для значення потрібенencodeURIComponent(). - «Після
302нашPOSTдійшов з тілом». Насправді при301/302клієнт може підмінити метод наGETі втратити тіло; гарантію дають307/308, а303змінює метод навмисно. - «Локально змішаного контенту немає — і в проді не буде». Насправді
file:і loopback-адреси вважаються захищеними походженнями, тож локально дефект не відтворюється, а на стенді з IP замість домену такі запити ще й блокуються замість оновлення. - «
https://example.com:8443іhttps://example.com— та сама адреса, просто порт інший». Насправді порт входить у трійку походження, тож це два різні походження; а от:443дляhttpsдійсно опускають. - «Слеш наприкінці
baseURL— косметика». Насправді при резолвінгу відкидається все після останнього/у шляху бази, тож без слеша останній сегмент зникає й відносний перехід веде на рівень вище.
Підсумок
- URL — структура, а не рядок. Компоненти мають різні правила й різну чутливість до регістру, а рівність двох URL визначається збігом серіалізованих форм.
- На сервер їдуть authority, шлях і запит; фрагмент — ніколи. Це вирішує суперечки про логи, про фільтри на бекенді й про зависле очікування після кліку по якорю.
- Кодують байти, а не символи, і завжди перед складанням URL — спершу текст у UTF-8, потім кожен байт як
%XX. Кодування значення й кодування цілого URL при цьому різні операції:encodeURIComponent()— для динамічного значення,encodeURI()— для вже зібраної адреси,URLSearchParams— для пар запиту, і саме він пише пробіл як+. - Відносний URL читається лише разом із базою. Резолвінг відкидає все після останнього
/у шляху бази, а браузер і тест- беруть за базу різні речі: перший — URL документа, другий —baseURLз конфігурації. - Походження — це трійка «схема + хост + порт». Шлях, запит і фрагмент у неї не входять, зате інший порт чи піддомен роблять походження іншим.
Можливі питання
- «З яких частин складається URL і що з них надсилається на сервер?» Чекають перелік компонентів і чітке «фрагмент лишається на клієнті»; сильна відповідь додає чому — він відокремлюється ще до звернення до ресурсу.
- «Навіщо потрібне відсоткове кодування і які символи кодують?» Перевіряють розуміння ролі роздільників. Слабка відповідь — «щоб не було пробілів»; сильна — «щоб дані не конфліктували з роллю зарезервованих символів», з прикладом
&у значенні параметра. - «Чим
encodeURI()відрізняється відencodeURIComponent()?» Класика, на якій видно практику: перший навмисно не чіпає синтаксичні символи URI, тож придатний для цілого URL, а не для значення. - «Чому в одному місці пробіл
%20, а в іншому+?» Дивляться, чи знає кандидат про форматapplication/x-www-form-urlencoded: справжній плюс у ньому —%2B, аdecodeURIComponentпро цю угоду не знає. - «Що таке origin і чи входить у нього шлях?» Питання-місток до CORS. Відповідь: трійка «схема + хост + порт», шлях, параметри й фрагмент не входять; хороший кандидат наводить приклад із різними портами на локалхості.
- «У чому різниця між
301,302,303,307і308?» Перевіряють дві осі — постійність і збереження методу з тілом. Сильна відповідь називає303як навмисну зміну методу наGETі згадує, що редиректи перевіряють із вимкненим автопрямуванням.
Джерела
- RFC 3986 — URI Generic Syntax — граматика URI, склад authority, означення схеми, хоста, шляху, запиту й фрагмента, нормалізація регістру.
- WHATWG URL Standard — мета замістити RFC 3986/3987, типові порти особливих схем, рівність за серіалізованими формами.
- RFC 9110 — HTTP Semantics — рекомендована нижня межа підтримки довжини URI у 8000 октетів.
- Apache HTTP Server 2.4 — Core Features (mod_core) — дефолти
LimitRequestLineіLimitRequestBody. - nginx — ngx_http_core_module —
large_client_header_buffersі414на завеликий рядок запиту.
Що з URL їде на сервер, а що ні
- RFC 3986 — URI Generic Syntax — які компоненти клієнт надсилає серверу, відокремлення фрагмента до звернення до ресурсу.
- WHATWG URL Standard — коректний рядок URL як або відносний, або абсолютний, за яким може йти фрагмент.
- RFC 3986 — URI Generic Syntax — зʼєднання шляхів: усе після останнього
/у шляху бази відкидається. - Playwright — Test configuration — резолвінг відносного аргументу
gotoвідносноbaseURLз конфігурації. - MDN — Redirections in HTTP — редирект як додатковий крок одразу після навігації.
Відсоткове кодування: що кодують і чому
- RFC 3986 — URI Generic Syntax — механізм відсоткового кодування, незарезервовані й зарезервовані символи, UTF-8 як проміжний крок, регістр шістнадцяткових цифр.
- WHATWG URL Standard — набори символів для кодування залежно від компонента URL і запис цифр у верхньому регістрі.
- OWASP WSTG — 4.5.1 Testing Directory Traversal File Include (WSTG-ATHZ-01) — URL-кодування й подвійне кодування як схеми обходу фільтра.
- CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') — порядок кроків: декодування й канонізація перед валідацією.
Кодувати компонент чи цілий URL
- MDN — encodeURI() — перелік символів, які функція не екранує, і правило вибору між
encodeURIтаencodeURIComponent. - MDN — encodeURIComponent() — кодування окремого значення разом із роздільниками й пробілом як
%20. - WHATWG URL Standard — формат
application/x-www-form-urlencoded, пробіл як+, серіалізація пар «імʼя-значення». - MDN — URL (Web API) — різниця між кодуванням шляху й серіалізацією
searchParams. - Playwright — Network — матчинг запитів у перехопленні мережі по розібраному URL.
Не-ASCII: кирилиця у шляху, у параметрах і в хості
- RFC 3986 — URI Generic Syntax — UTF-8 перед побайтовим кодуванням, дозвіл відсоткових октетів у
reg-name, модальностіmustдля IDNA перед пошуком імені йshouldдля форми в URI. - WHATWG URL Standard — форма хоста, яку віддає браузерне API.
Ресурс і ендпоінт: що стоїть за шляхом
- Roy T. Fielding — Architectural Styles and the Design of Network-based Software Architectures, ch.5 (REST) — ресурс як усе, чому можна дати назву; ресурс як концептуальне відображення; представлення ресурсу.
- RFC 3986 — URI Generic Syntax — шлях і запит як те, що разом ідентифікує ресурс; чутливість компонентів до регістру.
- RFC 9110 — HTTP Semantics — дію над ресурсом задає метод запиту.
Редиректи: що переїжджає разом з адресою
- MDN — Redirections in HTTP —
Locationі клас3xx, постійні й тимчасові редиректи, поведінка методу й тіла для301/302/303/307/308,304і300, ціна редиректу, петля,meta-refresh і JS-редирект. - RFC 9110 — HTTP Semantics — семантика класу
3xx, «this suggestion is usually ignored» для301, нормативна заборона зміни методу лише для307, вік308. - curl — manual page —
curlбез-Lза редиректом не йде.
Хост і порт: у що резолвиться адреса
- RFC 791 — Internet Protocol — IPv4-адреса як 32 біти, чотири октети.
- RFC 4291 — IP Version 6 Addressing Architecture — 128-бітні адреси, запис групами,
::1як loopback. - RFC 9293 — Transmission Control Protocol (TCP) — порти як ідентифікатори сервісів і мультиплексування потоків, сокет і пара сокетів.
- RFC 6335 — IANA Procedures for Port Numbers — поділ на well-known, registered і dynamic/ephemeral діапазони.
- IANA — Service Name and Transport Protocol Port Number Registry — типові номери портів сервісів.
- RFC 9110 — HTTP Semantics — типовий порт схеми можна не вказувати, нестандартний — треба.
- RFC 1122 — Requirements for Internet Hosts — loopback як «ця сама машина».
- RFC 6761 — Special-Use Domain Names — гарантії резолву імені
localhostв обидві loopback-адреси. - RFC 8305 — Happy Eyeballs Version 2 —
AAAA-запит першим і перевага IPv6 у двостекового клієнта. - RFC 3986 — URI Generic Syntax — IP-літерал у квадратних дужках як форма хоста.
Походження: схема, хост і порт разом
- MDN — Same-origin policy — походження як трійка «схема/хост/порт», що в неї не входить, піддомен як інший хост.
- WHATWG URL Standard — порт як 16-бітове значення й типові порти особливих схем, які в серіалізації опускають.
- RFC 6454 — The Web Origin Concept — походження як межа безпеки, до якої входить порт.
Змішаний контент: схема в URL як безпекова межа
- MDN — Mixed content — поділ на оновлюваний і блокований вміст,
<img src>протиsrcset/<picture>, блокування при IP-адресі в хості, локальні ресурси як захищені походження, попередження в консолі. - Playwright — class Page — подія
consoleяк спосіб побачити попередження браузера з тесту.
SPA і MPA: роль фрагмента й History API
- MDN Glossary — SPA (Single-page application) — один документ і оновлення вмісту через JavaScript-API замість перезавантаження.
- MDN — History API —
pushState/replaceStateбез запиту на сервер і те, що вони не генеруютьpopstate. - MDN — Window: load event — подія завершення завантаження документа як сигнал навігації документа.
- Playwright — class Page — стани завантаження сторінки й очікування контенту замість навігаційних подій.
З яких частин складається URL і навіщо тестувальнику знати межі між ними?
Адреса — це набір компонентів із власними правилами, а не суцільний текст: схема (https), необовʼязковий userinfo перед @, хост, порт, шлях, рядок запиту після ? і фрагмент після #. Межі між компонентами задають символи-роздільники, тому будь-який &, ? чи # усередині даних треба закодувати, інакше він працюватиме як роздільник, а не як текст. Компоненти ще й поводяться по-різному: схема й хост нечутливі до регістру, шлях і запит — чутливі; внутрішню будову запиту («ключ=значення через &») специфікація не диктує взагалі, це домовленість, успадкована від HTML-форм. Практичний зиск від такого погляду прямий: майже кожен «дивний» баг локалізується за компонентом. 404 — дивимось шлях, зниклий фільтр — рядок запиту, «якийсь CORS» — трійку схема + хост + порт, а поведінку, якої не видно в логах бекенда, — фрагмент.
Що з адреси доходить до сервера, а що лишається у браузері?
До сервера їдуть хост із портом, шлях і рядок запиту — усе, що потрібно, щоб знайти адресата й описати, чого від нього хочуть. Фрагмент не їде ніколи: браузер відрізає все після # ще до того, як складе запит, і обробляє його сам. Звідси три висновки, які закривають половину суперечок у команді. Перший: фрагмента немає й не може бути в логах сервера, тож фільтрувати чи роутити за ним на бекенді неможливо. Другий: клік по якірному посиланню не породжує мережевого запиту, тому очікування відповіді після нього просто вичерпає — і це не , а неправильне очікування. Третій: userinfo у стартовому рядку запиту теж не побачите — пару user:pass@ браузер зазвичай перекладає в заголовок Authorization.
Навіщо потрібне відсоткове кодування і які символи кодують?
Щоб дані не конфліктували з синтаксисом адреси. Відсоткове кодування (percent-encoding, URL-encoding) записує один байт трьома символами: знак % і дві шістнадцяткові цифри. Кодувати треба у двох випадках: символ узагалі не дозволений у цій позиції або він зарезервований під роль роздільника, а нам потрібне його буквальне значення. Спокійно проходять без кодування лише незарезервовані символи: латинські літери, цифри й чотири знаки — дефіс, крапка, підкреслення, тильда. Класична демонстрація — пошуковий запит red & blue: без кодування амперсанд розріже параметр навпіл, і бекенд побачить два параметри замість одного значення, а користувач отримає результати лише по red. Слабка відповідь на співбесіді звучить як «щоб прибрати пробіли»; сильна називає роль роздільників і показує на прикладі, що кодувати значення треба до того, як його підставили в URL.
Чому одна кирилична літера у шляху перетворюється на дві трійки %XX?
Бо кодуються байти, а не символи, і крок тут два. Спочатку текст перетворюється на байти в UTF-8, а вже далі кожен окремий байт отримує власну трійку %XX. Пробіл — це один байт 0x20, тому й трійка одна: %20. А українська «і» в UTF-8 займає два байти, тож на дроті вона виглядає як %D1%96; слово Київ у параметрі розростається до %D0%9A%D0%B8%D1%97%D0%B2. Тому довжина закодованого рядка не збігається з кількістю символів, і жоден на «рівно N символів у URL» тут не працює. Регістр шістнадцяткових цифр значення не має (%2F і %2f — той самий байт), але канонічна форма — верхній, і саме до неї приводять при нормалізації.
Чому URL чутливий до регістру лише частково?
Схема й хост нечутливі до регістру за специфікацією і нормалізуються в нижній: HTTPS://Example.COM і https://example.com — одна адреса. Усі інші компоненти за замовчуванням чутливі — окреме послаблення може прописати хіба що сама схема, — тому /Catalog і /catalog формально адресують різні ресурси. Далі починається реальність: як поведеться шлях, вирішує конкретний сервер разом із файловою системою під ним. Linux розрізняє регістр, Windows із IIS та чимало CDN — часто ні, а частина проєктів просто вішає редирект з одного написання на інше. Для QA це означає дві речі. По-перше, тест, який складає URL із даних (назва категорії, слаг із ), ловить 404 на рівному місці саме через регістр. По-друге, поведінку треба перевіряти на кожному середовищі окремо: локально на Windows може «працювати», а на Linux-стенді — ні.
Чим encodeURI() відрізняється від encodeURIComponent()?
Це різні задачі, а не «сильніша й слабша» версії однієї. encodeURI() призначений для вже зібраної, синтаксично коректної адреси: роздільники ; / ? : @ & = + $ , # він свідомо лишає недоторканими, адже в готовій адресі вони несуть синтаксичну роль і ламати її не можна. encodeURIComponent() — для одного динамічного значення: він кодує і роздільники теж, саме щоб вони не потрапили в «небажані місця». Звідси й типова помилка: значення прогнали через encodeURI(), воно виглядає закодованим, але & усередині вцілів і розірвав рядок запиту на два параметри. Правило просте: збираєш URL із шматків — кожен шматок через encodeURIComponent(); маєш готовий URL і хочеш його підчистити — encodeURI(). Третій варіант, найзручніший на практиці, — узагалі не клеїти рядки руками, а користуватися URLSearchParams, який серіалізує пари сам.
Чому пробіл в одному місці записаний як %20, а в іншому як +?
Бо угод про запис пробілу дві. Загальне відсоткове кодування дає %20 — так пише пробіл і encodeURI(), і encodeURIComponent(), і саме таку форму ви побачите у шляху. Але формат application/x-www-form-urlencoded, у якому серіалізуються дані HTML-форм і працює URLSearchParams, кодує пробіл як +; відповідно справжній плюс у ньому доводиться писати як %2B. Стандарт про цей формат висловлюється відверто — це історична , збережена заради сумісності. Обидві угоди уживаються всередині одного обʼєкта URL: у шляху пробіл там стає %20, а той самий пробіл у searchParams — плюсом. Пастка при читанні дзеркальна: парсер form-urlencoded знає, що + означає пробіл, а decodeURIComponent про цю домовленість не знає й залишить плюс плюсом — тому значення запиту читають через URLSearchParams, а не ручним декодуванням.
Тест бачить ?q=search+results замість search results. Це баг продукту?
Швидше за все ні — це коректна серіалізація form-urlencoded, у якій пробіл записується плюсом. Помилка тут у самому тесті: він порівнює сирі рядки, а ?q=search+results і ?q=search%20results як рядки різні, хоча декодоване значення в обох випадках однакове. Правильний асерт працює з розібраним URL: створити new URL(page.url()) і перевірити searchParams.get('q'), тобто вже декодоване значення. Той самий принцип поширюється на : матчер маршруту треба будувати по компонентах (pathname, конкретний параметр), а не по підрядку в адресі — інакше результат тесту залежить від того, який саме інструмент і як закодував значення. Коротке формулювання для співбесіди: порівнюємо значення, а не їхнє написання.
Чому великі дані радять передавати тілом запиту, а не в рядку запиту?
Бо верхньої межі довжини URL специфікація не задає, зате всі реалізації мають свою — і вона на порядки менша за ліміт на тіло. HTTP лише рекомендує підтримувати URI щонайменше у 8000 октетів, і реальні дефолти того самого порядку: у Apache за це відповідає LimitRequestLine з дефолтом 8190 байтів, а nginx, якщо стартовий рядок не вміщується у виділені буфери, відповідає кодом 414 («Request-URI Too Large»). Для тіла ліміти інші: типовий дефолт вимірюється гігабайтом. Практичний наслідок для тестування: складний фільтр із сотнею ідентифікаторів або -рядок у параметрі — це запрограмований 414, який спрацює не одразу, а на «великих» даних, тобто найімовірніше вже на проді. Побачивши 414, не шукайте баг у застосунку — дивіться на довжину адреси й конфіг фронтового сервера.
Що таке origin і які частини адреси до нього входять?
(origin) — це трійка «схема + хост + порт»; дві адреси належать до одного походження лише тоді, коли збігаються всі три складові. Шлях, параметри запиту й фрагмент у трійку не входять: https://site.com/a і https://site.com/b/c?x=1 — те саме походження. Логіка тут не довільна: трійка відповідає на питання «з ким саме зʼєднується браузер», а не «що він просить», тож деталі запиту в неї не потрапляють — фрагмент до того ж навіть не залишає клієнта, тому й потрапити в цю трійку він фізично не міг би. Три наслідки, з яких і виростає більшість «дивних» помилок: піддомен вважається окремим хостом, тож app.example.com і api.example.com — різні походження; заміна http на https теж дає інше походження; порт значущий, з поправкою на те, що типовий для схеми номер у серіалізації опускають — https://site.com і https://site.com:443 збігаються, а https://site.com:8443 вибивається з трійки.
Фронтенд на http://localhost:3000 і мок-API на http://localhost:4000 — чому браузер вважає це різними походженнями?
Бо порт входить у трійку походження нарівні зі схемою й хостом. Спільне імʼя localhost тут нічого не вирішує: 3000 і 4000 — різні члени трійки, отже, перед нами дві різні межі, до яких застосовуються всі обмеження. Саме тому локальні стенди дають потік «раптових» CORS-помилок, яких немає на проді, де фронт і API стоять за одним доменом і портом. Діагностика на одну дію: вивести new URL(...).origin для обох адрес і порівняти трійки цілком, а не початки рядків — різниця стане видно одразу. Лікування ж лежить на боці сервера: що саме забороняє ця межа і як її легально послаблюють, розбирає окрема глава про CORS.
Чому IPv6-адресу в URL беруть у квадратні дужки і звідки береться «на localhost не конектиться»?
IPv6-адреса записується групами через двокрапку, а двокрапка в URL уже зайнята роздільником порту — тому літерал беруть у квадратні дужки, інакше парсер не знав би, де закінчується хост. Звідси й друга, значно частіша проблема. Імʼя localhost резолвиться в дві адреси — 127.0.0.1 (IPv4) і ::1 (IPv6), — і пастка не в резолві, а у виборі сімейства адрес: двостековий клієнт спершу питає AAAA і свідомо віддає перевагу IPv6. Якщо процес слухає лише IPv4-адресу, а клієнт постукав у ::1, зʼєднання не встановлюється без жодних видимих причин — при тому що curl чи браузер з іншими налаштуваннями можуть працювати нормально. Практична перевірка: спробувати явну адресу замість імені (http://127.0.0.1:3000) — якщо запрацювало, справа саме в сімействі адрес, а не в застосунку.
Чим відрізняються коди перенаправлення 301, 302, 303, 307 і 308?
Осей дві: постійність і доля методу з тілом. 301 і 308 — постійні: стару адресу далі використовувати не варто. 302, 303, 307 — тимчасові, канонічною лишається початкова адреса. Друга вісь практичніша. При 301 і 302 GET лишається GET, а от інші методи клієнт має право підмінити на GET, і тіло тоді просто зникає — це історична поведінка, яку специфікація визнає. 303 змінює метод на GET навмисно: типовий сценарій — після обробки форми відправити користувача на сторінку результату. 307 і 308 — явні коди без двозначності: метод і тіло не змінюються. Деталь, на якій видно сильного кандидата: нормативна заборона змінювати метод прописана лише для 307 (для 308 норма живе в окремому RFC), а збереження тіла документує вже MDN, а не текст HTTP-семантики. Плюс 308 — молодший код, і специфікація сама застерігає, що розпізнають його не всюди.
Після редиректу зникли параметри фільтра, а в тесті видно лише фінальний 200. Як це діагностувати?
Ключ у тому, що редирект несе цільову адресу в заголовку Location — і рядок запиту переїде рівно тоді, коли сервер сам його туди переніс. Не переніс — параметри зникли, і користувач бачить це як самовільно скинутий фільтр чи повернення не на ту сторінку після логіну. Побачити зламану ланку заважає автопрямування: і браузер, і типовий HTTP-клієнт проходять весь ланцюг самостійно, тому тест отримує вже фінальний 200, а проблемний крок пролітає непоміченим. Тому редиректи перевіряють з вимкненим автопрямуванням: у curl це поведінка за замовчуванням (без -L він за редиректом не піде), в API-клієнті Playwright — maxRedirects: 0, після чого асертять і код 3xx, і вміст Location. У браузерному тесті той самий ланцюг видно в мережевій панелі: кожен крок окремим рядком зі своїм статусом і своїм Location.
Відносний URL у page.goto() відкрив не ту сторінку. Де шукати причину?
Відносна адреса не має самостійного змісту — її значення обчислюється щодо базової, і в цьому обчисленні шлях бази обрізається по останній похилій рисці. Тому завершальний слеш у базі не декоративний: з базою https://stage.example.com/app/ відносний search дасть /app/search, а з базою https://stage.example.com/app — уже /search, бо сегмент app відрізано. Друга половина пастки специфічна для Playwright: базою для відносного аргументу page.goto() служить baseURL із конфігурації, а зовсім не сторінка, на якій ви зараз стоїте. Плутанина цих двох баз і дає класичне «перейшли не туди»: людина думає в термінах поточної сторінки, а фреймворк відлічує від baseURL. Діагностика на одну дію — вивести page.url() після переходу й порівняти з очікуваним; лікування — або поправити слеш у baseURL, або передавати абсолютну адресу там, де база неочевидна.
Чому асерт «хост дорівнює приклад.укр» падає, хоча в адресному рядку стоїть саме він?
Бо адресний рядок показує людську форму, а браузерне API віддає форму xn--…: інтернаціоналізоване доменне імʼя перед пошуком у DNS перетворюється за правилами IDNA, і саме цей результат бачить ваш тест. Тобто падіння асерту тут не баг застосунку, а неправильне очікування — порівнювати треба з punycode-формою або нормалізувати обидві сторони перед порівнянням. Побічно варто знати, що поширене «у доменних іменах кодування не буває» неточне: граматика зареєстрованого імені відсоткові октети дозволяє, обмеження полягає в тому, що таке кодування має подавати послідовність UTF-8. Різними є й модальності: перетворення в IDNA перед пошуком імені — вимога, а от віддавати перевагу формі IDNA всередині самого URI стандарт лише радить, та й то з умовою — якщо вам залежить на максимальній сумісності. Для шляху й параметрів цієї магії немає: там кирилиця просто стає UTF-8 байтами у відсотковому кодуванні.
Навіщо QA розрізняти ресурс, ендпоінт і представлення?
Ресурс (resource) — це будь-що, чому можна дати назву: документ, зображення, колекція, навіть «сьогоднішня погода в місті». Головне, що ресурс — концептуальне відображення на набір сутностей, а не сама сутність у конкретну мить: наповнення однієї й тієї ж адреси змінюється щодня, а ресурс при цьому лишається тим самим. Те, що реально їде по мережі, — представлення (representation): байти плюс метадані. (endpoint) — конкретна адреса, за якою до ресурсу звертаються; звідси й прижилась відома домовленість про іменування — назва колекції в множині, ідентифікатор окремим сегментом (/api/v1/users/42), а все, що звужує вибірку (фільтри, сортування, сторінка), виноситься в параметри. Дію при цьому задає метод запиту, а не адреса. Для тестів висновок практичний: асерт має спиратися на інваріанти контракту, а не на «те, що там лежало вчора» — інакше кожне оновлення даних робить червоною без жодного дефекту в продукті.
Що таке змішаний контент і чому на локальному стенді він не відтворюється?
(mixed content) — ситуація, у якій сам документ приїхав захищеним каналом, а окремі його ресурси підвантажуються звичайним HTTP: замок у браузері обіцяє захист, хоча частина трафіку йде відкрито й піддається читанню або підміні. Сучасний поділ — на «оновлюваний» (upgradable) і «блокований» (blockable): перший браузер сам переписує на HTTPS, і якщо ресурсу там немає, запит просто провалюється без відкоту на HTTP; другий блокує — це скрипти, стилі, iframe, а також fetch() і XMLHttpRequest. Дві тонкості, на яких спотикаються навіть досвідчені: до оновлюваних належать не «зображення взагалі», а картинка з джерелом у src, тоді як та сама картинка, підключена через srcset або picture, потрапляє до блокованих; і запит, який інакше був би оновлений, блокується, якщо в хості стоїть IP-адреса замість доменного імені. Локально дефекту не видно, бо file:-адреси й loopback (localhost, 127.0.0.1) трактуються як захищені походження — тому перевіряти змішаний контент треба на середовищі з доменним імʼям. Слід є завжди в : попередження браузер пише в обох випадках — і коли переписав схему, і коли , — а з тесту ці повідомлення читаються через подію console. Один погляд у консоль економить півгодини правок в ситуації, де насправді заблокований fetch, а екран через це порожній.
Чому в SPA асерт лише на URL не доводить, що перехід відбувся?
Тому що адресу в односторінковому застосунку можна змінити, взагалі не ходячи на сервер: history.pushState() і history.replaceState() переписують URL миттєво й нікого не питають, а фрагмент за визначенням обробляється клієнтом. Тобто зелений асерт на адресу підтверджує лише те, що відпрацював роутер, — а чи приїхали дані й чи відмалювався екран, він не каже нічого. Друга частина проблеми — сигнали очікування: у багатосторінковому застосунку перехід дає навігаційні події й load, а в SPA документ не перезавантажується, тож ці сигнали не спрацьовують і чекати доводиться на появу самого контенту. Є ще деталь, на якій ловляться при написанні хелперів: подія popstate реагує тільки на переміщення історією, а самі виклики pushState/replaceState її не породжують — слухач на popstate клієнтської навігації не побачить. Робочий висновок: URL перевіряємо як додатковий сигнал, а основний асерт вішаємо на видимий стан — конкретний елемент, заголовок, завантажений список.
Як відсоткове кодування використовують для обходу фільтрів і що з цього випливає для тестів безпеки?
Кодування — це спосіб записати той самий символ інакше, і фільтр, який дивиться на сирий рядок, цієї еквівалентності не бачить. Тому в переліку схем, які треба перебрати при пошуку , URL-кодування й подвійне URL-кодування стоять першими: %2e%2e%2f і ..%2f означають той самий ../, а %252e після одного декодування перетворюється на %2e і може пройти повз перевірку, яку виконали зарано. Дзеркальна вимога до застосунку формулюється як порядок кроків: спочатку декодувати й канонізувати ввід, потім валідувати — і не декодувати двічі. Для QA звідси два практичні пункти. Перший: негативні тести на шлях і параметри треба ганяти не тільки «чистим» вводом, а й закодованим у кількох варіантах. Другий, важливіший для щоденної роботи: «символ закодований» не дорівнює «символ знешкоджений» — кодування вирішує задачу синтаксису адреси, а не задачу безпеки.
Три кейси, у яких адреса відповідає на питання швидше за код застосунку: розбір URL на компоненти перед тим, як заводити баг; значення з амперсандом у пошуку — від складання запиту до й -матчера; і ланцюг редиректів, який мовчки губить параметри фільтра. Скрізь — що саме дивитися і який з цього висновок.
Кейс 1. Розібрати адресу перед тим, як писати баг
Прийшла скарга: «на стейджі сторінка каталогу не відкривається, а локально все добре». Перш ніж дивитися код, адресу варто розкласти на компоненти — це одна команда в DevTools і жодних припущень.
const u = new URL('https://user:pw@Shop.Example.com:8443/Catalog/42?color=red&q=red+%26+blue#reviews');
u.protocol; // 'https:'
u.hostname; // 'shop.example.com' ← хост нормалізовано в нижній регістр
u.port; // '8443'
u.pathname; // '/Catalog/42' ← регістр збережено як є
u.searchParams.get('q'); // 'red & blue' ← вже декодоване значення
u.hash; // '#reviews' ← на сервер не поїде
u.origin; // 'https://shop.example.com:8443'
Вісім рядків дають чотири факти одразу: хост нечутливий до регістру й приведений до нижнього, шлях — чутливий і залишився змішаним, searchParams повернув значення вже декодованим (разом із плюсом, який тут означав пробіл), а містить порт 8443, тобто це не те саме походження, що https://shop.example.com.
Далі симптом читається за компонентом:
| Симптом | Компонент | Типовий діагноз |
|---|---|---|
404 лише на Linux-стенді | шлях | регістр: /Catalog проти /catalog, а тест склав URL із даних фікстури |
| «фільтр застосувався частково» | запит | значення з & підставили без кодування, параметр розірвався надвоє |
414 на складному фільтрі | запит | адреса переросла ліміт стартового рядка, дані треба слати тілом |
| «в логах бекенда немає цієї сторінки» | фрагмент | усе після # браузер обробляє сам, у запит воно не потрапляє |
| «CORS на локалхості» | схема + хост + порт | різні порти фронта й API — різні походження |
| картинки немає лише на проді | схема ресурсу | змішаний контент: локально loopback вважається захищеним походженням |
Що дивитися і чому:
origin— перше, що варто вивести при будь-якому «якийсь CORS». У трійку не входять ні шлях, ні параметри, тому порівнювати треба самеu.originдвох адрес, а не їхні початки як рядків.hashу розборі — сигнал «мережі тут не буде». Якщо очікування в тесті чекає на відповідь після кліку, а змінюється лише фрагмент, тест зависне до , і винен буде не застосунок.- Різниця між
pathnameіhostnameза регістром — не курйоз, а робоче правило. Хост можна порівнювати без нормалізації, шлях — ні: будь-яка генерація URL зі слагів має проходити через ту саму нормалізацію, що й на бекенді. - Консоль браузера — безкоштовний детектор . Браузер попереджає і про автоматичне оновлення схеми, і про блокування; у Playwright ці повідомлення читаються через подію
console, і це швидший діагноз, ніж правити елемента, якого немає через заблокованийfetch.
Кейс 2. Пошук red & blue: закодувати, заасертити, замокати
Класичний баг: у полі пошуку red & blue, а застосунок шукає тільки red. Причина майже завжди одна — значення підставили в адресу без кодування, і амперсанд спрацював як роздільник. Спочатку подивимось, що дають три інструменти на одному й тому ж вводі:
encodeURI('red & blue'); // 'red%20&%20blue' ← & вцілів!
encodeURIComponent('red & blue'); // 'red%20%26%20blue'
new URLSearchParams({ q: 'red & blue' }).toString(); // 'q=red+%26+blue'
Перший рядок і є пасткою: функція призначена для вже зібраної адреси, тому синтаксичні символи вона свідомо не чіпає — і закодований на вигляд рядок все одно розірве запит. Тому URL не клеять із шматків, а будують:
const url = new URL('/search', 'https://shop.example.com');
url.searchParams.set('q', 'red & blue');
url.searchParams.set('color', 'red');
url.toString(); // https://shop.example.com/search?q=red+%26+blue&color=red
Асерт у тесті працює з розібраною адресою, а не з рядком — інакше він червонітиме через + проти %20 на цілком робочому продукті:
import { test, expect } from '@playwright/test';
test('пошук зберігає значення з амперсандом', async ({ page }) => {
await page.getByTestId('search-input').fill('red & blue');
await page.getByTestId('search-submit').click();
const current = new URL(page.url());
// порівнюємо декодоване значення, а не написання
expect(current.searchParams.get('q')).toBe('red & blue');
expect(current.pathname).toBe('/search');
});
Той самий принцип у : матчер будується по компонентах, тож тест не залежить від того, хто і як закодував значення.
await page.route(
(u) => u.pathname === '/api/search' && u.searchParams.get('q') === 'red & blue',
(route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [] }),
}),
);
Що дивитися і чому:
- Перевірте, що бекенд отримав ціле значення, а не половину. У мережевій панелі відкрийте запит і подивіться розібрані параметри: два записи
q=redіblue=замість одногоq— це прямий доказ, що значення не закодували перед складанням адреси. +у рядку запиту — не пошкодження даних. Це угода форматуapplication/x-www-form-urlencoded; справжній плюс у ньому пишуть як%2B. Асерт на сирий рядок адреси зловить цю різницю й збреше про баг.- Не декодуйте параметри вручну.
decodeURIComponentне знає, що+означає пробіл, і залишить його плюсом — читайте черезsearchParams. - Матчер по підрядку адреси — джерело .
url.includes('q=red & blue')не спрацює ніколи, аurl.includes('q=red')спрацює зайвий раз на запитіq=red. Компоненти однозначні, рядок — ні.
Кейс 3. Редирект, який губить фільтр
Симптом із багтрекера: «після переходу зі старої адреси каталогу фільтр скидається». У коді тесту при цьому видно тільки фінальний 200, бо і браузер, і більшість HTTP-клієнтів ідуть за перенаправленнями самі. Щоб побачити зламану ланку, автопрямування вимикають.
# curl без -L за редиректом не піде — покаже саме перший крок
curl -sS -o /dev/null -D - 'https://shop.example.com/catalog?color=red&size=xl'
HTTP/1.1 301 Moved Permanently
Location: https://shop.example.com/catalog/shoes
Діагноз готовий: Location несе цільову адресу, і рядок запиту переїде рівно тоді, коли сервер сам його туди переніс. Тут не переніс — тому фільтр «скидається». Той самий контракт закріплюють тестом:
import { test, expect } from '@playwright/test';
test('редирект зі старої адреси зберігає параметри фільтра', async ({ request }) => {
const res = await request.get('https://shop.example.com/catalog?color=red&size=xl', {
maxRedirects: 0, // без цього побачимо лише фінальний 200
});
expect(res.status()).toBe(301);
// Location може бути відносним — резолвимо відносно початкової адреси
const target = new URL(res.headers()['location'], 'https://shop.example.com');
expect(target.pathname).toBe('/catalog/shoes');
expect(target.searchParams.get('color')).toBe('red');
expect(target.searchParams.get('size')).toBe('xl');
});
Для потоків, де важливий метод, перевіряють ще й код перенаправлення: якщо форма шлеться POST-ом, а сервер відповідає 301 чи 302, клієнт має право підмінити метод на GET — і тіло зникне разом із даними форми.
test('перенаправлення POST не втрачає тіло', async ({ request }) => {
const res = await request.post('https://shop.example.com/api/orders', {
data: { sku: 'A-1', qty: 2 },
maxRedirects: 0,
});
// 307/308 зберігають метод і тіло; 301/302 тут були б дефектом контракту
expect([307, 308]).toContain(res.status());
});
Що дивитися і чому:
- Спочатку код, потім
Location. Код каже, що станеться з методом і тілом,Location— куди саме поїде клієнт і що з параметрів переїде. Два асерти разом описують редирект повністю; окремо кожен з них половинчастий. Locationбуває відносним. Резолвіть його черезnew URL(location, base), інакше тест зламається на першому ж сервері, який віддає шлях без схеми й хоста.- У браузерному тесті ланцюг видно в мережевій панелі. Кожен крок — окремий рядок зі своїм статусом; якщо кроків більше двох-трьох, це вже й питання продуктивності, бо кожен редирект коштує повного HTTP-запиту.
- Не асертьте проміжний URL у UI-тесті. Він існує мілісекунди, і очікування на нього — готовий : перевіряйте фінальну адресу або видимий стан сторінки, а сам ланцюг закривайте API-тестом, як вище.
Структура URL і що з неї їде на сервер
- Можу назвати компоненти адреси (схема, userinfo, хост, порт, шлях, запит, фрагмент) і сказати, який роздільник відкриває кожен із них.
- Знаю, що на сервер їдуть хост із портом, шлях і рядок запиту, а фрагмент не їде ніколи — тому його немає в логах, за ним не можна роутити на бекенді, і очікування мережевої відповіді після кліку по якорю вичерпає . Дзеркально: userinfo (
user:pass@) у стартовому рядку теж не зʼявляється — браузер зазвичай перекладає його в заголовокAuthorization. - Знаю, які компоненти нечутливі до регістру (схема й хост) і які чутливі (шлях, запит), а також памʼятаю, що реальну поведінку шляху вирішує конкретний сервер із його файловою системою.
- Розумію, чому великі дані передають тілом, а не в рядку запиту: рекомендована нижня межа підтримки URI — 8000 октетів, на завеликий стартовий рядок сервер віддає
414, а ліміт на тіло на порядки щедріший.
Відсоткове кодування
- Можу пояснити, навіщо кодують: щоб дані не конфліктували з роллю зарезервованих символів-роздільників, а не «щоб прибрати пробіли» — і що кодувати значення треба до складання URL, інакше
&усередині нього розірве параметр навпіл. - Розумію, що кодуються байти, а не символи: текст спочатку переводиться в UTF-8, і трійку
%XXотримує кожен байт окремо — тому одна кирилична літера дає дві трійки. Набір символів для кодування залежить від компонента, а регістр шістнадцяткових цифр значення не має (канонічна форма — верхній). - Розумію, що закодований символ не означає знешкоджений: URL-кодування й подвійне кодування — відомі схеми обходу фільтрів, а застосунок має декодувати й канонізувати ввід перед валідацією.
Кодування значення проти кодування цілого URL
- Знаю різницю
encodeURI()vsencodeURIComponent(): перший навмисно лишає роздільники недоторканими й придатний для вже зібраної адреси, другий кодує їх і призначений для одного значення. - Можу пояснити, чому пробіл буває і
%20, і+: друга форма — угода форматуapplication/x-www-form-urlencoded, у якому справжній плюс записується як%2B;decodeURIComponentпро цю угоду не знає, тому значення запиту читають черезURLSearchParams. - Знаю правило : порівнюємо декодовані значення, а не сирі рядки, і будуємо матчер перехоплення по розібраному URL, а не по підрядку.
Абсолютні й відносні URL
- Памʼятаю правило резолвінгу: від шляху бази відкидається все після останнього
/, тому база зі слешем і без нього дають різні результати, аbaseURLтест- й URL документа — дві різні бази.
Не-ASCII, хост і порт
- Розумію, чому асерт «хост дорівнює
приклад.укр» падає: браузерне API віддає формуxn--…після перетворення IDNA, і це не дефект застосунку. Знаю й різні модальності: перетворення імені перед пошуком стандарт вимагає, а вибір цієї форми всередині URI лише радить. - Можу пояснити, чому IPv6-літерал в URL беруть у квадратні дужки, і памʼятаю, що типовий порт схеми в серіалізації опускають.
- Знаю пастку loopback: імʼя
localhostведе і на127.0.0.1, і на::1, а двостековий клієнт віддає перевагу IPv6 — тому сервіс, що слухає лише IPv4, «незрозуміло чому» недоступний.
Походження й межі безпеки
- Знаю, що — це трійка «схема + хост + порт», шлях, параметри й фрагмент до неї не входять, а
localhost:3000іlocalhost:4000— уже два різні походження (тоді якhttps://site.comіhttps://site.com:443— одне). - Можу пояснити, що таке і чим оновлюваний вміст відрізняється від блокованого (картинка через
srcоновлюється, черезsrcset— блокується), а також чому локально він не відтворюється:file:і loopback вважаються захищеними походженнями, а при IP замість домену запит блокується замість оновлення.
Редиректи
- Знаю різницю за двома осями:
301/308постійні,302/303/307тимчасові;301/302можуть підмінити метод наGETі втратити тіло,303змінює його навмисно,307/308зберігають метод і тіло. І не плутаю304 Not Modifiedіз переїздом — це відсилання до вже наявної копії. - Розумію, що рядок запиту переїжджає лише тоді, коли сервер сам переніс його в
Location, і що редиректи перевіряють із вимкненим автопрямуванням (maxRedirects: 0,curlбез-L) — інакше в коді видно лише фінальний200.
SPA, фрагмент і навігація в тестах
- Розумію, що адресу можна змінити без походу на сервер — фрагментом або через
pushState/replaceState, — і щоpopstateспрацьовує лише на переміщення історією, а самі ці виклики її не породжують. - Можу пояснити, чому асерт лише на URL у SPA доводить тільки роботу роутера, і чому чекати треба на видимий стан, а не на навігаційні події.
Квіз
Перед стартом
- Питань: 17
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
З яких компонентів складається URL?