← Valtive QA Quiz quiz.valtive.io/notes/api valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA інженерів, які тестують API руками й у Postman і готуються говорити про це на співбесіді. Підходить, якщо ти вже надсилала запити й хочеш скласти це в систему — від того, що летить у HTTP-запиті, до пагінації, лімітів і CORS у проді.
For QA engineers who test APIs by hand and in Postman and are getting ready to talk about it in an interview. It fits if you already send requests and want to put that into a system — from what actually goes out in an HTTP request to pagination, rate limits and CORS in production.
Як користуватисьHow to use it
Фільтруй питання по рівню — Junior, Middle або Senior. Відкривай картку, читай концепт і код. Потім закрий код і прочитай вголос з блоку 🗣 Як читати вголос — це тренує відповідь в живій розмові, а не тільки розуміння очима.
Filter the questions by level — Junior, Middle or Senior. Open a card, read the concept and the code. Then hide the code and say the answer out loud from the 🗣 How to say it out loud block — that trains the answer as a live conversation, not just recognition on the page.
Три базові якорі — з них випливає більшість відповідейThree anchors — most of the answers follow from them
ЯКІР 1 · КонтрактANCHOR 1 · The contract
Запит і відповідь — це домовленість. Метод, URL, заголовки й тіло кожен несуть своє; переплутані ролі — це баг, а не стиль.
A request and a response are an agreement. Method, URL, headers and body each carry their own part; swapping those roles is a bug, not a matter of style.
ЯКІР 2 · СтанANCHOR 2 · State
Токен, змінна колекції, курсор пагінації, ETag. Майже кожен флейк в API-тестах — це стан, який хтось вважав константою.
A token, a collection variable, a pagination cursor, an ETag. Almost every flake in API tests is state that somebody treated as a constant.
ЯКІР 3 · ДоказANCHOR 3 · Proof
Зелений прогін без асертів не доводить нічого. Перевіряй код, заголовок і тіло — і те, що сервер відмовив, теж.
A green run with no assertions proves nothing. Check the status code, the headers and the body — and check that the server refuses when it should.
—
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
HTTP
Postman
📚 4 РОЗДІЛИ📚 4 SECTIONS
—
Junior
—
Middle
—
Senior
0 / 0 · розгорни картку, щоб зарахувати їїopen a card to count it
▶ 1 7
1 Що таке API і що насправді летить від клієнта до сервера в одному запиті? What is an API, and what actually travels from client to server in one request?
ОсновиBasicsJunior▶

API — це домовленість між двома програмами: одна вміє щось робити, друга просить це зробити. Веб-API поверх HTTP — найпоширеніший випадок: клієнт (мобільний застосунок, фронтенд, інший сервіс, Postman) надсилає запит, сервер повертає відповідь. Жодного «інтерфейсу» в сенсі кнопок тут немає — тільки текст за домовленим форматом.

An API is an agreement between two programs: one knows how to do something, the other asks it to. A web API over HTTP is the most common case — a client (a mobile app, a frontend, another service, Postman) sends a request and the server returns a response. There is no “interface” here in the sense of buttons — only text in an agreed format.

Тестувальнику найважливіше зрозуміти одну річ: запит — це чотири блоки, і кожен з них ламається по-своєму. Стартовий рядок (метод + шлях), заголовки, порожній рядок, тіло. Відповідь має ту саму будову: рядок статусу, заголовки, порожній рядок, тіло.

The one thing a tester needs to get straight: a request is four blocks, and each of them breaks in its own way. The start line (method + path), the headers, a blank line, the body. The response has the same shape: the status line, headers, a blank line, the body.

Друга важлива річ — HTTP не має пам'яті. Сервер не пам'ятає, що ти надсилала хвилину тому. Кожен запит везе з собою все, що потрібно для його обробки: хто ти (заголовок авторизації), що саме ти хочеш (шлях і параметри), у якому форматі дані (Content-Type). Якщо запит працює в Postman і не працює у фронтенді — у більшості випадків різниця саме в цьому «всьому, що потрібно»: загубився заголовок, кука не долетіла, тіло пішло не в тому форматі.

The second important thing — HTTP has no memory. The server does not remember what you sent a minute ago. Every request carries everything needed to handle it: who you are (the authorization header), what exactly you want (the path and the parameters), what format the data is in (Content-Type). When a request works in Postman and not in the frontend, the difference is almost always inside that “everything needed”: a header went missing, a cookie never arrived, the body went out in the wrong format.

Чому це перше питання на співбесіді: людина, яка бачить запит як «натиснув Send у Postman», не вміє локалізувати баг. Людина, яка бачить чотири блоки, одразу питає: а що в заголовках? а який був статус? а що прийшло в тілі? Це різниця між «не працює» і баг-репортом.

Why this is the first question in an interview: someone who sees a request as “I pressed Send in Postman” cannot localise a bug. Someone who sees four blocks asks straight away: what was in the headers? what status came back? what was in the body? That is the difference between “it doesn't work” and a bug report.

ASCII — один round-trip цілком, як його бачить сніфер
ASCII — one full round-trip, the way a sniffer sees it
КЛІЄНТ                                                СЕРВЕР
  │                                                      │
  │  POST /api/v1/orders HTTP/1.1        ← стартовий рядок│
  │  Host: shop.example.com              ┐                │
  │  Authorization: Bearer eyJhbGciOi... │ заголовки      │
  │  Content-Type: application/json      │                │
  │  Idempotency-Key: 7c1f-…-a90b        ┘                │
  │                                      ← порожній рядок │
  │  {"sku":"A-17","qty":2}               ← тіло          │
  │  ────────────────────────────────────────────────▶    │
  │                                                       │  валідація
  │                                                       │  запис у БД
  │                                                       │  виклик платіжки
  │  HTTP/1.1 201 Created                ← рядок статусу   │
  │  Location: /api/v1/orders/8123       ┐                 │
  │  Content-Type: application/json      │ заголовки       │
  │  X-Request-Id: 3f9c…                 ┘                 │
  │                                      ← порожній рядок  │
  │  {"id":8123,"status":"pending"}       ← тіло           │
  │  ◀────────────────────────────────────────────────     │
  │                                                        │

Що з цього дивиться тестувальник і в якому порядку:
  1. статус          201 — чи той, що обіцяє контракт?
  2. заголовки       Location є? Content-Type правдивий?
  3. тіло            id прийшов? схема збіглася?
  4. побічний ефект  замовлення реально з'явилось у БД / в адмінці?
CLIENT                                                SERVER
  │                                                      │
  │  POST /api/v1/orders HTTP/1.1        ← start line     │
  │  Host: shop.example.com              ┐                │
  │  Authorization: Bearer eyJhbGciOi... │ headers        │
  │  Content-Type: application/json      │                │
  │  Idempotency-Key: 7c1f-…-a90b        ┘                │
  │                                      ← blank line     │
  │  {"sku":"A-17","qty":2}               ← body          │
  │  ────────────────────────────────────────────────▶    │
  │                                                       │  validation
  │                                                       │  write to DB
  │                                                       │  call payment provider
  │  HTTP/1.1 201 Created                ← status line     │
  │  Location: /api/v1/orders/8123       ┐                 │
  │  Content-Type: application/json      │ headers         │
  │  X-Request-Id: 3f9c…                 ┘                 │
  │                                      ← blank line      │
  │  {"id":8123,"status":"pending"}       ← body           │
  │  ◀────────────────────────────────────────────────     │
  │                                                        │

What a tester looks at here, and in what order:
  1. status          201 — is it what the contract promises?
  2. headers         Is Location there? Is Content-Type honest?
  3. body            did the id arrive? does the schema match?
  4. side effect     did the order really appear in the DB / admin panel?
HTTP — той самий запит у сирому вигляді і його переклад у curl
HTTP — the same request raw, and its curl equivalent
# Сирий HTTP — саме це йде по сокету
POST /api/v1/orders HTTP/1.1
Host: shop.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Content-Type: application/json
Accept: application/json

{"sku": "A-17", "qty": 2}

# Те саме через curl — корисно вміти, бо це те, що просять прикласти до бага
curl -i -X POST 'https://shop.example.com/api/v1/orders' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…' \
  -H 'Content-Type: application/json' \
  -d '{"sku":"A-17","qty":2}'
#  -i  → показати заголовки відповіді, а не тільки тіло
#       без -i ти бачиш половину відповіді і губиш статус
# Raw HTTP — this is exactly what goes over the socket
POST /api/v1/orders HTTP/1.1
Host: shop.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Content-Type: application/json
Accept: application/json

{"sku": "A-17", "qty": 2}

# The same via curl — worth knowing, because this is what people ask you to attach to a bug
curl -i -X POST 'https://shop.example.com/api/v1/orders' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…' \
  -H 'Content-Type: application/json' \
  -d '{"sku":"A-17","qty":2}'
#  -i  → show the response headers, not just the body  
#       without -i you see half the response and lose the status
🔧 HTTP / curl патерни в цьому прикладі METHOD /path HTTP/1.1 — стартовий рядок, три частини через пробіл; Header-Name: value — по одному заголовку на рядок, регістр імені не має значення; порожній рядок обов'язковий — саме він відділяє заголовки від тіла; curl -i — надрукувати заголовки відповіді разом із тілом; -H додає один заголовок, -d передає тіло і автоматично робить метод POST, якщо не вказати -X.
🔧 HTTP / curl patterns in this example METHOD /path HTTP/1.1 — the start line, three parts separated by spaces; Header-Name: value — one header per line, the case of the name is irrelevant; the blank line is mandatory — it is what separates headers from body; curl -i — print the response headers along with the body; -H adds one header, -d sends a body and automatically switches the method to POST unless you pass -X.
🗣 Як читати вголос API — це домовленість між двома програмами: клієнт надсилає запит, сервер повертає відповідь, обидва погодились на формат заздалегідь. Технічно і запит, і відповідь складаються з чотирьох блоків: стартовий рядок, заголовки, порожній рядок і тіло. У запиті стартовий рядок — це метод і шлях, у відповіді — код статусу. Ключова властивість HTTP у тому, що він не має стану: сервер нічого не пам'ятає між запитами, тому кожен запит везе з собою все потрібне — токен в Authorization, формат у Content-Type, параметри в URL. Саме тому найчастіша причина «в Postman працює, а у фронтенді ні» — це не логіка, а загублений заголовок або кука, яка не долетіла. Коли я перевіряю відповідь, я дивлюсь чотири речі по черзі: код статусу, заголовки, тіло і побічний ефект — чи справді запис з'явився в базі, а не тільки в JSON-відповіді.
🗣 How to say it out loud An API is an agreement between two programs: the client sends a request, the server sends back a response, and both sides agreed on the format up front. Technically both the request and the response are four blocks — a start line, headers, a blank line and a body. In the request the start line is the method and the path; in the response it's the status code. The key property of HTTP is that it's stateless: the server remembers nothing between requests, so every request has to carry everything it needs — the token in Authorization, the format in Content-Type, the parameters in the URL. That's why the classic “works in Postman, doesn't work in the frontend” is almost never logic, it's a header that went missing or a cookie that never arrived. And when I check a response I look at four things in order: the status code, the headers, the body, and then the side effect — whether the record actually landed in the database and not only in the JSON.
2 GET, POST, PUT, PATCH, DELETE — що кожен метод обіцяє і як обрати потрібний? GET, POST, PUT, PATCH, DELETE — what does each method promise and how do you pick one?
ОсновиBasicsJunior▶

Метод — це обіцянка серверу і всім, хто стоїть між клієнтом і сервером: браузеру, проксі, CDN, retry-логіці. Технічно сервер може реалізувати під GET що завгодно, включно з видаленням даних. Саме тому це й баг: обіцянку порушено, а всі навколо їй досі вірять.

A method is a promise to the server and to everyone standing between the client and the server: the browser, proxies, CDNs, retry logic. Technically the server can implement anything at all under GET, deleting data included. That is exactly why it is a bug: the promise has been broken and everyone around still believes it.

Коротко, що обіцяє кожен:

Briefly, what each of them promises:

GET — «я тільки читаю». POST — «створи новий ресурс або виконай дію, результат щоразу новий». PUT — «поклади ресурс за цією адресою цілком, ось він». PATCH — «зміни ось цю частину, решту не чіпай». DELETE — «прибери ресурс за цією адресою».

GET — “I'm only reading.” POST — “create a new resource or run an action, the result is new every time.” PUT — “put the resource at this address in full, here it is.” PATCH — “change this part, leave the rest alone.” DELETE — “remove the resource at this address.”

Практичне правило вибору, яке працює майже завжди: питаєш, чи змінюється стан. Ні — GET. Так, і адреса нового об'єкта наперед невідома (її вигадає сервер) — POST. Так, і адреса відома, а ти надсилаєш об'єкт повністю — PUT. Так, адреса відома, надсилаєш два поля з двадцяти — PATCH.

The practical rule for choosing, and it works nearly always: ask whether state changes. No — GET. Yes, and the address of the new object isn't known in advance (the server will invent it) — POST. Yes, the address is known and you're sending the object in full — PUT. Yes, the address is known and you're sending two fields out of twenty — PATCH.

Окремо варто знати HEAD і OPTIONS, бо вони вилазять у тестуванні: HEAD — це GET без тіла, зручно перевірити розмір файлу або що посилання живе; OPTIONS — це preflight, який браузер надсилає сам перед крос-доменним запитом, і чимала частина «CORS не працює» — це саме він.

HEAD and OPTIONS are worth knowing separately, because they surface in testing: HEAD is a GET without a body, handy for checking a file size or that a link is still alive; OPTIONS is the preflight the browser sends by itself before a cross-origin request, and a fair share of “CORS doesn't work” is exactly that.

ASCII — таблиця обіцянок, яку варто тримати в голові
ASCII — the table of promises worth keeping in your head
метод    що обіцяє                          safe   ідемпот.  типовий успіх
──────────────────────────────────────────────────────────────────────────
GET      нічого не змінює                    так      так      200 + тіло
HEAD     те саме, але без тіла               так      так      200 без тіла
POST     створює / запускає дію              ні       ні       201 + Location
PUT      кладе ресурс ЦІЛКОМ                 ні       так      200 або 204
PATCH    змінює ЧАСТИНУ                      ні      залежить  200 або 204
DELETE   прибирає ресурс                     ні       так      204 або 200
OPTIONS  що тут взагалі можна                так      так      204 + Allow

safe      = не змінює стан → можна викликати скільки завгодно і будь-коли
ідемпот.  = 1 виклик і 5 однакових викликів дають ОДИН І ТОЙ САМИЙ стан

«залежить» у PATCH — це не ухиляння від відповіді:
  PATCH {"qty": 3}                → ідемпотентний (тричі → все одно 3)
  PATCH {"op":"increment","by":1} → НЕ ідемпотентний (тричі → +3)
method   what it promises                   safe   idempot.  typical success
──────────────────────────────────────────────────────────────────────────
GET      changes nothing                     yes      yes      200 + body
HEAD     same, but no body                   yes      yes      200 without body
POST     creates / starts an action          no       no       201 + Location
PUT      puts the resource WHOLE             no       yes      200 or 204
PATCH    changes a PART                      no       depends  200 or 204
DELETE   removes the resource                no       yes      204 or 200
OPTIONS  what is allowed here                yes      yes      204 + Allow

safe      = does not change state → can be called any number of times, any time
idempot.  = 1 call and 5 identical calls produce ONE AND THE SAME state

"depends" for PATCH is not dodging the question:
  PATCH {"qty": 3}                → idempotent (three times → still 3)
  PATCH {"op":"increment","by":1} → NOT idempotent (three times → +3)
HTTP — той самий ресурс п'ятьма методами
HTTP — the same resource through five methods
// Прочитати список — колекція, множина
GET    /api/v1/orders?status=paid&limit=50        → 200 [ {...}, {...} ]

// Прочитати один — адреса конкретного елемента
GET    /api/v1/orders/8123                        → 200 { ... }

// Створити — адресу вигадає сервер і поверне в Location
POST   /api/v1/orders                             → 201, Location: /orders/8124
       {"sku":"A-17", "qty":2}                    двічі → ДВА замовлення

// Замінити цілком — усе, чого немає в тілі, має зникнути або стати дефолтом
PUT    /api/v1/orders/8123
       {"sku":"A-17", "qty":5, "note":null}    → 200, двічі → той самий стан

// Змінити частину — решта полів лишається як була
PATCH  /api/v1/orders/8123
       {"qty":5}                                → 200

// Прибрати
DELETE /api/v1/orders/8123                        → 204 без тіла
DELETE /api/v1/orders/8123                        → 404 — і це НОРМАЛЬНО:
                                                     стан той самий, змінилась
                                                     лише відповідь
// Read a list — a collection, plural  
GET    /api/v1/orders?status=paid&limit=50        → 200 [ {...}, {...} ]

// Read one — the address of a specific item   
GET    /api/v1/orders/8123                        → 200 { ... }

// Create — the server picks the address and returns it in Location
POST   /api/v1/orders                             → 201, Location: /orders/8124
       {"sku":"A-17", "qty":2}                    twice → TWO orders    

// Replace entirely — anything missing from the body must disappear or reset to default
PUT    /api/v1/orders/8123
       {"sku":"A-17", "qty":5, "note":null}    → 200, twice → same state    

// Change a part — the other fields stay as they were
PATCH  /api/v1/orders/8123
       {"qty":5}                                → 200

// Remove  
DELETE /api/v1/orders/8123                        → 204 with no body
DELETE /api/v1/orders/8123                        → 404 — and that is FINE:
                                                     the state is the same, only
                                                     the response changed
💡 Червоний прапорець на код-рев'ю і в API-доках: дієслово всередині шляху — /getUser, /users/17/delete, /order/create. Метод уже містить дієслово, шлях має містити іменник. Це не естетика: GET /users/17/delete ламає обіцянку safe, і рано чи пізно в цю адресу зайде краулер або прев'ю-бот месенджера.
💡 A red flag in code review and in API docs: a verb inside the path — /getUser, /users/17/delete, /order/create. The method already carries the verb, the path should carry a noun. This is not aesthetics: GET /users/17/delete breaks the safe promise, and sooner or later a crawler or a messenger's preview bot will walk into that address.
🗣 Як читати вголос HTTP-метод — це обіцянка не тільки серверу, а всій інфраструктурі між клієнтом і сервером: браузеру, проксі, CDN, retry-політикам. GET обіцяє, що нічого не змінюється, POST — що створюється щось нове, PUT — що ресурс кладеться за відомою адресою цілком, PATCH — що змінюється тільки частина, DELETE — що ресурс прибирається. Вибираю я так: якщо стан не змінюється — GET; якщо змінюється і адресу нового об'єкта вигадає сервер — POST; якщо адреса вже відома і я надсилаю об'єкт повністю — PUT; якщо надсилаю два поля з двадцяти — PATCH. Дві властивості, які я перевіряю окремо, — safe і ідемпотентність: safe означає «не змінює стан взагалі», ідемпотентність — «п'ять однакових запитів залишають той самий стан, що й один». PATCH тут особливий: він ідемпотентний, якщо описує кінцеве значення, і неідемпотентний, якщо описує операцію на кшталт «збільш на одиницю». І окремо тримаю в голові, що DELETE на вже видалений ресурс може повернути 404 — це не баг, стан однаковий, змінилась лише відповідь.
🗣 How to say it out loud An HTTP method is a promise, and not only to the server — it's a promise to the whole infrastructure between the client and the server: the browser, proxies, CDNs, retry policies. GET promises that nothing changes, POST that something new gets created, PUT that a resource is placed at a known address in full, PATCH that only a part changes, DELETE that the resource goes away. I pick like this: if state doesn't change, GET; if it changes and the server invents the address of the new object, POST; if the address is already known and I'm sending the whole object, PUT; if I'm sending two fields out of twenty, PATCH. Two properties I check separately are safe and idempotent — safe means it doesn't change state at all, idempotent means five identical requests leave the same state as one. PATCH is the interesting one there: it's idempotent when it describes a final value, and it isn't when it describes an operation like “increment by one.” And I keep in mind that a DELETE on an already deleted resource can come back 404 — that's not a bug, the state is identical, only the response changed.
3 Що належить в URL, що в заголовок, а що в тіло — і що ламається, коли їх переплутали? What belongs in the URL, what in a header, what in the body — and what breaks when they get mixed up?
ОсновиBasicsMiddle▶

Розподіл простий, якщо думати про час життя і видимість кожної частини, а не про те, «куди зручніше запхати».

The split is simple if you think about the lifetime and visibility of each part, rather than about “where is it easiest to stuff this.”

Шлях (path) — це ідентичність ресурсу. /orders/8123 означає «саме це замовлення». Якщо без цього шматка запит втрачає сенс — він у шляху.

The path is the identity of the resource. /orders/8123 means “this exact order.” If the request loses its meaning without that piece, it belongs in the path.

Query-параметри — це як саме ти дивишся на колекцію: фільтр, сортування, пагінація, поля. ?status=paid&sort=-created&limit=50. Ознака query-параметра — його можна прибрати, і запит лишиться валідним, просто поверне більше.

Query parameters are how you're looking at a collection: filtering, sorting, pagination, field selection. ?status=paid&sort=-created&limit=50. The giveaway of a query parameter: drop it and the request is still valid, it just returns more.

Заголовки — це метадані про транспорт, а не про предмет. Хто я (Authorization), у якому форматі я надсилаю (Content-Type) і що готова прийняти (Accept), кеш (If-None-Match), трасування (X-Request-Id), мова (Accept-Language). Бізнес-даних тут не буває.

Headers are metadata about transport, not about the subject. Who I am (Authorization), what format I'm sending (Content-Type) and what I'm willing to accept (Accept), caching (If-None-Match), tracing (X-Request-Id), language (Accept-Language). Business data never lives here.

Тіло — сам вантаж. Те, що ти створюєш або змінюєш.

The body is the payload itself. What you are creating or changing.

А тепер найважливіше для тестувальника — чому це не питання смаку. URL пишеться в лог веб-сервера, у метрики, в історію браузера, в Referer при переході на зовнішній сайт і в кеш проксі. Тіло — ні. Тому токен або номер картки в query-рядку — це не «негарно», це витік у три різні місця одночасно, часто ще й у систему, яку твоя команда не контролює.

And now the part that matters most for a tester — why this isn't a question of taste. The URL is written into the web server's log, into metrics, into browser history, into the Referer when someone follows a link to an external site, and into proxy caches. The body isn't. So a token or a card number in the query string isn't “ugly”, it's a leak into three different places at once, often including a system your team doesn't control.

ASCII — розбір одного URL по частинах
ASCII — one URL taken apart
https://api.shop.io/v1/orders/8123/items?fields=sku,qty&page=2#top
└─┬──┘  └────┬─────┘└┬┘└──────┬───────┘└──────────┬────────┘└┬┘
схема     хост      вер.    шлях                query       фрагмент
                                                            ↑
                                              на сервер НЕ надсилається
                                              взагалі — живе лише в браузері

шлях   /v1/orders/8123/items   ХТО це — ідентичність, прибрати не можна
query  ?fields=…&page=2       ЯК дивитись — прибереш, запит лишиться валідним

Куди що потрапляє після відправки:
                       у лог     в історію   у кеш    у Referer
                       nginx     браузера    проксі   на чужий сайт
  шлях + query          так        так        так       так
  заголовки             ні*        ні         ні**      ні
  тіло                  ні         ні         ні        ні

  *  якщо явно не додати в log_format
  ** окрім тих, що названі у Vary
https://api.shop.io/v1/orders/8123/items?fields=sku,qty&page=2#top
└─┬──┘  └────┬─────┘└┬┘└──────┬───────┘└──────────┬────────┘└┬┘
scheme    host      ver.    path                query       fragment
                                                            ↑
                                              is NOT sent to the server
                                              at all — lives only in the browser

path   /v1/orders/8123/items   WHO it is — identity, cannot be removed
query  ?fields=…&page=2       HOW to view — drop it and the request is still valid

Where each part ends up after sending:
                       to log    to history  to cache to Referer
                       nginx     of browser  of proxy on another site
  path + query          yes        yes        yes       yes
  headers               no*        no         no**      no
  body                  no         no         no        no

  *  unless explicitly added to log_format
  ** except the ones listed in Vary
HTTP — п'ять способів зіпсувати той самий запит
HTTP — five ways to ruin the same request
// ❌ Токен у query — осідає в логах nginx, в історії браузера,
//    у Referer при переході на зовнішній домен і в кеші проксі
GET /v1/orders?token=eyJhbGciOiJIUzI1NiJ9…
// ✅
GET /v1/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

// ❌ Пагінація в тілі GET — таке посилання не переслати колезі,
//    не покласти в закладку, і частина проксі просто викине тіло
GET /v1/orders
{"page": 2, "limit": 50}
// ✅
GET /v1/orders?page=2&limit=50

// ❌ Немає Content-Type — багато фреймворків мовчки парсять як form-data
//    і кладуть у модель null. Сервер поверне 400 «sku is required»,
//    і півдня піде на пошук бага в полі, яке ти точно надіслала
POST /v1/orders
{"sku": "A-17"}
// ✅
POST /v1/orders
Content-Type: application/json
{"sku": "A-17"}

// ❌ Ідентичність у тілі, а не в шляху — жоден кеш і жоден лог
//    не бачить, ЯКЕ замовлення читали
POST /v1/getOrder   {"id": 8123}
// ✅
GET  /v1/orders/8123
// ❌ Token in query — ends up in nginx logs, browser history, 
//    the Referer when navigating to an external domain, and proxy caches
GET /v1/orders?token=eyJhbGciOiJIUzI1NiJ9…
// ✅
GET /v1/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…

// ❌ Pagination in a GET body — you cannot send that link to a colleague,
//    cannot bookmark it, and some proxies simply drop the body  
GET /v1/orders
{"page": 2, "limit": 50}
// ✅
GET /v1/orders?page=2&limit=50

// ❌ No Content-Type — many frameworks silently parse it as form-data   
//    and put null into the model. The server returns 400 "sku is required",
//    and half a day goes into hunting a bug in a field you definitely sent
POST /v1/orders
{"sku": "A-17"}
// ✅
POST /v1/orders
Content-Type: application/json
{"sku": "A-17"}

// ❌ Identity in the body instead of the path — no cache and no log
//    can see WHICH order was read    
POST /v1/getOrder   {"id": 8123}
// ✅
GET  /v1/orders/8123
🔧 HTTP-патерни в цьому прикладі ?key=value&key2=value2 — query-рядок, & між парами, значення обов'язково URL-encoded (пробіл → %20, & всередині значення → %26); #fragment — єдина частина URL, яка не йде на сервер; Authorization: Bearer <token> — схема і токен через пробіл; Content-Type описує тіло запиту, а Accept — бажаний формат відповіді, це різні заголовки і плутати їх — класика.
🔧 HTTP patterns in this example ?key=value&key2=value2 — the query string, & between pairs, values must be URL-encoded (a space → %20, an & inside a value → %26); #fragment — the only part of a URL that never reaches the server; Authorization: Bearer <token> — scheme and token separated by a space; Content-Type describes the request body while Accept describes the wanted format of the response — different headers, and mixing them up is a classic.
🗣 Як читати вголос Я розкладаю запит за принципом «час життя і видимість». У шляху живе ідентичність ресурсу — те, без чого запит втрачає сенс, наприклад orders слеш вісім тисяч сто двадцять три. У query-параметрах — те, як я дивлюсь на колекцію: фільтр, сортування, пагінація; ознака проста — query можна прибрати, і запит лишиться валідним. У заголовках — метадані про транспорт: хто я, у якому форматі надсилаю, що готова прийняти, кеш, трасування; бізнес-даних у заголовках не буває. У тілі — сам вантаж. І це не питання смаку, а питання безпеки: URL потрапляє в лог веб-сервера, в історію браузера, в Referer при переході на зовнішній сайт і в кеш проксі, а тіло — нікуди з цього. Тому токен у query-рядку я заводжу як баг безпеки, а не як зауваження до стилю. Найпідступніша ж помилка в цьому списку — відсутній Content-Type: багато фреймворків тоді мовчки парсять JSON як форму, кладуть у модель null і повертають «поле обов'язкове» на поле, яке ти точно надіслала.
🗣 How to say it out loud I split a request by lifetime and visibility. The path holds the identity of the resource — the thing without which the request makes no sense, like orders slash eight one two three. Query parameters hold how I'm looking at the collection: filtering, sorting, pagination — and the test is simple, you can drop a query parameter and the request is still valid. Headers hold metadata about transport: who I am, what format I'm sending, what I'll accept, caching, tracing; business data never goes into a header. The body holds the payload. And this isn't a style question, it's a security one: the URL ends up in the web server log, in browser history, in the Referer when you follow a link off-site, and in proxy caches, while the body ends up in none of that. So a token in a query string I file as a security bug, not as a comment on style. The nastiest mistake on that list, though, is a missing Content-Type — a lot of frameworks then quietly parse JSON as a form, put null into the model, and hand you back “this field is required” for a field you definitely sent.
5 Ідемпотентність: чому повторений POST — інший ризик, ніж повторений PUT? Idempotency: why is a repeated POST a different risk from a repeated PUT?
ОсновиBasicsSenior▶

Ідемпотентність — це властивість стану сервера, а не відповіді. Формально: N однакових запитів залишають систему в тому самому стані, що й один. Відповіді при цьому можуть відрізнятись — другий DELETE цілком може віддати 404, і це не порушує ідемпотентності, бо стан не змінився.

Idempotency is a property of the server's state, not of the response. Formally: N identical requests leave the system in the same state as one. The responses can differ — a second DELETE may well return 404, and that doesn't break idempotency, because the state didn't move.

Тепер найважливіше — звідки взагалі беруться повтори. Не від «користувач двічі клікнув». Головне джерело в іншому: мережа не вміє сказати клієнту, чи запит доїхав. Клієнт побачив таймаут — і в нього два несумісні сценарії з однаковим виглядом: запит не дійшов до сервера (тоді треба повторити) або дійшов і відпрацював, а загубилась відповідь (тоді повторювати не можна). Розрізнити їх з боку клієнта неможливо. Тому ретрай зашитий у всі HTTP-клієнти, сервісні меші, черги і мобільні SDK — і зазвичай без твого відома.

Now the important part — where repeats come from at all. Not from “the user clicked twice.” The main source is elsewhere: the network can't tell the client whether the request arrived. The client saw a timeout, and it has two incompatible scenarios that look identical: the request never reached the server (retry it), or it arrived and did its job and the response got lost (do not retry). From the client side they can't be told apart. That's why retries are baked into every HTTP client, service mesh, queue and mobile SDK — and usually without your knowledge.

Ось де розходяться ризики. Повторений PUT /orders/8123 просто ще раз кладе той самий об'єкт за тією ж адресою — стан не змінюється, ретрай безкоштовний. Повторений POST /orders створює друге замовлення: адресу вигадує сервер, і другий виклик вигадає нову. Тому дефолтні retry-політики зазвичай повторюють GET, PUT і DELETE, і не повторюють POST.

This is where the risks diverge. A repeated PUT /orders/8123 simply puts the same object at the same address again — the state doesn't move, the retry is free. A repeated POST /orders creates a second order: the server invents the address, and the second call invents a new one. That's why default retry policies usually repeat GET, PUT and DELETE and do not repeat POST.

Коли POST усе-таки треба зробити безпечним для повтору — використовують Idempotency-Key: клієнт генерує унікальний ключ до першої спроби і повторює запит із тим самим ключем. Сервер зберігає пару «ключ → результат» і на повтор віддає збережену відповідь замість того, щоб виконувати операцію вдруге.

When a POST does have to be made safe to repeat, the answer is Idempotency-Key: the client generates a unique key before the first attempt and repeats the request with that same key. The server stores the “key → result” pair and returns the stored response on a repeat instead of performing the operation a second time.

Судження, яке відрізняє senior: ідемпотентність — це не «як має бути за стандартом», а чиї гроші. Подвійне читання профілю нікому не шкодить. Подвійне списання коштів — інцидент із поверненням грошей. Тому в POST на платіж я вимагаю Idempotency-Key і тестую його всерйоз, а в POST на «додати коментар» — здебільшого достатньо кнопки, заблокованої на час запиту.

The judgment that marks a senior: idempotency isn't “what the standard says”, it's whose money is on the line. Reading a profile twice hurts nobody. Charging a card twice is an incident with a refund attached. So on a payment POST I insist on an Idempotency-Key and test it seriously, while on “add a comment” a button disabled for the duration of the request is usually enough.

ASCII — таймаут неоднозначний, і саме тому все це існує
ASCII — a timeout is ambiguous, and that is why all of this exists
КЛІЄНТ                                СЕРВЕР            БАЗА
  │  POST /payments  ──────────────────▶ │
  │                                      │ ─── списати ──▶│ ✅ гроші списані
  │                                      │ ◀───── ok ─────│
  │              ✗ відповідь загубилась  │
  │  ⏱ таймаут                           │
  │                                                       │
  │  Клієнт бачить рівно одне: «відповіді немає».         │
  │  Два несумісні світи виглядають однаково:             │
  │     A) запит не доїхав       → треба повторити        │
  │     B) доїхав і спрацював    → повторювати НЕ можна   │
  │  Розрізнити їх з боку клієнта неможливо.              │
  │                                                       │
  │  POST /payments (повтор) ──────────▶ │                │
  │                                      │                │
  │  без Idempotency-Key:                │ ─── списати ──▶│ ❌ списано ДВІЧІ
  │                                      │                │
  │  з Idempotency-Key (той самий):      │                │
  │                                      │ ключ уже є →   │
  │                                      │ віддати        │
  │  ◀──── 200 + збережена відповідь ─── │ збережений     │ ✅ списано один раз
  │                                      │ результат      │
CLIENT                                SERVER            DB
  │  POST /payments  ──────────────────▶ │
  │                                      │ ─── charge ───▶│ ✅ money charged
  │                                      │ ◀───── ok ─────│
  │              ✗ response got lost     │
  │  ⏱ timeout                           │
  │                                                       │
  │  The client sees exactly one thing: "no response".    │
  │  Two incompatible worlds look identical:              │
  │     A) request never arrived → must retry             │
  │     B) arrived and ran       → must NOT retry         │
  │  The client side cannot tell them apart.              │
  │                                                       │
  │  POST /payments (retry)  ──────────▶ │                │
  │                                      │                │
  │  without Idempotency-Key:            │ ─── charge ───▶│ ❌ charged TWICE
  │                                      │                │
  │  with Idempotency-Key (same one):    │                │
  │                                      │ key exists →   │
  │                                      │ return the     │
  │  ◀──── 200 + stored response ─────── │ stored         │ ✅ charged once
  │                                      │ result         │
HTTP — Idempotency-Key і три його стани
HTTP — Idempotency-Key and its three states
// Ключ генерує КЛІЄНТ і генерує ОДИН РАЗ — до першої спроби.
// Якщо ключ генерувати перед кожним ретраєм, він марний.
POST /v1/payments
Idempotency-Key: "a3f9c1e2-7b44-4c10-9f0d-2b8e51c7d900"
Content-Type: application/json

{"order_id": 8123, "amount": 499.00, "currency": "UAH"}

// 1-ша спроба  → 201 Created, платіж створено
// 2-га з тим самим ключем і тим самим тілом
//               → 200 + ТА САМА відповідь, другого списання немає
// 3-тя з тим самим ключем, але ІНШИМ тілом
//               → 409 Conflict або 422 — сервер має відмовити,
//                 бо ключ уже прив'язаний до іншої операції
// The CLIENT generates the key, and generates it ONCE — before the first attempt.
// If the key is generated before every retry, it is useless.
POST /v1/payments
Idempotency-Key: "a3f9c1e2-7b44-4c10-9f0d-2b8e51c7d900"
Content-Type: application/json

{"order_id": 8123, "amount": 499.00, "currency": "UAH"}

// 1st attempt  → 201 Created, payment created
// 2nd with the same key and the same body  
//               → 200 + THE SAME response, no second charge      
// 3rd with the same key but a DIFFERENT body
//               → 409 Conflict or 422 — the server must refuse,
//                 because the key is already bound to another operation
JavaScript — тест-кейси, які реально ламають наївну реалізацію
JavaScript — the test cases that actually break a naive implementation
const key = crypto.randomUUID();
const body = { order_id: 8123, amount: 499 };
const pay = (b) => fetch('/v1/payments', {
  method: 'POST',
  headers: { 'Idempotency-Key': key, 'Content-Type': 'application/json' },
  body: JSON.stringify(b),
});

// 1. Послідовний повтор — базовий кейс, його зазвичай і роблять
const a = await pay(body);
const b = await pay(body);
expect(await b.json()).toEqual(await a.json());
expect(await countPayments(8123)).toBe(1); // ← перевірка в БД, не в тілі

// 2. Той самий ключ, інше тіло — має бути відмова, а не тиха підміна
const c = await pay({ ...body, amount: 1 });
expect([409, 422]).toContain(c.status);

// 3. ГОЛОВНИЙ кейс: два повтори ОДНОЧАСНО, а не по черзі.
//    Наївна реалізація «SELECT ключ → якщо немає, INSERT платіж»
//    падає саме тут: обидва SELECT-и не бачать ключа, обидва вставляють.
//    Це не теорія — так і виглядає подвійне списання на ретраї клієнта.
await Promise.all([pay(body), pay(body)]);
expect(await countPayments(8123)).toBe(1);

// 4. Термін життя ключа. Сервери зберігають його 24 год і т.п.
//    Питання до аналітика: що буде на повтор через тиждень —
//    новий платіж чи відмова? Обидві відповіді бувають правильні,
//    неправильно — коли її немає в документації.
const key = crypto.randomUUID();
const body = { order_id: 8123, amount: 499 };
const pay = (b) => fetch('/v1/payments', {
  method: 'POST',
  headers: { 'Idempotency-Key': key, 'Content-Type': 'application/json' },
  body: JSON.stringify(b),
});

// 1. Sequential retry — the basic case, the one people usually test
const a = await pay(body);
const b = await pay(body);
expect(await b.json()).toEqual(await a.json());
expect(await countPayments(8123)).toBe(1); // ← check in the DB, not in the body

// 2. Same key, different body — must be refused, not silently swapped
const c = await pay({ ...body, amount: 1 });
expect([409, 422]).toContain(c.status);

// 3. The KEY case: two retries AT THE SAME TIME, not one after another.
//    A naive "SELECT key → if missing, INSERT payment" implementation
//    fails exactly here: both SELECTs miss the key, both insert.        
//    Not theory — this is what a double charge on a client retry looks like.
await Promise.all([pay(body), pay(body)]);
expect(await countPayments(8123)).toBe(1);

// 4. Key lifetime. Servers keep it for 24 h or similar.       
//    Question for the analyst: what happens on a retry a week later —
//    a new payment or a refusal? Either answer can be right;     
//    what is wrong is when it is missing from the docs.
🔧 JS патерни в цьому прикладі crypto.randomUUID() — вбудований у платформу генератор UUID, без бібліотек; { ...body, amount: 1 } — spread-копія об'єкта з перевизначенням одного поля, оригінал не мутується; Promise.all([a, b]) — запускає обидва запити одночасно і чекає обох, саме це відтворює гонку; expect([409, 422]).toContain(x) — перевірка «один із допустимих», коли контракт дозволяє два коди.
🔧 JS patterns in this example crypto.randomUUID() — the platform's built-in UUID generator, no library needed; { ...body, amount: 1 } — a spread copy of the object with one field overridden, the original is not mutated; Promise.all([a, b]) — fires both requests at the same time and waits for both, which is what reproduces the race; expect([409, 422]).toContain(x) — the “one of the allowed values” check, for when the contract permits two codes.
🗣 Як читати вголос Ідемпотентність — це властивість стану, а не відповіді: п'ять однакових запитів мають лишити систему в тому самому стані, що й один, при цьому другий DELETE цілком може віддати 404 і це нормально. Повтори беруться не від подвійного кліку, а від мережі: коли клієнт бачить таймаут, він не може відрізнити «запит не доїхав» від «доїхав, спрацював, але загубилась відповідь» — і тому ретрай зашитий у більшість HTTP-клієнтів і сервісних мешів за замовчуванням. Ось звідки різниця в ризику: повторений PUT просто ще раз кладе той самий об'єкт за відомою адресою, а повторений POST створює другий об'єкт, бо адресу щоразу вигадує сервер. Саме тому дефолтні retry-політики повторюють GET, PUT і DELETE і не повторюють POST. Коли POST усе-таки має бути безпечним для повтору — наприклад, платіж — я вимагаю Idempotency-Key, який клієнт генерує один раз до першої спроби, і тестую три речі: повтор із тим самим ключем не створює другого запису в базі, той самий ключ з іншим тілом отримує відмову, і найголовніше — два повтори одночасно, бо наївна реалізація «перевір, чи є ключ, потім вставляй» падає саме на цій гонці. Загалом я вирішую за ціною помилки: подвійне читання профілю нікому не шкодить, подвійне списання коштів — це інцидент.
🗣 How to say it out loud Idempotency is a property of state, not of the response: five identical requests should leave the system exactly where one would, and a second DELETE coming back 404 is still fine. Repeats don't come from double clicks, they come from the network — when a client sees a timeout it can't tell “the request never arrived” from “it arrived, it worked, and the response got lost”, so retries are on by default in most HTTP clients and service meshes. That's where the risk splits: a repeated PUT just writes the same object to a known address again, while a repeated POST creates a second object, because the server invents the address every time. Which is exactly why default retry policies repeat GET, PUT and DELETE but not POST. When a POST does have to be repeat-safe — a payment, say — I ask for an Idempotency-Key generated once before the first attempt, and I test three things: that a repeat with the same key doesn't create a second row in the database, that the same key with a different body gets rejected, and, most importantly, two repeats fired at the same time, because the naive “check whether the key exists, then insert” implementation falls over exactly on that race. In general I decide by the cost of being wrong: reading a profile twice hurts nobody, charging twice is an incident.
6 Safe-методи: чому GET, який щось змінює, — це інцидент, а не питання стилю? Safe methods: why is a GET that changes something an incident rather than a question of style?
ОсновиBasicsSenior▶

Safe означає: з погляду клієнта запит нічого не змінює. Лічильники переглядів, записи в лог, оновлення кешу — усе це дозволено; заборонено те, що користувач вважав би дією. GET, HEAD і OPTIONS — safe. Усі safe-методи автоматично ідемпотентні; навпаки не працює — DELETE ідемпотентний, але аж ніяк не safe.

Safe means: from the client's point of view the request changes nothing. View counters, log entries, a warmed cache are all allowed; what's forbidden is anything the user would consider an action. GET, HEAD and OPTIONS are safe. Every safe method is automatically idempotent; the reverse doesn't hold — DELETE is idempotent but nowhere near safe.

Ключове, чого не видно на схемі архітектури: GET викликає не тільки твій клієнт. У цю адресу зайдуть, нікого не питаючи: prefetch браузера, який тягне посилання ще до кліку; бот прев'ю в месенджері, коли хтось кинув лінк у чат; корпоративний проксі й антивірус, що відкривають кожне посилання з листа перед доставкою; краулер пошуковика; CDN, який ходить за оригіналом; retry-політика балансувальника; і врешті сам користувач — F5 і кнопка «назад».

The part the architecture diagram doesn't show: your client isn't the only thing that calls a GET. Walking into that address without asking anyone: the browser's prefetch, pulling a link before the click; a messenger's preview bot when somebody drops the link into a chat; the corporate proxy and antivirus that open every link in an email before delivery; a search engine crawler; a CDN fetching the origin; the load balancer's retry policy; and finally the user, with F5 and the back button.

Тому GET /admin/users/17/delete — це не «негарний URL». Це діра, яка спрацює тоді, коли хтось із адмінським доступом вставить лінк на адмінку в робочий чат, і прев'ю-бот пройдеться по ній. Такі інциденти виглядають абсолютно містично в логах: ніхто нічого не натискав, дані зникли.

So GET /admin/users/17/delete isn't an “ugly URL.” It's a hole that goes off the day someone with admin access pastes an admin link into a work chat and the preview bot walks it. Incidents like that look absolutely mystical in the logs: nobody clicked anything, the data is gone.

Друга половина проблеми — кеш. Відповіді на GET кешуються за замовчуванням багатьма шарами. Якщо автентифікована відповідь віддається без Cache-Control: private, no-store і без коректного Vary, спільний проксі може віддати дані одного користувача іншому. У мультитенантних системах це найдорожчий баг із усіх, які знаходить тестувальник, і знаходиться він рівно двома заголовками у відповіді.

The other half of the problem is caching. Responses to GET are cached by default by many layers. If an authenticated response goes out without Cache-Control: private, no-store and without a correct Vary, a shared proxy can hand one user's data to another. In multi-tenant systems this is the most expensive bug a tester ever finds, and it is found with exactly two response headers.

А коли «не за стандартом» — це нормально? Буває. GET, який продовжує сесію або пише в аудит-лог, формально не safe — і це прийнятно, бо користувач не сприймає це як свою дію. Межа проходить так: чи засмутиться користувач, якщо цей запит виконається сам собою, без нього? Продовження сесії — ні. Списання бонусів — так. Ось і критерій.

And when is “not by the book” fine? It happens. A GET that extends a session or writes to an audit log is formally not safe — and that's acceptable, because the user doesn't experience it as their own action. The line runs here: would the user be upset if this request ran on its own, without them? Extending a session — no. Spending their loyalty points — yes. That's the criterion.

ASCII — хто ще ходить у твій GET, крім твого клієнта
ASCII — who else walks into your GET besides your client
            GET /admin/users/17/delete   (за кукою адміна)
                          ▲
     ┌────────────┬───────┴────┬──────────────┬────────────────┐
     │            │            │              │                │
 prefetch    прев'ю-бот   корпоративний   краулер          retry
 браузера    месенджера   проксі/антивірус пошуковика      балансувальника
     │            │            │              │                │
 тягне лінк   хтось кинув  відкриває кожен  індексує    повторив запит,
 до кліку     URL у чат    лінк із листа    що знайшов  бо був таймаут
     │            │            │              │                │
     └────────────┴────────────┴──────────────┴────────────────┘
                          │
                 усі вони впевнені, що GET безпечний,
                 бо так обіцяє метод
                          ▼
                 користувач 17 видалений.
                 у логах: ніхто нічого не натискав.

Те саме навпаки — кеш:
  GET /api/me   →  200  {"email":"anna@..."}   без Cache-Control
                          │
                    спільний проксі кешує за URL
                          ▼
  GET /api/me   →  той самий кеш іншому користувачу
                   (URL однаковий, токен різний, а Vary немає)
            GET /admin/users/17/delete   (with the admin cookie)
                          ▲
     ┌────────────┬───────┴────┬──────────────┬────────────────┐
     │            │            │              │                │
 prefetch    preview bot  corporate       search           retry
 browser     messenger    proxy/antivirus  crawler         load balancer
     │            │            │              │                │
 fetches      someone put  opens every      indexes     retried request
 the link     URL in chat  email link       all it finds after timeout
     │            │            │              │                │
     └────────────┴────────────┴──────────────┴────────────────┘
                          │
                 they are all sure GET is safe,
                 because the method promises it
                          ▼
                 user 17 is deleted.
                 logs: nobody clicked anything.

The same in reverse — caching:
  GET /api/me   →  200  {"email":"anna@..."}   no Cache-Control
                          │
                    a shared proxy caches by URL
                          ▼
  GET /api/me   →  the same cached copy for another user
                   (same URL, different token, and no Vary)
HTTP — як я це перевіряю руками за три хвилини
HTTP — how I check this by hand in three minutes
# 1. GET двічі і звірити побічний ефект, а не відповідь.
#    Якщо стан змінився — safe порушено, крапка.
curl -s 'https://app.io/api/cart/add?sku=A-17' -H 'Cookie: sid=…'
curl -s 'https://app.io/api/cart/add?sku=A-17' -H 'Cookie: sid=…'
# → у кошику 2 позиції? баг. І prefetch браузера зробить те саме сам.

# 2. Заголовки кешу на будь-якій автентифікованій відповіді.
curl -sI 'https://app.io/api/me' -H 'Authorization: Bearer …' | grep -i 'cache\|vary\|age'
# очікую побачити:
Cache-Control: private, no-store
# або принаймні:
Cache-Control: private, max-age=0
Vary: Authorization, Accept-Language
# ❌ червоний прапорець: Cache-Control: public на персональних даних
# ❌ ще гірше: заголовків нема взагалі — проксі вирішить сам

# 3. Швидкий аудит таблиці роутів: дієслова в шляхах під GET.
grep -rnE '(get|GET)\(.*(delete|remove|create|update|send|reset|cancel)' src/
# п'ять секунд, а знаходить те, на що ручне тестування
# не натрапить ніколи, бо в UI такої кнопки просто немає
# 1. GET twice and compare the side effect, not the response.
#    If the state changed, safety is broken, full stop.
curl -s 'https://app.io/api/cart/add?sku=A-17' -H 'Cookie: sid=…'
curl -s 'https://app.io/api/cart/add?sku=A-17' -H 'Cookie: sid=…'
# → 2 items in the cart? Bug. And browser prefetch will do the same on its own.

# 2. Cache headers on any authenticated response.           
curl -sI 'https://app.io/api/me' -H 'Authorization: Bearer …' | grep -i 'cache\|vary\|age'
# expected:       
Cache-Control: private, no-store
# or at least:  
Cache-Control: private, max-age=0
Vary: Authorization, Accept-Language
# ❌ red flag: Cache-Control: public on personal data               
# ❌ even worse: no headers at all — the proxy decides on its own

# 3. Quick route table audit: verbs in paths served by GET.  
grep -rnE '(get|GET)\(.*(delete|remove|create|update|send|reset|cancel)' src/
# five seconds, and it finds what manual testing      
# will never hit, because the UI simply has no such button
🔧 curl / shell патерни в цьому прикладі curl -s — приглушити прогрес-бар, щоб вивід можна було пайпити; -I — надіслати HEAD і показати тільки заголовки відповіді; | grep -i — фільтр без урахування регістру, бо імена заголовків регістронезалежні; grep -rnE — рекурсивно, з номерами рядків, розширений regex; | усередині regex — «або», саме так одним запитом шукають кілька дієслів одразу.
🔧 curl / shell patterns in this example curl -s — silence the progress bar so the output can be piped; -I — send a HEAD and print only the response headers; | grep -i — a case-insensitive filter, because header names are case-insensitive; grep -rnE — recursive, with line numbers, extended regex; | inside the regex — “or”, which is how you hunt for several verbs in a single pass.
🗣 Як читати вголос Safe означає, що з погляду користувача запит нічого не змінює — GET, HEAD і OPTIONS. Лічильник переглядів чи запис у лог порушенням не вважаються, бо користувач не сприймає їх як свою дію. Чому це не питання стилю: у GET-адресу ходить не тільки твій клієнт. Туди зайдуть prefetch браузера, прев'ю-бот месенджера, коли хтось кинув лінк у чат, корпоративний проксі й антивірус, які відкривають кожне посилання з листа, краулер пошуковика і retry балансувальника — і всі вони роблять це, бо метод пообіцяв, що це безпечно. Тому GET, який щось видаляє, рано чи пізно вистрілить сам собою, і в логах це виглядатиме містично: ніхто нічого не натискав, а дані зникли. Друга половина проблеми — кеш: відповіді на GET кешуються за замовчуванням, тож автентифікована відповідь без Cache-Control private і без коректного Vary може приїхати від спільного проксі чужому користувачу. Перевіряю я це двома простими речами: надсилаю GET двічі і дивлюсь не на відповідь, а на стан у базі, і окремо дивлюсь заголовки кешу на будь-якій персональній відповіді. Межу я проводжу питанням «чи засмутиться користувач, якщо цей запит виконається сам собою» — продовження сесії ні, списання бонусів так.
🗣 How to say it out loud Safe means that from the user's point of view the request changes nothing — GET, HEAD and OPTIONS. A view counter or a log line doesn't count as a violation, because the user doesn't experience it as their own action. Why this isn't a style question: your client isn't the only thing that calls a GET. Browser prefetch goes there, a messenger's preview bot goes there when somebody drops the link into a chat, the corporate proxy and the antivirus open every link that arrives by email, crawlers index whatever they find, and the load balancer retries — and all of them do it because the method promised it was safe. So a GET that deletes something will eventually fire on its own, and in the logs it looks mystical: nobody clicked anything and the data is gone. The other half is caching — responses to GET are cached by default, so an authenticated response without Cache-Control private and a correct Vary can come from a shared proxy to somebody else. I check this with two simple things: I send the GET twice and look at the state in the database rather than at the response, and separately I look at the cache headers on any personal response. And I draw the line by asking whether the user would be upset if this request ran by itself — extending a session, no; spending their points, yes.
7 Сервер віддає 200 OK з "error": true у тілі. Це баг — і як довести, що так? The server returns 200 OK with "error": true in the body. Is that a bug — and how do you prove it?
ОсновиBasicsSenior▶

Якщо одним реченням — так, зазвичай баг. Але розгорнута відповідь цікавіша, бо на співбесіді питають саме її: чому баг, і в яких випадках ні.

In one sentence: yes, usually a bug. The full answer is more interesting, because that's what the interview is actually after — why it's a bug, and in which cases it isn't.

Аргумент, який працює на розробника і на менеджера. Код статусу читає не людина, а вся інфраструктура над HTTP — і вона не заглядає в тіло. Моніторинг рахує error rate за статусами: дашборд зелений, поки користувачі не можуть оплатити. Алерти не спрацюють. Retry-політика клієнта не повторить те, що варто повторити, — бо для неї це успіх. CDN і проксі закешують «успішну» відповідь із помилкою всередині і роздадуть її іншим. Клієнтські SDK перевіряють res.ok і підуть у гілку успіху з поламаними даними. Це не про естетику стандарту — це про те, що збій став невидимим для всіх систем спостереження одразу.

The argument that works on a developer and on a manager. A status code isn't read by a human, it's read by the whole stack above HTTP — and that stack never looks inside the body. Monitoring counts error rate by status: the dashboard stays green while users can't pay. Alerts don't fire. The client's retry policy won't repeat what's worth repeating, because to it this was a success. CDNs and proxies cache a “successful” response with an error inside and serve it to others. Client SDKs check res.ok and walk into the success branch with broken data. This isn't about the beauty of the standard — it's about a failure becoming invisible to every observability system at once.

Тепер коли це виправдано — і те, що ви це знаєте, важить не менше. GraphQL за специфікацією віддає 200 і масив errors, бо один запит містить багато полів, і частина з них могла відпрацювати. Пакетні ендпоінти, де з 50 елементів упало 3, — тут теж дивно віддавати 4xx на весь батч, чесна відповідь це 207 Multi-Status із результатом на кожен елемент. Приймач вебхуків, який мусить швидко підтвердити прийом і обробити пізніше, теж віддає 2xx одразу — але чесний код тут 202 Accepted, а не 200 OK.

Now, when it is justified — and knowing this counts for just as much. GraphQL returns 200 with an errors array by specification, because one request covers many fields and some of them may well have worked. Batch endpoints where 3 items out of 50 failed are the same story — returning 4xx for the whole batch is odd, and the honest answer there is 207 Multi-Status with a result per item. A webhook receiver that has to acknowledge fast and process later also returns 2xx immediately — but the honest code there is 202 Accepted, not 200 OK.

Дзеркальний баг зустрічається не рідше і його теж треба називати: 500 на помилку валідації. Користувач ввів літеру в поле віку — а на чергового інженера прилетів алерт про падіння сервісу. Наслідок гірший, ніж здається: коли 500-ток багато і всі вони «сміттєві», команда перестає на них реагувати, і справжній збій тоне у фоні.

The mirror-image bug turns up just as often and it deserves to be named too: 500 on a validation error. A user typed a letter into the age field — and the on-call engineer got paged about a service outage. The consequence is worse than it looks: when there are many 500s and all of them are junk, the team stops reacting to them, and a real outage drowns in the background.

Як написати цей баг, щоб його полагодили: не «неправильний код статусу, за стандартом має бути 400». А так: «збій оплати не потрапляє в error rate — алертинг його не бачить; клієнт не повторює те, що повторив би; відповідь із помилкою кешується CDN на 5 хвилин». Перше формулювання виглядає як вкусовщина і живе в беклозі роками. Друге — це операційний ризик із цифрами, і воно виправляється.

How to write this bug so that it actually gets fixed: not “wrong status code, the standard says it should be 400.” Like this instead: “a payment failure never reaches error rate — alerting can't see it; the client doesn't retry what it would have retried; the error response is cached by the CDN for five minutes.” The first wording reads as a matter of taste and lives in the backlog for years. The second is an operational risk with numbers, and it gets fixed.

ASCII — що бачить кожен шар над HTTP
ASCII — what each layer above HTTP sees
Оплата не пройшла. Дві реалізації однієї відповіді:

              ┌──────────────────────────┬──────────────────────────┐
              │  200 OK                  │  402 / 422               │
              │  {"error":true,          │  {"code":"CARD_DECLINED",│
              │   "msg":"card declined"} │   "msg":"card declined"} │
──────────────┼──────────────────────────┼──────────────────────────┤
моніторинг    │ error rate 0% ✅ зелено  │ видно сплеск ✅          │
алерти        │ мовчать                  │ спрацьовують             │
retry клієнта │ не повторить             │ вирішить за кодом        │
CDN / проксі  │ може закешувати як успіх │ 4xx не кешується         │
res.ok у JS   │ true → гілка успіху ❌   │ false → гілка помилки ✅ │
SLA-звіт      │ 100% доступності         │ чесні цифри              │
──────────────┴──────────────────────────┴──────────────────────────┘

Правило, яке я тримаю в голові:
  статус  → для машин: моніторинг, кеш, ретраї, балансувальник
  тіло    → для людей і для UI: що саме сталось і що робити далі
  Обидва обов'язкові. 200 з помилкою в тілі — це відповідь,
  у якої є половина для людини і немає половини для машини.
Payment failed. Two implementations of the same response:

              ┌──────────────────────────┬──────────────────────────┐
              │  200 OK                  │  402 / 422               │
              │  {"error":true,          │  {"code":"CARD_DECLINED",│
              │   "msg":"card declined"} │   "msg":"card declined"} │
──────────────┼──────────────────────────┼──────────────────────────┤
monitoring    │ error rate 0% ✅ green   │ spike visible ✅         │
alerts        │ stay silent              │ fire                     │
client retry  │ will not retry           │ decides by status code   │
CDN / proxy   │ may cache it as success  │ 4xx is not cached        │
res.ok in JS  │ true → success path ❌   │ false → error path ✅    │
SLA report    │ 100% availability        │ honest numbers           │
──────────────┴──────────────────────────┴──────────────────────────┘

The rule I keep in mind:
  status  → for machines: monitoring, cache, retries, load balancer
  body    → for people and the UI: what exactly happened and what to do next
  Both are mandatory. A 200 with an error in the body is a response
  that has the half for humans and lacks the half for machines.
JavaScript — чому res.ok мовчки проковтне таку відповідь
JavaScript — why res.ok swallows a response like this without a word
// Типовий клієнтський код. Він написаний правильно —
// це сервер зламав контракт, на який код спирається.
const res = await fetch('/v1/payments', { method: 'POST', body });

if (!res.ok) {                    // ok === true для будь-якого 2xx
  return showError(await res.json());
}
const payment = await res.json();
showSuccess(payment.id);         // ❌ payment.id === undefined
                                   //    користувач бачить «Оплачено!»
                                   //    і порожній номер квитанції

// ─────────────────────────────────────────────────────
// Автотест, який ловить це на рівні контракту.
// Суть: статус і тіло мають розповідати ОДНУ історію.
test('статус відповіді збігається зі змістом тіла', async () => {
  const res = await pay({ card: DECLINED_CARD });
  const json = await res.json();

  const bodySaysError = json.error === true || Boolean(json.errors?.length);
  const statusSaysError = res.status >= 400;

  expect(bodySaysError).toBe(statusSaysError);
  // ↑ один assert, який ловить обидва напрямки розсинхрону:
  //   і 200 з помилкою в тілі, і 500 з нормальними даними
});
// Typical client code. It is written correctly —    
// it is the server that broke the contract the code relies on.
const res = await fetch('/v1/payments', { method: 'POST', body });

if (!res.ok) {                    // ok === true for any 2xx       
  return showError(await res.json());
}
const payment = await res.json();
showSuccess(payment.id);         // ❌ payment.id === undefined
                                   //    the user sees "Paid!"        
                                   //    and an empty receipt number

// ─────────────────────────────────────────────────────
// An automated test that catches this at the contract level.
// The point: status and body must tell ONE story.    
test('response status matches the body content'   , async () => {
  const res = await pay({ card: DECLINED_CARD });
  const json = await res.json();

  const bodySaysError = json.error === true || Boolean(json.errors?.length);
  const statusSaysError = res.status >= 400;

  expect(bodySaysError).toBe(statusSaysError);
  // ↑ one assert that catches both directions of mismatch: 
  //   both a 200 with an error in the body and a 500 with valid data
});
🔧 JS патерни в цьому прикладі res.ok — булеве поле Response, true для статусів 200–299 і тільки для них; json.errors?.length — optional chaining, безпечно читає довжину, якщо поля errors взагалі немає (інакше TypeError); Boolean(x) — явне зведення до булевого, щоб expect(...).toBe(...) порівнював true/false, а не число з булевим; a === b у expect над двома обчисленими прапорцями — асерт на узгодженість, а не на конкретне значення.
🔧 JS patterns in this example res.ok — a boolean field on Response, true for statuses 200–299 and only for those; json.errors?.length — optional chaining, safely reads the length when there is no errors field at all (otherwise a TypeError); Boolean(x) — an explicit cast, so that expect(...).toBe(...) compares true/false rather than a number against a boolean; a === b in an expect over two computed flags — an assertion about consistency rather than about a specific value.
🗣 Як читати вголос Зазвичай так, це баг, і аргументую я його не стандартом, а наслідками. Код статусу читає не людина, а вся інфраструктура над HTTP, і вона в тіло не заглядає: моніторинг рахує error rate за статусами, тож дашборд лишається зеленим, поки користувачі не можуть оплатити; алерти мовчать; retry-політика не повторює те, що варто було б повторити, бо для неї це успіх; CDN може закешувати «успішну» відповідь із помилкою всередині; а клієнтський код перевіряє res.ok, іде в гілку успіху і показує «Оплачено» з порожнім номером квитанції. Тобто збій став невидимим для всіх систем спостереження одразу. Але я знаю й винятки: GraphQL за специфікацією віддає 200 з масивом errors, бо в одному запиті багато полів і частина могла відпрацювати; для пакетних операцій чесна відповідь — 207 Multi-Status із результатом на кожен елемент; а приймач вебхуків справді має швидко підтвердити прийом, тільки чесний код там 202 Accepted. Дзеркальний баг я називаю так само охоче — 500 на помилку валідації, коли через літеру в полі віку будить чергового інженера. І формулюю я такий баг завжди через ризик, а не через стандарт: «збій не потрапляє в error rate, клієнт не ретраїть, відповідь кешується» — бо баг «неправильний код за стандартом» живе в беклозі роками, а операційний ризик виправляють.
🗣 How to say it out loud Usually yes, it's a bug, and I argue it from the consequences rather than from the standard. The status code isn't read by a human, it's read by everything above HTTP, and none of that looks inside the body: monitoring counts error rate by status, so the dashboard stays green while people can't pay; alerts stay quiet; the retry policy doesn't repeat something it would have repeated, because to it this was a success; a CDN can cache the “successful” response with the error inside; and the client code checks res.ok, walks into the success branch, and shows “Paid” with an empty receipt number. So the failure went invisible to every observability system at once. But I do know the exceptions: GraphQL returns 200 with an errors array by spec, because one request covers many fields and some of them may have worked; for batch operations the honest answer is 207 Multi-Status with a result per item; and a webhook receiver really does have to acknowledge fast, only the honest code there is 202 Accepted. I'm just as happy to name the mirror bug — a 500 on a validation error, where a letter in the age field wakes the on-call engineer. And I always phrase a bug like this as a risk rather than as a standards violation, because “wrong code per the spec” lives in the backlog for years while an operational risk gets fixed.
8 PUT vs PATCH: що саме ламається, коли часткове оновлення назвали PUT-ом? PUT vs PATCH: what exactly breaks when a partial update is called a PUT?
ОсновиBasicsSenior▶

За контрактом PUT — це заміна цілком: те, що ти надіслала, і є новий стан ресурсу; поля, яких у тілі немає, мають зникнути або впасти в дефолт. PATCH — це опис зміни: чіпаємо назване, решту не чіпаємо.

By contract PUT is a full replacement: what you sent is the new state of the resource, and fields that aren't in the body must disappear or fall back to their defaults. PATCH is a description of a change: touch what's named, leave the rest alone.

На практиці ж половина API називає ендпоінт PUT, а реалізує злиття: прийшло одне поле — оновили одне поле. Працює? Працює. Проблема в тому, що назва методу — це публічна обіцянка, і на неї спираються люди, яких ти не знаєш. Інтегратор читає «PUT», надсилає повний об'єкт зі своєї моделі — і мовчки затирає поля, про які його модель не знала, бо їх додали після того, як він писав інтеграцію.

In practice half of all APIs call the endpoint PUT and implement a merge: one field came in, one field was updated. Does it work? It works. The problem is that the name of the method is a public promise, and people you've never met rely on it. An integrator reads “PUT”, sends the full object from their own model — and silently wipes the fields their model never knew about, because those were added after they wrote the integration.

Далі — три конкретні поломки, які я перевіряю завжди.

Next, the three concrete failures I always check.

1. Немає способу очистити поле. У merge-реалізації «поле відсутнє» і «поле дорівнює null» зазвичай зливаються в одне — і користувач не може прибрати по батькові чи другу адресу. Це два різні наміри, і вони мають давати різний результат: відсутнє — не чіпати, null — стерти. Тест простий і майже завжди щось знаходить.

1. There is no way to clear a field. In a merge implementation “field absent” and “field equals null” usually collapse into one, and the user can't remove a middle name or a second address. Those are two different intents and they have to produce different results: absent — don't touch, null — erase. The test is simple and it almost always finds something.

2. Lost update. Класичний сценарій: двоє відкрили ту саму картку, обидва зробили GET, перший змінив телефон і зберіг, другий за секунду зберіг свою версію — з телефоном, який він прочитав до зміни. Ніякої помилки, ніякого конфлікту: правка першого зникла безслідно. Лікується умовним запитом: сервер віддає ETag, клієнт повертає його в If-Match, і на розбіжність отримує 412 Precondition Failed. Це один із небагатьох багів, які ручним тестуванням не знаходять ніколи, бо в одному браузері їх не відтворити.

2. Lost update. The classic scenario: two people open the same record, both do a GET, the first changes the phone number and saves, and a second later the other one saves their version — with the phone number they read before the change. No error, no conflict: the first person's edit vanished without a trace. The cure is a conditional request: the server returns an ETag, the client sends it back in If-Match, and on a mismatch gets 412 Precondition Failed. This is one of the few bugs manual testing never finds, because you can't reproduce it in a single browser.

3. Ідемпотентність PATCH. PATCH {"qty": 3} ідемпотентний. PATCH {"op":"increment","by":1} — ні, і ретрай проксі перетворить одне натискання на три. Якщо API дозволяє операційні патчі, вони обов'язково мають ходити з ключем ідемпотентності.

3. PATCH idempotency. PATCH {"qty": 3} is idempotent. PATCH {"op":"increment","by":1} is not, and a proxy retry turns one click into three. If the API allows operational patches, they must always travel with an idempotency key.

Що я реально кажу команді. Не «перейменуйте PUT на PATCH» — це ламає всіх наявних клієнтів заради чистоти. А так: задокументуйте фактичну семантику, зафіксуйте її контрактним тестом, окремо визначте поведінку null, і додайте If-Match там, де одну сутність редагують кілька людей. Правильна назва методу — приємний бонус; передбачувана поведінка — вимога.

What I actually tell the team. Not “rename PUT to PATCH” — that breaks every existing client for the sake of purity. This instead: document the real semantics, pin them with a contract test, define the behaviour of null separately, and add If-Match wherever several people edit the same entity. The right method name is a pleasant bonus; predictable behaviour is the requirement.

ASCII — той самий ресурс, три семантики, три різні результати
ASCII — the same resource, three semantics, three different results
Було:  {"id":8123, "qty":2, "note":"дзвонити зранку", "gift":true}

Надсилаємо тіло:  {"qty":5}

┌─ PUT за стандартом (заміна цілком) ───────────────────────────┐
│ {"id":8123, "qty":5, "note":null, "gift":false}               │
│ note і gift зникли — їх не було в тілі. Це і є PUT.           │
│ Часто відповідь на таке тіло — 400: не всі поля надіслані.    │
└───────────────────────────────────────────────────────────────┘
┌─ PATCH (опис зміни) ──────────────────────────────────────────┐
│ {"id":8123, "qty":5, "note":"дзвонити зранку", "gift":true}   │
│ Чіпаємо назване, решту не чіпаємо.                            │
└───────────────────────────────────────────────────────────────┘
┌─ «PUT», який насправді merge ─────────────────────────────────┐
│ {"id":8123, "qty":5, "note":"дзвонити зранку", "gift":true}   │
│ Результат як у PATCH, назва як у PUT.                         │
│ Пастка: інтегратор надсилає ПОВНИЙ об'єкт зі своєї моделі —   │
│ і затирає поля, які в його моделі просто не описані.          │
└───────────────────────────────────────────────────────────────┘

Lost update, якого не видно в одному браузері:

  Аня                    сервер                   Богдан
   │  GET ────────────────▶│◀──────────────── GET │
   │  {"qty":2,"note":"A"} │  {"qty":2,"note":"A"} │
   │                       │                       │
   │  PUT {"qty":5,        │                       │
   │       "note":"A"} ───▶│  qty=5                │
   │                       │                       │
   │                       │◀── PUT {"qty":2,      │
   │                       │        "note":"Б"}    │
   │                       │  qty=2  ← правка Ані  │
   │                       │         зникла        │
   │                       │  200 OK обом. Помилки немає.

  З ETag / If-Match:
   │                       │◀── PUT + If-Match:"v1"│
   │                       │  на сервері вже "v2"  │
   │                       │  ── 412 ─────────────▶│  «перечитай і повтори»
Before: {"id":8123, "qty":2, "note":"call in the AM", "gift":true}

Request body:     {"qty":5}

┌─ PUT per the standard (full replacement) ─────────────────────┐
│ {"id":8123, "qty":5, "note":null, "gift":false}               │
│ note and gift are gone: not in the body. That is PUT.         │
│ Often the answer to such a body is 400: not all fields sent.  │
└───────────────────────────────────────────────────────────────┘
┌─ PATCH (a description of the change) ─────────────────────────┐
│ {"id":8123, "qty":5, "note":"call in the AM", "gift":true}    │
│ We touch what is named and leave the rest alone.              │
└───────────────────────────────────────────────────────────────┘
┌─ "PUT" that is actually a merge ──────────────────────────────┐
│ {"id":8123, "qty":5, "note":"call in the AM", "gift":true}    │
│ Result like PATCH, name like PUT.                             │
│ Trap: an integrator sends the FULL object from their model —  │
│ and wipes the fields their model simply does not describe.    │
└───────────────────────────────────────────────────────────────┘

A lost update you cannot see in a single browser:

  Anna                   server                   Bob
   │  GET ────────────────▶│◀──────────────── GET │
   │  {"qty":2,"note":"A"} │  {"qty":2,"note":"A"} │
   │                       │                       │
   │  PUT {"qty":5,        │                       │
   │       "note":"A"} ───▶│  qty=5                │
   │                       │                       │
   │                       │◀── PUT {"qty":2,      │
   │                       │        "note":"B"}    │
   │                       │  qty=2  ← Anna's edit │
   │                       │         is lost       │
   │                       │  200 OK to both. No error.

  With ETag / If-Match:
   │                       │◀── PUT + If-Match:"v1"│
   │                       │  server has "v2"      │
   │                       │  ── 412 ─────────────▶│  "re-read and retry"
HTTP — чотири тіла, чотири різні наміри
HTTP — four bodies, four different intents
// 1. Часткове оновлення. Тіло описує зміну.
PATCH /v1/orders/8123
Content-Type: application/merge-patch+json
{"qty": 5}

// 2. Очистити поле. null — це НЕ те саме, що відсутнє поле.
//    Саме тут і падає більшість merge-реалізацій.
PATCH /v1/orders/8123
{"note": null}          // стерти нотатку
{}                        // не чіпати нічого

// 3. Заміна цілком + захист від затирання чужої правки.
PUT /v1/orders/8123
If-Match: "W/\"v2-9f3a\""     // ETag з попереднього GET
{"sku":"A-17", "qty":5, "note":null, "gift":false}
// → 200, якщо версія збіглася
// → 412 Precondition Failed, якщо хтось встиг змінити раніше

// 4. Операційний патч — НЕ ідемпотентний.
//    Якщо API таке дозволяє, ключ ідемпотентності обов'язковий.
PATCH /v1/orders/8123
Idempotency-Key: "c2b1-…"
{"op": "increment", "field": "qty", "by": 1}
// 1. Partial update. The body describes the change.
PATCH /v1/orders/8123
Content-Type: application/merge-patch+json
{"qty": 5}

// 2. Clear a field. null is NOT the same as a missing field.
//    This is exactly where most merge implementations break.
PATCH /v1/orders/8123
{"note": null}          // erase the note
{}                        // touch nothing   

// 3. Full replacement + protection against overwriting someone else's edit.
PUT /v1/orders/8123
If-Match: "W/\"v2-9f3a\""     // ETag from the previous GET
{"sku":"A-17", "qty":5, "note":null, "gift":false}
// → 200 if the version matches
// → 412 Precondition Failed if someone changed it first     

// 4. An operation patch is NOT idempotent.
//    If the API allows it, an idempotency key is mandatory.    
PATCH /v1/orders/8123
Idempotency-Key: "c2b1-…"
{"op": "increment", "field": "qty", "by": 1}
JavaScript — чек-лист із чотирьох тестів на такий ендпоінт
JavaScript — a four-test checklist for an endpoint like this
// 1. Чи це справді заміна? Надсилаємо неповний об'єкт у PUT.
//    Будь-яка з двох відповідей прийнятна — неприйнятно,
//    коли в документації не написано, яка саме.
const res = await put('/v1/orders/8123', { qty: 5 });
// заміна    → 400 «sku is required» або note стерто
// merge     → 200 і note на місці
expect(documented(res)).toBe(true);

// 2. null vs відсутнє поле — два різні наміри.
await patch(url, { note: null });
expect((await get(url)).note).toBeNull();      // стерлось
await patch(url, {});
expect((await get(url)).note).toBeNull();      // не воскресло

// 3. Lost update. Двоє читають, обидва пишуть.
//    Це найцінніший тест у списку — вручну його не відтворити.
const [v1, v2] = [await get(url), await get(url)];
await put(url, { ...v1, qty: 5 },   { 'If-Match': v1.etag });
const late = await put(url, { ...v2, note: 'Б' }, { 'If-Match': v2.etag });
expect(late.status).toBe(412);   // без If-Match тут буде 200
                                 // і тихо втрачена правка

// 4. Ідемпотентність. Той самий PATCH тричі.
for (let i = 0; i < 3; i++) await patch(url, { qty: 3 });
expect((await get(url)).qty).toBe(3);      // не 9
// 1. Is it really a replacement? Send an incomplete object in PUT.
//    Either of two responses is acceptable — what is not acceptable
//    is the docs not saying which one.         
const res = await put('/v1/orders/8123', { qty: 5 });
// replace   → 400 "sku is required" or note erased 
// merge     → 200 and note still there
expect(documented(res)).toBe(true);

// 2. null vs a missing field — two different intentions.
await patch(url, { note: null });
expect((await get(url)).note).toBeNull();      // erased  
await patch(url, {});
expect((await get(url)).note).toBeNull();      // did not come back

// 3. Lost update. Two readers, both write.    
//    The most valuable test on the list — you cannot reproduce it by hand.
const [v1, v2] = [await get(url), await get(url)];
await put(url, { ...v1, qty: 5 },   { 'If-Match': v1.etag });
const late = await put(url, { ...v2, note: 'B' }, { 'If-Match': v2.etag });
expect(late.status).toBe(412);   // without If-Match this is a 200
                                 // and a silently lost edit

// 4. Idempotency. The same PATCH three times.
for (let i = 0; i < 3; i++) await patch(url, { qty: 3 });
expect((await get(url)).qty).toBe(3);      // not 9
🔧 JS / HTTP патерни в цьому прикладі { ...v1, qty: 5 } — spread-копія прочитаного об'єкта з однією зміною, саме так клієнт і формує тіло PUT, і саме тому lost update такий частий; const [a, b] = [...] — деструктуризація масиву, два читання в один рядок; If-Match: "W/\"v2\"" — слабкий ETag, префікс W/ означає «еквівалентні за змістом», лапки всередині значення обов'язкові; application/merge-patch+json — окремий media type, який явно каже серверу, що тіло це патч-документ, а не новий ресурс.
🔧 JS / HTTP patterns in this example { ...v1, qty: 5 } — a spread copy of the object you just read with a single change, which is exactly how a client builds a PUT body and exactly why lost updates are so common; const [a, b] = [...] — array destructuring, two reads on one line; If-Match: "W/\"v2\"" — a weak ETag, the W/ prefix means “equivalent in meaning”, and the quotes inside the value are mandatory; application/merge-patch+json — a separate media type that tells the server explicitly that the body is a patch document, not a new resource.
🗣 Як читати вголос За контрактом PUT — це заміна цілком: що надіслала, те й стало ресурсом, а поля, яких у тілі немає, мають зникнути або впасти в дефолт. PATCH — це опис зміни: чіпаємо назване, решту лишаємо. На практиці багато API називають ендпоінт PUT, а реалізують злиття, і працює це рівно доти, доки на обіцянку не спереться зовнішній інтегратор: він читає «PUT», надсилає повний об'єкт зі своєї моделі і мовчки затирає поля, яких його модель не знала. Я завжди перевіряю три речі. Перша — чи є взагалі спосіб очистити поле: у merge-реалізаціях «поле відсутнє» і «поле дорівнює null» часто зливаються в одне, і користувач не може прибрати, скажімо, другу адресу. Друга і найцінніша — lost update: двоє відкрили одну картку, обидва зробили GET, перший зберіг телефон, другий за секунду зберіг свою версію з телефоном, який прочитав до зміни, і правка першого зникає без жодної помилки; лікується це умовним запитом через ETag та If-Match із відповіддю 412, і вручну цей баг не відтворити ніколи. Третя — ідемпотентність патча: «qty дорівнює три» ідемпотентний, а «збільш на один» ні, і ретрай проксі перетворить одне натискання на три. Команді при цьому я кажу не «перейменуйте метод» — це ламає наявних клієнтів; я кажу задокументувати фактичну семантику, закріпити її контрактним тестом, окремо визначити поведінку null і додати If-Match там, де одну сутність редагують кілька людей.
🗣 How to say it out loud By contract PUT is a full replacement: what you sent becomes the resource, and fields that aren't in the body should disappear or fall back to defaults. PATCH describes a change: touch what's named, leave the rest. In practice a lot of APIs call the endpoint PUT and implement a merge, and that works right up until an external integrator leans on the promise — they read “PUT”, send the full object from their model, and silently wipe fields their model didn't know about. I always check three things. First, whether there's any way to clear a field at all: in merge implementations “absent” and “null” often collapse into one, and the user can't remove, say, a second address. Second, and this is the valuable one, lost update: two people open the same record, both do a GET, the first saves a phone number, a second later the other saves their version with the number they read before the change, and the first edit disappears without any error at all; the cure is a conditional request with ETag and If-Match answering 412, and by hand you'll never reproduce it. Third, patch idempotency: “qty equals three” is idempotent, “increment by one” isn't, and a proxy retry turns one click into three. What I tell the team, though, isn't “rename the method” — that breaks existing clients; it's document the real semantics, pin them with a contract test, define null separately, and add If-Match wherever several people edit the same entity.
▶ 2 8
P1 Що таке колекція в Postman і чим вона відрізняється від просто збережених запитів? What is a collection in Postman, and how is it different from just saved requests?
Postman Junior ▶

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

The simplest explanation, and at the same time the wrong one: “a collection is a folder where my requests live.” A folder does nothing. A collection is an executable artifact: it can be run whole, without a single click, and it gives you a list of passed and failed checks.

Що колекція насправді тримає в собі:

What a collection actually holds:

  • Запити — метод, URL, заголовки, тіло, параметри.
  • Скрипти — Pre-request (перед відправкою) і Tests (після відповіді). Вони живуть на трьох рівнях: колекції, папки й окремого запиту.
  • Змінні колекції — те, що належить самій колекції, а не середовищу.
  • Налаштування авторизації — задані один раз зверху й успадковані всіма запитами всередині.
  • Requests — method, URL, headers, body, parameters.
  • Scripts — Pre-request (before sending) and Tests (after the response). They live at three levels: the collection, a folder and a single request.
  • Collection variables — what belongs to the collection itself rather than to an environment.
  • Authorization settings — set once at the top and inherited by every request inside.

Успадкування — головне слово. Скрипт, написаний на рівні колекції, виконується перед кожним запитом у ній. Тому токен беруть один раз зверху, а не копіюють у тридцять запитів — інакше зміна ендпоінта авторизації означає тридцять правок.

Inheritance is the key word. A script written at collection level runs before every request in it. That's why the token is fetched once at the top instead of being copied into thirty requests — otherwise changing the auth endpoint means thirty edits.

І остання властивість, заради якої все це існує: колекція експортується в один JSON-файл. Той самий файл читає newman у CI. Тобто те, що ти клікаєш руками, і те, що ганяє пайплайн, — буквально один і той самий об'єкт.

And the last property, the one all of this exists for: a collection exports into a single JSON file. That same file is what newman reads in CI. So what you click by hand and what the pipeline runs are literally the same object.

Що успадковується згори вниз
What is inherited top down
Колекція «Orders API»
 |
 |-- Authorization: Bearer {{token}}   успадкують усі запити всередині
 |-- Pre-request script                виконується ПЕРЕД кожним запитом
 |-- Tests                             виконується ПІСЛЯ кожної відповіді
 |-- Variables: baseUrl, orderId
 |
 |-- [папка] 00 setup
 |     |-- POST /auth/login
 |
 |-- [папка] orders
 |     |-- POST /orders        + власні Tests поверх успадкованих
 |     |-- GET  /orders/{{orderId}}
 |
 |-- [папка] 99 cleanup
       |-- DELETE /orders/{{orderId}}

Runner іде зверху вниз у тому порядку, який ти бачиш у списку.
Порядок у списку = порядок виконання. Це не декорація.
Collection "Orders API"
 |
 |-- Authorization: Bearer {{token}}   inherited by every request inside
 |-- Pre-request script                runs BEFORE every request
 |-- Tests                             runs AFTER every response
 |-- Variables: baseUrl, orderId
 |
 |-- [folder] 00 setup
 |     |-- POST /auth/login
 |
 |-- [folder] orders
 |     |-- POST /orders        + its own Tests on top of the inherited ones
 |     |-- GET  /orders/{{orderId}}
 |
 |-- [folder] 99 cleanup
       |-- DELETE /orders/{{orderId}}

Runner goes top to bottom in the order you see in the list.
Order in the list = execution order. It is not decoration.

Практичний висновок: якщо колекцію не можна запустити кнопкою Run і побачити зелено-червоний результат — це ще не колекція, це історія запитів. Різниця в тому, чи є в запитах вкладка Tests з реальними перевірками.

The practical takeaway: if you can't hit Run on a collection and see a green-and-red result, it isn't a collection yet, it's a request history. The difference is whether the requests have a Tests tab with real checks in it.

🔧 Postman / JS патерни в цьому прикладі {{variable}} — підстановка змінної в будь-яке текстове поле: URL, заголовок, тіло; успадкування — скрипт рівня колекції виконується перед кожним запитом, скрипт запиту додається до нього, а не замінює; експорт v2.1 — JSON-формат колекції, який читає і Postman, і Newman.
🔧 Postman / JS patterns in this example {{variable}} — variable substitution in any text field: URL, header, body; inheritance — a collection-level script runs before every request, and a request-level script is added to it rather than replacing it; the v2.1 export — the collection's JSON format, read by both Postman and Newman.
🗣 Як читати вголос Колекція — це не папка із запитами, а виконуваний артефакт: її можна запустити цілком і отримати список пройдених та впалих перевірок. Крім самих запитів вона тримає скрипти, змінні й налаштування авторизації, причому все це працює на трьох рівнях — колекції, папки й окремого запиту. Головне тут — успадкування: скрипт, написаний на рівні колекції, виконується перед кожним запитом усередині, тому токен беруть один раз зверху, а не копіюють у тридцять місць. Порядок запитів у списку — це і є порядок виконання в Runner, він не декоративний. І найважливіше: колекція експортується в один JSON, який потім читає Newman у пайплайні, тобто те, що я клікаю руками, і те, що ганяє CI, — буквально один і той самий файл.
🗣 How to say it out loud A collection isn't a folder with requests in it, it's an executable artifact: you can run the whole thing and get a list of passed and failed checks. Besides the requests it holds scripts, variables and auth settings, and all of that works at three levels — the collection, a folder and a single request. Inheritance is the main thing here: a script written at collection level runs before every request inside, so the token is fetched once at the top instead of being copied into thirty places. The order of requests in the list is the execution order in the Runner, and it isn't decorative. And the most important part: a collection exports into one JSON that Newman then runs in the pipeline, so what I click by hand and what CI runs are literally the same file.
P2 Environment, змінні колекції, глобальні — де що зберігати і що виграє при конфлікті? Environment, collection and global variables — where does what belong, and which one wins on a clash?
Postman Junior ▶

Змінні в Postman існують заради однієї речі: щоб та сама колекція йшла проти dev, stage і prod без жодної правки в запитах. Замість https://stage.shop.io/api/orders у запиті стоїть {{baseUrl}}/orders, а конкретне значення дає обране середовище.

Variables exist in Postman for one reason: so that the same collection can run against dev, stage and prod without a single edit inside the requests. Instead of https://stage.shop.io/api/orders the request says {{baseUrl}}/orders, and the selected environment supplies the actual value.

Рівнів кілька, і при однаковій назві виграє найвужчий. Це та деталь, через яку люди годинами дивляться, чому запит іде «не туди»: у глобальних лишився старий baseUrl, а в environment його просто немає.

There are several levels, and on identical names the narrowest one wins. That's the detail people spend hours on when a request goes “to the wrong place”: an old baseUrl is still sitting in globals, and the environment simply doesn't have one.

Порядок пошуку значення для {{baseUrl}} — зверху вниз
Lookup order for {{baseUrl}} — top down
1. local        pm.variables.set()        живе один запит, далі зникає
2. data         CSV/JSON у Runner         рядок поточної ітерації
3. environment  dev / stage / prod        те, що змінюється між середовищами
4. collection   pm.collectionVariables    те, що належить самій колекції
5. global       «для всіх колекцій»       майже завжди помилка

Перший знайдений виграє. Пошук зупиняється на ньому.
1. local        pm.variables.set()        lives for one request, then disappears
2. data         CSV/JSON in Runner        row of the current iteration
3. environment  dev / stage / prod        what changes between environments
4. collection   pm.collectionVariables    what belongs to the collection itself
5. global       "for all collections"     almost always a mistake

The first match wins. The lookup stops there.

Проста рознарядка, яка тримається:

A simple split that holds up:

  • Environment — усе, що відрізняє одне середовище від іншого: baseUrl, ідентифікатор тестового мерчанта, версія API.
  • Змінні колекції — усе, що колекція створює сама для себе під час прогону: orderId, token, userId. Вони не про середовище, вони про сценарій.
  • Глобальні — не використовуй. Вони не експортуються з колекцією, тому в тебе працює, а в колеги й у CI — ні.
  • Environment — everything that tells one environment from another: baseUrl, the test merchant id, the API version.
  • Collection variables — everything the collection creates for itself during a run: orderId, token, userId. They're not about the environment, they're about the scenario.
  • Globals — don't use them. They aren't exported with the collection, so it works for you and not for a colleague or in CI.

Про секрети окремо. У Postman кожна змінна має Initial value й Current value. Initial синхронізується в команду і потрапляє в експортований файл; Current лишається локально. Пароль, вписаний в Initial value і закомічений у репозиторій, — класична витік-історія. Секрети або тримають типом secret, або взагалі не тримають у файлі: у CI їх підставляють ключем --env-var зі сховища секретів пайплайна.

A word on secrets. In Postman every variable has an Initial value and a Current value. Initial syncs to the team and lands in the exported file; Current stays local. A password typed into Initial value and committed to the repository is the classic leak story. Secrets are either held with the secret type, or not held in the file at all: in CI they're injected with --env-var from the pipeline's secret store.

Читання і запис зі скрипта
Reading and writing from a script
// прочитати — байдуже, з якого рівня прийшло значення
const base = pm.environment.get('baseUrl');

// записати те, що створила колекція — у змінні колекції
pm.collectionVariables.set('orderId', 8821);

// одноразове значення тільки для цього запиту
pm.variables.set('nonce', Date.now());

// прибрати за собою наприкінці сценарію
pm.collectionVariables.unset('orderId');
// read — no matter which level the value came from   
const base = pm.environment.get('baseUrl');

// write what the collection created — into collection variables
pm.collectionVariables.set('orderId', 8821);

// one-off value just for this request        
pm.variables.set('nonce', Date.now());

// clean up after yourself at the end of the scenario
pm.collectionVariables.unset('orderId');
🗣 Як читати вголос Змінні існують заради того, щоб одна колекція йшла проти dev, stage і prod без правок у самих запитах: у запиті стоїть подвійна фігурна дужка baseUrl, а значення дає обране середовище. Рівнів кілька — локальні, дані ітерації, environment, змінні колекції й глобальні — і при однаковій назві виграє найвужчий, тобто пошук іде згори вниз і зупиняється на першому знайденому. Розподіляю я так: в environment кладу все, що відрізняє одне середовище від іншого, у змінні колекції — все, що колекція створює сама під час прогону, наприклад orderId чи токен. Глобальні не використовую взагалі, бо вони не експортуються разом із колекцією, і виходить класичне «у мене працює, а в CI ні». Окремо стежу за секретами: в Postman initial value синхронізується в команду й потрапляє в експорт, а current value лишається локально, тому пароль у initial value — це готовий витік, і в пайплайні я підставляю секрети через env-var зі сховища CI.
🗣 How to say it out loud Variables exist so that one collection can run against dev, stage and prod without editing the requests themselves: the request says double curly brace baseUrl, and the selected environment supplies the value. There are several levels — local, iteration data, environment, collection and globals — and on identical names the narrowest one wins, so the lookup goes top down and stops at the first hit. I split them like this: the environment holds everything that tells one environment from another, and collection variables hold everything the collection creates itself during a run, an orderId or a token for example. I don't use globals at all, because they aren't exported together with the collection, and you get the classic “works for me, doesn't work in CI.” Secrets I watch separately: in Postman the initial value syncs to the team and ends up in the export while the current value stays local, so a password in the initial value is a leak waiting to happen — in the pipeline I inject secrets through env-var from the CI secret store.
P3 Як влаштований pm.test і що робить перевірку в ньому хорошою? How does pm.test work, and what makes a check inside it a good one?
Postman Middle ▶

Механіка проста до банальності. pm.test(name, fn) реєструє перевірку з назвою і запускає функцію. Якщо функція завершилась без винятку — перевірка зелена. Якщо всередині щось кинуло виняток — червона, і в звіті буде назва плюс текст помилки. Асерти pm.expect — це Chai, вони саме кидають виняток при розбіжності.

The mechanics are almost boringly simple. pm.test(name, fn) registers a check with a name and runs the function. If the function finishes without throwing, the check is green. If something inside throws, it's red, and the report carries the name plus the error text. The pm.expect assertions are Chai, and throwing on a mismatch is exactly what they do.

З цієї механіки випливають усі правила. Назва — це не коментар, це рядок у звіті. Коли Newman у CI надрукує сорок перевірок, «Test 1» не скаже нічого; «Створення замовлення повертає 201 і числовий id» скаже все, і людині не доведеться відкривати колекцію.

Every rule follows from those mechanics. The name isn't a comment, it's a line in the report. When Newman prints forty checks in CI, “Test 1” says nothing; “Creating an order returns 201 and a numeric id” says everything, and nobody has to open the collection.

Другий наслідок: одна перевірка — одне твердження. Десять expect в одному pm.test дають один рядок у звіті і зупиняються на першій розбіжності — решту дев'ять ти не побачиш, поки не полагодиш першу. Розбий на кілька pm.test, і звіт покаже всі проблеми за один прогін.

Second consequence: one check, one claim. Ten expects inside one pm.test give a single line in the report and stop at the first mismatch — you won't see the other nine until you've fixed the first. Split them into several pm.tests and the report shows every problem in one run.

Третій — порядок. Спершу статус, потім тіло. Якщо сервер відповів 500 з HTML-сторінкою, а перший тест одразу робить pm.response.json(), ти отримаєш JSONError: Unexpected token і будеш дебажити парсер замість того, щоб побачити «сервіс упав».

Third — order. Status first, then the body. If the server answered 500 with an HTML page and the first test goes straight to pm.response.json(), you get JSONError: Unexpected token and end up debugging a parser instead of seeing “the service is down.”

Що перевіряти по суті:

What to check, substantively:

  • Контракт — статус, тип контенту, наявність і типи полів. Це те, що ламає клієнтів.
  • Семантику — відповідь пов'язана з запитом: створили замовлення на дві позиції — у відповіді дві позиції.
  • Межі — порожній список повертає [], а не null; ціна не від'ємна.
  • The contract — status, content type, presence and types of fields. That's what breaks clients.
  • The semantics — the response is tied to the request: you created an order with two items, the response has two items.
  • The edges — an empty list returns [] and not null; a price is never negative.

І чого не перевіряти: точні згенеровані значення (id, timestamps), повне тіло цілком (перший новий необов'язковий ключ від бекенду покладе всю сюїту), і жорсткий час відповіді на кшталт 200 мс на спільному CI-раннері — це не тест, це генератор флейку.

And what not to check: exact generated values (id, timestamps), the whole body as a unit (the first new optional key from the backend will take the entire suite down), and a hard response time such as 200 ms on a shared CI runner — that isn't a test, it's a flake generator.

Погано — один тест, три проблеми
Bad — one test, three problems
pm.test('Test 1', function () {
  const json = pm.response.json();          // впаде першим, якщо це не JSON
  pm.expect(pm.response.code).to.eql(201);
  pm.expect(json.id).to.eql(8821);       // id генерується — завтра буде інший
  pm.expect(pm.response.responseTime).to.be.below(200);
});
pm.test('Test 1', function () {
  const json = pm.response.json();          // fails first if this is not JSON
  pm.expect(pm.response.code).to.eql(201);
  pm.expect(json.id).to.eql(8821);       // id is generated — tomorrow it will differ
  pm.expect(pm.response.responseTime).to.be.below(200);
});
Добре — кожен рядок звіту читається окремо
Good — every line of the report reads on its own
pm.test('Створення замовлення повертає 201', () => {
  pm.response.to.have.status(201);
});

pm.test('Відповідь — JSON', () => {
  pm.response.to.have.header('Content-Type', 'application/json');
});

pm.test('Замовлення має числовий id і статус new', () => {
  const order = pm.response.json();
  pm.expect(order.id).to.be.a('number');
  pm.expect(order.status).to.eql('new');
});

pm.test('Позиції з запиту збереглися повністю', () => {
  const sent = JSON.parse(pm.request.body.raw);
  pm.expect(pm.response.json().items).to.have.lengthOf(sent.items.length);
});
pm.test('Creating an order returns 201'    , () => {
  pm.response.to.have.status(201);
});

pm.test('Response is JSON', () => {
  pm.response.to.have.header('Content-Type', 'application/json');
});

pm.test('Order has a numeric id and status new'  , () => {
  const order = pm.response.json();
  pm.expect(order.id).to.be.a('number');
  pm.expect(order.status).to.eql('new');
});

pm.test('Items from the request are saved in full', () => {
  const sent = JSON.parse(pm.request.body.raw);
  pm.expect(pm.response.json().items).to.have.lengthOf(sent.items.length);
});

Пастка, яка виглядає як зелений тест: асерт, написаний поза pm.test, теж працює — і теж валить запит, але окремим рядком у звіті він не з'явиться. А pm.test взагалі без жодного expect-а всередині завжди зелений. Зелена колекція з нулем асертів — найгірший можливий результат прогону, бо вона мовчки погоджується з будь-чим.

The trap that looks like a green test: an assertion written outside pm.test still runs and still fails the request, but it never appears as its own line in the report. And a pm.test with no expect inside it at all is always green. A green collection with zero assertions is the worst possible outcome of a run, because it silently agrees with anything.

🔧 Postman / JS патерни в цьому прикладі pm.test(name, fn) — реєстрація перевірки; помилка всередині fn = червоний тест; pm.expect — вбудований Chai (eql, to.be.a, lengthOf); pm.response.to.have.status() — коротка форма для найчастішої перевірки; pm.request.body.raw — доступ до тіла власного запиту, щоб порівняти надіслане з отриманим.
🔧 Postman / JS patterns in this example pm.test(name, fn) — registers a check; an error inside fn = a red test; pm.expect — the bundled Chai (eql, to.be.a, lengthOf); pm.response.to.have.status() — the short form of the most frequent check; pm.request.body.raw — access to your own request body, so you can compare what was sent with what came back.
🗣 Як читати вголос pm.test реєструє перевірку з назвою і виконує функцію: пройшла без винятку — зелено, кинула виняток — червоно, а expect під капотом це Chai, який саме кидає виняток при розбіжності. Звідси всі правила. Назва тесту — це рядок у звіті Newman, тому вона має читатися як речення: не «Test 1», а «Створення замовлення повертає 201 і числовий id». Одна перевірка — одне твердження, бо десять expect в одному тесті дають один рядок і зупиняються на першій розбіжності, і решту проблем я побачу тільки наступним прогоном. Порядок теж важливий: спершу статус і тип контенту, потім тіло, інакше на п'ятисотці з HTML-сторінкою я отримаю JSONError і буду дебажити парсер замість того, щоб побачити, що сервіс лежить. По суті я перевіряю контракт і семантику — статус, типи полів, зв'язок відповіді із запитом — і свідомо не перевіряю згенеровані id, повне тіло цілком і жорсткий час відповіді, бо це не тести, а генератори флейку.
🗣 How to say it out loud pm.test registers a check with a name and runs a function: finishes without throwing and it's green, throws and it's red — and expect underneath is Chai, which throws on a mismatch. Everything else follows from that. The test name is a line in the Newman report, so it has to read like a sentence: not “Test 1” but “Creating an order returns 201 and a numeric id.” One check, one claim, because ten expects in a single test give one line and stop at the first mismatch, and I'd only see the rest of the problems on the next run. Order matters too: status and content type first, body afterwards, otherwise a 500 with an HTML page hands me a JSONError and I'm debugging a parser instead of noticing that the service is down. Substantively I check the contract and the semantics — status, field types, whether the response is actually tied to the request — and I deliberately don't check generated ids, the whole body as a unit, or a hard response time, because those aren't tests, they're flake generators.
P4 Pre-request script: як зробити авторизацію і підготовку даних без копіпасту Pre-request script: how to do auth and data setup without copy-paste
Postman Middle ▶

Pre-request виконується перед тим, як запит піде на сервер, і має ту саму трирівневість, що й Tests: колекція, папка, запит. Спочатку скрипт колекції, потім папки, потім самого запиту. Це не «ще одне місце для коду», це відповідь на питання «як зробити щось один раз для всіх».

A pre-request script runs before the request goes out to the server, and it has the same three levels as Tests: collection, folder, request. The collection script first, then the folder's, then the request's own. This isn't “one more place to put code”, it's the answer to “how do I do something once for everything.”

Сценарій перший, найчастіший — токен. Наївний варіант: перший запит у колекції логіниться і кладе токен у змінну. Працює, поки ти не запустиш одну папку окремо — тоді токена немає й усе валиться 401-ми.

Scenario one, the most common — the token. The naive version: the first request in the collection logs in and puts the token into a variable. That works until you run one folder on its own — then there's no token and the whole thing falls over with 401s.

Робочий варіант — pre-request на рівні колекції, який дивиться, чи токен ще живий, і ходить по новий тільки якщо ні. pm.sendRequest дозволяє відправити HTTP-запит зі скрипта, не створюючи для нього окремий запит у колекції.

The version that works is a collection-level pre-request script that checks whether the token is still alive and only goes for a new one if it isn't. pm.sendRequest lets you fire an HTTP request from a script without creating a separate request for it in the collection.

Pre-request колекції — токен із кешуванням за терміном дії
Collection pre-request — a token cached by its expiry
const expiresAt = pm.collectionVariables.get('tokenExpiresAt');

// живий і не «майже протух» — нічого не робимо
if (expiresAt && Number(expiresAt) - Date.now() > 60000) {
  return;
}

pm.sendRequest({
  url: pm.environment.get('baseUrl') + '/auth/login',
  method: 'POST',
  header: { 'Content-Type': 'application/json' },
  body: {
    mode: 'raw',
    raw: JSON.stringify({
      login: pm.environment.get('apiUser'),
      password: pm.environment.get('apiPassword')
    })
  }
}, (err, res) => {
  // падіння тут виглядає як падіння основного запиту — гучно і зрозуміло
  if (err || res.code !== 200) {
    throw new Error('Авторизація не вдалася: ' + (err || res.code));
  }
  const body = res.json();
  pm.collectionVariables.set('token', body.access_token);
  pm.collectionVariables.set(
    'tokenExpiresAt', Date.now() + body.expires_in * 1000
  );
});
const expiresAt = pm.collectionVariables.get('tokenExpiresAt');

// alive and not "about to expire" — do nothing
if (expiresAt && Number(expiresAt) - Date.now() > 60000) {
  return;
}

pm.sendRequest({
  url: pm.environment.get('baseUrl') + '/auth/login',
  method: 'POST',
  header: { 'Content-Type': 'application/json' },
  body: {
    mode: 'raw',
    raw: JSON.stringify({
      login: pm.environment.get('apiUser'),
      password: pm.environment.get('apiPassword')
    })
  }
}, (err, res) => {
  // a failure here looks like a failure of the main request — loud and clear
  if (err || res.code !== 200) {
    throw new Error('Authorization failed: '   + (err || res.code));
  }
  const body = res.json();
  pm.collectionVariables.set('token', body.access_token);
  pm.collectionVariables.set(
    'tokenExpiresAt', Date.now() + body.expires_in * 1000
  );
});

Далі авторизація ставиться один раз на рівні колекції як Bearer {{token}}, і жоден окремий запит про неї не знає. Зміниться схема логіну — правиш одне місце.

After that, authorization is set once at collection level as Bearer {{token}} and no individual request knows anything about it. Change the login scheme and you edit one place.

Сценарій другий — унікальні дані. Прогін, який другого разу падає з «email вже зайнято», — не тест, а разова демонстрація. Postman має динамічні змінні, які генеруються при кожній підстановці, і їх достатньо в переважній більшості випадків.

Scenario two — unique data. A run that fails the second time with “email already taken” isn't a test, it's a one-off demo. Postman has dynamic variables that are generated on every substitution, and they're enough in the overwhelming majority of cases.

Унікальні дані: динамічні змінні vs скрипт
Unique data: dynamic variables vs a script
// прямо в тілі запиту — без жодного скрипта
{
  "email": "{{$randomEmail}}",
  "externalId": "{{$guid}}",
  "createdAt": "{{$isoTimestamp}}"
}

// у pre-request, коли значення потрібне ще й у перевірках
const email = 'qa+' + Date.now() + '@valtive.io';
pm.variables.set('newEmail', email);   // local: зникне після запиту
// right in the request body — no script at all
{
  "email": "{{$randomEmail}}",
  "externalId": "{{$guid}}",
  "createdAt": "{{$isoTimestamp}}"
}

// in pre-request, when the value is also needed in assertions
const email = 'qa+' + Date.now() + '@valtive.io';
pm.variables.set('newEmail', email);   // local: gone after the request

Де межа. Pre-request не має власних асертів — усе, що в ньому впало, читається у звіті як проблема основного запиту. Тому створення сутності на п'ять запитів туди не кладуть: для цього є папка 00 setup зі звичайними запитами, у яких є свої Tests і які видно у звіті окремими рядками. У pre-request лишається тільки те, що потрібне кожному запиту: токен, підпис, унікальний ключ.

Where the line is. A pre-request script has no assertions of its own — anything that fails in it reads in the report as a problem with the main request. So a five-request entity setup doesn't go in there: that's what a 00 setup folder is for, with ordinary requests that have their own Tests and show up in the report as their own lines. What stays in a pre-request script is only what every request needs: the token, a signature, a unique key.

🔧 Postman / JS патерни в цьому прикладі pm.sendRequest(opts, callback) — HTTP зі скрипта, колбек-стиль, не Promise; pm.collectionVariables — кеш, який переживає весь прогін; pm.variables.set — локальна змінна на один запит; динамічні {{$randomEmail}}, {{$guid}}, {{$isoTimestamp}} — генеруються Postman при кожній підстановці; throw new Error у колбеку — єдиний спосіб зробити падіння setup-у помітним.
🔧 Postman / JS patterns in this example pm.sendRequest(opts, callback) — HTTP from a script, callback style, not a Promise; pm.collectionVariables — a cache that survives the whole run; pm.variables.set — a local variable for a single request; the dynamic {{$randomEmail}}, {{$guid}}, {{$isoTimestamp}} — generated by Postman on every substitution; throw new Error inside the callback — the only way to make a failing setup visible.
🗣 Як читати вголос Pre-request виконується перед відправкою запиту і має ту саму трирівневість, що й тести: спершу скрипт колекції, потім папки, потім самого запиту. Найчастіший сценарій — токен, і тут важливо не робити його першим запитом у списку, бо тоді запуск однієї папки окремо валиться 401-ми. Я кладу в pre-request колекції перевірку, чи токен ще живий за терміном дії, і тільки якщо ні — беру новий через pm.sendRequest, а сам заголовок ставлю один раз на рівні колекції як Bearer з підстановкою токена. Другий сценарій — унікальні дані: прогін, який другого разу падає з «email вже зайнято», це не тест, а демонстрація, і для цього є динамічні змінні на кшталт randomEmail і guid, які генеруються при кожній підстановці. Але межу я тримаю чітко: у pre-request живе тільки те, що потрібне кожному запиту, бо в нього немає власних асертів і будь-яке падіння там читається у звіті як проблема основного запиту — складнішу підготовку я виношу в папку setup звичайними запитами зі своїми тестами.
🗣 How to say it out loud A pre-request script runs before the request goes out and has the same three levels as tests: the collection script first, then the folder, then the request itself. The most common use is the token, and the thing to avoid is making it the first request in the list, because then running a single folder on its own falls over with 401s. I put a check into the collection's pre-request script for whether the token is still alive by its expiry, and only fetch a new one through pm.sendRequest if it isn't; the header itself is set once at collection level as Bearer with the token substituted in. The second use is unique data — a run that fails the second time with “email already taken” is a demo, not a test, and for that there are dynamic variables like randomEmail and guid, generated fresh on every substitution. But I keep the line clear: only what every request needs lives in a pre-request script, because it has no assertions of its own and anything failing there reads in the report as a problem with the main request — heavier setup I move into a setup folder as ordinary requests with their own tests.
P5 Ланцюжок запитів через змінні — і чому саме він найчастіше стає джерелом флейку Chaining requests through variables — and why that's the most common source of flake
Postman Senior ▶

Механіка ланцюга — три рядки: у Tests першого запиту дістати значення з відповіді, покласти в змінну, у наступному запиті підставити її в URL або тіло. Це вміє кожен через тиждень роботи з Postman. Складне починається далі — коли ланцюг із трьох запитів виростає у двадцять і колекція починає падати «іноді».

The mechanics of a chain are three lines: in the Tests of the first request pull a value out of the response, put it into a variable, and substitute it into the URL or the body of the next one. Anyone can do that after a week with Postman. The hard part starts later — when a chain of three requests grows into twenty and the collection starts failing “sometimes.”

Потік значення через сценарій
How a value flows through the scenario
POST /orders              Tests:
  201 {"id": 8821}  --->   pm.collectionVariables.set('orderId', json.id)
                                     |
GET /orders/{{orderId}}  <-----------+
  200 {...}         --->   перевіряє, що поля збіглися зі створеними
                                     |
POST /orders/{{orderId}}/pay  <------+
  200 {"status":"paid"}
                                     |
DELETE /orders/{{orderId}}   <-------+
  204               --->   pm.collectionVariables.unset('orderId')

Кожна стрілка «вниз» — це припущення, що попередній крок відпрацював.
Двадцять кроків = двадцять припущень підряд.
POST /orders              Tests:
  201 {"id": 8821}  --->   pm.collectionVariables.set('orderId', json.id)
                                     |
GET /orders/{{orderId}}  <-----------+
  200 {...}         --->   checks the fields match the created ones
                                     |
POST /orders/{{orderId}}/pay  <------+
  200 {"status":"paid"}
                                     |
DELETE /orders/{{orderId}}   <-------+
  204               --->   pm.collectionVariables.unset('orderId')

Every "down" arrow is an assumption that the previous step worked.
Twenty steps = twenty assumptions in a row.

Перше рішення — куди класти значення. pm.collectionVariables для всього, що колекція народила сама: воно живе рівно стільки, скільки прогін, і не забруднює environment, який лежить у репозиторії. pm.environment.set — тільки коли значення справді про середовище і потрібне поза цим прогоном. Глобальні — ні: у CI їх просто не буде.

The first decision — where to put the value. pm.collectionVariables for everything the collection produced itself: it lives exactly as long as the run and doesn't pollute the environment file that sits in the repository. pm.environment.set only when the value really is about the environment and is needed beyond this run. Globals — no: in CI they simply won't be there.

Друге — де ланцюг ламається. Чотири типові місця, і всі чотири впізнаються за формою падіння:

The second — where a chain breaks. Four typical places, and all four are recognisable by the shape of the failure:

  • Запуск підмножини. Хтось запускає папку orders окремо — {{orderId}} не підставився, у сервер полетів літеральний рядок {{orderId}}, у відповідь 404 або 400. Виглядає як баг продукту, є багом колекції.
  • Повторний прогін. Перший прогін створив користувача і не прибрав — другий падає на унікальності. Cleanup наприкінці не «гарна практика», а умова повторюваності.
  • Паралельні прогони. Два пайплайни на одному stage працюють із тими самими даними. Лікується унікальними даними на прогін, а не «давайте не запускати одночасно».
  • Асинхронність бекенду. POST повернув 202, ресурс з'явиться за пів секунди, наступний GET іде негайно і бачить 404. Тут потрібен опитувальний цикл, а не сподівання.
  • Running a subset. Somebody runs the orders folder on its own — {{orderId}} never got substituted, the literal string {{orderId}} flew to the server, and back came a 404 or a 400. Looks like a product bug, is a collection bug.
  • A repeat run. The first run created a user and didn't clean up — the second fails on uniqueness. Cleanup at the end isn't “good practice”, it's the condition for repeatability.
  • Parallel runs. Two pipelines against the same stage work with the same data. The cure is unique data per run, not “let's agree not to run at the same time.”
  • An asynchronous backend. POST returned 202, the resource will exist in half a second, the next GET goes out immediately and sees a 404. That needs a polling loop, not hope.

Третє — setNextRequest. Він змінює порядок виконання прямо зі скрипта. Працює тільки в Runner і Newman (у одиночному запиті мовчки ігнорується), робить реальний порядок невидимим у списку колекції й дозволяє зациклити прогін назавжди. Виправданий рівно в одному випадку: пропустити решту сценарію, коли ключовий крок упав і всі наступні впадуть каскадом. Будувати на ньому логіку — ні.

The third — setNextRequest. It changes the execution order straight from a script. It works only in the Runner and Newman (in a single request it's silently ignored), it makes the real order invisible in the collection list, and it lets you loop a run forever. It's justified in exactly one case: skipping the rest of a scenario when a key step failed and everything after it would fail in a cascade. Building logic on it — no.

Захист ланцюга: не продовжувати наосліп
Guarding the chain: don't carry on blind
// Tests запиту POST /orders
const ok = pm.response.code === 201;

pm.test('Замовлення створено', () => {
  pm.expect(ok, 'очікували 201, отримали ' + pm.response.code).to.be.true;
});

if (!ok) {
  // далі йти нема сенсу: 12 наступних впадуть на порожньому orderId
  pm.execution.setNextRequest('99 cleanup');
} else {
  pm.collectionVariables.set('orderId', pm.response.json().id);
}
// Tests of the POST /orders request
const ok = pm.response.code === 201;

pm.test('Order created'      , () => {
  pm.expect(ok, 'expected 201, got '       + pm.response.code).to.be.true;
});

if (!ok) {
  // no point going further: the next 12 will fail on an empty orderId
  pm.execution.setNextRequest('99 cleanup');
} else {
  pm.collectionVariables.set('orderId', pm.response.json().id);
}

Судження, яке відрізняє senior: ланцюг — це спосіб описати один бізнес-сценарій, а не спосіб зекономити на підготовці даних. Якщо два запити перевіряють різні речі й пов'язані лише тим, що другому «зручно взяти id від першого» — це не сценарій, це прихована залежність, і одного дня вона впаде в CI о третій ночі. Незалежним перевіркам дають власні дані, навіть якщо це зайвий POST.

The judgment that marks a senior: a chain is a way to describe one business scenario, not a way to save on preparing data. If two requests check different things and are linked only because the second one finds it “convenient to take the id from the first”, that isn't a scenario, it's a hidden dependency — and one day it will fail in CI at three in the morning. Independent checks get their own data, even if that costs an extra POST.

🔧 Postman / JS патерни в цьому прикладі pm.collectionVariables.set/unset — стан у межах прогону; другий аргумент pm.expect(value, message) — власний текст у звіті замість «expected false to be true»; pm.execution.setNextRequest(name) — стрибок по колекції, працює лише в Runner/Newman; ранній вихід через if замість продовження наосліп.
🔧 Postman / JS patterns in this example pm.collectionVariables.set/unset — state scoped to the run; the second argument of pm.expect(value, message) — your own text in the report instead of “expected false to be true”; pm.execution.setNextRequest(name) — a jump inside the collection, works only in Runner/Newman; an early exit through if instead of carrying on blind.
🗣 Як читати вголос Сама механіка ланцюга тривіальна: у тестах першого запиту дістаю значення з відповіді, кладу у змінну колекції, у наступному запиті підставляю її в URL. Значення я тримаю саме у collectionVariables, бо вони живуть рівно стільки, скільки прогін, і не забруднюють environment, який лежить у репозиторії. Цікаве починається, коли ланцюг виростає у двадцять кроків: кожна стрілка вниз — це припущення, що попередній крок відпрацював, і ламається це в чотирьох типових місцях — запуск однієї папки окремо, повторний прогін без прибирання за собою, два паралельні пайплайни на одному стенді й асинхронний бекенд, який повернув 202 раніше, ніж ресурс реально з'явився. setNextRequest я використовую тільки щоб обірвати сценарій, коли ключовий крок упав, бо він працює лише в Runner і Newman і робить справжній порядок невидимим у списку. І головне судження: ланцюг описує один бізнес-сценарій, а не економить на підготовці даних — якщо два запити перевіряють різні речі й пов'язані лише тим, що другому зручно взяти id від першого, це прихована залежність, яка колись впаде вночі в CI.
🗣 How to say it out loud The mechanics of a chain are trivial: in the tests of the first request I pull a value out of the response, put it into a collection variable, and substitute it into the URL of the next one. I keep the value in collectionVariables specifically, because those live exactly as long as the run and don't pollute the environment file that sits in the repo. It gets interesting when the chain grows to twenty steps: every arrow downwards is an assumption that the previous step worked, and it breaks in four typical places — running one folder on its own, a repeat run with no cleanup, two pipelines against the same stand, and an async backend that returned 202 before the resource actually existed. I use setNextRequest only to cut a scenario short when a key step has failed, because it works in the Runner and Newman only and makes the real order invisible in the list. And the main judgment: a chain describes one business scenario, it doesn't save on preparing data — if two requests check different things and are linked only because the second finds it convenient to grab the id from the first, that's a hidden dependency, and one night it'll go off in CI.
P6 Як організувати колекцію під сюїт, а не під демо? How do you lay out a collection as a suite rather than as a demo?
Postman Senior ▶

Колекція-демо і колекція-сюїт виглядають однаково перші два тижні. Різниця вилазить, коли колекцію треба запустити в пайплайні, віддати іншій людині або дописати через три місяці.

A demo collection and a suite collection look identical for the first two weeks. The difference shows up when the collection has to run in a pipeline, be handed to another person, or be extended three months later.

Демо оптимізоване під «показати запит»: плоский список, назви на кшталт GET users, URL захардкоджений, авторизація вбита в кожен запит окремо, тести є у трьох запитах із тридцяти. Сюїт оптимізований під звіт і під повторний запуск.

A demo is optimised for “show me the request”: a flat list, names like GET users, a hardcoded URL, authorization hammered into each request separately, tests in three requests out of thirty. A suite is optimised for the report and for being run again.

Те саме API у двох виглядах
The same API in two shapes
ДЕМО                              СЮЇТ
─────────────────────────         ─────────────────────────────────────
GET users                         [колекція] Shop API — regression
POST user                          |  Auth: Bearer {{token}}
GET user by id                     |  Pre-request: токен із кешем
POST user copy                     |
POST user copy 2                   |-- 00 setup
DELETE user                        |     |-- Створити тестового мерчанта
GET orders                         |
POST order                         |-- Замовлення: щасливий шлях
POST order (тест 500)              |     |-- Створити замовлення повертає 201 і id
GET order                          |     |-- Оплата переводить у статус paid
...                                |     |-- Оплачене замовлення видно у списку
                                   |
плоско, назви для себе,            |-- Замовлення: помилки
порядок випадковий,                |     |-- Порожній кошик повертає 422 з полем items
авторизація в кожному              |     |-- Чужий id повертає 404, а не 403
запиті окремо                      |     |-- Прострочений токен повертає 401
                                   |
                                   |-- 99 cleanup
                                         |-- Видалити створене за прогін
DEMO                              SUITE
─────────────────────────         ─────────────────────────────────────
GET users                         [collection] Shop API — regression
POST user                          |  Auth: Bearer {{token}}
GET user by id                     |  Pre-request: token with cache
POST user copy                     |
POST user copy 2                   |-- 00 setup
DELETE user                        |     |-- Create a test merchant
GET orders                         |
POST order                         |-- Orders: happy path
POST order (test 500)              |     |-- Create order returns 201 and id
GET order                          |     |-- Payment moves it to status paid
...                                |     |-- Paid order is visible in the list
                                   |
flat, names only you get,          |-- Orders: errors
random order,                      |     |-- Empty cart returns 422 with field items
auth set up in each                |     |-- Someone else's id returns 404, not 403
request separately                 |     |-- Expired token returns 401
                                   |
                                   |-- 99 cleanup
                                         |-- Delete what the run created

Що конкретно змінилось і навіщо:

What changed exactly, and what for:

  • Папки за сценарієм, не за методом. Папка «щасливий шлях замовлення» запускається окремо і має сенс окремо. Папка «всі POST» не має сенсу ніколи.
  • Назви запитів — речення для звіту. У виводі Newman видно рівно ці рядки. «Чужий id повертає 404, а не 403» діагностує сама себе; GET user by id вимагає відкрити колекцію.
  • Самодостатність папки. Усе, що папці потрібно, вона або створює сама, або бере з 00 setup. Це те, що дозволяє ганяти в CI підмножину: --folder зі смоуком на кожен PR і повний прогін на нічному білді.
  • Одне місце для авторизації — рівень колекції, решта успадковує.
  • Cleanup наприкінці — щоб десятий прогін був схожий на перший.
  • Негативні перевірки поруч із позитивними. У демо їх немає взагалі; у сюїті їх зазвичай більше, ніж позитивних, бо саме там живуть цікаві баги.
  • Folders by scenario, not by method. A “happy path for an order” folder runs on its own and makes sense on its own. An “all the POSTs” folder never makes sense.
  • Request names are sentences for the report. Newman's output shows exactly these lines. “Someone else's id returns 404, not 403” diagnoses itself; GET user by id makes you open the collection.
  • A folder that stands on its own. Whatever a folder needs it either creates itself or takes from 00 setup. That's what makes it possible to run a subset in CI: --folder with a smoke on every PR and the full run on the nightly build.
  • One place for authorization — collection level, everything else inherits.
  • Cleanup at the end — so that the tenth run looks like the first.
  • Negative checks next to the positive ones. A demo has none at all; a suite usually has more of them than positive ones, because that's where the interesting bugs live.

Data-driven замість копіпасту. П'ять запитів «створити замовлення з різними знижками» — це один запит і файл даних: Runner і Newman приймають CSV чи JSON і проганяють колекцію по ітерації на рядок, підставляючи колонки як змінні. Копія запиту з іншим числом у тілі — це п'ять місць, які треба правити разом.

Data-driven instead of copy-paste. Five requests called “create an order with a different discount” are one request plus a data file: the Runner and Newman take a CSV or JSON and run the collection once per row, feeding the columns in as variables. A copy of a request with a different number in the body is five places you have to edit together.

І межа самого інструмента. Postman добрий, поки колекція — це переважно HTTP плюс трохи перевірок. Коли в Tests з'являються цикли, спільні хелпери, які доводиться копіювати між запитами, і кількість JS перевищує кількість HTTP — колекція переросла формат. Далі дешевше взяти код-фреймворк (supertest, pytest+requests, RestAssured), де є нормальний імпорт, дебагер і рев'ю по diff-у. Сказати це вголос на співбесіді — сильніший сигнал, ніж перелічити двадцять можливостей Postman.

And the limit of the tool itself. Postman is good while a collection is mostly HTTP plus a few checks. Once Tests grow loops and shared helpers you keep copying between requests, and there's more JavaScript than HTTP, the collection has outgrown the format. From there it's cheaper to take a code framework (supertest, pytest+requests, RestAssured) with real imports, a debugger and diff-based review. Saying that out loud in an interview is a stronger signal than listing twenty Postman features.

🗣 Як читати вголос Колекція-демо і колекція-сюїт виглядають однаково перші два тижні, а різниця вилазить, коли її треба запустити в пайплайні або віддати іншій людині. Демо оптимізоване під «показати запит»: плоский список, назви для себе, URL захардкоджений, авторизація вбита в кожен запит. Сюїт оптимізований під звіт і під повторний запуск, тому я ріжу його на папки за сценаріями, а не за методами, і називаю запити реченнями — у виводі Newman видно рівно ці рядки, і «чужий id повертає 404, а не 403» діагностує себе сама. Кожна папка самодостатня: або створює дані сама, або бере їх із setup — саме це дозволяє ганяти в CI підмножину через folder, смоук на кожен PR і повний прогін уночі. Авторизація живе одним місцем на рівні колекції, наприкінці стоїть cleanup, а варіації даних я роблю не копіями запиту, а файлом даних, де кожен рядок — окрема ітерація. І я тримаю в голові межу самого інструмента: коли в тестах з'являються цикли й спільні хелпери, а джаваскрипту стає більше, ніж HTTP, колекція переросла формат, і далі дешевше взяти код-фреймворк із нормальним імпортом, дебагером і рев'ю по diff-у.
🗣 How to say it out loud A demo collection and a suite collection look the same for the first two weeks, and the difference shows up when it has to run in a pipeline or be handed to somebody else. A demo is optimised for showing a request: flat list, names for yourself, hardcoded URL, authorization hammered into every request. A suite is optimised for the report and for being run again, so I cut it into folders by scenario rather than by method, and I name requests as sentences — Newman's output shows exactly those lines, and “someone else's id returns 404, not 403” diagnoses itself. Every folder stands on its own: it either creates its data or takes it from setup, and that's what lets CI run a subset through folder, a smoke on every PR and the full run overnight. Authorization lives in one place at collection level, cleanup sits at the end, and data variations I do with a data file where each row is a separate iteration, not with copies of the request. And I keep the tool's own limit in mind: once tests grow loops and shared helpers and there's more JavaScript than HTTP, the collection has outgrown the format, and from there a code framework with real imports, a debugger and diff-based review is cheaper.
P7 Newman у CI: як запускати колекцію в пайплайні і що при цьому не комітити Newman in CI: running a collection in the pipeline, and what not to commit while doing it
Postman Senior ▶

Newman — консольний раннер тих самих колекцій. Не «експорт у скрипти», не конвертація: він виконує рівно той JSON, який ти експортувала з Postman, тими самими правилами підстановки змінних і тими самими скриптами.

Newman is a console runner for the very same collections. Not “export to scripts”, not a conversion: it executes exactly the JSON you exported from Postman, with the same variable substitution rules and the same scripts.

Головне, що з нього треба знати для CI, — код виходу. Хоча б один впалий асерт або недоступний запит дає ненульовий exit code, і крок пайплайна червоніє. Це вся інтеграція; решта — як зробити падіння читабельним.

The main thing to know about it for CI is the exit code. A single failed assertion or an unreachable request gives a non-zero exit code, and the pipeline step goes red. That's the whole integration; everything else is about making the failure readable.

Що комітимо в репозиторій: колекцію і файл середовища з порожніми значеннями — тільки ключі. Значення підставляє пайплайн зі своїх секретів через --env-var. Файл середовища з живим токеном, закомічений «щоб працювало», — типова причина того, що токен потім доводиться відкликати.

What we commit to the repository: the collection and the environment file with empty values — keys only. The values are injected by the pipeline from its own secrets through --env-var. An environment file with a live token, committed “so that it works”, is the usual reason a token has to be revoked later.

Крок пайплайна — GitHub Actions
The pipeline step — GitHub Actions
- name: API regression
  run: |
    npx newman run tests/api/shop.postman_collection.json \
      --environment tests/api/stage.postman_environment.json \
      --env-var "baseUrl=https://stage.shop.io/api" \
      --env-var "apiPassword=$API_PASSWORD" \
      --iteration-data tests/api/discounts.csv \
      --timeout-request 15000 \
      --reporters cli,junit,htmlextra \
      --reporter-junit-export reports/api-junit.xml \
      --reporter-htmlextra-export reports/api.html
  env:
    API_PASSWORD: ${{ secrets.API_PASSWORD }}

- name: Зберегти звіт навіть коли впало
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: api-report
    path: reports/
- name: API regression
  run: |
    npx newman run tests/api/shop.postman_collection.json \
      --environment tests/api/stage.postman_environment.json \
      --env-var "baseUrl=https://stage.shop.io/api" \
      --env-var "apiPassword=$API_PASSWORD" \
      --iteration-data tests/api/discounts.csv \
      --timeout-request 15000 \
      --reporters cli,junit,htmlextra \
      --reporter-junit-export reports/api-junit.xml \
      --reporter-htmlextra-export reports/api.html
  env:
    API_PASSWORD: ${{ secrets.API_PASSWORD }}

- name: Save the report even on failure
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: api-report
    path: reports/

Ключі, які вирішують більшість проблем:

The flags that solve most of the problems:

  • --folder — запустити частину колекції. Смоук на кожен PR, повний прогін на нічному білді — один файл, два розклади.
  • --iteration-data + -n — data-driven прогін, ітерація на рядок файлу.
  • --timeout-request — без нього зависла відповідь тримає джобу до загального таймауту пайплайна.
  • --bail — зупинитись на першому падінні. Корисно для смоуку-воротаря, шкідливо для регресії: ти хочеш бачити всі падіння за один прогін, а не по одному за пуш.
  • --reporters — cli для логу, junit для машини (CI показує впалі тести списком), htmlextra для людини, яка відкриє артефакт.
  • --folder — run part of the collection. A smoke on every PR, the full run on the nightly build — one file, two schedules.
  • --iteration-data + -n — a data-driven run, one iteration per row of the file.
  • --timeout-request — without it a hung response holds the job until the pipeline's global timeout.
  • --bail — stop at the first failure. Useful for a smoke gate, harmful for regression: you want to see every failure in one run, not one per push.
  • --reporters — cli for the log, junit for the machine (CI lists the failed tests), htmlextra for the human who opens the artifact.

Крок із артефактом обов'язково з if: always(). Класична помилка: newman упав, крок вивантаження звіту не виконався, і в тебе є червоний хрестик без жодних деталей. Звіт потрібен саме тоді, коли впало.

The artifact step has to carry if: always(). The classic mistake: newman failed, the report upload step never ran, and all you have is a red cross with no detail at all. The report is needed exactly when something failed.

Де ставити цей крок — питання, на яке чекають відповідь «залежить». API-регресія ходить у реальний стенд: вона потрібна після деплою на stage, а не на кожен пуш у гілку, бо інакше десять розробників одночасно ганяють дані одного середовища. На PR має сенс короткий контрактний смоук проти піднятого в тому ж джобі сервісу. Різниця не в інструменті, а в тому, чиї дані ти псуєш.

Where to put this step is a question that expects the answer “it depends.” API regression talks to a real environment: it belongs after a deploy to stage, not on every push to a branch, because otherwise ten developers are churning the same environment's data at once. On a PR what makes sense is a short contract smoke against a service brought up inside the same job. The difference isn't in the tool, it's in whose data you're spoiling.

🔧 Postman / CI патерни в цьому прикладі npx newman run — без глобальної установки; --env-var — перевизначає значення з файлу середовища, найбезпечніший шлях для секретів; ${{ secrets.* }} — сховище GitHub Actions; if: always() — крок виконується попри падіння попереднього; junit-репортер — той самий формат, який читають усі CI-системи.
🔧 Postman / CI patterns in this example npx newman run — without a global install; --env-var — overrides a value from the environment file, the safest route for secrets; ${{ secrets.* }} — the GitHub Actions secret store; if: always() — the step runs despite the previous one failing; the junit reporter — the same format every CI system reads.
🗣 Як читати вголос Newman — це консольний раннер тих самих колекцій: він виконує рівно той JSON, який я експортувала з Postman, з тими самими змінними й скриптами. Для CI з нього треба знати передусім код виходу — хоча б один впалий асерт дає ненульовий exit, і крок пайплайна червоніє, це і є вся інтеграція. У репозиторій я комічу колекцію і файл середовища з порожніми значеннями, самі лише ключі, а значення підставляю з секретів пайплайна через env-var, бо закомічений токен «щоб працювало» рано чи пізно доводиться відкликати. З ключів найкорисніші folder — щоб ганяти смоук на кожен PR і повну регресію вночі з одного файлу, iteration-data для data-driven прогону, timeout-request, щоб зависла відповідь не тримала джобу, і три репортери одразу: cli в лог, junit для CI, htmlextra для людини. Крок вивантаження звіту обов'язково ставлю з if always, бо звіт потрібен саме тоді, коли впало. А де саме стоїть цей крок — залежить: повна регресія ходить у реальний стенд, тому їй місце після деплою на stage, а на PR розумніше тримати короткий контрактний смоук проти сервісу, піднятого в тому ж джобі.
🗣 How to say it out loud Newman is a console runner for the same collections: it executes exactly the JSON I exported from Postman, with the same variables and the same scripts. For CI the first thing to know about it is the exit code — one failed assertion gives a non-zero exit and the pipeline step goes red, and that's the whole integration. Into the repository I commit the collection and an environment file with empty values, keys only, and I inject the values from the pipeline's secrets through env-var, because a token committed “so that it works” sooner or later has to be revoked. Of the flags the most useful are folder — so a smoke runs on every PR and the full regression overnight out of one file — iteration-data for a data-driven run, timeout-request so a hung response doesn't hold the job, and three reporters at once: cli into the log, junit for CI, htmlextra for a human. The report upload step I always set with if always, because the report is needed exactly when something failed. And where that step sits depends: full regression talks to a real environment, so it belongs after a deploy to stage, while on a PR it's smarter to keep a short contract smoke against a service brought up in the same job.
P8 Прийшов червоний звіт Newman — як відрізнити падіння продукту від падіння колекції? A red Newman report has landed — how do you tell a product failure from a collection failure?
Postman Senior ▶

Звіт Newman — це підсумкова табличка плюс нумерований список падінь. Читати його треба не зверху вниз, а за типом помилки: тип майже завжди одразу каже, кому це заводити.

A Newman report is a summary table plus a numbered list of failures. It should be read not top to bottom but by the type of the error: the type almost always tells you straight away who to file this with.

Типовий вивід — три різні хвороби в одному прогоні
A typical output — three different diseases in one run
                          executed    failed
       iterations                1         0
         requests               14         1
     test-scripts               26         0
       assertions               31         4

  1. AssertionError   Оплата переводить у статус paid
                      expected 'new' to equal 'paid'

  2. TypeError        Оплачене замовлення видно у списку
                      Cannot read properties of undefined (reading 'id')

  3. Error            Створити тестового мерчанта
                      connect ECONNREFUSED 10.0.0.4:8080
                          executed    failed
       iterations                1         0
         requests               14         1
     test-scripts               26         0
       assertions               31         4

  1. AssertionError   Payment moves it to status paid
                      expected 'new' to equal 'paid'

  2. TypeError        Paid order is visible in the list
                      Cannot read properties of undefined (reading 'id')

  3. Error            Create a test merchant
                      connect ECONNREFUSED 10.0.0.4:8080

Три типи — три різні адресати:

Three types, three different recipients:

  • AssertionError — запит пройшов, відповідь прийшла, значення не те. Це або справжній баг, або застаріле очікування після свідомої зміни контракту. Єдиний тип, який узагалі може бути багом продукту.
  • Error / ECONNREFUSED, ETIMEDOUT, ENOTFOUND — запит не дійшов. Сервіс не піднявся, URL із іншого середовища, мережа. Це інфраструктура; заводити баг на команду розробки тут — спалити свою репутацію.
  • TypeError у скрипті — впала сама колекція. У 90% випадків це обірваний ланцюг: попередній крок не поклав змінну, і json.data.id звалюється на undefined. Твій баг, не їхній.
  • AssertionError — the request went through, a response came back, the value is wrong. That's either a real bug or a stale expectation after a deliberate change of contract. The only type that can be a product bug at all.
  • Error / ECONNREFUSED, ETIMEDOUT, ENOTFOUND — the request never arrived. The service didn't come up, the URL is from another environment, the network. That's infrastructure; filing a bug on the dev team here burns your own credibility.
  • TypeError in a script — the collection itself fell over. In 90% of cases it's a broken chain: the previous step didn't set the variable, and json.data.id blows up on undefined. Your bug, not theirs.

Далі — правило каскаду. Одне падіння на початку сценарію породжує десяток наслідків. Дивись на перше за порядком, а не на найгучніше: у прикладі вище ECONNREFUSED на мерчанті стоїть третім у списку, але хронологічно він перший — і два інші падіння, найімовірніше, просто його тінь. Полагодиш його — зникнуть усі три.

Next — the cascade rule. One failure at the start of a scenario spawns a dozen consequences. Look at the first one in order, not at the loudest: in the example above the ECONNREFUSED on the merchant is third in the list, but chronologically it's first — and the other two failures are most likely just its shadow. Fix it and all three disappear.

Тепер найважливіше, і воно про зелений звіт, а не про червоний. Подивись на рядок assertions. Прогін, де виконано 14 запитів і 0 асертів, — зелений. Пайплайн задоволений. Перевірено нуль.

Now the most important part, and it's about a green report rather than a red one. Look at the assertions row. A run with 14 requests executed and 0 assertions is green. The pipeline is happy. Nothing was checked.

Так буває частіше, ніж здається: помилка в --folder (назва папки змінилась — Newman не знайшов її і не запустив нічого), колекція без вкладки Tests, або тести, які мовчки не виконались. Тому в CI варто дивитися не тільки на код виходу, а й на кількість асертів — і питати «скільки перевірок пройшло», а не «чи зелено».

That happens more often than you'd think: a mistake in --folder (the folder was renamed, Newman didn't find it and ran nothing), a collection with no Tests tab, or tests that silently never executed. So in CI it's worth watching not only the exit code but the number of assertions too — and asking “how many checks passed”, not “is it green.”

Ще одна пара цифр, яку варто читати: requests executed проти того, скільки їх у колекції. Розбіжність означає, що прогін обірвався або хтось стрибав через setNextRequest. І iterations: якщо їх більше однієї, «впало 4 асерти» може означати одну проблему, помножену на чотири рядки даних.

One more pair of numbers worth reading: requests executed against how many there are in the collection. A gap means the run was cut short, or somebody was jumping around with setNextRequest. And iterations: if there's more than one, “4 assertions failed” can mean one problem multiplied by four rows of data.

Порядок дій, коли прийшов червоний прогін
What to do when a red run lands
1. Тип першого за часом падіння?
     Error/ECONNREFUSED  -> середовище. Далі не читаю, іду піднімати стенд.
     TypeError           -> колекція. Дивлюсь, який крок не поклав змінну.
     AssertionError      -> читаю далі.

2. Це очікування чи продукт?
     git log по колекції: очікування не мінялось тижнями?
     changelog/PR по сервісу: контракт мінявся свідомо?
       так  -> оновлюю тест, це не баг
       ні   -> відтворюю руками тим самим запитом

3. Відтворилось руками з тим самим тілом і заголовками?
     так -> баг: прикладаю запит, відповідь, середовище, час
     ні  -> дані прогону або паралельний прогін. Шукаю різницю
            між тим, що надіслав Newman, і тим, що надіслала я.
1. Type of the earliest failure?
     Error/ECONNREFUSED  -> environment. I stop reading and go bring the stand up.
     TypeError           -> collection. I check which step did not set the variable.
     AssertionError      -> read on.

2. Is it the expectation or the product?
     git log of the collection: expectation unchanged for weeks?
     changelog/PR of the service: was the contract changed on purpose?
       yes  -> I update the test, it is not a bug
       no   -> I reproduce it by hand with the same request

3. Reproduced by hand with the same body and headers?
     yes -> bug: I attach request, response, environment, time
     no  -> run data or a parallel run. I look for the difference
            between what Newman sent and what I sent.
🗣 Як читати вголос Звіт Newman я читаю не зверху вниз, а за типом помилки, бо тип майже одразу каже, кому це заводити. AssertionError означає, що запит пройшов і відповідь прийшла, але значення не те — це єдиний тип, який узагалі може бути багом продукту, і перш ніж його заводити, я перевіряю, чи не мінявся контракт свідомо. ECONNREFUSED або таймаут — це не баг, а середовище: запит просто не дійшов, сервіс не піднявся або URL з іншого стенду. TypeError у скрипті — це падіння самої колекції, майже завжди обірваний ланцюг, коли попередній крок не поклав змінну, і це мій баг, а не розробників. Далі я тримаю в голові каскад: дивлюсь на перше за часом падіння, а не на найгучніше, бо одна проблема на початку сценарію дає десяток наслідків. І окремо дивлюся на кількість асертів, бо найгірший звіт — не червоний, а зелений з нулем перевірок: помилка в назві папки або колекція без тестів дають прогін, який усіх влаштовує і не перевіряє нічого.
🗣 How to say it out loud I read a Newman report by the type of error rather than top to bottom, because the type almost immediately tells me who to file it with. An AssertionError means the request went through and a response came back but the value is wrong — that's the only type that can be a product bug at all, and before filing it I check whether the contract was changed deliberately. An ECONNREFUSED or a timeout isn't a bug, it's the environment: the request simply never arrived, the service didn't come up, or the URL is from another stand. A TypeError in a script is the collection itself falling over, almost always a broken chain where the previous step didn't set a variable, and that's my bug, not the developers'. Then I keep the cascade in mind: I look at the first failure by time, not at the loudest, because one problem at the start of a scenario produces a dozen consequences. And I look separately at the number of assertions, because the worst report isn't a red one, it's a green one with zero checks: a typo in a folder name or a collection with no tests gives you a run that pleases everybody and verifies nothing.
▶ 3 8
auth-1 Чотири схеми автентифікації в API — як кожна передається і що в кожній перевіряє тестер? Four authentication schemes in an API — how each one travels and what a tester checks in each?
Auth Junior ▶

Автентифікація в HTTP API — це майже завжди один заголовок у запиті. Різниця між схемами не в тому, «як сильно вони захищають», а в тому, звідки береться значення цього заголовка і хто його може відкликати.

Authentication in an HTTP API is almost always one header on the request. The schemes differ not in “how strongly they protect you”, but in where the value of that header comes from and who is able to revoke it.

API key — довгий рядок, який видали разом з акаунтом. Живе або у власному заголовку (X-API-Key), або, у гірших API, у query-параметрі. Не має терміну дії, не знає нічого про конкретного користувача — це ключ від дверей інтеграції, а не паспорт людини.

API key — a long string handed out together with the account. It lives either in its own header (X-API-Key) or, in worse APIs, in a query parameter. It has no expiry and knows nothing about a specific user — it is the key to an integration's door, not a person's passport.

Basic — логін і пароль, склеєні через двокрапку і закодовані в base64. Пароль летить у кожному запиті. Тримається виключно на HTTPS.

Basic — a login and a password glued together with a colon and encoded in base64. The password travels on every request. It rests entirely on HTTPS.

Bearer / JWT — «пред'явницький» токен: хто його тримає, той і є користувачем. Видається після логіну, має термін дії, всередині може нести інформацію про користувача і його права.

Bearer / JWT — a bearer token: whoever holds it is the user. Issued after login, it has an expiry, and inside it can carry information about the user and their permissions.

OAuth2 — це не формат заголовка, а процедура отримання токена. Наприкінці процедури ти все одно надсилаєш Authorization: Bearer …. Тому «у нас OAuth2» і «у нас Bearer» — відповіді на різні питання, і плутати їх на співбесіді не варто.

OAuth2 — not a header format but a procedure for obtaining a token. At the end of that procedure you still send Authorization: Bearer …. So “we use OAuth2” and “we use Bearer” answer different questions, and mixing them up in an interview does not help.

Що тестер перевіряє в будь-якій з них — той самий короткий список:

What a tester checks in any of them — the same short list:

  • Запит без облікових даних — має бути 401, а не 200 і не 500.
  • Запит зі сміттям замість токена — теж 401, і тіло відповіді не має розповідати, чому саме токен поганий.
  • Запит з чужим валідним токеном на твій ресурс — 403 або 404, і ніколи 200. Це найдорожча помилка в цьому списку.
  • Облікові дані тільки в заголовку, ніколи в URL — query-рядок осідає в логах сервера, в історії браузера і в заголовку Referer на сусідні домени.
  • A request without credentials — it has to be 401, not 200 and not 500.
  • A request with garbage instead of a token — 401 again, and the response body must not explain what exactly was wrong with the token.
  • A request with someone else's valid token against your resource — 403 or 404, and never 200. This is the most expensive mistake on the list.
  • Credentials in the header only, never in the URL — the query string settles in server logs, in browser history and in the Referer header sent to neighbouring domains.
Як виглядає кожна схема в сирому HTTP-запиті
What each scheme looks like in a raw HTTP request
# 1. API key — власний заголовок
GET /v1/orders HTTP/1.1
Host: api.shop.io
X-API-Key: sk_live_9f2b1c7d4e88a013

# 2. Basic — base64 від "login:password"
GET /v1/orders HTTP/1.1
Authorization: Basic dGVzdGVyOnAkc3N3MHJk

# 3. Bearer / JWT — три частини через крапку
GET /v1/orders HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.k3Rf_u1s

# 4. OAuth2 — спочатку окремий запит по токен...
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=qa-runner&client_secret=***

# ...а далі той самий Bearer, що і в пункті 3
# 1. API key — custom header
GET /v1/orders HTTP/1.1
Host: api.shop.io
X-API-Key: sk_live_9f2b1c7d4e88a013

# 2. Basic — base64 of "login:password"
GET /v1/orders HTTP/1.1
Authorization: Basic dGVzdGVyOnAkc3N3MHJk

# 3. Bearer / JWT — three dot-separated parts
GET /v1/orders HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.k3Rf_u1s

# 4. OAuth2 — first a separate request for the token...
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=qa-runner&client_secret=***

# ...then the same Bearer as in item 3
Червоний прапорець: ключ у query-рядку
Red flag: the key in the query string
// Так робити не можна — і це не педантизм, а реальний канал витоку
GET /v1/orders?api_key=sk_live_9f2b1c7d4e88a013

// Куди він потрапляє автоматично, без жодної помилки розробника:
//   → access.log nginx і будь-якого проксі по дорозі
//   → історія браузера і закладки
//   → заголовок Referer, коли зі сторінки клікнуть на зовнішнє посилання
//   → системи аналітики, які пишуть повний URL
// Never do this — it is not pedantry, it is a real leak channel
GET /v1/orders?api_key=sk_live_9f2b1c7d4e88a013

// Where it ends up automatically, without any developer mistake:
//   → access.log nginx and every proxy along the way
//   → browser history and bookmarks
//   → the Referer header when someone clicks an external link on the page
//   → analytics systems that record the full URL
🔧 Патерни HTTP у цьому прикладі Authorization: <scheme> <credentials> — стандартний двослівний формат заголовка, схема відділена пробілом; X- у назві заголовка історично означало «нестандартний, вигаданий цим API»; application/x-www-form-urlencoded — формат тіла, у якому OAuth2 очікує запит по токен, не JSON.
🔧 HTTP patterns in this example Authorization: <scheme> <credentials> — the standard two-word header format, the scheme separated by a space; X- in a header name historically meant “non-standard, invented by this API”; application/x-www-form-urlencoded — the body format OAuth2 expects for the token request, not JSON.
🗣 Як читати вголос Практично всі схеми автентифікації в HTTP зводяться до одного заголовка в запиті, а різниця між ними — у тому, звідки береться значення і хто його може відкликати. API key — це довгий рядок, який видали інтеграції разом з акаунтом, він не має терміну дії і нічого не знає про конкретного користувача. Basic — це логін і пароль, склеєні через двокрапку і закодовані в base64, тобто пароль летить у кожному запиті і тримається тільки на HTTPS. Bearer, найчастіше у вигляді JWT — це токен на пред'явника, який видали після логіну, він має термін дії і всередині може нести права користувача. OAuth2 я б окремо підкреслив: це не формат заголовка, а процедура отримання токена, і в кінці цієї процедури ти все одно шлеш звичайний Authorization Bearer. А перевіряю я в усіх чотирьох одне й те саме: запит без облікових даних має давати 401, запит зі сміттям замість токена теж 401 і без підказок у тілі, запит з чужим валідним токеном на мій ресурс має давати 403 або 404 і ніколи 200, і облікові дані мають їхати тільки в заголовку — ключ у query-рядку осідає в логах, в історії браузера і в Referer.
🗣 How to say it out loud Pretty much every auth scheme in HTTP comes down to one header on the request, and what actually differs is where that value comes from and who can revoke it. An API key is a long string handed to an integration along with the account; it has no expiry and it knows nothing about a particular user. Basic is a login and password glued together with a colon and base64-encoded, so the password rides along on every single request and the whole thing rests on HTTPS. Bearer, usually in the form of a JWT, is a token issued after login: it expires, and it can carry the user's permissions inside it. The one I'd call out separately is OAuth2 — it isn't a header format at all, it's the procedure for getting a token, and at the end of that procedure you still send a plain Authorization Bearer. And in all four I check the same short list: no credentials at all should give a 401, garbage instead of a token should also give a 401 with nothing in the body explaining why, someone else's valid token against my resource should give 403 or 404 and never 200, and credentials go in the header only — a key in the query string ends up in the logs, in browser history and in the Referer.
auth-2 Basic auth — чому base64 це не шифрування, і що з цього випливає для тестів? Basic auth — why base64 is not encryption, and what follows from that for testing?
Auth Junior ▶

Basic auth бере логін і пароль, склеює їх через двокрапку і кодує результат у base64. Ключове слово — кодує, не шифрує.

Basic auth takes the login and the password, glues them with a colon and encodes the result in base64. The key word is encodes, not encrypts.

Різниця проста і її варто вміти сформулювати одним реченням: шифрування вимагає ключа, щоб прочитати назад, кодування — ні. Base64 — це просто інший спосіб записати ті самі байти, щоб вони пролізли в заголовок без проблемних символів. Розкодувати може будь-хто, одним рядком у консолі, без жодного пароля.

The difference is simple and worth being able to state in one sentence: encryption needs a key to read it back, encoding does not. Base64 is just another way of writing the same bytes so they get through a header without awkward characters. Anyone can decode it, in one line in a console, with no password at all.

Тобто з точки зору безпеки Basic auth дорівнює передачі пароля відкритим текстом. Єдине, що робить його прийнятним — TLS: по https:// весь заголовок зашифровано транспортом. По http:// пароль читає будь-хто на шляху.

So from a security point of view Basic auth equals sending the password in clear text. The only thing that makes it acceptable is TLS: over https:// the whole header is encrypted by the transport. Over http:// anyone on the path reads the password.

Що з цього випливає практично:

What follows from that in practice:

  • Base64 у баг-репорті — це той самий пароль. Замальовуй його на скріншотах так само, як замальовував би відкритий.
  • Пароль летить у кожному запиті, а не один раз при логіні. Тому Basic не має ані терміну дії, ані способу «розлогінитись»: щоб відкликати доступ, треба міняти пароль.
  • Редирект з https на http у ланцюжку — це готовий баг. Перевір, що API взагалі не слухає 80-й порт або жорстко редиректить на 443 до того, як прийме заголовок.
  • Base64 in a bug report is the password itself. Blur it on screenshots exactly as you would blur a plain one.
  • The password travels on every request, not once at login. So Basic has neither an expiry nor a way to log out: to revoke access you have to change the password.
  • A redirect from https to http anywhere in the chain is a ready-made bug. Check that the API does not listen on port 80 at all, or redirects hard to 443 before it accepts the header.

Пастка автоматизації: коли сервер відповідає 401 із заголовком WWW-Authenticate: Basic realm="…", браузер показує нативне вікно вводу логіна. Це вікно — не частина сторінки, воно не в DOM, і UI-автотест на нього не клікне звичайним локатором. Тому в E2E облікові дані для Basic підставляють на рівні контексту браузера, а не через форму.

An automation trap: when the server answers 401 with WWW-Authenticate: Basic realm="…", the browser shows its native dialog for the login. That dialog is not part of the page, it is not in the DOM, and a UI test will not click it with an ordinary locator. That is why in E2E the Basic credentials are supplied at the browser-context level rather than through a form.

Розкодувати Basic — це один рядок, ключ не потрібен
Decoding Basic is one line, and no key is needed
// Заголовок, який ти бачиш у перехопленому запиті:
// Authorization: Basic dGVzdGVyOnAkc3N3MHJk

// У консолі браузера:
atob('dGVzdGVyOnAkc3N3MHJk');
// → 'tester:p$ssw0rd'   ← логін і пароль, відкритим текстом

// І назад — так його збирає клієнт:
const header = 'Basic ' + btoa('tester:p$ssw0rd');

// У терміналі те саме:
// echo 'dGVzdGVyOnAkc3N3MHJk' | base64 -d
// The header you see in an intercepted request:
// Authorization: Basic dGVzdGVyOnAkc3N3MHJk

// In the browser console:
atob('dGVzdGVyOnAkc3N3MHJk');
// → 'tester:p$ssw0rd'   ← login and password, in plain text

// And back — this is how the client builds it:
const header = 'Basic ' + btoa('tester:p$ssw0rd');

// Same thing in the terminal:
// echo 'dGVzdGVyOnAkc3N3MHJk' | base64 -d
Кодування vs шифрування — те, що відрізняє їх у житті
Encoding vs encryption — what actually separates them in practice
КОДУВАННЯ (base64)                ШИФРУВАННЯ (AES, TLS)
        │                                  │
   мета: сумісність                   мета: секретність
   «щоб байти пролізли»               «щоб ніхто не прочитав»
        │                                  │
   ключ НЕ потрібен                   без ключа не прочитати
        │                                  │
   atob() і все                       потрібен ключ + алгоритм
        │                                  │
        └── Basic auth тут            └── HTTPS тут, і саме він
            (ніякого захисту)             робить Basic прийнятним
ENCODING (base64)                 ENCRYPTION (AES, TLS)
        │                                  │
   goal: compatibility                goal: secrecy
   "so the bytes get through"         "so nobody can read it"
        │                                  │
   NO key needed                      unreadable without the key
        │                                  │
   atob() and done                    needs key + algorithm
        │                                  │
        └── Basic auth is here        └── HTTPS is here, and it is what
            (no protection at all)        makes Basic acceptable
🔧 Патерни у цьому прикладі btoa() / atob() — вбудовані функції браузера: binary-to-ASCII і назад, ніяких бібліотек; base64 -d — той самий декод у shell; WWW-Authenticate — заголовок відповіді, яким сервер каже клієнту, яку схему він очікує.
🔧 Patterns in this example btoa() / atob() — built-in browser functions: binary-to-ASCII and back, no libraries; base64 -d — the same decode in a shell; WWW-Authenticate — a header on the response, the server's way of telling the client which scheme it expects.
🗣 Як читати вголос Basic auth склеює логін і пароль через двокрапку і кодує їх у base64 — саме кодує, а не шифрує, і це принципова різниця. Шифрування без ключа не прочитати, а base64 — це просто інший спосіб записати ті самі байти, і розкодувати його може будь-хто одним викликом atob у консолі. Тому з точки зору безпеки Basic — це передача пароля відкритим текстом, а прийнятним його робить тільки TLS: по https заголовок захищає транспорт, по http його читає будь-хто на шляху. Практичних наслідків три. Base64 у баг-репорті — це той самий пароль, його треба замальовувати на скріншотах. Пароль летить у кожному запиті, а не один раз, тому в Basic немає ні терміну дії, ні логауту — відкликати доступ можна тільки зміною пароля. І окремо пастка для автоматизації: коли сервер відповідає 401 із заголовком WWW-Authenticate Basic, браузер малює своє нативне вікно вводу, якого немає в DOM, і звичайний локатор до нього не дотягнеться — облікові дані треба підставляти на рівні контексту браузера.
🗣 How to say it out loud Basic auth glues the login and the password together with a colon and encodes them in base64 — encodes, not encrypts, and that distinction is the whole point. You can't read encryption back without a key, whereas base64 is just another way of writing the same bytes, and anyone can decode it with one call to atob in a console. So security-wise Basic is sending the password in clear text, and the only thing that makes it acceptable is TLS: over https the transport protects the header, over plain http anyone on the path reads it. There are three practical consequences. Base64 in a bug report is the password, so I blur it on screenshots the same way. The password rides along on every request rather than once at login, which means Basic has no expiry and no logout — the only way to revoke access is to change the password. And there's a trap for automation: when the server answers 401 with WWW-Authenticate Basic, the browser draws its own native dialog, which isn't in the DOM, so no ordinary locator will reach it — you have to supply the credentials at the browser-context level instead.
auth-3 Що насправді лежить усередині JWT і чому його payload читається без жодного ключа? What is actually inside a JWT, and why can its payload be read with no key at all?
Auth Middle ▶

JWT — це три частини, розділені крапками: header, payload, signature. Перші дві — звичайний JSON, закодований у base64url. Третя — підпис перших двох.

A JWT is three parts separated by dots: header, payload, signature. The first two are ordinary JSON encoded in base64url. The third is a signature over the first two.

І ось найважливіше, що плутають найчастіше: JWT не приховує вміст, він гарантує, що вміст не змінювали. Payload читає будь-хто, у кого є токен — це не діра, це дизайн. Підпис захищає від підробки, а не від читання.

And here is the thing people mix up most often: a JWT does not hide the contents, it guarantees the contents were not changed. Anyone holding the token can read the payload — that is not a hole, that is the design. The signature protects against forgery, not against reading.

Механіка перевірки на сервері: взяти header і payload у тому вигляді, як вони прийшли, порахувати підпис своїм секретом і порівняти з третьою частиною. Змінив у payload "role": "user" на "admin" — підпис перестав сходитись, сервер віддає 401. Тому підмінити роль у токені не можна, а прочитати її — можна і треба.

How the check works on the server: take the header and the payload exactly as they arrived, compute the signature with your own secret and compare it with the third part. Change "role": "user" to "admin" in the payload and the signature stops matching, so the server answers 401. That is why you cannot swap a role inside a token — but you can read it, and you should.

Стандартні поля (claims), які варто впізнавати:

Standard claims worth recognising:

  • sub — subject, ідентифікатор користувача. Те, за що сервер вважає тебе тобою.
  • exp — момент, після якого токен недійсний. Unix-час у секундах, не в мілісекундах — класична помилка на *1000.
  • iat — коли видали, nbf — раніше цього моменту не приймати.
  • iss — хто видав, aud — для якого сервісу видали. Про aud — окрема картка, це серйозна перевірка.
  • scope / roles / permissions — нестандартні, але майже завжди є. Саме вони дають 403.
  • sub — subject, the user identifier. The thing that makes the server consider you you.
  • exp — the moment after which the token is invalid. Unix time in seconds, not milliseconds — the classic bug is multiplying by 1000.
  • iat — when it was issued, nbf — do not accept it before this moment.
  • iss — who issued it, aud — which service it was issued for. aud gets a card of its own; it is a serious check.
  • scope / roles / permissions — non-standard, but almost always there. These are the ones that produce a 403.

Наслідок для розробки, який тестер має ловити на рев'ю: у payload не можна класти нічого чутливого — ні пошту, ні телефон, ні внутрішні ідентифікатори, які не мають витікати. Токен лежить у localStorage, в логах проксі, в баг-репортах і в скріншотах. Все, що ти туди поклав, ти опублікував.

A development consequence a tester should catch at review: nothing sensitive belongs in the payload — no email, no phone number, no internal identifiers that must not leak. The token sits in localStorage, in proxy logs, in bug reports and in screenshots. Everything you put in there, you published.

Чим це корисно особисто тобі: коли запит несподівано дає 403, першим ділом розкодуй payload. У 90% випадків відповідь просто там — не той scope, не той tenant, exp у минулому. Це швидше, ніж читати логи бекенду.

Why this is useful to you personally: when a request suddenly returns 403, decode the payload first. In 90% of cases the answer is simply there — the wrong scope, the wrong tenant, exp in the past. It is faster than reading the backend logs.

Три частини JWT — що в них і хто їх читає
The three parts of a JWT — what is in them and who reads them
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsInNjb3BlIjoi… . 3Rf_u1sQm2
└──────────── header ──────────────┘   └──────── payload ────────┘   └ signature ┘
        base64url(JSON)                     base64url(JSON)          HMAC/RSA від
                                                                     перших двох
             │                                    │                        │
      {"alg":"HS256",                   {"sub":"42",              рахується секретом,
       "typ":"JWT"}                      "scope":"orders:read",   який є ТІЛЬКИ
                                         "exp":1789234567}        у сервера
             │                                    │                        │
      ЧИТАЄ БУДЬ-ХТО                      ЧИТАЄ БУДЬ-ХТО           перевіряє сервер
      (це не баг)                         (і це теж не баг)        (ось тут захист)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsInNjb3BlIjoi… . 3Rf_u1sQm2
└──────────── header ──────────────┘   └──────── payload ────────┘   └ signature ┘
        base64url(JSON)                     base64url(JSON)          HMAC/RSA over
                                                                     the first two
             │                                    │                        │
      {"alg":"HS256",                   {"sub":"42",              computed with a secret
       "typ":"JWT"}                      "scope":"orders:read",   that ONLY
                                         "exp":1789234567}        the server has
             │                                    │                        │
      ANYONE CAN READ                     ANYONE CAN READ          the server verifies
      (not a bug)                         (not a bug either)       (this is the protection)
Розкодувати payload у консолі — 30 секунд дебагу замість 30 хвилин
Decoding the payload in a console — 30 seconds of debugging instead of 30 minutes
const token = localStorage.getItem('access_token');

// base64url — це base64, у якому '-' замість '+' і '_' замість '/',
// а хвостові '=' відкинуті. atob() про це не знає, тому повертаємо назад:
const decode = part =>
  JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));

const payload = decode(token.split('.')[1]);
console.log(payload);
// { sub: '42', scope: 'orders:read', exp: 1789234567, aud: 'orders-api' }

// Живий чи протух — рахуємо самі. exp у СЕКУНДАХ, Date.now() у мілісекундах:
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
console.log(secondsLeft > 0 ? `живий ще ${secondsLeft}с` : 'протух');
const token = localStorage.getItem('access_token');

// base64url is base64 with '-' instead of '+' and '_' instead of '/',
// and the trailing '=' dropped. atob() does not know that, so we convert it back:
const decode = part =>
  JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));

const payload = decode(token.split('.')[1]);
console.log(payload);
// { sub: '42', scope: 'orders:read', exp: 1789234567, aud: 'orders-api' }

// Valid or expired — we compute it ourselves. exp is in SECONDS, Date.now() in milliseconds:
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
console.log(secondsLeft > 0 ? `still valid for ${secondsLeft}s` : 'expired');
🔧 Патерни JS у цьому прикладі token.split('.')[1] — payload завжди друга частина з трьох; base64url vs base64 — заміна двох символів перед atob(), інакше отримаєш помилку декодування на приблизно кожному третьому токені; Math.floor(Date.now()/1000) — переведення в unix-секунди, у яких живе exp.
🔧 JS patterns in this example token.split('.')[1] — the payload is always the second of the three parts; base64url vs base64 — two characters to swap before atob(), otherwise roughly every third token fails to decode; Math.floor(Date.now()/1000) — converting to the unix seconds that exp lives in.
🗣 Як читати вголос JWT складається з трьох частин через крапку: header, payload і signature. Перші дві — це звичайний JSON у base64url, тобто їх читає будь-хто, у кого є токен, і це не діра, а дизайн: JWT не приховує вміст, він гарантує, що вміст не змінювали. Сервер бере header і payload як вони прийшли, рахує підпис своїм секретом і порівнює з третьою частиною — тому підмінити роль у токені не вийде, підпис одразу перестане сходитись, а прочитати роль можна вільно. З цього є два практичні наслідки. Перший для розробки: у payload не можна класти нічого чутливого, бо токен лежить у localStorage, у логах проксі і в скріншотах баг-репортів — усе, що ти туди поклав, ти фактично опублікував. Другий для мене як тестера: коли запит несподівано віддає 403, я першим ділом розкодовую payload — там майже завжди і лежить відповідь: не той scope, не той tenant або exp у минулому. І окремо пам'ятаю, що exp — це unix-час у секундах, а Date.now у мілісекундах, на цьому множенні помиляються постійно.
🗣 How to say it out loud A JWT is three parts separated by dots: header, payload and signature. The first two are just JSON in base64url, so anyone holding the token can read them, and that isn't a hole, it's the design — a JWT doesn't hide the contents, it guarantees they weren't changed. The server takes the header and payload exactly as they arrived, computes the signature with its own secret and compares it with the third part, so you can't swap a role inside a token — the signature stops matching straight away — but you can read that role freely. There are two practical consequences. One for development: nothing sensitive goes in the payload, because the token sits in localStorage, in proxy logs and in bug-report screenshots, so whatever you put there you've effectively published. The other for me as a tester: when a request suddenly returns 403, the first thing I do is decode the payload, and the answer is almost always right there — wrong scope, wrong tenant, or exp in the past. And I keep in mind that exp is unix time in seconds while Date.now is in milliseconds; people get that multiplication wrong constantly.
auth-4 401 чи 403: у чому різниця між недійсним токеном і токеном без потрібного scope? 401 or 403: what is the difference between an invalid token and a token without the right scope?
Auth Middle ▶

Формулювання, яке варто вивчити дослівно: 401 — це питання «хто ти?», 403 — це відповідь «я знаю, хто ти, і тобі не можна».

A phrasing worth learning word for word: 401 is the question “who are you?”, 403 is the answer “I know who you are, and you may not”.

401 Unauthorized — сервер не зміг тебе ідентифікувати. Токена немає, він зіпсований, підпис не сходиться, exp у минулому. Назва статусу історично невдала: там мало би бути Unauthenticated. За стандартом 401 зобов'язаний нести заголовок WWW-Authenticate, який каже, як саме автентифікуватись.

401 Unauthorized — the server could not identify you. There is no token, it is corrupted, the signature does not match, exp is in the past. The name of the status is historically unfortunate: it should have been Unauthenticated. By the standard a 401 has to carry a WWW-Authenticate header saying how to authenticate.

403 Forbidden — сервер тебе впізнав, підпис валідний, токен живий, але прав на цю дію немає. Немає потрібного scope, роль не та, ресурс належить іншому користувачеві.

403 Forbidden — the server recognised you, the signature is valid, the token is alive, but there are no rights for this action. The required scope is missing, the role is wrong, the resource belongs to another user.

Чому це не педантизм — клієнт поводиться на них по-різному:

Why this is not pedantry — a client behaves differently on each:

  • На 401 правильний клієнт мовчки йде оновлювати токен і повторює запит. Повторна спроба має сенс.
  • На 403 повторювати безглуздо — новий токен нічого не змінить, права видає адміністратор, а не refresh.
  • On a 401 a correct client quietly goes off to refresh the token and repeats the request. Retrying makes sense.
  • On a 403 retrying is pointless — a new token changes nothing, rights are granted by an administrator, not by a refresh.

Тому переплутані коди — це справжній баг, а не косметика. API віддає 401 на брак прав → клієнт іде рефрешити токен, отримує новий, знову 401, знову рефреш: нескінченний цикл і DDoS власного auth-сервісу. Дзеркальний випадок: API віддає 403 на протухлий токен → клієнт вирішує, що це назавжди, не рефрешить і викидає користувача на логін щогодини.

So swapped codes are a real bug, not cosmetics. The API returns 401 for missing rights → the client goes to refresh the token, gets a new one, 401 again, refresh again: an endless loop and a DDoS of your own auth service. The mirror case: the API returns 403 for an expired token → the client decides this is permanent, never refreshes, and throws the user back to the login screen every hour.

Коли 404 замість 403 — це правильно, а не помилка

When 404 instead of 403 is right, not a mistake

Чесний 403 на /orders/777 підтверджує, що замовлення 777 існує. Перебором можна дізнатись, скільки в системи клієнтів. Тому багато API навмисно віддають 404 на чужий ресурс: «нема такого — і крапка». Це не баг, це рішення, і в тест-кейсі має бути записано, який варіант обрав продукт.

An honest 403 on /orders/777 confirms that order 777 exists. Enumerate and you learn how many customers the system has. That is why many APIs deliberately return 404 for someone else's resource: “no such thing, end of story”. It is not a bug, it is a decision, and the test case has to record which option the product chose.

І головне, чого не спіймає жодна перевірка статус-коду: найдорожчий баг цієї теми — це не 401 замість 403, а 200 там, де мав бути 403. Валідний токен користувача A відкриває дані користувача B. Статус-код правильний, схема відповіді правильна, тест зелений — а це витік. Ловиться він тільки одним способом: свідомо надіслати запит токеном одного акаунта на ресурс іншого і перевірити не тіло, а сам факт відмови.

And the thing no status-code assertion will catch: the most expensive bug in this topic is not 401 instead of 403, it is 200 where 403 belonged. User A's valid token opens user B's data. The status code is right, the response schema is right, the test is green — and that is a leak. There is exactly one way to catch it: deliberately send a request with one account's token against another account's resource and assert the refusal itself, not the body.

Дерево рішень сервера — і що з нього випливає для клієнта
The server's decision tree — and what follows from it for the client
          запит з Authorization
                    │
      ┌─────────────┴─────────────┐
      │ токен є? підпис сходиться?│
      │ exp у майбутньому?        │
      └─────────────┬─────────────┘
             ні     │     так
       ┌────────────┘     └────────────┐
       ▼                               ▼
  401 Unauthorized            ┌─────────────────────┐
  + WWW-Authenticate          │ є потрібний scope?  │
       │                      │ ресурс твій?        │
       │                      └──────────┬──────────┘
       │                          ні     │     так
       │                    ┌────────────┘     └────────┐
       │                    ▼                           ▼
       │            403 Forbidden                  200 OK
       │            (або 404, якщо ховаємо
       │             існування ресурсу)
       ▼                    ▼
  клієнт: рефреш      клієнт: показати «немає прав»
  і ПОВТОРИТИ         і НЕ повторювати
          request with Authorization
                    │
      ┌─────────────┴─────────────┐
      │ token present? sig valid? │
      │ exp in the future?        │
      └─────────────┬─────────────┘
             no     │     yes
       ┌────────────┘     └────────────┐
       ▼                               ▼
  401 Unauthorized            ┌─────────────────────┐
  + WWW-Authenticate          │ required scope?     │
       │                      │ your resource?      │
       │                      └──────────┬──────────┘
       │                          no     │     yes
       │                    ┌────────────┘     └────────┐
       │                    ▼                           ▼
       │            403 Forbidden                  200 OK
       │            (or 404 if we hide
       │             that the resource exists)
       ▼                    ▼
  client: refresh     client: show "no permission"
  and RETRY           and do NOT retry
Мінімальний набір перевірок авторизації на один захищений ендпоінт
The minimum set of authorization checks for one protected endpoint
// Postman / Newman — по одному тесту на кожен рядок дерева вище

// 1. Без токена
pm.test('без токена → 401', () => {
  pm.response.to.have.status(401);
  pm.expect(pm.response.headers.get('WWW-Authenticate')).to.exist;
});

// 2. Живий токен, але scope тільки на читання, а метод — DELETE
pm.test('читацький токен на DELETE → 403, не 401', () => {
  pm.response.to.have.status(403);
});

// 3. Найважливіший. Токен акаунта A, ресурс акаунта B.
//    Очікування — 403 або 404 за домовленістю. Будь-який 2xx тут = витік даних.
pm.test('чужий ресурс не віддається', () => {
  pm.expect(pm.response.code).to.be.oneOf([403, 404]);
});
// Postman / Newman — one test per branch of the tree above

// 1. No token
pm.test('no token → 401', () => {
  pm.response.to.have.status(401);
  pm.expect(pm.response.headers.get('WWW-Authenticate')).to.exist;
});

// 2. Valid token, but the scope is read-only and the method is DELETE
pm.test('read-only token on DELETE → 403, not 401', () => {
  pm.response.to.have.status(403);
});

// 3. The most important one. Token of account A, resource of account B.
//    Expected: 403 or 404, as agreed. Any 2xx here = a data leak.
pm.test('another user's resource is not returned', () => {
  pm.expect(pm.response.code).to.be.oneOf([403, 404]);
});
🔧 Патерни Postman у цьому прикладі pm.response.to.have.status(n) — стандартна перевірка коду; pm.response.headers.get('…') — доступ до заголовка відповіді за назвою, регістр не має значення; to.be.oneOf([…]) — коли контракт свідомо допускає два коди, це чесніше, ніж прибити один і потім правити тест.
🔧 Postman patterns in this example pm.response.to.have.status(n) — the standard status check; pm.response.headers.get('…') — access a response header by name, case-insensitive; to.be.oneOf([…]) — when the contract deliberately allows two codes, this is more honest than nailing one down and patching the test later.
🗣 Як читати вголос Найкоротше формулювання: 401 — це питання «хто ти», а 403 — це відповідь «я знаю, хто ти, і тобі не можна». 401 означає, що сервер не зміг мене ідентифікувати: токена немає, він зіпсований, підпис не сходиться або термін дії вийшов, і за стандартом така відповідь має нести заголовок WWW-Authenticate. 403 означає, що токен валідний і живий, але прав на цю дію немає — не той scope, не та роль, чужий ресурс. Різниця не косметична, бо клієнт поводиться на них по-різному: на 401 він мовчки йде оновлювати токен і повторює запит, а на 403 повторювати безглуздо, бо права видає адміністратор, а не refresh. Тому переплутані коди — це справжній баг: 401 замість 403 заганяє клієнта в нескінченний цикл рефрешів, а 403 замість 401 змушує його викидати користувача на логін щогодини. Окремо згадаю 404 замість 403 — це часто свідоме рішення, щоб не підтверджувати існування чужого ресурсу, і в тест-кейсі має бути записано, який варіант обрав продукт. І головне: найдорожчий баг цієї теми — не переплутані 401 і 403, а 200 там, де мав бути 403, коли токен одного користувача відкриває дані іншого. Статус правильний, схема правильна, тест зелений — а це витік, і ловиться він тільки навмисним запитом чужим токеном.
🗣 How to say it out loud The shortest way I put it: 401 is the question “who are you”, and 403 is the answer “I know who you are, and you may not”. A 401 means the server couldn't identify me — no token, a corrupted one, a signature that doesn't match, or an expired one — and by the standard that response should carry a WWW-Authenticate header. A 403 means the token is valid and alive but there are no rights for this action: wrong scope, wrong role, someone else's resource. The difference isn't cosmetic, because clients behave differently on each: on a 401 a client quietly refreshes and retries, and on a 403 retrying is pointless, because rights come from an administrator, not from a refresh. So swapped codes are a genuine bug — a 401 in place of a 403 drives the client into an endless refresh loop, and a 403 in place of a 401 makes it throw the user back to login every hour. I'd also mention 404 instead of 403: that's often a deliberate decision so the API doesn't confirm that someone else's resource exists, and the test case has to say which option the product picked. And the main thing: the most expensive bug here isn't confusing 401 and 403, it's a 200 where a 403 belonged, when one user's token opens another user's data. The status is right, the schema is right, the test is green — and it's a leak, and the only way to catch it is to send that request with the wrong account's token on purpose.
auth-5 Термін дії токена і refresh — що саме від цього ламається у твоєму прогоні? Token expiry and refresh — what exactly does that break in your test run?
Auth Senior ▶

Пара токенів існує через компроміс, який варто вміти пояснити. Access-токен віддається на кожен запит, тому його бачить багато хто — його роблять коротким, 5–15 хвилин, щоб вкрадений швидко протух. Refresh-токен їде тільки в один ендпоінт і тільки зрідка — його роблять довгим, дні або тижні, і саме він тримає сесію.

The token pair exists because of a trade-off worth being able to explain. The access token goes out on every request, so a lot of things see it — that is why it is made short, 5–15 minutes, so a stolen one expires quickly. The refresh token goes to one endpoint only and only occasionally — that is why it is made long, days or weeks, and it is what actually holds the session.

Тобто exp access-токена — це не «скільки триває сесія», а «наскільки застарілими можуть бути права, які в ньому записані». Скасували роль у базі — токен про це не дізнається до наступного рефрешу. Це прямо відповідає на питання «чому адмін зняв права, а користувач ще 10 хвилин працює».

So the access token's exp is not “how long the session lasts”, it is “how stale the permissions written inside it are allowed to be”. Revoke a role in the database and the token will not know until the next refresh. That answers directly the question “why did the admin remove the rights and the user kept working for another 10 minutes”.

Три способи, якими це ламає саме тестовий прогін:

Three ways this breaks a test run specifically:

1. Токен, отриманий один раз у global setup. Сюїт іде 40 хвилин, токен живе 15. Перші тести зелені, потім вал 401 — і ти витрачаєш півдня, дебагаючи «нестабільний» бізнес-функціонал, якого ніхто не ламав. Впізнається за формою: падають ті самі тести, які запускаються пізніше, і перестають падати, коли запустиш їх окремо.

1. A token fetched once in global setup. The suite runs for 40 minutes, the token lives for 15. The first tests are green, then a wave of 401s — and you spend half a day debugging “unstable” business functionality that nobody broke. You recognise it by its shape: the failing tests are the ones that run later, and they stop failing when you run them on their own.

2. Розбіжність годинників. Сервер порівнює exp і nbf зі своїм часом. Якщо CI-раннер спішить на пару хвилин, свіжовиданий токен приїжджає з iat у майбутньому і сервер його відхиляє. Класична форма — «локально працює, в CI 401», і причина не в коді.

2. Clock skew. The server compares exp and nbf with its own time. If the CI runner is a couple of minutes fast, a freshly issued token arrives with iat in the future and the server rejects it. The classic shape is “works locally, 401 in CI”, and the cause is not in the code.

3. Ротація refresh-токенів. У нормальних реалізаціях кожен рефреш видає новий refresh-токен, а старий негайно анулюється. Повторне використання старого трактується як крадіжка і вбиває всю сім'ю токенів. Тепер запусти п'ять паралельних воркерів з одним спільним refresh-токеном у environment — двоє встигнуть, решта дістануть анульовану сесію, і виглядатиме це як «флейк автентифікації».

3. Refresh token rotation. In a sane implementation every refresh issues a new refresh token and invalidates the old one immediately. Reusing the old one is treated as theft and kills the whole token family. Now start five parallel workers sharing one refresh token in the environment — two of them make it, the rest get a dead session, and it all looks like “an auth flake”.

Що з цього робити:

What to do about it:

  • Токен отримувати на воркер, а не на сюїт, і не ділити refresh між паралельними процесами.
  • Оновлювати за станом, а не за розкладом: перед запитом подивитись на exp і взяти новий, якщо лишилось менше запасу. Це три рядки в pre-request і воно вбиває цілий клас флейків.
  • Ніколи не комітити добутий токен у файл environment — завтра він протухне і тест впаде не там, де ти дивишся.
  • Окремо протестувати сам механізм: протухлий access → 401; рефреш → нова пара; повторний рефреш старим токеном → відмова. Третій пункт пропускають майже завжди, а він і є той, що ловить крадіжку.
  • Fetch the token per worker, not per suite, and never share a refresh token between parallel processes.
  • Refresh by state, not on a schedule: before a request look at exp and take a new token if less than your margin is left. That is three lines in a pre-request script and it kills a whole class of flakes.
  • Never commit a fetched token into an environment file — tomorrow it expires and the test fails somewhere you are not looking.
  • Test the mechanism itself separately: an expired access token → 401; a refresh → a new pair; a second refresh with the old token → refused. The third one is skipped almost every time, and it is the one that catches theft.
Життєвий цикл пари токенів — і де в ньому падає сюїт
The life cycle of a token pair — and where the suite falls over in it
t=0        логін → access (exp +15хв) + refresh (exp +30 днів)
           │
t=0..15    ████████████ запити з access — 200
           │
t=15       access протух
           │
           ├─ клієнт зробив як треба:
           │     запит → 401 → POST /oauth/token (refresh)
           │            → нова пара → повтор запиту → 200
           │            └─ старий refresh АНУЛЬОВАНО назавжди
           │
           └─ сюїт узяв токен один раз у global setup:
                 запит → 401 → 401 → 401 → ...
                 і в звіті це виглядає як «нестабільний чекаут»

ПАРАЛЕЛЬНІ ВОРКЕРИ З ОДНИМ refresh — окрема пастка:
  worker 1 ──refresh──▶ ok, отримав нову пару
  worker 2 ──refresh──▶ той самий старий токен → «повторне використання»
  worker 3 ──refresh──▶ сервер вбиває ВСЮ сім'ю → 401 навіть у worker 1
t=0        login → access (exp +15min) + refresh (exp +30 days)
           │
t=0..15    ████████████ requests with access — 200
           │
t=15       access expired
           │
           ├─ client did it right:
           │     request → 401 → POST /oauth/token (refresh)
           │            → new pair → retry the request → 200
           │            └─ old refresh REVOKED for good
           │
           └─ suite took the token once in global setup:
                 request → 401 → 401 → 401 → ...
                 and in the report it looks like a "flaky checkout"

PARALLEL WORKERS WITH ONE refresh — a separate trap:
  worker 1 ──refresh──▶ ok, got a new pair
  worker 2 ──refresh──▶ the same old token → "reuse detected"
  worker 3 ──refresh──▶ server kills the WHOLE family → 401 even for worker 1
Pre-request: оновлювати за станом токена, а не за розкладом
Pre-request: refresh by the token's state, not on a schedule
// Три рядки логіки, які прибирають цілий клас «флейків автентифікації»
const token = pm.environment.get('access_token');
const SKEW = 60; // запас на дорогу і на розбіжність годинників

function expiresIn(jwt) {
  if (!jwt) return -1;
  const body = JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  return body.exp - Math.floor(Date.now() / 1000);
}

if (expiresIn(token) < SKEW) {
  pm.sendRequest({
    url: pm.environment.get('baseUrl') + '/oauth/token',
    method: 'POST',
    body: { mode: 'urlencoded', urlencoded: [
      { key: 'grant_type', value: 'client_credentials' },
      { key: 'client_id',  value: pm.environment.get('client_id') },
      { key: 'client_secret', value: pm.environment.get('client_secret') },
    ]},
  }, (err, res) => {
    // свідомо в environment, а не в collection variable — див. картку про витоки
    pm.environment.set('access_token', res.json().access_token);
  });
}
// Three lines of logic that remove a whole class of "auth flakes"
const token = pm.environment.get('access_token');
const SKEW = 60; // margin for network time and clock skew

function expiresIn(jwt) {
  if (!jwt) return -1;
  const body = JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  return body.exp - Math.floor(Date.now() / 1000);
}

if (expiresIn(token) < SKEW) {
  pm.sendRequest({
    url: pm.environment.get('baseUrl') + '/oauth/token',
    method: 'POST',
    body: { mode: 'urlencoded', urlencoded: [
      { key: 'grant_type', value: 'client_credentials' },
      { key: 'client_id',  value: pm.environment.get('client_id') },
      { key: 'client_secret', value: pm.environment.get('client_secret') },
    ]},
  }, (err, res) => {
    // deliberately in environment, not a collection variable — see the card on leaks
    pm.environment.set('access_token', res.json().access_token);
  });
}
🔧 Патерни Postman у цьому прикладі pm.sendRequest(opts, cb) — єдиний спосіб сходити по мережу з pre-request скрипта, працює через колбек, не через await; mode: 'urlencoded' — бо OAuth2 очікує форму, а не JSON; запас SKEW — той самий прийом, що й у клієнтських SDK: не чекати рівно до exp, а оновлюватись трохи раніше.
🔧 Postman patterns in this example pm.sendRequest(opts, cb) — the only way to make a network call from a pre-request script, callback-based rather than await; mode: 'urlencoded' — because OAuth2 expects a form, not JSON; the SKEW margin — the same trick client SDKs use: do not wait until exactly exp, refresh a little earlier.
🗣 Як читати вголос Пара access і refresh існує через простий компроміс: access їде в кожному запиті, тому його бачить багато хто і його роблять коротким, п'ять-п'ятнадцять хвилин, а refresh їде тільки в один ендпоінт і зрідка, тому він живе днями і саме він тримає сесію. Важливо розуміти, що exp access-токена — це не тривалість сесії, а те, наскільки застарілими можуть бути права всередині: якщо адмін зняв роль, токен дізнається про це тільки на наступному рефреші. Мій прогін це ламає трьома способами. Перший — токен, отриманий один раз у global setup: сюїт іде сорок хвилин, токен живе п'ятнадцять, і половина тестів падає як «нестабільний бізнес-функціонал». Другий — розбіжність годинників: сервер звіряє exp і nbf зі своїм часом, і якщо раннер спішить, свіжий токен приїжджає з датою видачі в майбутньому, звідси класичне «локально працює, в CI 401». Третій — ротація refresh-токенів: кожен рефреш анулює попередній, а повторне використання старого сервер трактує як крадіжку і вбиває всю сім'ю, тому п'ять паралельних воркерів з одним спільним refresh гарантовано дадуть флейк. Тому я беру токен на воркер, а не на сюїт, оновлюю його за станом — дивлюсь на exp із запасом секунд у шістдесят, — і окремо тестую сам механізм: протухлий access дає 401, рефреш дає нову пару, а повторний рефреш старим токеном має бути відхилений. Останній пункт пропускають майже завжди, а він і є той, що ловить крадіжку токена.
🗣 How to say it out loud The access and refresh pair exists because of a simple trade-off: the access token goes out on every request, so plenty of things see it and it's kept short, five to fifteen minutes, while the refresh token only ever goes to one endpoint and only occasionally, so it can live for days and it's what actually holds the session. The thing to understand is that the access token's exp isn't the session length, it's how stale the permissions inside it are allowed to be — if an admin removes a role, the token only finds out at the next refresh. In my test runs this breaks three ways. First, a token fetched once in global setup: the suite runs forty minutes, the token lives fifteen, and half the tests fail looking like flaky business logic. Second, clock skew: the server compares exp and nbf against its own clock, so if the runner is running fast a fresh token arrives issued in the future — that's the classic “works locally, 401 in CI”. Third, refresh rotation: every refresh invalidates the previous one, and reusing an old one is treated as theft and kills the whole family, so five parallel workers sharing one refresh token will flake by design. So I take a token per worker rather than per suite, refresh by state — I look at exp with about sixty seconds of margin — and I test the mechanism itself separately: an expired access gives 401, a refresh gives a new pair, and a second refresh with the old token has to be rejected. That last one gets skipped almost every time, and it's the one that catches a stolen token.
auth-6 Після logout старий JWT усе ще працює. Це баг чи ні? The old JWT still works after logout. Is that a bug or not?
Auth Senior ▶

Це найкраще питання цієї секції, бо правильна відповідь — «залежить, і зараз скажу, від чого». Хто одразу каже «баг», не розуміє механіки; хто одразу каже «не баг», не розуміє ризику.

This is the best question in the section, because the right answer is “it depends, and here is what on”. Anyone who says “bug” straight away does not understand the mechanics; anyone who says “not a bug” straight away does not understand the risk.

Механіка. Класичний JWT — stateless. Сервер не зберігає виданих токенів. Валідність = підпис сходиться + exp у майбутньому. Більше нічого не перевіряється. А POST /logout у такій системі — це операція на клієнті: витерти токен зі сховища. Сам токен від цього не зникає — якщо ти встиг його скопіювати, він працює рівно до exp.

The mechanics. A classic JWT is stateless. The server keeps no record of the tokens it issued. Valid = the signature matches + exp is in the future. Nothing else is checked. And POST /logout in such a system is an operation on the client: wipe the token from storage. The token itself does not disappear — if you managed to copy it, it works right up to exp.

І це не халтура — це та сама властивість, за яку JWT і брали: перевірка без походу в базу, будь-яка репліка сервісу валідує токен самостійно. Відкликання — це стан. Додаєш відкликання — частково втрачаєш stateless. Тому дизайнери свідомо обирають, скільки саме stateless віддати.

And that is not sloppiness — it is the very property JWT was chosen for: verification without a trip to the database, every replica of the service validating a token on its own. Revocation is state. Add revocation and you give up part of being stateless. So designers deliberately choose how much of it to give up.

Чим це роблять на практиці:

How it is done in practice:

  • Короткий TTL і все. Вікно ризику 5–15 хвилин вважають прийнятним. Найпоширеніший варіант.
  • Denylist за jti. Ідентифікатор токена кладуть у Redis до моменту exp і перевіряють на кожному запиті. Відкликання є, але з'явився похід у сховище.
  • Лічильник версій у користувача. У токені ver: 7, у профілі теж 7. Logout або зміна пароля збільшує лічильник — і всі старі токени стають нерівні. Одна цифра вбиває всі сесії відразу.
  • Відкликати тільки refresh. Access доживає своє, а нового вже не буде. Компроміс, який обирають найчастіше і який треба вміти впізнати.
  • A short TTL and nothing else. A risk window of 5–15 minutes is considered acceptable. The most common option.
  • A denylist by jti. The token id goes into Redis until exp and is checked on every request. You get revocation, but you also get a trip to storage.
  • A version counter on the user. The token carries ver: 7, the profile says 7 too. Logout or a password change bumps the counter — and every old token stops matching. One number kills every session at once.
  • Revoke the refresh token only. The access token lives out its life, but there will be no new one. The compromise chosen most often, and worth being able to recognise.

Як це перетворити на роботу тестера. Перевірка одна: зберегти access-токен, зробити logout, повторити запит тим самим токеном. А от очікуваний результат треба взяти зі специфікації, а не з інтуїції. Якщо продукт заявляє миттєве відкликання, а токен працює — це баг. Якщо продукт свідомо stateless, то баг не в logout, а в тому, що access-токен живе 24 години: у репорті пишеш саме це, з часом життя і вікном ризику, а не «logout не працює».

How to turn this into a tester's work. There is one check: save the access token, log out, repeat the request with the same token. But the expected result comes from the specification, not from intuition. If the product claims instant revocation and the token still works — that is a bug. If the product is deliberately stateless, the bug is not in logout but in the access token living for 24 hours: that is what you write in the report, with the lifetime and the risk window, rather than “logout doesn't work”.

Випадок, у якому згодні всі. Після зміни пароля старі сесії мають померти — це сценарій «мене зламали, я міняю пароль», і залишити зловмиснику живий токен неприпустимо. Те саме для примусового відключення співробітника. Якщо ці два сценарії не покриті, це дефект незалежно від того, наскільки stateless система.

The case everybody agrees on. After a password change the old sessions have to die — this is the “I was compromised, I am changing my password” scenario, and leaving an attacker a live token is unacceptable. The same goes for forcibly cutting off an employee. If those two scenarios are not covered, it is a defect no matter how stateless the system is.

Що перевіряє сервер: чистий stateless vs stateless + відкликання
What the server checks: pure stateless vs stateless plus revocation
STATELESS (класичний JWT)              STATELESS + DENYLIST
─────────────────────────              ────────────────────────
1. підпис сходиться?                   1. підпис сходиться?
2. exp у майбутньому?                  2. exp у майбутньому?
                                       3. jti є в denylist?  ← похід у Redis
   → пускаємо                             → пускаємо, якщо немає

   logout = стерти токен                  logout = записати jti
   у клієнта                              в Redis до моменту exp
        │                                       │
   старий токен живий                     старий токен мертвий
   до exp                                 одразу
        │                                       │
   0 запитів у сховище                    +1 запит у сховище
   на кожен виклик API                    на кожен виклик API
        │                                       │
   └── за це JWT і брали              └── за це заплатили латентністю

ЛІЧИЛЬНИК ВЕРСІЙ — дешевша середина:
  токен несе ver:7, у профілі теж 7 → пускаємо
  logout / зміна пароля → у профілі стає 8 → всі старі токени мертві
STATELESS (classic JWT)                STATELESS + DENYLIST
─────────────────────────              ────────────────────────
1. signature valid?                    1. signature valid?
2. exp in the future?                  2. exp in the future?
                                       3. jti in denylist?   ← a trip to Redis
   → let it through                       → let through if absent

   logout = delete the token              logout = store the jti
   on the client                          in Redis until exp
        │                                       │
   old token stays valid                  old token is dead
   until exp                              immediately
        │                                       │
   0 storage lookups                      +1 storage lookup
   per API call                           per API call
        │                                       │
   └── this is why JWT was chosen     └── paid for with latency

VERSION COUNTER — a cheaper middle ground:
  token carries ver:7, profile also has 7 → let it through
  logout / password change → profile becomes 8 → all old tokens are dead
Тест, який ставить питання правильно
The test that asks the question the right way
// Крок 1 — зберегти токен ДО логауту
pm.environment.set('token_before_logout', pm.environment.get('access_token'));

// Крок 2 — POST /logout

// Крок 3 — повторити захищений запит тим самим, збереженим токеном.
// Очікування береться зі специфікації. Обидва варіанти — валідні продукти:

// A) продукт заявив миттєве відкликання
pm.test('токен після logout не приймається', () => {
  pm.response.to.have.status(401);
});

// B) продукт свідомо stateless — тоді предмет перевірки інший:
//    не «чи працює токен», а «наскільки довге вікно ризику»
pm.test('вікно ризику після logout не більше 15 хвилин', () => {
  const exp = payloadOf(pm.environment.get('token_before_logout')).exp;
  pm.expect(exp - Math.floor(Date.now() / 1000)).to.be.below(900);
});

// Крок 4 — те саме після ЗМІНИ ПАРОЛЯ. Тут очікування одне для всіх: 401.
// Step 1 — save the token BEFORE logout
pm.environment.set('token_before_logout', pm.environment.get('access_token'));

// Step 2 — POST /logout

// Step 3 — repeat the protected request with the same saved token.
// The expectation comes from the spec. Both options are valid products:

// A) the product promises instant revocation
pm.test('token is rejected after logout', () => {
  pm.response.to.have.status(401);
});

// B) the product is deliberately stateless — then we check something else:
//    not "does the token work" but "how long is the risk window"
pm.test('risk window after logout is at most 15 minutes', () => {
  const exp = payloadOf(pm.environment.get('token_before_logout')).exp;
  pm.expect(exp - Math.floor(Date.now() / 1000)).to.be.below(900);
});

// Step 4 — the same after a PASSWORD CHANGE. Here the expectation is the same for everyone: 401.
🔧 Патерни у цьому прикладі jti — стандартний claim з унікальним ідентифікатором токена, саме він робить denylist можливим; TTL запису в Redis дорівнює exp токена — після нього зберігати нічого, запис самознищується; to.be.below(900) — перевірка не факту, а межі: тест на характеристику системи, а не на її поведінку.
🔧 Patterns in this example jti — the standard claim carrying a unique token id, the thing that makes a denylist possible; the TTL of the Redis entry equals the token's exp — after that there is nothing left to keep and the entry deletes itself; to.be.below(900) — asserting a bound rather than a fact: a test about a property of the system, not about its behaviour.
🗣 Як читати вголос Моя відповідь тут — залежить, і зараз скажу, від чого. Класичний JWT stateless: сервер не зберігає виданих токенів, валідність — це підпис плюс exp у майбутньому, і більше нічого. Тому logout у такій системі — операція на клієнті, вона просто стирає токен зі сховища, а сам токен, якщо його встигли скопіювати, працює до кінця свого терміну. І це не халтура, а та сама властивість, за яку JWT і брали: перевірка без походу в базу. Відкликання — це стан, і додаючи його, ти частково втрачаєш саме те, заради чого брав JWT, тому дизайнери свідомо вибирають, скільки віддати: або просто короткий TTL, або denylist за jti у Redis, або лічильник версій у профілі користувача, або відкликають тільки refresh, а access доживає своє. Як тестер я роблю одну перевірку: зберігаю токен, роблю logout і повторюю запит тим самим токеном. Але очікуваний результат беру зі специфікації, а не з інтуїції. Якщо продукт заявив миттєве відкликання, а токен працює — це баг. Якщо продукт свідомо stateless, то баг не в логауті, а в тому, що access-токен живе двадцять чотири години, і в репорті я пишу саме це — час життя і вікно ризику. І є два сценарії, у яких згодні всі: після зміни пароля і після примусового відключення співробітника старі сесії мають померти, тут виправдань немає.
🗣 How to say it out loud My answer here is: it depends, and let me say on what. A classic JWT is stateless — the server keeps no record of issued tokens, validity is just the signature plus an exp in the future, and nothing more. So logout in that kind of system is a client-side operation: it wipes the token from storage, and the token itself, if someone copied it, keeps working until it expires. That's not sloppiness, it's the exact property JWT was picked for — verification without a trip to the database. Revocation is state, and by adding it you give back part of what you took JWT for, so designers deliberately decide how much to give back: either just a short TTL, or a denylist by jti in Redis, or a version counter on the user profile, or they revoke only the refresh token and let the access token live out its life. As a tester I run one check: save the token, log out, repeat the request with the same token. But I take the expected result from the specification, not from intuition. If the product claimed instant revocation and the token still works, that's a bug. If the product is deliberately stateless, then the bug isn't logout — it's that the access token lives twenty-four hours, and that's what I write in the report, with the lifetime and the risk window. And there are two scenarios nobody argues about: after a password change and after an employee is cut off, the old sessions have to die. There are no excuses there.
auth-7 OAuth2: який flow перед тобою і що в ньому реально твоя зона відповідальності? OAuth2: which flow are you looking at, and what in it is actually your responsibility?
Auth Senior ▶

Перше, що варто сказати вголос: OAuth2 — це протокол делегування доступу, а не логіну. Він відповідає на питання «як дати цьому додатку право читати мій календар, не віддаючи йому пароль». Логін поверх нього — це вже OpenID Connect, окрема надбудова, яка додає id_token. Плутанина між ними — стандартне питання на співбесіді.

The first thing worth saying out loud: OAuth2 is a protocol for delegating access, not for logging in. It answers the question “how do I give this app the right to read my calendar without handing it my password”. Login on top of it is already OpenID Connect, a separate layer that adds an id_token. Confusing the two is a standard interview question.

Два flow, які ти реально зустрінеш:

Two flows you will actually meet:

Authorization Code + PKCE — коли за екраном є людина. Браузер іде на сторону провайдера, людина логіниться там (твій додаток пароля не бачить взагалі), назад прилітає редирект з одноразовим code, і вже бекенд міняє цей код на токени. Код летить через адресний рядок, тому він одноразовий і живе секунди; PKCE додає пару «випадкове значення + його хеш», щоб перехоплений код не можна було обміняти чужому клієнту.

Authorization Code + PKCE — when there is a human in front of the screen. The browser goes off to the provider, the person logs in there (your app never sees the password), a redirect comes back with a one-time code, and it is the backend that exchanges that code for tokens. The code travels through the address bar, so it is single-use and lives for seconds; PKCE adds a pair of “a random value plus its hash” so an intercepted code cannot be exchanged by a different client.

Client Credentials — коли людини немає взагалі: сервіс до сервісу, або твій CI до API. Просто client_id + client_secret в обмін на токен. Жодних редиректів. Це і є той flow, яким ти отримуєш токен для автотестів.

Client Credentials — when there is no human at all: service to service, or your CI to the API. Just client_id + client_secret in exchange for a token. No redirects. This is the flow you get your automation token with.

Окремо: Resource Owner Password Credentials — коли додаток сам питає логін і пароль і надсилає їх провайдеру. Він прибраний з рекомендацій, бо повністю руйнує сенс OAuth2 — додаток знову бачить пароль. Якщо ти зустрів його в проді, це знахідка, а не «легасі, ну і хай».

Separately: Resource Owner Password Credentials — where the app itself asks for the login and password and sends them to the provider. It has been dropped from the recommendations because it destroys the point of OAuth2 entirely — the app sees the password again. If you meet it in production, that is a finding, not “legacy, never mind”.

Тепер про межу відповідальності — це і є senior-частина відповіді.

Now the boundary of responsibility — this is the senior part of the answer.

Ти не тестуєш провайдера. Keycloak, Auth0, Google — це чужий протестований продукт, і автоматизувати браузерний танець редиректів заради кожного API-тесту — марна витрата часу і головне джерело флейків. Токен добувається один раз через client credentials або через тестовий refresh, а далі ти тестуєш своє API.

You do not test the provider. Keycloak, Auth0, Google are someone else's tested product, and automating the browser dance of redirects for every API test is wasted time and the main source of flakes. The token is obtained once through client credentials or a test refresh, and after that you test your own API.

А от що справді твоє:

What really is yours:

  • Scope реально enforced. Попросити токен з вузьким scope і перевірити, що ендпоінт поза цим scope віддає 403. Дуже часто scope видають, записують у токен — і ніде не перевіряють.
  • Аудиторія (aud). Токен, випущений для сервісу A, підсунути сервісу B. Підпис валідний, exp живий — і якщо B його прийняв, це серйозна діра: будь-який сусідній сервіс стає точкою входу.
  • Одноразовість коду. Обміняти code на токен двічі. Друга спроба має провалитись, і хороший провайдер ще й анулює видану пару.
  • redirect_uri. Підставити свій домен або дописати хвіст до дозволеного. Має бути точний збіг зі списком, а не «починається з».
  • state. Прибрати або підмінити — запит має бути відхилений, це захист від CSRF на самому логіні.
  • Scope actually enforced. Ask for a token with a narrow scope and check that an endpoint outside that scope returns 403. Very often scopes are issued and written into the token — and checked nowhere.
  • Audience (aud). Take a token issued for service A and hand it to service B. The signature is valid, exp is alive — and if B accepts it, that is a serious hole: any neighbouring service becomes an entry point.
  • The code is single-use. Exchange the code for a token twice. The second attempt has to fail, and a good provider also invalidates the pair it already issued.
  • redirect_uri. Substitute your own domain, or append a tail to an allowed one. It has to be an exact match against the list, not “starts with”.
  • state. Remove it or swap it — the request has to be rejected; this is the CSRF protection on the login itself.
Два flow поруч — і де в кожному твоя межа
The two flows side by side — and where your boundary runs in each
AUTHORIZATION CODE + PKCE            CLIENT CREDENTIALS
(є людина за екраном)                (сервіс до сервісу, твій CI)

 користувач                           твій тест / бекенд
     │                                      │
     │ 1. редирект у провайдера             │ 1. POST /oauth/token
     ▼                                      │    grant_type=client_credentials
 [ сторінка логіну провайдера ]             │    client_id + client_secret
   пароль вводиться ТУТ,                    ▼
   твій додаток його не бачить          [ провайдер ]
     │                                      │
     │ 2. редирект назад: ?code=…&state=…   │ 2. { access_token, expires_in }
     ▼                                      ▼
 твій бекенд                            твій тест
     │ 3. code + verifier → токени           │
     ▼                                       │
 { access_token, refresh_token }             │
     │                                       │
     └──────────────┬────────────────────────┘
                    ▼
        Authorization: Bearer …  ← далі обидва однакові,
                    │                і саме тут починається
                    ▼                твоя зона відповідальності
              ТВОЄ API: scope enforced? aud правильний?
AUTHORIZATION CODE + PKCE            CLIENT CREDENTIALS
(a human at the screen)              (service to service, your CI)

 user                                 your test / backend
     │                                      │
     │ 1. redirect to the provider          │ 1. POST /oauth/token
     ▼                                      │    grant_type=client_credentials
 [ provider login page ]                    │    client_id + client_secret
   password is typed HERE,                  ▼
   your app never sees it               [ provider ]
     │                                      │
     │ 2. redirect back: ?code=…&state=…    │ 2. { access_token, expires_in }
     ▼                                      ▼
 your backend                           your test
     │ 3. code + verifier → tokens           │
     ▼                                       │
 { access_token, refresh_token }             │
     │                                       │
     └──────────────┬────────────────────────┘
                    ▼
        Authorization: Bearer …  ← from here both are the same,
                    │                and this is exactly where
                    ▼                your responsibility begins
              YOUR API: scope enforced? aud correct?
Дві перевірки, які пропускають найчастіше
The two checks that get skipped most often
// 1. Чи справді scope щось означає, чи це просто напис у токені
//    Беремо токен з orders:read і йдемо в ендпоінт, що вимагає orders:write
pm.test('вузький scope не відкриває чужий ендпоінт', () => {
  pm.response.to.have.status(403);
  // 200 тут = scope видається, але ніде не перевіряється.
  // Помилка тиха: у токені все правильно написано.
});

// 2. Аудиторія. Токен для billing-api несемо в orders-api.
//    Підпис той самий, exp живий — усе тримається тільки на перевірці aud.
pm.test('токен чужого сервісу не приймається', () => {
  const aud = payloadOf(pm.environment.get('billing_token')).aud;
  pm.expect(aud).to.not.equal('orders-api');  // передумова тесту
  pm.response.to.have.status(401);
});
// 1. Does the scope actually mean anything, or is it just text in the token
//    Take a token with orders:read and call an endpoint that requires orders:write
pm.test('a narrow scope does not open another endpoint', () => {
  pm.response.to.have.status(403);
  // 200 here = the scope is issued but never enforced.
  // The bug is silent: everything in the token looks right.
});

// 2. Audience. We take a token for billing-api to orders-api.
//    Same signature, exp still valid — everything rests on the aud check alone.
pm.test('a token for another service is rejected', () => {
  const aud = payloadOf(pm.environment.get('billing_token')).aud;
  pm.expect(aud).to.not.equal('orders-api');  // test precondition
  pm.response.to.have.status(401);
});
🔧 Патерни OAuth2 у цьому прикладі grant_type — поле, що називає flow, і найшвидший спосіб зрозуміти, який саме перед тобою; code_verifier / code_challenge — пара PKCE: клієнт вигадує випадкове значення, спершу шле його хеш, а при обміні коду — оригінал; aud і scope — два claim, які перетворюють «токен валідний» на «токен валідний саме тут і саме на це».
🔧 OAuth2 patterns in this example grant_type — the field that names the flow, and the fastest way to work out which one is in front of you; code_verifier / code_challenge — the PKCE pair: the client invents a random value, sends its hash first and the original when it exchanges the code; aud and scope — the two claims that turn “the token is valid” into “the token is valid here and for this”.
🗣 Як читати вголос Почну з того, що OAuth2 — це протокол делегування доступу, а не логіну: він відповідає на питання, як дати додатку право читати мій календар, не віддаючи йому пароль, а логін поверх нього — це вже OpenID Connect з окремим id_token. На практиці я зустрічаю два flow. Authorization Code з PKCE — коли за екраном є людина: браузер іде на сторінку провайдера, пароль вводиться там, назад прилітає одноразовий код, і вже бекенд міняє його на токени, а PKCE не дає перехопленому коду спрацювати в чужого клієнта. І Client Credentials — коли людини немає взагалі: client_id плюс client_secret в обмін на токен, без жодних редиректів, і саме ним я добуваю токен для автотестів. Окремо згадаю Resource Owner Password — його прибрали з рекомендацій, бо додаток знову бачить пароль, і якщо він трапився в проді, це знахідка, а не легасі. Головне, що я хочу сказати про межу відповідальності: я не тестую провайдера — Keycloak чи Auth0 це чужий протестований продукт, і автоматизувати браузерний танець редиректів заради кожного API-тесту означає купити собі флейки. Моє починається після того, як токен уже в руках: чи справді scope перевіряється на ендпоінті, а не просто написаний у токені; чи перевіряється aud, тобто чи не прийме моє API токен, випущений для сусіднього сервісу; чи одноразовий код; чи звіряється redirect_uri точним збігом; чи відхиляється запит без state.
🗣 How to say it out loud I'd start by saying that OAuth2 is a protocol for delegating access, not for logging in: it answers the question of how to give an app the right to read my calendar without handing over my password, and login on top of it is already OpenID Connect with its own id_token. In practice I meet two flows. Authorization Code with PKCE, when there's a human in front of the screen: the browser goes to the provider's page, the password is typed there, a one-time code comes back, and it's the backend that exchanges it for tokens, while PKCE stops an intercepted code from working for a different client. And Client Credentials, when there's no human at all: client id plus client secret in exchange for a token, no redirects, and that's how I get the token for my automation. I'd also mention Resource Owner Password — it was dropped from the recommendations because the app sees the password again, so if it turns up in production that's a finding, not legacy. The main thing I want to say about the boundary is that I don't test the provider: Keycloak or Auth0 is someone else's tested product, and automating the browser redirect dance for every API test just buys me flakes. My part starts once the token is in hand — whether the scope is actually enforced at the endpoint rather than merely written into the token, whether aud is checked so my API won't accept a token issued for a neighbouring service, whether the code is really single-use, whether redirect_uri is matched exactly, and whether a request without state gets rejected.
auth-8 Де витікають секрети зі спільної колекції — і чому «я нікому її не давав» тут не аргумент? Where do secrets leak out of a shared collection — and why is “I never gave it to anyone” not an argument here?
Auth Senior ▶

Секрети з колекцій витікають не тому, що хтось вирішив їх розголосити. Вони витікають, бо в інструменті є кілька місць, які виглядають однаково, а їдуть у різні боки. Знати цю різницю — рівно та річ, яка відрізняє людину, що вже палилась, від тієї, що ще ні.

Secrets do not leak out of collections because somebody decided to disclose them. They leak because the tool has several places that look the same and travel in different directions. Knowing that difference is exactly what separates the person who has already been burned from the one who has not.

Головна пастка — два значення однієї змінної.

The main trap — one variable with two values.

У Postman у змінної є initial value і current value. Current — локальне, залишається на твоїй машині. Initial — те, що синхронізується в командний воркспейс і потрапляє в експортований JSON. Людина вставляє токен, не дивлячись у яке поле, натискає Share — і токен їде разом з файлом. Візуально в інтерфейсі різниці майже немає.

In Postman a variable has an initial value and a current value. Current is local, it stays on your machine. Initial is what syncs to the team workspace and lands in the exported JSON. Someone pastes a token without looking at which field they are in, hits Share — and the token travels with the file. Visually the interface barely tells them apart.

Друге за поширеністю — рівень зберігання.

Second most common — the storage level.

Collection variables і налаштування Authorization на рівні колекції експортуються разом з колекцією — це її вміст. Environment — окрема сутність, її можна не віддавати. Тому правило просте: у колекції живе {{token}}, значення живе в environment або в секретах CI. Колекція має бути такою, щоб її не соромно було прикріпити до тікета.

Collection variables and collection-level Authorization settings are exported together with the collection — they are its content. An environment is a separate entity, and you can simply not hand it over. So the rule is simple: the collection holds {{token}}, the value lives in the environment or in CI secrets. A collection should be something you would not be embarrassed to attach to a ticket.

Канали, про які згадують рідше, а горять на них так само:

Channels people mention less often, and get burned by just as much:

  • console.log(pm.environment.get('token')) у тестовому скрипті. Логи Newman у CI зазвичай публічні для всієї команди, а в опенсорсі — для всіх.
  • Збережені приклади відповідей. Зручна фіча: зберегти реальну відповідь як example. Разом з нею зберігається і запит — з живим заголовком Authorization.
  • Файл environment у репозиторії. *.postman_environment.json, закомічений «щоб у всіх працювало».
  • Скріншот у баг-репорті. Панель заголовків розгорнута, токен видно. Тікет живе роками.
  • Токен у query-рядку — осідає в логах усього, що стоїть на шляху, і ти навіть не дізнаєшся, де саме.
  • console.log(pm.environment.get('token')) in a test script. Newman logs in CI are usually public to the whole team — and in open source, to everyone.
  • Saved example responses. A handy feature: save a real response as an example. The request is saved with it — including a live Authorization header.
  • An environment file in the repository. *.postman_environment.json, committed “so it works for everyone”.
  • A screenshot in a bug report. The headers panel is expanded, the token is visible. Tickets live for years.
  • A token in the query string — it settles in the logs of everything on the path, and you will never even learn where.

І найважливіше правило реакції. Секрет, який хоч раз кудись поїхав, вважається скомпрометованим — назавжди. Виправити коміт, видалити повідомлення, перезаписати історію — це прибирає сліди, а не витік: сканери форків і кеш дзеркал уже все забрали. Єдина справжня дія — ротація: відкликати старий ключ і випустити новий. Прибирання історії робиться після ротації, а не замість неї.

And the most important rule of all — how to react. A secret that has travelled anywhere even once is considered compromised — permanently. Fixing the commit, deleting the message, rewriting history removes the traces, not the leak: fork scanners and mirror caches have already taken everything. The only real action is rotation: revoke the old key and issue a new one. Cleaning up the history happens after rotation, not instead of it.

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

Cheap prevention that genuinely works: test credentials should be separate, with minimal permissions and only against a staging environment. Then a leak hurts but is not fatal. A production key must never live in a collection used for manual testing — not even for five minutes, not even “just to check something quickly”.

Що їде разом зі спільною колекцією, а що лишається в тебе
What travels with a shared collection and what stays with you
ЇДЕ РАЗОМ З КОЛЕКЦІЄЮ                 ЛИШАЄТЬСЯ ЛОКАЛЬНО
(експорт, Share, синхронізація)       (якщо не експортувати окремо)
──────────────────────────────        ──────────────────────────────
collection variables                  environment → CURRENT value
collection-level Authorization        (значення тільки в тебе)
pre-request / test скрипти
збережені example-відповіді           ⚠ environment → INITIAL value
  (з живим заголовком!)                 їде в командний воркспейс
описи, назви, коментарі                 і в експортований файл
                                        ← ось де палиться більшість

            ┌──────────────────────────────────┐
            │  У колекції — тільки {{token}}.  │
            │  Значення — в environment        │
            │  або в секретах CI.              │
            └──────────────────────────────────┘

Newman у CI не потребує файлу з секретами взагалі:
  newman run api.json -e stage.json --env-var "token=$API_TOKEN"
                                               └─ зі сховища секретів CI
TRAVELS WITH THE COLLECTION           STAYS LOCAL
(export, Share, sync)                 (unless exported separately)
──────────────────────────────        ──────────────────────────────
collection variables                  environment → CURRENT value
collection-level Authorization        (the value is only on your machine)
pre-request / test scripts
saved example responses               ⚠ environment → INITIAL value
  (with a live header!)                 goes to the team workspace
descriptions, names, comments           and into the exported file
                                        ← this is where most people get burned

            ┌──────────────────────────────────┐
            │  Collection: only {{token}}.     │
            │  The value — in the environment  │
            │  or in CI secrets.               │
            └──────────────────────────────────┘

Newman in CI needs no secrets file at all:
  newman run api.json -e stage.json --env-var "token=$API_TOKEN"
                                               └─ from the CI secret store
Три рядки, які самі знаходять витік у колекції перед тим, як її віддати
Three lines that find a leak in a collection before you hand it over
# Перед тим як прикріпити експорт до тікета або закомітити:

# 1. Живі значення там, де мають бути тільки плейсхолдери
grep -nE '(sk_|Bearer [A-Za-z0-9._-]{20,}|eyJ[A-Za-z0-9._-]{20,})' api.postman_collection.json

# 2. Непусті initial values в environment — саме вони їдуть далі
grep -n '"value"' stage.postman_environment.json | grep -v '"value": ""'

# 3. Логування секретів у скриптах
grep -n 'console.log' api.postman_collection.json | grep -iE 'token|secret|password|key'

# Знайшов у гілці, яка вже запушена? Спочатку ротація ключа,
# і тільки потім прибирання історії. Не навпаки.
# Before attaching an export to a ticket or committing it:

# 1. Live values where only placeholders belong
grep -nE '(sk_|Bearer [A-Za-z0-9._-]{20,}|eyJ[A-Za-z0-9._-]{20,})' api.postman_collection.json

# 2. Non-empty initial values in the environment — these are what get shared
grep -n '"value"' stage.postman_environment.json | grep -v '"value": ""'

# 3. Secrets logged in scripts
grep -n 'console.log' api.postman_collection.json | grep -iE 'token|secret|password|key'

# Found it in a branch that is already pushed? Rotate the key first,
# and only then clean the history. Not the other way round.
🔧 Патерни у цьому прикладі {{token}} — синтаксис підстановки Postman: у файлі лишається ім'я, а не значення; --env-var "k=v" — спосіб передати секрет у Newman з CI, не кладучи його у файл; eyJ у grep — це base64 від {", тобто початок будь-якого JWT: найдешевший спосіб знайти токен у будь-якому тексті.
🔧 Patterns in this example {{token}} — Postman's substitution syntax: the file keeps the name, not the value; --env-var "k=v" — the way to pass a secret into Newman from CI without putting it in a file; eyJ in the grep — that is base64 for {", the start of any JWT, and the cheapest way to find a token in any text.
🗣 Як читати вголос Секрети з колекцій витікають не тому, що хтось вирішив їх розголосити, а тому, що в інструменті є місця, які виглядають однаково, а їдуть у різні боки. Головна пастка — це initial value і current value у Postman: current лишається на моїй машині, а initial синхронізується в командний воркспейс і потрапляє в експортований файл, і людина вставляє токен, не дивлячись у яке поле. Друге — рівень зберігання: collection variables і налаштування Authorization на рівні колекції експортуються разом з нею, бо це її вміст, а environment — окрема сутність. Тому правило в мене просте: у колекції живе плейсхолдер, значення живе в environment або в секретах CI, і колекція має бути такою, щоб її не соромно було прикріпити до тікета. Є ще канали, про які згадують рідше: console.log з токеном у тестовому скрипті, бо логи Newman бачить уся команда; збережені example-відповіді, які зберігаються разом із живим заголовком Authorization; закомічений файл environment; скріншот у баг-репорті з розгорнутою панеллю заголовків. І найважливіше — правило реакції: секрет, який хоч раз кудись поїхав, вважається скомпрометованим назавжди. Виправити коміт чи видалити повідомлення — це прибирає сліди, а не витік, бо сканери форків уже все забрали. Єдина справжня дія — ротація ключа, а прибирання історії робиться після неї, а не замість.
🗣 How to say it out loud Secrets leak out of collections not because somebody decided to disclose them, but because the tool has places that look the same and travel in different directions. The main trap is initial value versus current value in Postman: current stays on my machine, while initial syncs to the team workspace and ends up in the exported file — and people paste a token without looking at which field they're in. The second is the storage level: collection variables and collection-level Authorization are exported with the collection because they are its content, whereas an environment is a separate entity. So my rule is simple — the collection holds a placeholder, the value lives in the environment or in CI secrets, and the collection should be something I'd be happy to attach to a ticket. There are other channels people mention less: a console.log with the token in a test script, because the whole team can read Newman's logs; saved example responses, which get saved together with a live Authorization header; a committed environment file; a screenshot in a bug report with the headers panel expanded. And the most important part is how you react: a secret that has travelled anywhere even once is compromised forever. Fixing the commit or deleting the message removes the traces, not the leak, because fork scanners have already taken it. The only real action is rotating the key — and cleaning up the history comes after that, never instead of it.
▶ 4 8
prod-1 Що таке пагінація і чим offset відрізняється від cursor? What is pagination, and how does offset differ from cursor?
ProductionJunior▶

Жоден нормальний сервер не віддає 400 тисяч замовлень одним JSON. Він ріже список на сторінки і віддає по шматку, а клієнт ходить за наступним. Це і є пагінація — вона є майже в кожному ендпоінті, який повертає список.

No sane server hands back 400 thousand orders as one JSON. It cuts the list into pages and returns one chunk at a time, and the client walks for the next one. That is pagination — it is on almost every endpoint that returns a list.

Offset-пагінація — «дай 20 штук, пропустивши перші 40».

Offset pagination — “give me 20, skipping the first 40”.

Параметри — звичайні числа: ?limit=20&offset=40 або ?page=3&per_page=20. Це прямий переклад у SQL LIMIT 20 OFFSET 40. Плюс — можна стрибнути одразу на сторінку 7 і показати користувачу «сторінка 3 з 42», бо сервер знає загальну кількість.

The parameters are plain numbers: ?limit=20&offset=40 or ?page=3&per_page=20. It maps straight onto SQL LIMIT 20 OFFSET 40. The upside — you can jump straight to page 7 and show the user “page 3 of 42”, because the server knows the total count.

Cursor-пагінація (вона ж keyset) — «дай 20 штук після ось цього маркера».

Cursor pagination (also called keyset) — “give me 20 after this marker”.

Сервер разом зі сторінкою повертає next_cursor — непрозорий рядок, усередині якого зазвичай зашитий ключ сортування останнього рядка. Ти його не вигадуєш і не парсиш, ти просто повертаєш його назад у наступному запиті. Стрибнути на сторінку 7 не можна: тільки крок уперед (і назад, якщо сервер дає prev_cursor).

Along with the page the server returns a next_cursor — an opaque string that usually has the sort key of the last row baked into it. You do not invent it and you do not parse it, you simply hand it back in the next request. You cannot jump to page 7: only a step forward (and back, if the server offers a prev_cursor).

Що з цього безпосередньо стосується тестера: у offset параметри — це числа, які можна і треба крутити (0, -1, 999999, abc). У cursor крутити нічого — курсор або той, що дав сервер, або підроблений; підроблений має дати 400 Bad Request, а не 500 зі стектрейсом бази.

What this means directly for a tester: with offset the parameters are numbers you can and should push around (0, -1, 999999, abc). With a cursor there is nothing to push: the cursor is either the one the server gave you or a forged one, and a forged one has to give 400 Bad Request, not a 500 with a database stack trace.

Де шукати наступну сторінку: у тілі відповіді (next, next_cursor, has_more) або в заголовку Link з rel="next". Це два різні стилі, і документація має казати, який тут — вгадувати не треба.

Where to look for the next page: in the response body (next, next_cursor, has_more) or in a Link header with rel="next". Those are two different styles, and the documentation should say which one is in use — no guessing required.

Схема: два стилі пагінації на тих самих даних
Diagram: two pagination styles over the same data
// ── OFFSET ────────────────────────────────────────────────────────
// GET /orders?limit=3&offset=0        GET /orders?limit=3&offset=3
//
//   [ #101 #102 #103 ] #104 #105        #101 #102 #103 [ #104 #105 #106 ]
//     ▲ беремо 3                          ▲ пропустили 3   ▲ беремо 3
//
// {"items": [...], "total": 128, "limit": 3, "offset": 0}
// → сторінок можна порахувати наперед: ceil(128 / 3) = 43

// ── CURSOR ────────────────────────────────────────────────────────
// GET /orders?limit=3
// {"items": [#101, #102, #103], "next_cursor": "b3JkOjEwMw", "has_more": true}
//                                              ▲ це "ord:103" у base64
//
// GET /orders?limit=3&cursor=b3JkOjEwMw
// {"items": [#104, #105, #106], "next_cursor": "b3JkOjEwNg", "has_more": true}
//
// ... остання сторінка:
// {"items": [#128], "next_cursor": null, "has_more": false}
//                    ▲ ознака кінця — курсор став null
//
// total тут зазвичай НЕМАЄ: порахувати всі рядки коштує дорого,
// і саме тому cursor тримає темп на великих таблицях
// ── OFFSET ────────────────────────────────────────────────────────
// GET /orders?limit=3&offset=0        GET /orders?limit=3&offset=3
//
//   [ #101 #102 #103 ] #104 #105        #101 #102 #103 [ #104 #105 #106 ]
//     ▲ take 3                            ▲ skipped 3      ▲ take 3
//
// {"items": [...], "total": 128, "limit": 3, "offset": 0}
// → page count is known up front: ceil(128 / 3) = 43

// ── CURSOR ────────────────────────────────────────────────────────
// GET /orders?limit=3
// {"items": [#101, #102, #103], "next_cursor": "b3JkOjEwMw", "has_more": true}
//                                              ▲ this is "ord:103" in base64
//
// GET /orders?limit=3&cursor=b3JkOjEwMw
// {"items": [#104, #105, #106], "next_cursor": "b3JkOjEwNg", "has_more": true}
//
// ... last page:
// {"items": [#128], "next_cursor": null, "has_more": false}
//                    ▲ end marker — the cursor became null
//
// there is usually NO total here: counting all rows is expensive,
// and that is exactly why cursor stays fast on large tables
HTTP — як це виглядає у справжніх заголовках
HTTP — what it looks like in real headers
# Стиль 1 — навігація в тілі відповіді
GET /api/v1/orders?limit=20&offset=40
HTTP/1.1 200 OK
Content-Type: application/json
{ "items": [...], "total": 128, "limit": 20, "offset": 40 }

# Стиль 2 — навігація в заголовку Link (так робить GitHub)
GET /api/v1/orders?per_page=20&page=3
HTTP/1.1 200 OK
Link: <.../orders?page=4>; rel="next", <.../orders?page=7>; rel="last"
# ↑ клієнт має брати готове посилання, а не збирати URL руками
# Style 1 — navigation in the response body
GET /api/v1/orders?limit=20&offset=40
HTTP/1.1 200 OK
Content-Type: application/json
{ "items": [...], "total": 128, "limit": 20, "offset": 40 }

# Style 2 — navigation in the Link header (GitHub does this)
GET /api/v1/orders?per_page=20&page=3
HTTP/1.1 200 OK
Link: <.../orders?page=4>; rel="next", <.../orders?page=7>; rel="last"
# ↑ the client should take the ready link, not build the URL by hand
🔧 HTTP-патерни в цьому прикладі query-параметри — пари ?a=1&b=2, амперсанд між ними; opaque token — непрозорий рядок, який клієнт повертає як є і не намагається розібрати; заголовок Link з rel="next" — стандартний спосіб віддати навігацію окремо від даних.
🔧 HTTP patterns in this example query parameters — pairs like ?a=1&b=2, with an ampersand between them; opaque token — a string the client hands back as it is and never tries to take apart; the Link header with rel="next" — the standard way to serve navigation separately from data.
🗣 Як читати вголос Пагінація — це коли сервер віддає довгий список не цілком, а сторінками, і клієнт ходить за наступною. Є два поширені стилі. Offset — це «дай двадцять штук, пропустивши перші сорок»: параметри звичайні числа, вони прямо лягають у SQL limit і offset, і сервер зазвичай віддає ще й total, тому можна показати «сторінка три з сорока двох» і стрибнути одразу на сьому. Cursor — це «дай двадцять штук після ось цього маркера»: сервер повертає непрозорий рядок next_cursor, я його не парсю, а просто віддаю назад у наступному запиті, і рухатись можна тільки послідовно. Для мене як тестера різниця дуже практична: в offset параметри — це числа, які я кручу на нуль, мінус один і мільйон, а в cursor крутити нічого, зате підроблений курсор має повертати чотириста, а не п'ятисоту з помилкою бази. І перед тестами я завжди дивлюсь, де взагалі живе навігація — у тілі відповіді чи в заголовку Link, бо клієнт має брати готове посилання, а не збирати URL руками.
🗣 How to say it out loud Pagination is the server handing back a long list in pages instead of all at once, with the client walking for the next one. There are two common styles. Offset is “give me twenty, skipping the first forty”: the parameters are plain numbers, they map straight onto SQL limit and offset, and the server usually returns a total as well, so you can show “page three of forty-two” and jump straight to page seven. Cursor is “give me twenty after this marker”: the server returns an opaque next_cursor, I don't parse it, I just hand it back on the next request, and I can only move sequentially. For me as a tester the difference is very practical — with offset the parameters are numbers I push to zero, minus one and a million, whereas with a cursor there's nothing to push, but a forged cursor should come back as a four hundred, not a five hundred with a database error. And before I start testing I always check where the navigation actually lives, in the body or in the Link header, because the client should follow the link it's given rather than assembling URLs by hand.
prod-2 Що таке rate limit, який код він повертає і які заголовки його описують? What is a rate limit, what code does it return, and which headers describe it?
ProductionJunior▶

Rate limit — це стеля на кількість запитів за проміжок часу: «сто запитів на хвилину з одного токена». Вона існує не щоб дошкулити тобі, а щоб один клієнт із кривим циклом не з'їв ресурс, за який платять усі інші.

A rate limit is a ceiling on the number of requests in a period of time: “a hundred requests a minute per token”. It exists not to annoy you but so that one client with a broken loop does not eat the capacity everybody else is paying for.

Перевищив — сервер відповідає 429 Too Many Requests. Це важливо розрізняти: 403 означає «тобі сюди не можна взагалі», 503 — «серверу зле», а 429 — «ти легітимний, просто зачасто». Якщо на перевищення ліміту прилітає 500 — це баг сервера, і його треба заводити окремо від самої фічі ліміту.

Go over it and the server answers 429 Too Many Requests. The distinction matters: 403 means “you may not be here at all”, 503 means “the server is unwell”, and 429 means “you are legitimate, just too often”. If exceeding the limit produces a 500, that is a server bug and it gets filed separately from the limit feature itself.

Заголовки, які описують стан ліміту:

The headers that describe the state of the limit:

X-RateLimit-Limit — скільки всього дозволено у вікні. X-RateLimit-Remaining — скільки лишилось. X-RateLimit-Reset — коли вікно оновиться. Префікс X- — історичний де-факто стандарт; новіші API віддають ті самі три без нього: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset.

X-RateLimit-Limit — how much is allowed in the window in total. X-RateLimit-Remaining — how much is left. X-RateLimit-Reset — when the window renews. The X- prefix is a historical de-facto standard; newer APIs serve the same three without it: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset.

Окремо стоїть Retry-After — єдиний із цієї компанії, який справді описаний у специфікації HTTP. Він буває або числом секунд (Retry-After: 30), або датою (Retry-After: Wed, 10 Sep 2026 12:00:00 GMT). Клієнт має вміти обидва формати, і це окремий тест-кейс.

Retry-After stands apart — the only one of the group actually described in the HTTP specification. It comes either as a number of seconds (Retry-After: 30) or as a date (Retry-After: Wed, 10 Sep 2026 12:00:00 GMT). A client has to handle both formats, and that is a test case of its own.

Головна пастка з Reset: в одних API це unix-таймстемп («о 14:32:00»), в інших — кількість секунд до скидання («через 47»). Число 47 і число 1789567920 сплутати важко, а от 0 і 1 — легко. Не вгадуй за значенням: подивись документацію і зафіксуй одиниці в тесті.

The main trap with Reset: in some APIs it is a unix timestamp (“at 14:32:00”), in others the number of seconds until the reset (“in 47”). It is hard to confuse 47 with 1789567920, but easy to confuse 0 with 1. Do not guess from the value: read the documentation and pin the unit down in the test.

HTTP — нормальна відповідь і відповідь на перевищенні
HTTP — a normal response and a response at the ceiling
# Запит 98-й із 100 дозволених — усе добре, але лічильник уже видно
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 2
X-RateLimit-Reset: 1789567920
Content-Type: application/json

# Запит 101-й — стеля
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1789567920
Retry-After: 47
Content-Type: application/json

{ "error": "rate_limit_exceeded", "message": "Try again in 47 seconds" }

# ❌ А ось так виглядає баг, який ти заводиш:
HTTP/1.1 429 Too Many Requests
# ...і все. Ні Retry-After, ні Reset, ні тіла.
# Клієнт не знає, скільки чекати → або довбить далі й ловить бан,
# або чекає навмання й гальмує там, де вже можна було працювати.
# Request 98 of 100 allowed — all fine, but the counter is already visible
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 2
X-RateLimit-Reset: 1789567920
Content-Type: application/json

# Request 101 — the ceiling
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1789567920
Retry-After: 47
Content-Type: application/json

{ "error": "rate_limit_exceeded", "message": "Try again in 47 seconds" }

# ❌ And this is what the bug you file looks like:
HTTP/1.1 429 Too Many Requests
# ...and that's it. No Retry-After, no Reset, no body.
# The client does not know how long to wait → it either keeps hammering and gets banned,
# or waits at random and stalls when it could already be working.
Postman — перевірка ліміту в тестах колекції
Postman — checking the limit in collection tests
pm.test('ліміт описаний заголовками', () => {
  pm.response.to.have.header('X-RateLimit-Limit');
  pm.response.to.have.header('X-RateLimit-Remaining');
});

// Лічильник має спадати, а не стояти на місці
const left = Number(pm.response.headers.get('X-RateLimit-Remaining'));
const prev = pm.collectionVariables.get('rl_prev');

if (prev !== undefined && prev !== '') {
  pm.test('Remaining не зростає в межах вікна', () => {
    pm.expect(left).to.be.at.most(Number(prev));
  });
}
pm.collectionVariables.set('rl_prev', left);

// І окремо — що на 429 є чим керуватись
if (pm.response.code === 429) {
  pm.test('429 каже, коли повертатись', () => {
    pm.expect(pm.response.headers.has('Retry-After')).to.be.true;
  });
}
pm.test('the limit is described by headers', () => {
  pm.response.to.have.header('X-RateLimit-Limit');
  pm.response.to.have.header('X-RateLimit-Remaining');
});

// The counter must go down, not stand still
const left = Number(pm.response.headers.get('X-RateLimit-Remaining'));
const prev = pm.collectionVariables.get('rl_prev');

if (prev !== undefined && prev !== '') {
  pm.test('Remaining does not grow within the window', () => {
    pm.expect(left).to.be.at.most(Number(prev));
  });
}
pm.collectionVariables.set('rl_prev', left);

// And separately — that a 429 gives the client something to act on
if (pm.response.code === 429) {
  pm.test('429 says when to come back', () => {
    pm.expect(pm.response.headers.has('Retry-After')).to.be.true;
  });
}
🔧 Postman / JS патерни в цьому прикладі pm.response.headers.get(name) — читання заголовка відповіді, повертає рядок або null; pm.collectionVariables — сховище, яке живе між запитами колекції, тому в ньому можна тримати попереднє значення лічильника; Number(x) — заголовок завжди рядок, порівнювати числа без явного приведення не можна.
🔧 Postman / JS patterns in this example pm.response.headers.get(name) — reads a response header, returns a string or null; pm.collectionVariables — storage that survives between requests in a collection, so it can hold the previous value of the counter; Number(x) — a header is always a string, and numbers cannot be compared without converting explicitly.
🗣 Як читати вголос Rate limit — це стеля на кількість запитів за вікно часу, зазвичай на токен, на користувача або на IP. Коли клієнт її перевищує, сервер відповідає чотириста двадцять дев'ять, Too Many Requests — і саме цей код: чотириста три означає «тобі сюди не можна взагалі», п'ятсот три — «серверу зле», а чотириста двадцять дев'ять — «ти легітимний, просто зачасто». Стан ліміту сервер описує заголовками: RateLimit-Limit — скільки всього дозволено, RateLimit-Remaining — скільки лишилось, RateLimit-Reset — коли вікно скинеться, і Retry-After — через скільки секунд повертатись; останній єдиний зі списку справді стандартизований у HTTP. Коли я тестую, я дивлюсь на три речі: що Remaining справді спадає, що на перевищенні прилітає чотириста двадцять дев'ять із Retry-After, і що після очікування запит нормально проходить. І окремо перевіряю, в яких одиницях приходить Reset — в одних API це unix-час, в інших секунди до скидання, і це треба брати з документації, а не вгадувати за значенням.
🗣 How to say it out loud A rate limit is a ceiling on how many requests you get in a window of time, usually per token, per user or per IP. When a client goes over it the server answers four twenty-nine, Too Many Requests — and that code specifically: four oh three means “you may not be here at all”, five oh three means “the server is unwell”, and four twenty-nine means “you're legitimate, just too often”. The server describes the state of the limit with headers: RateLimit-Limit is how much is allowed, RateLimit-Remaining is how much is left, RateLimit-Reset is when the window renews, and Retry-After says how long to wait — that last one is the only one in the group that's genuinely standardised in HTTP. When I test this I look at three things: that Remaining really goes down, that going over gives me a four twenty-nine with a Retry-After, and that after waiting the request goes through normally. And separately I check what unit Reset comes in, because in some APIs it's a unix timestamp and in others it's seconds until the reset — that's something to take from the documentation rather than guess from the value.
prod-3 Як ти тестуєш пагінацію — які сторінки перевіряєш першими? How do you test pagination — which pages do you check first?
ProductionMiddle▶

Перша сторінка — найменш цікаве, що є в пагінації. Вона працює завжди, бо саме її бачив розробник, поки писав код. Баги живуть на краях.

The first page is the least interesting thing in pagination. It always works, because it is the one the developer was looking at while writing the code. Bugs live at the edges.

1. Порожня колекція.

1. An empty collection.

Новий акаунт, жодного замовлення. Правильна відповідь — 200 зі списком [] і total: 0. Не 404: 404 означає «такого ендпоінта немає», а не «даних немає». І не items: null — на фронті це падіння в .map(), а не порожній список.

A new account, not a single order. The right answer is 200 with a list of [] and total: 0. Not 404: 404 means “there is no such endpoint”, not “there is no data”. And not items: null — on the front end that is a crash in .map(), not an empty list.

2. Остання сторінка.

2. The last page.

Неповна: 13 елементів при limit=20. Тут має зникнути next або has_more має стати false. Класичний баг — сервер віддає next завжди, і клієнт, який ходить «поки є next», крутиться вічно або тягне порожні сторінки до таймауту.

Partial: 13 items with limit=20. Here next has to disappear or has_more has to become false. The classic bug is a server that always returns next, and a client that walks “while there is a next” spins forever or drags empty pages until it times out.

3. Рівно кратна кількість. Ось цю забувають.

3. An exact multiple. This is the one people forget.

40 елементів при limit=20: друга сторінка повна, і саме на ній має бути has_more: false. Якщо сервер визначає кінець за ознакою «сторінка неповна» — він тут помилиться і віддасть третю, порожню. Технічно не смертельно, але клієнт робить зайвий запит на кожен список, а на мобільному це вже помітно.

40 items with limit=20: the second page is full, and that is exactly where has_more: false belongs. If the server decides the end by “the page is not full”, it gets this wrong and serves a third, empty page. Technically not fatal, but the client makes an extra request for every list, and on mobile that is already noticeable.

4. Сторінка за межами і сміття в параметрах.

4. A page beyond the end, and garbage in the parameters.

page=999 на трьох сторінках даних; limit=0, limit=-1, limit=100000, page=abc. Тут немає єдиної правильної відповіді — важлива консистентність із документацією: або 400 з поясненням, або мовчазне обрізання до максимуму. Чого не має бути ніколи — 500, таймаута або віддачі всієї таблиці на limit=100000.

page=999 over three pages of data; limit=0, limit=-1, limit=100000, page=abc. There is no single right answer here — what matters is consistency with the documentation: either 400 with an explanation, or silent clamping to the maximum. What must never happen is a 500, a timeout, or the whole table coming back for limit=100000.

5. Сума частин.

5. The sum of the parts.

Найцінніша перевірка і найрідше написана: пройти всі сторінки, зібрати id і порівняти їх кількість із total. Плюс перевірити, що дублів немає — new Set(ids).size === ids.length. Якщо на межі сторінок один запис задвоївся або зник, це видно тільки так: кожен окремий запит виглядає бездоганно.

The most valuable check and the most rarely written: walk every page, collect the ids and compare their count with total. Plus check there are no duplicates — new Set(ids).size === ids.length. If one record was doubled or dropped on a page boundary, this is the only way to see it: every individual request looks flawless.

💡 Ще один край, який ламається саме на демо: сортування. Якщо сервер не сортує явно — немає ORDER BY або сортування йде за неунікальним полем, скажімо за датою, де в двохсот записів одна й та сама дата — порядок між запитами не гарантований. Тоді сторінка 2 може повернути рядок, який ти вже бачив на сторінці 1. Стабільна пагінація вимагає стабільного сортування, і зазвичай це означає «поле + id як tie-breaker».
💡 One more edge that breaks exactly during a demo: sorting. If the server does not sort explicitly — no ORDER BY, or sorting by a non-unique field, say a date that two hundred records share — the order between requests is not guaranteed. Then page 2 can return a row you already saw on page 1. Stable pagination requires stable sorting, and that usually means “the field plus id as a tie-breaker”.
Схема: краї, які треба перевірити (total = 40, limit = 20)
Diagram: the edges worth checking (total = 40, limit = 20)
// ┌──────────────┬──────────────┬───────────────────────────────────┐
// │ запит        │ items        │ що саме перевіряємо               │
// ├──────────────┼──────────────┼───────────────────────────────────┤
// │ порожня БД   │ []           │ 200, не 404; total=0; не null     │
// │ page=1       │ 20           │ has_more=true, next присутній     │
// │ page=2       │ 20 (кратно!) │ has_more=FALSE — тут ламається    │
// │ page=3       │ []           │ 200 + порожньо або 404 — але так  │
// │              │              │ само, як написано в документації  │
// │ page=999     │ []           │ те саме, що page=3, і без 500     │
// │ limit=0      │ ?            │ 400 або дефолт — не тиша          │
// │ limit=-1     │ ?            │ 400 — і точно не «весь список»    │
// │ limit=100000 │ ?            │ обрізання до max (напр. 100)      │
// │ page=abc     │ ?            │ 400, не 500                       │
// └──────────────┴──────────────┴───────────────────────────────────┘
//
// Пастка кратності:
//   40 записів, limit=20  →  [ 20 ][ 20 ][ 0 ]
//                               ▲     ▲     ▲
//                обидві повні ──┘     │     └─ зайвий запит, якщо кінець
//                                     │        визначають за «неповна»
//                    has_more має стати false ТУТ
// ┌──────────────┬──────────────┬───────────────────────────────────┐
// │ request      │ items        │ what exactly we check             │
// ├──────────────┼──────────────┼───────────────────────────────────┤
// │ empty DB     │ []           │ 200, not 404; total=0; not null   │
// │ page=1       │ 20           │ has_more=true, next present       │
// │ page=2       │ 20 (=limit!) │ has_more=FALSE — breaks here      │
// │ page=3       │ []           │ 200 + empty or 404 — but exactly  │
// │              │              │ as the documentation says         │
// │ page=999     │ []           │ same as page=3, and no 500        │
// │ limit=0      │ ?            │ 400 or default — not silence      │
// │ limit=-1     │ ?            │ 400 — never "the whole list"      │
// │ limit=100000 │ ?            │ capped at max (e.g. 100)          │
// │ page=abc     │ ?            │ 400, not 500                      │
// └──────────────┴──────────────┴───────────────────────────────────┘
//
// The exact-multiple trap:
//   40 records, limit=20  →  [ 20 ][ 20 ][ 0 ]
//                               ▲     ▲     ▲
//                   both full ──┘     │     └─ extra request if the end
//                                     │        is detected by "not full"
//                    has_more must become false HERE
JS — прохід усіх сторінок і перевірка суми частин
JS — walking every page and checking the sum of the parts
// Один тест, який ловить дублі, пропуски і нескінченний next
async function collectAll(baseUrl, limit = 20) {
  const ids = [];
  let page = 1;
  let total = null;

  while (true) {
    // Запобіжник: без нього баг «next є завжди» вішає тест, а не валить його
    if (page > 200) throw new Error('пагінація не закінчується: 200+ сторінок');

    const res = await fetch(baseUrl + '?page=' + page + '&limit=' + limit);
    if (res.status !== 200) throw new Error('page ' + page + ' → ' + res.status);

    const body = await res.json();
    total ??= body.total;

    ids.push(...body.items.map(o => o.id));
    if (!body.has_more) break;
    page++;
  }
  return { ids, total, pages: page };
}

// Три асерти, які варті решти сюїти на пагінацію разом узятої:
const { ids, total } = await collectAll('/api/v1/orders');

expect(ids.length).toBe(total);                     // нічого не загубилось
expect(new Set(ids).size).toBe(ids.length);       // нічого не задвоїлось
expect(ids).toEqual([...ids].sort((a, b) => a - b));  // порядок стабільний
// One test that catches duplicates, gaps and an endless next
async function collectAll(baseUrl, limit = 20) {
  const ids = [];
  let page = 1;
  let total = null;

  while (true) {
    // Safety valve: without it the "next is always there" bug hangs the test instead of failing it
    if (page > 200) throw new Error('pagination never ends: 200+ pages');

    const res = await fetch(baseUrl + '?page=' + page + '&limit=' + limit);
    if (res.status !== 200) throw new Error('page ' + page + ' → ' + res.status);

    const body = await res.json();
    total ??= body.total;

    ids.push(...body.items.map(o => o.id));
    if (!body.has_more) break;
    page++;
  }
  return { ids, total, pages: page };
}

// Three asserts worth the rest of the pagination suite combined:
const { ids, total } = await collectAll('/api/v1/orders');

expect(ids.length).toBe(total);                     // nothing lost
expect(new Set(ids).size).toBe(ids.length);       // nothing duplicated
expect(ids).toEqual([...ids].sort((a, b) => a - b));  // order is stable
🔧 JS-патерни в цьому прикладі total ??= body.total — nullish-присвоєння: запишемо тільки якщо там null або undefined, тобто беремо total з першої сторінки; ids.push(...arr) — spread розгортає масив в аргументи, інакше отримаємо вкладений масив; new Set(ids).size — найкоротший спосіб порахувати унікальні; [...ids].sort() — копія перед сортуванням, бо sort() мутує оригінал.
🔧 JS patterns in this example total ??= body.total — nullish assignment: write only if it is null or undefined, i.e. take total from the first page; ids.push(...arr) — spread turns the array into arguments, otherwise you get a nested array; new Set(ids).size — the shortest way to count unique values; [...ids].sort() — a copy before sorting, because sort() mutates the original.
🗣 Як читати вголос Перша сторінка мене цікавить найменше — вона працює завжди, бо саме її бачив розробник. Я починаю з країв. Порожня колекція має віддати двісті з порожнім масивом і total нуль — не чотириста чотири і не null, бо null на фронті це падіння в map. Остання сторінка має погасити ознаку продовження: або зникає next, або has_more стає false, інакше клієнт, який ходить «поки є next», крутиться вічно. Далі — випадок, який забувають найчастіше: коли кількість записів кратна ліміту, скажімо сорок записів по двадцять. Тоді друга сторінка повна, і саме на ній має бути has_more false; якщо сервер визначає кінець за ознакою «сторінка неповна», він тут помилиться і змусить клієнта робити зайвий запит на кожен список. Потім сміття в параметрах — нуль, мінус один, сто тисяч, літери замість числа: тут немає єдиної правильної відповіді, важливо, щоб поведінка збігалася з документацією і щоб не було п'ятисотої. І найцінніше наприкінці: я проходжу всі сторінки, збираю ідентифікатори і перевіряю, що їх рівно total і що дублів немає — саме так ловляться записи, які на межі сторінок задвоїлись або зникли, бо кожен окремий запит при цьому виглядає бездоганно.
🗣 How to say it out loud The first page interests me least — it always works, because that's the one the developer was looking at. I start at the edges. An empty collection should give two hundred with an empty array and a total of zero, not a four oh four and definitely not null, because null on the front end is a crash in map. The last page has to switch off the continuation flag: either next disappears or has_more goes false, otherwise a client that walks “while there's a next” spins forever. Then the case people forget most often: when the number of records is an exact multiple of the limit, say forty records at twenty a page. The second page is full, and that's precisely where has_more should be false; if the server decides the end by “the page isn't full”, it gets this wrong and makes the client do an extra request for every single list. After that, garbage in the parameters — zero, minus one, a hundred thousand, letters instead of a number: there's no single right answer, what matters is that the behaviour matches the documentation and that there's no five hundred. And the most valuable one last: I walk every page, collect the ids and check there are exactly total of them and no duplicates — that's how you catch records that got doubled or dropped on a page boundary, because each individual request looks flawless.
prod-4 Де живе версія API і що вважається breaking change? Where does the API version live, and what counts as a breaking change?
ProductionMiddle▶

Версіонування існує з однієї причини: у публічного API є клієнти, яких ти не контролюєш. Мобільний застосунок на телефоні користувача не оновиться синхронно з бекендом — тому стару поведінку треба десь тримати, поки та версія жива.

Versioning exists for one reason: a public API has clients you do not control. A mobile app on someone's phone will not update in step with the backend — so the old behaviour has to be kept somewhere while that version is alive.

Три місця, де ховають версію:

Three places the version hides:

У шляху — /api/v2/orders. Видно в логах, у браузері, в кожному тікеті; найпростіше тестувати. У заголовку — X-API-Version: 2 або через тип контенту Accept: application/vnd.acme.v2+json. Чистіше з погляду REST (URL адресує ресурс, а не його формат), але заголовок легко забути — і клієнт непомітно поїде на дефолтну версію. І зрідка в query — ?version=2; ця форма зручно кешується неправильно і легко губиться при редиректі.

In the path — /api/v2/orders. Visible in logs, in the browser, in every ticket; the easiest to test. In a header — X-API-Version: 2, or through the content type, Accept: application/vnd.acme.v2+json. Cleaner from a REST point of view (a URL addresses a resource, not its format), but a header is easy to forget — and then the client silently drifts onto the default version. And occasionally in the query — ?version=2; that form caches wrongly rather conveniently and gets lost on redirects.

Що таке breaking change — з боку клієнта, а не з боку схеми.

What a breaking change is — from the client's side, not the schema's.

Ламає той, хто змушує клієнта переписати код: прибрали або перейменували поле; звузили тип ("42" стало 42, або null тепер неможливий там, де на нього була гілка); поле в запиті стало обов'язковим; змінився код відповіді на тій самій ситуації (200 з порожнім тілом → 204); змінився формат помилки; змінився дефолт — limit був 100, став 20, або дефолтне сортування перевернулось.

It breaks if it forces the client to rewrite code: a field removed or renamed; a type narrowed ("42" became 42, or null is no longer possible where the client had a branch for it); a request field made mandatory; a different response code for the same situation (200 with an empty body → 204); a changed error format; a changed default — limit was 100 and became 20, or the default sort order flipped.

Не ламає: додали нове необов'язкове поле у відповідь, додали необов'язковий параметр у запит, додали новий ендпоінт, розширили enum вхідних значень. З важливою застереженням: додавання поля не ламає лише доти, доки клієнти толерують невідомі поля. Якщо на боці клієнта строга схема з additionalProperties: false — нове поле стає breaking, і саме тому такий валідатор ставлять на свої відповіді, а не на чужі.

It does not break: a new optional field in the response, a new optional parameter in the request, a new endpoint, a widened enum of input values. With one important caveat: adding a field is non-breaking only as long as clients tolerate unknown fields. If the client side has a strict schema with additionalProperties: false, a new field becomes breaking — and that is exactly why such a validator is put on your own responses, not on someone else's.

Що з цього робить тестер: тримає контрактний тест на кожну живу версію, поки її не вимкнули; окремим тестом перевіряє, що дефолтна версія (запит без версії) не змінилась мовчки — це найтихіший спосіб зламати всіх одразу; і перевіряє Deprecation / Sunset заголовки, якщо команда обіцяла їх віддавати.

What a tester does with this: keeps a contract test for every live version until it is switched off; checks with a separate test that the default version (a request with no version at all) has not changed silently — that is the quietest way to break everyone at once; and checks the Deprecation / Sunset headers if the team promised to serve them.

HTTP — три способи попросити версію 2
HTTP — three ways to ask for version 2
# 1. У шляху — найпростіше і найвидніше
GET /api/v2/orders/42

# 2. У кастомному заголовку
GET /api/orders/42
X-API-Version: 2

# 3. У типі контенту (content negotiation)
GET /api/orders/42
Accept: application/vnd.acme.v2+json

# А ось відповідь на застарілу версію, яку варто перевірити окремо:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Tue, 30 Jun 2026 23:59:59 GMT
Link: <https://docs.acme.io/migrate-v2>; rel="deprecation"
# ↑ якщо це обіцяли в документації — це такий самий контракт, як тіло
# 1. In the path — simplest and most visible
GET /api/v2/orders/42

# 2. In a custom header
GET /api/orders/42
X-API-Version: 2

# 3. In the content type (content negotiation)
GET /api/orders/42
Accept: application/vnd.acme.v2+json

# And here is a response for a deprecated version, worth checking separately:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Tue, 30 Jun 2026 23:59:59 GMT
Link: <https://docs.acme.io/migrate-v2>; rel="deprecation"
# ↑ if the docs promised this, it is as much a contract as the body
Схема: breaking чи ні — з боку того, хто вже написав клієнт
Diagram: breaking or not — from the side of someone who already wrote the client
// v1                              v2                    вердикт
// ───────────────────────────────────────────────────────────────────
// "customer_name": "Ivan"    →   "customerName": "Ivan"   ❌ BREAKING
//                                 (перейменування = зникле поле)
//
// "total": "199.90"          →   "total": 199.90          ❌ BREAKING
//                                 (рядок став числом — .trim() падає)
//
// 200 + {"items": []}        →   204 No Content           ❌ BREAKING
//                                 (клієнт парсить тіло, а його нема)
//
// ?limit за замовчуванням 100 →  за замовчуванням 20      ❌ BREAKING
//                                 (мовчки, і саме тому найзліше)
//
// {"id", "total"}            →   {"id", "total", "note"}  ✅ ок*
//                                 * якщо клієнт не валідує строгою
//                                   схемою з additionalProperties:false
//
// GET /orders                →   GET /orders + /invoices  ✅ ок
//                                 (новий ендпоінт нікого не чіпає)
// v1                              v2                    verdict
// ───────────────────────────────────────────────────────────────────
// "customer_name": "Ivan"    →   "customerName": "Ivan"   ❌ BREAKING
//                                 (a rename = a missing field)
//
// "total": "199.90"          →   "total": 199.90          ❌ BREAKING
//                                 (string became a number — .trim() fails)
//
// 200 + {"items": []}        →   204 No Content           ❌ BREAKING
//                                 (the client parses a body that is gone)
//
// ?limit default 100         →   default 20               ❌ BREAKING
//                                 (silent, which is exactly why it is the nastiest)
//
// {"id", "total"}            →   {"id", "total", "note"}  ✅ ok*
//                                 * unless the client validates with a strict
//                                   schema with additionalProperties:false
//
// GET /orders                →   GET /orders + /invoices  ✅ ok
//                                 (a new endpoint affects no one)
🔧 HTTP / контрактні патерни в цьому прикладі content negotiation — вибір представлення через Accept, вендорний media type vnd.*+json; Deprecation / Sunset — заголовки життєвого циклу, які кажуть клієнту «працює, але ось дедлайн»; additionalProperties: false — прапорець JSON Schema, який забороняє невідомі поля і тим самим робить будь-яке доповнення відповіді ламким.
🔧 HTTP / contract patterns in this example content negotiation — choosing the representation through Accept, with a vendor media type vnd.*+json; Deprecation / Sunset — lifecycle headers that tell the client “it still works, but here is the deadline”; additionalProperties: false — the JSON Schema flag that forbids unknown fields and thereby makes any addition to a response breaking.
🗣 Як читати вголос Версія живе в одному з трьох місць: у шляху, як slash v2 slash orders, у заголовку — власному, типу X-API-Version, або через Accept із вендорним медіатипом, і зрідка в query. Шлях найпростіше тестувати і видно в логах, заголовок чистіший з погляду REST, але його легко забути, і тоді клієнт мовчки їде на дефолтну версію. Breaking change я визначаю не за схемою, а за клієнтом: ламає все, що змушує його переписати код — прибране або перейменоване поле, звужений тип, нове обов'язкове поле в запиті, інший код відповіді на тій самій ситуації, інший формат помилки і, найтихіше, змінений дефолт: був ліміт сто, став двадцять. Не ламає — нове необов'язкове поле у відповіді, новий необов'язковий параметр, новий ендпоінт; але тільки доти, доки клієнт толерує невідомі поля, бо строга схема з additionalProperties false робить breaking навіть із доповнення. Практично я тримаю контрактний тест на кожну живу версію і окремо перевіряю, що запит без версії не поїхав на іншу поведінку — це найдешевший спосіб зламати одразу всіх, хто не оновлювався.
🗣 How to say it out loud The version lives in one of three places: in the path, as slash v2 slash orders, in a header — either a custom one like X-API-Version or through Accept with a vendor media type — and occasionally in the query string. The path is the easiest to test and it shows up in logs; the header is cleaner from a REST point of view, but it's easy to forget, and then the client silently drifts onto the default version. I decide what's a breaking change from the client's side rather than the schema's: anything that forces them to rewrite code breaks — a removed or renamed field, a narrowed type, a new required field in the request, a different response code for the same situation, a different error format and, quietest of all, a changed default: the limit used to be a hundred, now it's twenty. What doesn't break is a new optional field in the response, a new optional parameter, a new endpoint — but only as long as the client tolerates unknown fields, because a strict schema with additionalProperties false turns even an addition into a breaking change. In practice I keep a contract test for every live version, and I check separately that a request with no version hasn't quietly moved onto different behaviour — that's the cheapest way to break everyone who never upgraded.
prod-5 Як тестувати webhook — те, що не ти викликаєш, а що викликає тебе? How do you test a webhook — the thing you do not call, but that calls you?
ProductionSenior▶

Webhook перевертає ролі. У звичайному API ініціатор — ти: відправив запит, отримав відповідь, перевірив. У webhook ініціатор — чужий сервер, а твоя система виступає сервером, який має бути доступним і мовчки прийняти виклик. Тому звичний прийом «відправ і подивись на відповідь» тут не працює взагалі: спершу потрібне місце, куди приймати.

A webhook flips the roles. In a normal API you are the initiator: you send a request, get a response, check it. With a webhook the initiator is someone else's server, and your system acts as the server that has to be reachable and quietly accept the call. So the familiar “send it and look at the response” does not work here at all: first you need somewhere to receive.

Три способи мати таке місце.

Three ways to have that somewhere.

Публічний тунель на локальну машину — швидко для ручної розвідки, непридатно для CI (адреса щоразу нова, ще й треба її десь зареєструвати). Власний мок-приймач — двадцять рядків HTTP-сервера, який складає все, що прийшло, у масив; це дефолт для CI. Або взагалі не приймати самому: подіяти на систему-відправника і перевіряти наслідок у своїй системі — через її ж API або базу. Третій варіант чесніший (він перевіряє наскрізь), але повільніший і не дає побачити сам запит.

A public tunnel to your local machine — fast for manual exploration, useless for CI (the address is new every time, and it has to be registered somewhere). Your own mock receiver — twenty lines of an HTTP server that pushes everything that arrives into an array; this is the default for CI. Or do not receive at all: act on the sending system and check the consequence inside your own — through its API or its database. The third option is more honest (it tests end to end), but it is slower and it never lets you see the request itself.

Що перевіряти, крім «прийшло».

What to check besides “it arrived”.

Підпис. Серйозний відправник підписує тіло HMAC-ом і кладе підпис у заголовок. Тест на щастя тут неповний: треба ще запит із підробленим підписом — і він має отримати 401, а не 200. Інакше твій ендпоінт приймає замовлення від будь-кого, хто знає URL.

The signature. A serious sender signs the body with HMAC and puts the signature in a header. Testing only the happy path is incomplete here: you also need a request with a forged signature — and it has to get a 401, not a 200. Otherwise your endpoint accepts orders from anyone who knows the URL.

Доставка «щонайменше один раз». Практично всі системи доставляють at-least-once, а не exactly-once. Той самий event_id прийде двічі — після таймауту, після ретраю, після рестарту черги. Обробник має бути ідемпотентним, і це перевіряється прямо: відправ ту саму подію двічі й переконайся, що замовлення одне, лист один, гроші списано раз.

At-least-once delivery. Practically every system delivers at-least-once, not exactly-once. The same event_id will arrive twice — after a timeout, after a retry, after a queue restart. The handler has to be idempotent, and that is checked directly: send the same event twice and make sure there is one order, one email, one charge.

Порядок. Не гарантований. order.updated цілком може прилетіти раніше за order.created. Правильна система дивиться на мітку часу або версію в події, а не на порядок надходження — і тест має спробувати переставити їх місцями.

Ordering. Not guaranteed. order.updated can perfectly well arrive before order.created. A correct system looks at the timestamp or the version inside the event, not at the order of arrival — and the test should try swapping them around.

Час відповіді. Відправник чекає 2xx кілька секунд і на таймаут ретраїть. Якщо твій обробник робить усю роботу синхронно і відповідає на восьмій секунді — ти отримаєш дубль. Правильно: прийняв, поклав у чергу, відповів 200, працюєш далі.

Response time. The sender waits a few seconds for a 2xx and retries on timeout. If your handler does all the work synchronously and answers on the eighth second, you get a duplicate. The right shape: accept it, put it on a queue, answer 200, carry on working.

💡 Найпоширеніша помилка в автотесті на webhook — sleep(5) перед перевіркою. У CI під навантаженням п'яти секунд не вистачить, і тест стане флейком, який «іноді падає». Замість паузи — опитування приймача з таймаутом: перевіряй кожні 100 мс до 10 секунд, вийди одразу як прийшло. Той самий тест стає і швидшим, і стабільнішим.
💡 The most common mistake in an automated webhook test — sleep(5) before the assertion. Under load in CI five seconds is not enough, and the test becomes a flake that “sometimes fails”. Instead of a pause, poll the receiver with a timeout: check every 100 ms for up to 10 seconds and exit the moment it arrives. The same test gets both faster and more stable.
Схема: хто кому дзвонить і де ретраї
Diagram: who calls whom, and where the retries are
// ЗВИЧАЙНИЙ API                    WEBHOOK
// ─────────────                    ───────
// тест ──запит──► сервер           сервер ──подія──► ТИ (приймач)
//      ◄─відповідь─                       ◄──200────
// ти ініціатор                     ти сервер, і маєш бути доступним
//
// ── Ретраї, які й породжують дублі ──────────────────────────────
//
//  спроба 1 ──event_id=evt_88──►  приймач працює 8 c…
//           ✗ таймаут на 5-й
//  спроба 2 ──event_id=evt_88──►  приймач ОБРОБЛЯЄ ДРУГИЙ РАЗ
//           ◄────── 200 ───────
//                                 → два листи, два списання
//
//  Правильний приймач:
//  спроба 1 ──evt_88──► записав evt_88, поклав у чергу, 200 за 40 мс
//  спроба 2 ──evt_88──► бачить evt_88 у сховищі → 200, нічого не робить
//
// ── Backoff і dead-letter ───────────────────────────────────────
//  ти віддаєш 500:  спроба 1 → +1c → +2c → +4c → +8c → dead letter
//  тест: віддай 500 тричі, перевір, що 4-та прийшла і що паузи ростуть
// REGULAR API                      WEBHOOK
// ─────────────                    ───────
// test ──request──► server         server ──event──► YOU (receiver)
//      ◄─response──                       ◄──200────
// you are the initiator            you are the server and must be reachable
//
// ── Retries are what create duplicates ──────────────────────────
//
//  attempt 1 ──event_id=evt_88──►  receiver works for 8 s…
//            ✗ timeout at 5 s
//  attempt 2 ──event_id=evt_88──►  receiver PROCESSES IT A SECOND TIME
//            ◄────── 200 ───────
//                                  → two emails, two charges
//
//  A correct receiver:
//  attempt 1 ──evt_88──► stored evt_88, queued it, 200 in 40 ms
//  attempt 2 ──evt_88──► sees evt_88 in storage → 200, does nothing
//
// ── Backoff and dead-letter ─────────────────────────────────────
//  you return 500:  attempt 1 → +1s → +2s → +4s → +8s → dead letter
//  test: return 500 three times, check the 4th arrives and the pauses grow
Node — приймач для CI без жодної залежності
Node — a receiver for CI with no dependencies at all
import http from 'node:http';
import crypto from 'node:crypto';

const received = [];   // усе, що прилетіло — тест дивиться сюди
let failNext = 0;      // скільки наступних відповідей віддати як 500

const server = http.createServer((req, res) => {
  let raw = '';
  req.on('data', chunk => { raw += chunk; });
  req.on('end', () => {
    // Підпис рахуємо по СИРОМУ тілу, не по JSON.stringify(parsed):
    // перепакування міняє пробіли і порядок ключів → підпис не зійдеться
    const expected = crypto.createHmac('sha256', process.env.WH_SECRET)
      .update(raw).digest('hex');

    received.push({
      at: Date.now(),
      body: JSON.parse(raw),
      signatureOk: req.headers['x-signature'] === expected,
    });

    if (failNext > 0) { failNext--; res.writeHead(500).end(); return; }
    res.writeHead(200).end();   // відповідаємо ШВИДКО, робота — потім
  });
});
server.listen(3999);

// Замість sleep(5) — опитування з таймаутом
async function waitFor(predicate, timeout = 10_000) {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    const hit = received.find(predicate);
    if (hit) return hit;
    await new Promise(r => setTimeout(r, 100));
  }
  throw new Error('webhook не прийшов за 10 c; отримано: ' + received.length);
}
import http from 'node:http';
import crypto from 'node:crypto';

const received = [];   // everything that arrived — the test looks here
let failNext = 0;      // how many next responses to answer with 500

const server = http.createServer((req, res) => {
  let raw = '';
  req.on('data', chunk => { raw += chunk; });
  req.on('end', () => {
    // Compute the signature over the RAW body, not JSON.stringify(parsed):
    // re-serializing changes whitespace and key order → the signature won't match
    const expected = crypto.createHmac('sha256', process.env.WH_SECRET)
      .update(raw).digest('hex');

    received.push({
      at: Date.now(),
      body: JSON.parse(raw),
      signatureOk: req.headers['x-signature'] === expected,
    });

    if (failNext > 0) { failNext--; res.writeHead(500).end(); return; }
    res.writeHead(200).end();   // respond FAST, do the work later
  });
});
server.listen(3999);

// Instead of sleep(5) — polling with a timeout
async function waitFor(predicate, timeout = 10_000) {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    const hit = received.find(predicate);
    if (hit) return hit;
    await new Promise(r => setTimeout(r, 100));
  }
  throw new Error('webhook did not arrive within 10 s; received: ' + received.length);
}
Тести, які варто мати на webhook
The tests worth having on a webhook
test('подія доходить і підписана', async () => {
  await createOrder({ total: 199 });
  const evt = await waitFor(e => e.body.type === 'order.created');
  expect(evt.signatureOk).toBe(true);
});

test('підроблений підпис відхиляється', async () => {
  const res = await post('/webhooks/acme', payload, { 'x-signature': 'deadbeef' });
  expect(res.status).toBe(401);   // ❗ найважливіший тест у цьому файлі
});

test('повторна доставка не дублює наслідок', async () => {
  await deliver(sameEvent);
  await deliver(sameEvent);          // той самий event_id
  expect(await countOrders()).toBe(1);
  expect(await countEmails()).toBe(1);
});

test('відправник ретраїть з backoff', async () => {
  failNext = 2;                     // дві перші спроби впадуть
  await createOrder({ total: 50 });
  await waitFor(() => received.length === 3, 30_000);
  const [a, b, c] = received.map(e => e.at);
  expect(c - b).toBeGreaterThan(b - a);  // паузи ростуть
});
test('event arrives and is signed', async () => {
  await createOrder({ total: 199 });
  const evt = await waitFor(e => e.body.type === 'order.created');
  expect(evt.signatureOk).toBe(true);
});

test('forged signature is rejected', async () => {
  const res = await post('/webhooks/acme', payload, { 'x-signature': 'deadbeef' });
  expect(res.status).toBe(401);   // ❗ the most important test in this file
});

test('redelivery does not duplicate the effect', async () => {
  await deliver(sameEvent);
  await deliver(sameEvent);          // the same event_id
  expect(await countOrders()).toBe(1);
  expect(await countEmails()).toBe(1);
});

test('sender retries with backoff', async () => {
  failNext = 2;                     // the first two attempts will fail
  await createOrder({ total: 50 });
  await waitFor(() => received.length === 3, 30_000);
  const [a, b, c] = received.map(e => e.at);
  expect(c - b).toBeGreaterThan(b - a);  // pauses grow
});
🔧 Node / JS патерни в цьому прикладі http.createServer зі stdlib — жодної залежності, приймач піднімається просто в тесті; req.on('data') / req.on('end') — тіло запиту приходить чанками, збирати треба вручну; crypto.createHmac(...).update(raw).digest('hex') — HMAC саме по сирому тілу; await new Promise(r => setTimeout(r, 100)) — мінімальна пауза без бібліотек, усередині polling-циклу.
🔧 Node / JS patterns in this example http.createServer from stdlib — no dependency, the receiver starts up right inside the test; req.on('data') / req.on('end') — the request body arrives in chunks and has to be assembled by hand; crypto.createHmac(...).update(raw).digest('hex') — the HMAC over the raw body specifically; await new Promise(r => setTimeout(r, 100)) — a minimal pause with no libraries, inside the polling loop.
🗣 Як читати вголос Webhook перевертає ролі: у звичайному API ініціатор я, а тут ініціатор — чужий сервер, і моя система виступає сервером, який має бути доступним. Тому перше, що потрібно, — це місце, куди приймати: або тунель на локальну машину для ручної розвідки, або власний мок-приймач на двадцять рядків, який у CI складає все, що прилетіло, у масив. Далі я перевіряю не тільки те, що подія прийшла. Підпис: серйозний відправник підписує тіло HMAC-ом, і мій найважливіший тест — це запит із підробленим підписом, який має отримати чотириста один, бо інакше ендпоінт приймає замовлення від будь-кого, хто знає адресу. Доставка практично завжди at-least-once, тому та сама подія прийде двічі — після таймауту або ретраю — і обробник має бути ідемпотентним; я перевіряю це прямо, надсилаю ту саму подію двічі й дивлюсь, що замовлення одне і лист один. Порядок теж не гарантований, updated може випередити created, тому система має дивитись на версію або мітку часу в самій події. І окремо перевіряю ретраї: віддаю п'ятисоту кілька разів і дивлюсь, що спроби повторюються, паузи ростуть і врешті подія йде в dead letter. А чекаю я завжди опитуванням із таймаутом, ніколи не sleep на п'ять секунд — саме звідти беруться флейки в CI.
🗣 How to say it out loud A webhook flips the roles: in a normal API I'm the initiator, but here the initiator is someone else's server and my system is the one that has to be reachable. So the first thing I need is somewhere to receive — either a tunnel to my local machine for manual exploration, or my own twenty-line mock receiver that collects everything that arrives into an array, which is what I use in CI. After that I check more than just “the event arrived”. The signature: a serious sender signs the body with HMAC, and my most important test is a request with a forged signature that has to come back as a four oh one, because otherwise the endpoint accepts orders from anyone who knows the address. Delivery is almost always at-least-once, so the same event will arrive twice after a timeout or a retry, and the handler has to be idempotent — I check that directly by sending the same event twice and confirming there's one order and one email. Ordering isn't guaranteed either; updated can overtake created, so the system has to look at the version or the timestamp inside the event. And I check the retries separately: I answer five hundred a few times and watch that the attempts repeat, that the gaps grow and that the event eventually lands in a dead-letter queue. I always wait by polling with a timeout, never with a five-second sleep — that's exactly where CI flakes come from.
prod-6 Що таке ETag і умовний запит — і як кеш дає зелений тест на мертвому сервері? What are an ETag and a conditional request — and how does a cache give you a green test on a dead server?
ProductionSenior▶

ETag — це версія конкретного представлення ресурсу. Сервер віддає його заголовком (ETag: "a7f3c1"), клієнт запам'ятовує і наступного разу питає умовно: «дай, якщо змінилось» — If-None-Match: "a7f3c1". Не змінилось — сервер відповідає 304 Not Modified без тіла. Клієнт бере своє з кешу.

An ETag is the version of one particular representation of a resource. The server serves it as a header (ETag: "a7f3c1"), the client remembers it and next time asks conditionally: “give it to me if it changed” — If-None-Match: "a7f3c1". If it has not changed, the server answers 304 Not Modified with no body. The client takes its own copy from cache.

Той самий механізм існує на датах: Last-Modified + If-Modified-Since. Він грубіший — секундна точність, тобто дві зміни в одну секунду він не розрізняє. ETag точніший, бо це хеш або лічильник версії, а не час.

The same mechanism exists on dates: Last-Modified + If-Modified-Since. It is coarser — one-second precision, so it cannot tell two changes within the same second apart. An ETag is more precise, because it is a hash or a version counter rather than a time.

Cache-Control вирішує інше питання: чи взагалі можна кешувати і як довго. max-age=60 — вважай свіжим хвилину. no-cache — кешувати можна, але перед кожним використанням спитай (тобто саме тут і працює ETag). no-store — не зберігати ніде взагалі. private — тільки в браузері користувача, не в спільному проксі.

Cache-Control answers a different question: whether it may be cached at all and for how long. max-age=60 — treat it as fresh for a minute. no-cache — you may cache it, but ask before every use (which is exactly where an ETag does its work). no-store — do not store it anywhere at all. private — only in the user's browser, not in a shared proxy.

Що тут ламається насправді.

What actually breaks here.

304 із тілом — клієнти реагують по-різному, від ігнорування до падіння. ETag, який змінюється на кожен запит (усередині опинилась мітка часу генерації) — кеш не спрацьовує ніколи, а виглядає як «увімкнений». Зворотнє й гірше: ETag, який не змінився після PUT — клієнт місяцями показує стару версію і клянеться, що не змінював. І класика безпеки: no-store, забутий на ендпоінті з персональними даними — відповідь осідає у спільному проксі, і наступний користувач бачить чужий профіль.

A 304 with a body — clients react differently, from ignoring it to crashing. An ETag that changes on every request (a generation timestamp ended up inside it) — the cache never hits, while looking “switched on”. The reverse, and worse: an ETag that does not change after a PUT — the client shows the old version for months and swears it changed nothing. And the security classic: no-store forgotten on an endpoint with personal data — the response settles in a shared proxy, and the next user sees somebody else's profile.

Пастка, через яку тест бреше.

The trap that makes a test lie.

Зелений тест на кешованій відповіді нічого не доводить. Між тестом і сервером стоять браузер, CDN, корпоративний проксі, іноді сам HTTP-клієнт. Сервер може лежати, а тест — проходити, бо відповідь взагалі не покидала кеш. Ознаки: заголовок Age більший за нуль, X-Cache: HIT, підозріло рівний час відповіді. Лікування: cache-buster у query або явний Cache-Control: no-cache в запиті — і окремий тест, який навпаки перевіряє, що кеш працює.

A green test against a cached response proves nothing. Between the test and the server sit the browser, a CDN, a corporate proxy, sometimes the HTTP client itself. The server can be down while the test passes, because the response never left the cache. The signs: an Age header greater than zero, X-Cache: HIT, a suspiciously flat response time. The cure: a cache buster in the query or an explicit Cache-Control: no-cache on the request — plus a separate test that checks the opposite, that caching does work.

Найменш протестована частина теми — умовний запис. If-Match з ETag на PUT — це оптимістичне блокування: «онови, тільки якщо версія все ще та». Два редактори правлять одну картку; другий має отримати 412 Precondition Failed, а не тихо затерти чужі зміни. Це відтворюваний тест на дві сесії, і в дев'яти сюїтах із десяти його немає.

The least tested part of the topic is the conditional write. If-Match with an ETag on a PUT is optimistic locking: “update it only if the version is still the same”. Two editors work on one card; the second has to get a 412 Precondition Failed rather than silently overwrite the other's changes. It is a reproducible two-session test, and nine suites out of ten do not have it.

Схема: повний цикл — 200 → 304 → PUT → 412
Diagram: the full cycle — 200 → 304 → PUT → 412
// ── 1. Перший запит: віддали тіло і версію ──────────────────────
// GET /articles/42
// 200 OK
// ETag: "v7"
// Cache-Control: no-cache        ← «кешуй, але щоразу перепитуй»
// { "id": 42, "title": "…", ... }        ~48 KB

// ── 2. Повторний запит: клієнт питає умовно ─────────────────────
// GET /articles/42
// If-None-Match: "v7"
// 304 Not Modified
// ETag: "v7"
// (тіла немає)                            ~200 B — у 240 разів менше

// ── 3. Редактор A зберігає — умовний запис ──────────────────────
// PUT /articles/42
// If-Match: "v7"                 ← «онови, тільки якщо версія та сама»
// 200 OK
// ETag: "v8"                     ← версія зросла

// ── 4. Редактор B зберігає, а в нього стара версія ──────────────
// PUT /articles/42
// If-Match: "v7"                 ← а на сервері вже "v8"
// 412 Precondition Failed        ← ✅ правильно: чужі зміни цілі
//
// ❌ Якщо тут 200 — це «остання правка перемагає» без попередження.
//    Дані зникають тихо, скарга приходить через тиждень.
// ── 1. First request: returned the body and the version ─────────
// GET /articles/42
// 200 OK
// ETag: "v7"
// Cache-Control: no-cache        ← "cache it, but revalidate every time"
// { "id": 42, "title": "…", ... }        ~48 KB

// ── 2. Repeat request: the client asks conditionally ────────────
// GET /articles/42
// If-None-Match: "v7"
// 304 Not Modified
// ETag: "v7"
// (no body)                               ~200 B — 240 times smaller

// ── 3. Editor A saves — a conditional write ─────────────────────
// PUT /articles/42
// If-Match: "v7"                 ← "update only if the version is still the same"
// 200 OK
// ETag: "v8"                     ← version went up

// ── 4. Editor B saves, but holds an old version ─────────────────
// PUT /articles/42
// If-Match: "v7"                 ← but the server already has "v8"
// 412 Precondition Failed        ← ✅ correct: the other person's changes survive
//
// ❌ If this is 200, it is "last write wins" with no warning.
//    Data disappears silently, the complaint arrives a week later.
Перевірки, які варто прописати явно
Checks worth spelling out explicitly
test('умовний GET економить тіло', async () => {
  const first = await get('/articles/42');
  const etag = first.headers.etag;
  expect(etag).toBeTruthy();

  const second = await get('/articles/42', { 'If-None-Match': etag });
  expect(second.status).toBe(304);
  expect(second.body).toBe('');          // 304 з тілом — баг
});

test('ETag стабільний, поки ресурс не змінювався', async () => {
  const a = (await get('/articles/42')).headers.etag;
  const b = (await get('/articles/42')).headers.etag;
  expect(a).toBe(b);   // різні = всередині мітка часу, кеш мертвий
});

test('ETag змінюється після запису', async () => {
  const before = (await get('/articles/42')).headers.etag;
  await put('/articles/42', { title: 'нова назва' }, { 'If-Match': before });
  const after = (await get('/articles/42')).headers.etag;
  expect(after).not.toBe(before);  // інакше клієнт бачить старе вічно
});

test('персональні дані не осідають у спільному кеші', async () => {
  const res = await get('/me/profile');
  expect(res.headers['cache-control']).toMatch(/no-store|private/);
});

// І перевірка, що відповідь узагалі з мережі, а не з кешу:
expect(Number(res.headers.age ?? 0)).toBe(0);
test('conditional GET saves the body', async () => {
  const first = await get('/articles/42');
  const etag = first.headers.etag;
  expect(etag).toBeTruthy();

  const second = await get('/articles/42', { 'If-None-Match': etag });
  expect(second.status).toBe(304);
  expect(second.body).toBe('');          // a 304 with a body is a bug
});

test('ETag is stable while the resource is unchanged', async () => {
  const a = (await get('/articles/42')).headers.etag;
  const b = (await get('/articles/42')).headers.etag;
  expect(a).toBe(b);   // different = a timestamp inside, the cache is useless
});

test('ETag changes after a write', async () => {
  const before = (await get('/articles/42')).headers.etag;
  await put('/articles/42', { title: 'new title' }, { 'If-Match': before });
  const after = (await get('/articles/42')).headers.etag;
  expect(after).not.toBe(before);  // otherwise the client sees stale data forever
});

test('personal data does not end up in a shared cache', async () => {
  const res = await get('/me/profile');
  expect(res.headers['cache-control']).toMatch(/no-store|private/);
});

// And a check that the response came from the network, not from a cache:
expect(Number(res.headers.age ?? 0)).toBe(0);
🔧 HTTP-патерни в цьому прикладі conditional request — If-None-Match для читання і If-Match для запису; optimistic concurrency — конфлікт ловиться не блокуванням, а порівнянням версій уже на записі, звідси 412; cache-buster — довільний параметр у query, який робить URL унікальним і тим самим пробиває будь-який кеш по дорозі.
🔧 HTTP patterns in this example conditional request — If-None-Match for reads and If-Match for writes; optimistic concurrency — the conflict is caught not by locking but by comparing versions at write time, hence the 412; cache-buster — an arbitrary query parameter that makes the URL unique and so punches through any cache on the way.
🗣 Як читати вголос ETag — це версія представлення ресурсу: сервер віддає її заголовком, клієнт запам'ятовує і наступного разу питає умовно, через If-None-Match. Якщо нічого не змінилось, сервер відповідає триста чотири Not Modified без тіла, і клієнт бере дані зі свого кешу — замість мегабайта летить кількасот байтів. Те саме є на датах, Last-Modified плюс If-Modified-Since, але воно грубіше, бо точність до секунди. Cache-Control відповідає на інше питання — чи можна кешувати і як довго: max-age, no-cache у сенсі «кешуй, але щоразу перепитуй», no-store для персональних даних і private, щоб відповідь не осіла у спільному проксі. Що я тут перевіряю: що триста четверта приходить без тіла, що ETag стабільний, поки ресурс не змінювався, і що він обов'язково змінюється після запису — бо ETag, який не оновився після PUT, означає, що клієнт місяцями бачить стару версію. Окремо перевіряю умовний запис: PUT з If-Match — це оптимістичне блокування, і другий редактор зі старою версією має отримати чотириста дванадцять, а не тихо затерти чужі зміни. І пам'ятаю головну пастку: зелений тест на кешованій відповіді нічого не доводить — сервер може вже лежати, тому я дивлюсь на Age і X-Cache або пробиваю кеш cache-buster'ом.
🗣 How to say it out loud An ETag is the version of a representation of a resource: the server serves it as a header, the client remembers it and next time asks conditionally with If-None-Match. If nothing changed, the server answers three oh four Not Modified with no body and the client uses its cached copy — so instead of a megabyte you get a few hundred bytes. The same thing exists on dates, Last-Modified plus If-Modified-Since, but it's coarser because it's only accurate to the second. Cache-Control answers a different question, whether you may cache and for how long: max-age, no-cache in the sense of “cache it but ask every time”, no-store for personal data, and private so the response doesn't settle in a shared proxy. What I check is that the three oh four really comes back with no body, that the ETag is stable while the resource hasn't changed, and that it definitely does change after a write — because an ETag that didn't update after a PUT means the client sees a stale version for months. I check the conditional write separately: a PUT with If-Match is optimistic locking, and the second editor holding the old version should get a four twelve rather than quietly overwriting someone else's work. And I keep the main trap in mind: a green test on a cached response proves nothing, the server could already be down, so I look at Age and X-Cache or punch through with a cache buster.
prod-7 Чому запит проходить у Postman і падає в браузері — що таке CORS? Why does the request pass in Postman and fail in the browser — what is CORS?
ProductionSenior▶

Найкоротша відповідь: CORS — це правило браузера, а не сервера. Postman, curl, твій Java-клієнт, бекенд-до-бекенду виклик — не браузери. У них немає походження сторінки (origin) і немає same-origin policy, тому вони бачать відповідь завжди. Браузер її теж отримує — але не віддає JavaScript-у, якщо сервер не дозволив явно.

The shortest answer: CORS is a rule of the browser, not of the server. Postman, curl, your Java client, a backend-to-backend call — none of them are browsers. They have no page origin and no same-origin policy, so they always see the response. The browser receives it too — but does not hand it to JavaScript unless the server allowed that explicitly.

Механіка проста. Сторінка на https://app.acme.io робить запит на https://api.acme.io — це різні origin (домен, схема або порт відрізняються). Браузер додає заголовок Origin. Сервер має відповісти Access-Control-Allow-Origin зі значенням, яке покриває цей origin. Немає заголовка — браузер блокує доступ до відповіді на рівні JS і пише в консоль про CORS.

The mechanics are simple. A page on https://app.acme.io makes a request to https://api.acme.io — different origins (the domain, the scheme or the port differ). The browser adds an Origin header. The server has to answer with an Access-Control-Allow-Origin whose value covers that origin. No header — the browser blocks access to the response at the JS level and writes a CORS message into the console.

Найважливіше для дебагу, і саме це найчастіше плутають:

The most important thing for debugging, and the thing that gets confused most often:

Запит зазвичай уже дійшов до сервера і виконався. Блокується не відправка, а читання відповіді. Тому «CORS-помилка» на POST /orders цілком може означати, що замовлення створене — просто фронт про це не дізнався. Побачити 200 в логах сервера і червону помилку в консолі одночасно — нормальна картина, а не парадокс. Виняток — preflight: якщо він не пройшов, основний запит браузер не надсилає взагалі.

The request usually has already reached the server and run. What is blocked is not the send, it is reading the response. So a “CORS error” on POST /orders can perfectly well mean the order was created — the front end simply never found out. Seeing a 200 in the server logs and a red error in the console at the same time is a normal picture, not a paradox. The exception is preflight: if that fails, the browser does not send the main request at all.

Preflight.

Preflight.

Для «непростих» запитів браузер спершу шле OPTIONS і питає дозволу. Непростий — це метод PUT, PATCH, DELETE, або Content-Type: application/json, або будь-який кастомний заголовок на кшталт Authorization чи X-Request-Id. Тобто практично будь-який реальний API-виклик. Сервер має відповісти на OPTIONS кодом 2xx і переліком дозволених методів та заголовків.

For “non-simple” requests the browser first sends an OPTIONS and asks permission. Non-simple means the method is PUT, PATCH, DELETE, or Content-Type: application/json, or any custom header such as Authorization or X-Request-Id. That is to say, practically any real API call. The server has to answer the OPTIONS with a 2xx and a list of the allowed methods and headers.

Класичний баг тут один і той самий у кожній команді: сам POST налаштовано правильно, а OPTIONS прилітає в мідлвар авторизації і повертає 401. Preflight іде без креденшлів за визначенням — тому автентифікація на OPTIONS завжди помилка конфігурації.

The classic bug here is the same in every team: the POST itself is configured correctly, but the OPTIONS lands in the authorization middleware and comes back 401. A preflight goes out without credentials by definition — so authentication on OPTIONS is always a configuration mistake.

Креденшли.

Credentials.

Якщо запит іде з cookie (credentials: 'include'), сервер має віддати Access-Control-Allow-Credentials: true і конкретний origin. Зірочка з креденшлами заборонена специфікацією — браузер відкине відповідь, навіть якщо все інше правильно. Це друге за поширеністю «в Postman працює».

If the request carries cookies (credentials: 'include'), the server has to return Access-Control-Allow-Credentials: true and a specific origin. A wildcard with credentials is forbidden by the specification — the browser drops the response even if everything else is right. This is the second most common “but it works in Postman”.

💡 І головне, що варто сказати вголос на співбесіді: CORS нікого не захищає. Він обмежує тільки браузерний JavaScript на чужому origin — і все. Ендпоінт без авторизації однаково беруть curl'ом за секунду. Тому в баг-репорті не пиши «CORS відкритий — вразливість»; пиши те, що є насправді: «ендпоінт віддає персональні дані без автентифікації». Дірка не в CORS, і закривати її треба не заголовком.
💡 And the main thing worth saying out loud in an interview: CORS protects nobody. It restricts browser JavaScript on a different origin, and that is all. An endpoint with no authorization is still fetched with curl in a second. So do not write “CORS is open — vulnerability” in a bug report; write what is actually true: “the endpoint serves personal data without authentication”. The hole is not in CORS, and it is not a header that closes it.
Схема: чому Postman зелений, а браузер червоний
Diagram: why Postman is green and the browser is red
// ── POSTMAN / curl ──────────────────────────────────────────────
//   немає origin, немає same-origin policy
//
//   Postman ──POST /orders──► api.acme.io
//           ◄─── 201 ───────  ✅ бачить відповідь. Завжди.

// ── БРАУЗЕР, сторінка на app.acme.io ────────────────────────────
//
//   1) PREFLIGHT (бо Content-Type: application/json)
//   браузер ──OPTIONS /orders───────────────► api.acme.io
//             Origin: https://app.acme.io
//             Access-Control-Request-Method: POST
//             Access-Control-Request-Headers: content-type, authorization
//           ◄─204 No Content────────────────
//             Access-Control-Allow-Origin: https://app.acme.io
//             Access-Control-Allow-Methods: GET, POST, PUT, DELETE
//             Access-Control-Allow-Headers: content-type, authorization
//             Access-Control-Max-Age: 600      ← 10 хв не питати знову
//
//   2) САМ ЗАПИТ
//   браузер ──POST /orders─────────────────► api.acme.io
//           ◄─201 + Access-Control-Allow-Origin: https://app.acme.io
//   ✅ JS отримує відповідь

// ── ЩО БАЧИШ, КОЛИ ЗАГОЛОВКА НЕМАЄ ──────────────────────────────
//   браузер ──POST /orders─────────────────► api.acme.io
//                                            замовлення СТВОРЕНО ✅
//           ◄─201 (без Allow-Origin)────────
//   ❌ браузер не віддає відповідь у JS → у консолі "blocked by CORS"
//
//   Тобто помилка є, а побічний ефект стався. Дубль замовлення
//   після ретраю користувача — саме звідси.
// ── POSTMAN / curl ──────────────────────────────────────────────
//   no origin, no same-origin policy
//
//   Postman ──POST /orders──► api.acme.io
//           ◄─── 201 ───────  ✅ sees the response. Always.

// ── BROWSER, page on app.acme.io ────────────────────────────────
//
//   1) PREFLIGHT (because Content-Type: application/json)
//   browser ──OPTIONS /orders───────────────► api.acme.io
//             Origin: https://app.acme.io
//             Access-Control-Request-Method: POST
//             Access-Control-Request-Headers: content-type, authorization
//           ◄─204 No Content────────────────
//             Access-Control-Allow-Origin: https://app.acme.io
//             Access-Control-Allow-Methods: GET, POST, PUT, DELETE
//             Access-Control-Allow-Headers: content-type, authorization
//             Access-Control-Max-Age: 600      ← don't ask again for 10 min
//
//   2) THE REQUEST ITSELF
//   browser ──POST /orders─────────────────► api.acme.io
//           ◄─201 + Access-Control-Allow-Origin: https://app.acme.io
//   ✅ JS gets the response

// ── WHAT YOU SEE WHEN THE HEADER IS MISSING ─────────────────────
//   browser ──POST /orders─────────────────► api.acme.io
//                                            order CREATED ✅
//           ◄─201 (no Allow-Origin)─────────
//   ❌ the browser does not hand the response to JS → console says "blocked by CORS"
//
//   So there is an error, yet the side effect happened. That is exactly where
//   duplicate orders after a user retry come from.
Дебаг — як за дві хвилини відрізнити CORS від справжньої помилки
Debugging — telling CORS from a real error in two minutes
# 1. Повторити preflight руками. Якщо тут не 2xx — далі не йде нічого.
curl -i -X OPTIONS https://api.acme.io/orders \
  -H 'Origin: https://app.acme.io' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type, authorization'

# Шукаємо у відповіді рівно це:
#   HTTP/1.1 204            ← не 401 і не 405
#   Access-Control-Allow-Origin: https://app.acme.io
#   Access-Control-Allow-Headers: content-type, authorization
#                                              ▲ забутий authorization —
#                                                найчастіша причина

# 2. Перевірити сам GET з origin — заголовок має бути і у звичайній відповіді
curl -i https://api.acme.io/orders -H 'Origin: https://app.acme.io'

# 3. І з іншим origin — сюди дивиться безпека:
curl -i https://api.acme.io/orders -H 'Origin: https://evil.example'
#   Allow-Origin, який просто відлунює будь-який Origin,
#   разом з Allow-Credentials: true — це вже справжня знахідка.
# 1. Replay the preflight by hand. If this is not 2xx, nothing else gets through.
curl -i -X OPTIONS https://api.acme.io/orders \
  -H 'Origin: https://app.acme.io' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type, authorization'

# In the response we look for exactly this:
#   HTTP/1.1 204            ← not 401 and not 405
#   Access-Control-Allow-Origin: https://app.acme.io
#   Access-Control-Allow-Headers: content-type, authorization
#                                              ▲ a forgotten authorization —
#                                                the most common cause

# 2. Check the GET itself with an origin — the header must be in the normal response too
curl -i https://api.acme.io/orders -H 'Origin: https://app.acme.io'

# 3. And with a different origin — this is what security looks at:
curl -i https://api.acme.io/orders -H 'Origin: https://evil.example'
#   an Allow-Origin that simply echoes back any Origin,
#   together with Allow-Credentials: true — that is a real finding.
🔧 HTTP / браузерні патерни в цьому прикладі same-origin policy — базове правило браузера, CORS лише керовано його послаблює; preflight — попередній OPTIONS-запит, який іде без креденшлів; Access-Control-Max-Age — кешування дозволу, щоб не питати перед кожним викликом; curl -i — показати заголовки відповіді разом із тілом.
🔧 HTTP / browser patterns in this example same-origin policy — the browser's base rule, which CORS only relaxes in a controlled way; preflight — the preliminary OPTIONS request, which goes out without credentials; Access-Control-Max-Age — caching of the permission, so it is not asked for before every call; curl -i — show the response headers along with the body.
🗣 Як читати вголос CORS — це правило браузера, а не сервера, і саме тому в Postman усе зелене: Postman і curl не браузери, у них немає origin і немає same-origin policy, тому вони бачать відповідь завжди. У браузері сторінка з одного домену звертається на інший, браузер додає заголовок Origin, і сервер має відповісти Access-Control-Allow-Origin, який цей origin покриває; якщо заголовка немає, браузер отримує відповідь, але не віддає її JavaScript-у. Найважливіше при дебагу: запит зазвичай уже дійшов до сервера і виконався, блокується читання відповіді — тому CORS-помилка на створенні замовлення цілком може означати, що замовлення створене, просто фронт про це не дізнався, і звідси беруться дублі після ретраю. Для непростих запитів — а це майже все з JSON-тілом або з заголовком Authorization — браузер спершу шле OPTIONS, preflight, і найтиповіший баг у тому, що OPTIONS прилітає в мідлвар авторизації і повертає чотириста один, хоча preflight за визначенням іде без креденшлів. Якщо запит іде з кукою, потрібен Allow-Credentials true і конкретний origin — зірочка з креденшлами заборонена специфікацією. І те, що я обов'язково додаю: CORS нікого не захищає, він обмежує тільки браузерний JavaScript, тому в баг-репорті я пишу не «CORS відкритий», а «ендпоінт віддає дані без автентифікації».
🗣 How to say it out loud CORS is a rule of the browser, not of the server, and that's exactly why everything is green in Postman: Postman and curl aren't browsers, they have no origin and no same-origin policy, so they always see the response. In a browser, a page on one domain calls another, the browser adds an Origin header, and the server has to answer with an Access-Control-Allow-Origin that covers it; if that header is missing, the browser still receives the response but won't hand it to JavaScript. The most important thing when debugging is that the request has usually already reached the server and run — what's blocked is reading the response — so a CORS error on creating an order can perfectly well mean the order was created and the front end simply never found out, and that's where duplicates after a user retry come from. For non-simple requests, which is basically anything with a JSON body or an Authorization header, the browser first sends an OPTIONS, the preflight, and the most typical bug is that this OPTIONS lands in the auth middleware and comes back four oh one, even though a preflight goes out without credentials by definition. If the request carries a cookie you need Allow-Credentials true and a specific origin, because a wildcard with credentials is forbidden by the spec. And the thing I always add: CORS protects nobody, it only restricts browser JavaScript, so in a bug report I don't write “CORS is open”, I write “the endpoint serves data without authentication”.
prod-8 Як тестувати rate limit, не поклавши спільне середовище — і чому глобальні ретраї в тестах небезпечні? How do you test a rate limit without taking down a shared environment — and why are global retries in tests dangerous?
ProductionSenior▶

Очевидний підхід — цикл на тисячу запитів, поки не прилетить 429. Він працює рівно один раз, після чого ти вичерпав ліміт для всіх на спільному стенді: інші сюїти червоніють без причини, ручні тестувальники бачать помилки, і хтось питає в чаті, що сталось. Тому перше питання не «як довести до 429», а «на кого рахується цей лічильник».

The obvious approach is a loop of a thousand requests until a 429 shows up. It works exactly once, after which you have burned the limit for everyone on the shared environment: other suites go red for no reason, manual testers see errors, and somebody asks in the chat what happened. So the first question is not “how do I get to a 429” but “who is this counter counted against”.

Ключ ліміту вирішує все.

The key of the limit decides everything.

Якщо ліміт на токен — заведи окремий технічний токен для цього тесту, і вибух буде локальним. Якщо на користувача — окремого користувача. Якщо на IP — на спільному CI-раннері це не тестується взагалі, бо IP один на всіх; лишається локальний прогін або ізольоване оточення. Якщо на ендпоінт — обери найдешевший з них, а не той, що робить запис у базу. Це питання розробнику, а не гадання: відповідь змінює весь план тесту.

If the limit is per token — set up a separate technical token for this test and the blast radius stays local. If it is per user — a separate user. If it is per IP — on a shared CI runner it cannot be tested at all, because the IP is the same for everyone; that leaves a local run or an isolated environment. If it is per endpoint — pick the cheapest one, not the one that writes to the database. This is a question for a developer, not something to guess: the answer changes the whole test plan.

Не бий у стелю, опусти стелю.

Do not hit the ceiling, lower it.

Набагато дешевше домовитись про тестову конфігурацію з лімітом 5 запитів на хвилину, ніж генерувати тисячу справжніх. Тоді реальний ліміт лишається одним димовим тестом, а не сюїтою. А поведінку клієнта на 429 — чи він читає Retry-After, чи робить backoff, чи показує людині осмислене повідомлення — взагалі краще тестувати моком, який завжди відповідає 429: це швидко, детерміновано і не залежить від чужого середовища.

Agreeing on a test configuration with a limit of 5 requests a minute is far cheaper than generating a thousand real ones. Then the real limit stays a single smoke test rather than a suite. And the client's behaviour on a 429 — whether it reads Retry-After, whether it backs off, whether it shows a human a meaningful message — is better tested against a mock that always answers 429: fast, deterministic and independent of somebody else's environment.

Що саме перевіряти, коли дійшло до діла.

What exactly to check when it comes to it.

Що прилітає 429, а не 500 і не тиша. Що Retry-After присутній і осмислений (не 0, не 86400 на хвилинному вікні). Що після очікування запит проходить — тобто вікно справді скидається. Що Remaining спадає монотонно і повертається до Limit після Reset. І окремо, вже з боку безпеки: чи рахуються в ліміт невдалі запити — якщо ні, то перебір паролів безкоштовний; якщо так, але ключ по користувачу, а не по джерелу, то чужий акаунт можна дешево заблокувати чужими 401-ми. Обидві крайності — знахідки, які варто нести розробнику.

That a 429 arrives, not a 500 and not silence. That Retry-After is present and sensible (not 0, not 86400 on a one-minute window). That the request goes through after the wait — that is, the window really does reset. That Remaining falls monotonically and returns to Limit after Reset. And separately, from the security side: do failed requests count towards the limit — if they do not, guessing passwords is free; if they do, but the key is per user rather than per source, somebody else's account can be locked out cheaply with somebody else's 401s. Both extremes are findings worth taking to a developer.

Тепер про ретраї — і це найважливіше в картці.

Now about retries — and this is the most important part of the card.

Глобальний retry у HTTP-клієнті тестів (retries: 3 в конфізі) виглядає як турбота про стабільність, а насправді ковтає симптоми. 429 тихо ретрайнеться, тест зелений — а в проді клієнт так само довбиться і врешті отримує бан. Те саме з 502 і таймаутами: ти перестаєш бачити, що бекенд регулярно падає на кожному п'ятому запиті. Ретрай доречний як явна локальна поведінка конкретного тесту, з backoff і з коментарем, чому саме тут. Як глобальний прапорець він перетворює сюїту на детектор тільки постійних поломок.

A global retry in the tests' HTTP client (retries: 3 in the config) looks like caring about stability, but what it actually does is swallow symptoms. A 429 is quietly retried, the test is green — and in production the client hammers away in exactly the same way and eventually gets banned. Same with 502s and timeouts: you stop seeing that the backend falls over regularly on every fifth request. A retry belongs as explicit local behaviour of one specific test, with a backoff and a comment on why it is there. As a global flag it turns the suite into a detector of permanent breakage only.

Практичне з CI: тести йдуть паралельно з одного раннера, тобто з одного IP і часто під одним технічним токеном. Вісім воркерів ділять один ліміт. Якщо після переходу з 4 на 8 воркерів з'явились «випадкові» падіння — це не флейк і не гонка, це rate limit, і в логах на нього вказує рівно один заголовок.

A practical note from CI: tests run in parallel from one runner, which means one IP and often one technical token. Eight workers share one limit. If “random” failures appeared after moving from 4 workers to 8 — that is not a flake and not a race, it is the rate limit, and exactly one header in the logs points at it.

Схема: як обрати стратегію за ключем ліміту
Diagram: choosing a strategy by the key of the limit
// На що рахується ліміт?
//
//  ┌── на токен ──────► окремий технічний токен для тесту
//  │                    вибух локальний → можна бити чесно
//  │
//  ├── на користувача ► окремий тестовий користувач, те саме
//  │
//  ├── на IP ─────────► на спільному раннері НЕ тестуємо взагалі:
//  │                    покладемо всіх. Локально або в ізоляції.
//  │
//  └── на ендпоінт ───► обери найдешевший GET, не той, що пише в БД
//
// І окремо — рівні перевірки:
//
//   мок 429            ← поведінка КЛІЄНТА: backoff, повідомлення
//     ↑ швидко, детерміновано, більшість кейсів тут
//   низький ліміт у тестовій конфізі (5/хв)
//     ↑ поведінка СЕРВЕРА: коди, заголовки, скидання вікна
//   справжнє вичерпання
//     ↑ ОДИН димовий тест. Не сюїта.
// What is the limit counted against?
//
//  ┌── per token ─────► a dedicated technical token for the test
//  │                    blast radius is local → safe to hit it for real
//  │
//  ├── per user ──────► a dedicated test user, same idea
//  │
//  ├── per IP ────────► NOT tested at all on a shared runner:
//  │                    we would block everyone. Locally or in isolation.
//  │
//  └── per endpoint ──► pick the cheapest GET, not one that writes to the DB
//
// And separately — the levels of checks:
//
//   mock 429           ← CLIENT behaviour: backoff, user message
//     ↑ fast, deterministic, most cases live here
//   a low limit in the test config (5/min)
//     ↑ SERVER behaviour: codes, headers, window reset
//   real exhaustion
//     ↑ ONE smoke test. Not a suite.
JS — чесне вичерпання ліміту на власному токені
JS — an honest exhaustion of the limit on your own token
test('ліміт спрацьовує і вікно скидається', async () => {
  // Токен саме цього тесту. Ніхто інший ним не користується.
  const h = { Authorization: 'Bearer ' + process.env.RL_TEST_TOKEN };

  // Послідовно, не Promise.all: паралельні запити ганяють лічильник
  // недетерміновано, і 429 прилітає на випадковій ітерації.
  let limited = null;
  for (let i = 0; i < 30; i++) {          // стеля в тестовій конфізі — 5/хв
    const res = await fetch('/api/v1/ping', { headers: h });
    if (res.status === 429) { limited = res; break; }
    expect(res.status).toBe(200);       // по дорозі не має бути 500
  }

  expect(limited, 'ліміт не спрацював за 30 запитів').not.toBeNull();

  const wait = Number(limited.headers.get('Retry-After'));
  expect(wait).toBeGreaterThan(0);
  expect(wait).toBeLessThanOrEqual(60);      // не доба на хвилинному вікні

  // Вікно має справді скидатись — інакше «ліміт» це просто бан
  await new Promise(r => setTimeout(r, (wait + 1) * 1000));
  const after = await fetch('/api/v1/ping', { headers: h });
  expect(after.status).toBe(200);
});

// ❌ Так робити не треба — глобальний ретрай у конфізі клієнта:
//    const api = createClient({ retries: 3, retryOn: [429, 502, 503] });
//    Сюїта зелена, а в проді клієнт ловить бан. Ретрай — явно й локально.
test('limit kicks in and the window resets', async () => {
  // A token owned by this test only. Nobody else uses it.
  const h = { Authorization: 'Bearer ' + process.env.RL_TEST_TOKEN };

  // Sequential, not Promise.all: parallel requests drive the counter
  // non-deterministically, and 429 lands on a random iteration.
  let limited = null;
  for (let i = 0; i < 30; i++) {          // the cap in the test config is 5/min
    const res = await fetch('/api/v1/ping', { headers: h });
    if (res.status === 429) { limited = res; break; }
    expect(res.status).toBe(200);       // no 500s along the way
  }

  expect(limited, 'limit did not trigger within 30 requests').not.toBeNull();

  const wait = Number(limited.headers.get('Retry-After'));
  expect(wait).toBeGreaterThan(0);
  expect(wait).toBeLessThanOrEqual(60);      // not a whole day on a one-minute window

  // The window must really reset — otherwise the "limit" is just a ban
  await new Promise(r => setTimeout(r, (wait + 1) * 1000));
  const after = await fetch('/api/v1/ping', { headers: h });
  expect(after.status).toBe(200);
});

// ❌ Don't do this — a global retry in the client config:
//    const api = createClient({ retries: 3, retryOn: [429, 502, 503] });
//    The suite is green, while in prod the client gets banned. Retry explicitly and locally.
🔧 JS / тестові патерни в цьому прикладі послідовний for замість Promise.all — детермінований лічильник замість гонки; expect(x, 'повідомлення') — другий аргумент показує, що саме не сталось, коли тест падає через півроку; await new Promise(r => setTimeout(r, ms)) — пауза, розрахована з Retry-After, а не константа; окремий токен з process.env — межа вибуху проходить по ньому.
🔧 JS / testing patterns in this example a sequential for instead of Promise.all — a deterministic counter instead of a race; expect(x, 'message') — the second argument says what exactly did not happen when the test fails six months from now; await new Promise(r => setTimeout(r, ms)) — a pause computed from Retry-After rather than a constant; a separate token from process.env — the blast radius runs along it.
🗣 Як читати вголос Перше питання не «як довести до чотириста двадцять дев'ятої», а «на що рахується цей лічильник». Якщо на токен або на користувача — я заводжу окремий технічний токен для цього тесту, і тоді вибух локальний. Якщо на IP — на спільному раннері я це не тестую взагалі, бо покладу ліміт для всіх сюїт і для ручних тестувальників; лишається локальний прогін або ізольоване оточення. Далі я намагаюсь не бити в стелю, а опустити її: домовитись про тестову конфігурацію з лімітом п'ять на хвилину набагато дешевше, ніж генерувати тисячу справжніх запитів, а поведінку клієнта на чотириста двадцять дев'яту — чи він читає Retry-After і робить backoff — взагалі краще перевіряти моком. Реальне вичерпання лишається одним димовим тестом, не сюїтою. Перевіряю я те, що прилітає саме чотириста двадцять дев'ять, а не п'ятисота, що Retry-After осмислений, що після очікування запит проходить і вікно справді скинулось, і окремо — чи рахуються в ліміт невдалі спроби, бо тут ховаються дві протилежні дірки: або безкоштовний перебір паролів, або дешеве блокування чужого акаунта. І я категорично проти глобального ретраю в конфізі HTTP-клієнта: він мовчки ковтає і чотириста двадцять дев'яті, і п'ятсот другі, сюїта зелена, а в проді клієнт довбиться і ловить бан. Ретрай має бути явною локальною поведінкою конкретного тесту, з backoff і з коментарем, чому саме там.
🗣 How to say it out loud The first question isn't “how do I get to a four twenty-nine”, it's “what is this counter counted against”. If it's per token or per user, I set up a separate technical token just for this test and the blast radius stays local. If it's per IP, I don't test it on a shared runner at all, because I'd burn the limit for every other suite and for the manual testers; that leaves a local run or an isolated environment. Then I try not to hit the ceiling but to lower it: agreeing on a test config with a limit of five a minute is much cheaper than generating a thousand real requests, and the client's behaviour on a four twenty-nine — whether it reads Retry-After and backs off — is better checked against a mock anyway. Real exhaustion stays one smoke test, not a suite. What I check is that it's a four twenty-nine and not a five hundred, that Retry-After is sensible, that the request goes through after waiting so the window really did reset, and separately whether failed attempts count towards the limit — because two opposite holes hide there: either free password guessing, or a cheap way to lock somebody else's account. And I'm firmly against a global retry in the HTTP client config: it silently swallows both four twenty-nines and five oh twos, the suite is green, and in production the client hammers away and gets banned. A retry should be explicit local behaviour of one test, with a backoff and a comment saying why it's there.