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.
КЛІЄНТ СЕРВЕР
│ │
│ 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 — саме це йде по сокету
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
METHOD /path HTTP/1.1 — стартовий рядок, три частини через пробіл; Header-Name: value — по одному заголовку на рядок, регістр імені не має значення; порожній рядок обов'язковий — саме він відділяє заголовки від тіла; curl -i — надрукувати заголовки відповіді разом із тілом; -H додає один заголовок, -d передає тіло і автоматично робить метод POST, якщо не вказати -X.
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.
Метод — це обіцянка серверу і всім, хто стоїть між клієнтом і сервером: браузеру, проксі, 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.
метод що обіцяє 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)
// Прочитати список — колекція, множина
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
/getUser, /users/17/delete, /order/create. Метод уже містить дієслово, шлях має містити іменник. Це не естетика: GET /users/17/delete ламає обіцянку safe, і рано чи пізно в цю адресу зайде краулер або прев'ю-бот месенджера./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.Розподіл простий, якщо думати про час життя і видимість кожної частини, а не про те, «куди зручніше запхати».
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.
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
// ❌ Токен у 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
?key=value&key2=value2 — query-рядок, & між парами, значення обов'язково URL-encoded (пробіл → %20, & всередині значення → %26); #fragment — єдина частина URL, яка не йде на сервер; Authorization: Bearer <token> — схема і токен через пробіл; Content-Type описує тіло запиту, а Accept — бажаний формат відповіді, це різні заголовки і плутати їх — класика.
?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.
Ідемпотентність — це властивість стану сервера, а не відповіді. Формально: 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.
КЛІЄНТ СЕРВЕР БАЗА
│ 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 │
// Ключ генерує КЛІЄНТ і генерує ОДИН РАЗ — до першої спроби.
// Якщо ключ генерувати перед кожним ретраєм, він марний.
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
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.
crypto.randomUUID() — вбудований у платформу генератор UUID, без бібліотек; { ...body, amount: 1 } — spread-копія об'єкта з перевизначенням одного поля, оригінал не мутується; Promise.all([a, b]) — запускає обидва запити одночасно і чекає обох, саме це відтворює гонку; expect([409, 422]).toContain(x) — перевірка «один із допустимих», коли контракт дозволяє два коди.
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.
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.
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)
# 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 -s — приглушити прогрес-бар, щоб вивід можна було пайпити; -I — надіслати HEAD і показати тільки заголовки відповіді; | grep -i — фільтр без урахування регістру, бо імена заголовків регістронезалежні; grep -rnE — рекурсивно, з номерами рядків, розширений regex; | усередині regex — «або», саме так одним запитом шукають кілька дієслів одразу.
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.
Якщо одним реченням — так, зазвичай баг. Але розгорнута відповідь цікавіша, бо на співбесіді питають саме її: чому баг, і в яких випадках ні.
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.
Оплата не пройшла. Дві реалізації однієї відповіді:
┌──────────────────────────┬──────────────────────────┐
│ 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.
// Типовий клієнтський код. Він написаний правильно —
// це сервер зламав контракт, на який код спирається.
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
});
res.ok — булеве поле Response, true для статусів 200–299 і тільки для них; json.errors?.length — optional chaining, безпечно читає довжину, якщо поля errors взагалі немає (інакше TypeError); Boolean(x) — явне зведення до булевого, щоб expect(...).toBe(...) порівнював true/false, а не число з булевим; a === b у expect над двома обчисленими прапорцями — асерт на узгодженість, а не на конкретне значення.
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.
За контрактом 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.
Було: {"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"
// 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}
// 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
{ ...v1, qty: 5 } — spread-копія прочитаного об'єкта з однією зміною, саме так клієнт і формує тіло PUT, і саме тому lost update такий частий; const [a, b] = [...] — деструктуризація масиву, два читання в один рядок; If-Match: "W/\"v2\"" — слабкий ETag, префікс W/ означає «еквівалентні за змістом», лапки всередині значення обов'язкові; application/merge-patch+json — окремий media type, який явно каже серверу, що тіло це патч-документ, а не новий ресурс.
{ ...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.
Найпростіше пояснення, яке водночас неправильне: «колекція — це папка, де лежать мої запити». Папка нічого не робить. Колекція — виконуваний артефакт: її можна запустити цілком, без жодного кліку, і отримати список пройдених та впалих перевірок.
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) andTests(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.
Колекція «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.
{{variable}} — підстановка змінної в будь-яке текстове поле: URL, заголовок, тіло; успадкування — скрипт рівня колекції виконується перед кожним запитом, скрипт запиту додається до нього, а не замінює; експорт v2.1 — JSON-формат колекції, який читає і Postman, і Newman.
{{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.
Змінні в 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.
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.
// прочитати — байдуже, з якого рівня прийшло значення
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');
Механіка проста до банальності. 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 notnull; 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.
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);
});
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.
pm.test(name, fn) — реєстрація перевірки; помилка всередині fn = червоний тест; pm.expect — вбудований Chai (eql, to.be.a, lengthOf); pm.response.to.have.status() — коротка форма для найчастішої перевірки; pm.request.body.raw — доступ до тіла власного запиту, щоб порівняти надіслане з отриманим.
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.
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.
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.
// прямо в тілі запиту — без жодного скрипта
{
"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.
pm.sendRequest(opts, callback) — HTTP зі скрипта, колбек-стиль, не Promise; pm.collectionVariables — кеш, який переживає весь прогін; pm.variables.set — локальна змінна на один запит; динамічні {{$randomEmail}}, {{$guid}}, {{$isoTimestamp}} — генеруються Postman при кожній підстановці; throw new Error у колбеку — єдиний спосіб зробити падіння setup-у помітним.
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.
Механіка ланцюга — три рядки: у 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.”
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
ordersfolder 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.
POSTreturned 202, the resource will exist in half a second, the nextGETgoes 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.
// 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.
pm.collectionVariables.set/unset — стан у межах прогону; другий аргумент pm.expect(value, message) — власний текст у звіті замість «expected false to be true»; pm.execution.setNextRequest(name) — стрибок по колекції, працює лише в Runner/Newman; ранній вихід через if замість продовження наосліп.
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.
Колекція-демо і колекція-сюїт виглядають однаково перші два тижні. Різниця вилазить, коли колекцію треба запустити в пайплайні, віддати іншій людині або дописати через три місяці.
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.
ДЕМО СЮЇТ
───────────────────────── ─────────────────────────────────────
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 idmakes 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:--folderwith 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.
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.
- 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—clifor the log,junitfor the machine (CI lists the failed tests),htmlextrafor 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.
npx newman run — без глобальної установки; --env-var — перевизначає значення з файлу середовища, найбезпечніший шлях для секретів; ${{ secrets.* }} — сховище GitHub Actions; if: always() — крок виконується попри падіння попереднього; junit-репортер — той самий формат, який читають усі CI-системи.
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 — це підсумкова табличка плюс нумерований список падінь. Читати його треба не зверху вниз, а за типом помилки: тип майже завжди одразу каже, кому це заводити.
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.
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.idblows up onundefined. 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.
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.
Автентифікація в 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
Refererheader sent to neighbouring domains.
# 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
// Так робити не можна — і це не педантизм, а реальний канал витоку
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
Authorization: <scheme> <credentials> — стандартний двослівний формат заголовка, схема відділена пробілом; X- у назві заголовка історично означало «нестандартний, вигаданий цим API»; application/x-www-form-urlencoded — формат тіла, у якому OAuth2 очікує запит по токен, не JSON.
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.
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
httpstohttpanywhere 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.
// Заголовок, який ти бачиш у перехопленому запиті:
// 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
КОДУВАННЯ (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 — заголовок відповіді, яким сервер каже клієнту, яку схему він очікує.
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.
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.audgets 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.
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)
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');
token.split('.')[1] — payload завжди друга частина з трьох; base64url vs base64 — заміна двох символів перед atob(), інакше отримаєш помилку декодування на приблизно кожному третьому токені; Math.floor(Date.now()/1000) — переведення в unix-секунди, у яких живе exp.
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.
Формулювання, яке варто вивчити дослівно: 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.
запит з 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
// 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]);
});
pm.response.to.have.status(n) — стандартна перевірка коду; pm.response.headers.get('…') — доступ до заголовка відповіді за назвою, регістр не має значення; to.be.oneOf([…]) — коли контракт свідомо допускає два коди, це чесніше, ніж прибити один і потім правити тест.
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.
Пара токенів існує через компроміс, який варто вміти пояснити. 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
expand 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.
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
// Три рядки логіки, які прибирають цілий клас «флейків автентифікації»
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);
});
}
pm.sendRequest(opts, cb) — єдиний спосіб сходити по мережу з pre-request скрипта, працює через колбек, не через await; mode: 'urlencoded' — бо OAuth2 очікує форму, а не JSON; запас SKEW — той самий прийом, що й у клієнтських SDK: не чекати рівно до exp, а оновлюватись трохи раніше.
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.
Це найкраще питання цієї секції, бо правильна відповідь — «залежить, і зараз скажу, від чого». Хто одразу каже «баг», не розуміє механіки; хто одразу каже «не баг», не розуміє ризику.
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 untilexpand 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 (класичний 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
// Крок 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) — перевірка не факту, а межі: тест на характеристику системи, а не на її поведінку.
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.
Перше, що варто сказати вголос: 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,expis 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
codefor 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.
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?
// 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);
});
grant_type — поле, що називає flow, і найшвидший спосіб зрозуміти, який саме перед тобою; code_verifier / code_challenge — пара PKCE: клієнт вигадує випадкове значення, спершу шле його хеш, а при обміні коду — оригінал; aud і scope — два claim, які перетворюють «токен валідний» на «токен валідний саме тут і саме на це».
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”.
Секрети з колекцій витікають не тому, що хтось вирішив їх розголосити. Вони витікають, бо в інструменті є кілька місць, які виглядають однаково, а їдуть у різні боки. Знати цю різницю — рівно та річ, яка відрізняє людину, що вже палилась, від тієї, що ще ні.
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
Authorizationheader. - 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”.
ЇДЕ РАЗОМ З КОЛЕКЦІЄЮ ЛИШАЄТЬСЯ ЛОКАЛЬНО
(експорт, 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
# Перед тим як прикріпити експорт до тікета або закомітити:
# 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: найдешевший спосіб знайти токен у будь-якому тексті.
{{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.
Жоден нормальний сервер не віддає 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.
// ── 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
# Стиль 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
query-параметри — пари ?a=1&b=2, амперсанд між ними; opaque token — непрозорий рядок, який клієнт повертає як є і не намагається розібрати; заголовок Link з rel="next" — стандартний спосіб віддати навігацію окремо від даних.
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.
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.
# Запит 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.
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;
});
}
pm.response.headers.get(name) — читання заголовка відповіді, повертає рядок або null; pm.collectionVariables — сховище, яке живе між запитами колекції, тому в ньому можна тримати попереднє значення лічильника; Number(x) — заголовок завжди рядок, порівнювати числа без явного приведення не можна.
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.
Перша сторінка — найменш цікаве, що є в пагінації. Вона працює завжди, бо саме її бачив розробник, поки писав код. Баги живуть на краях.
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».
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”.
// ┌──────────────┬──────────────┬───────────────────────────────────┐
// │ запит │ 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
// Один тест, який ловить дублі, пропуски і нескінченний 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
total ??= body.total — nullish-присвоєння: запишемо тільки якщо там null або undefined, тобто беремо total з першої сторінки; ids.push(...arr) — spread розгортає масив в аргументи, інакше отримаємо вкладений масив; new Set(ids).size — найкоротший спосіб порахувати унікальні; [...ids].sort() — копія перед сортуванням, бо sort() мутує оригінал.
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.
Версіонування існує з однієї причини: у публічного 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.
# 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
// 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)
content negotiation — вибір представлення через Accept, вендорний media type vnd.*+json; Deprecation / Sunset — заголовки життєвого циклу, які кажуть клієнту «працює, але ось дедлайн»; additionalProperties: false — прапорець JSON Schema, який забороняє невідомі поля і тим самим робить будь-яке доповнення відповіді ламким.
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.
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.
sleep(5) перед перевіркою. У CI під навантаженням п'яти секунд не вистачить, і тест стане флейком, який «іноді падає». Замість паузи — опитування приймача з таймаутом: перевіряй кожні 100 мс до 10 секунд, вийди одразу як прийшло. Той самий тест стає і швидшим, і стабільнішим.
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.
// ЗВИЧАЙНИЙ 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
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);
}
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
});
http.createServer зі stdlib — жодної залежності, приймач піднімається просто в тесті; req.on('data') / req.on('end') — тіло запиту приходить чанками, збирати треба вручну; crypto.createHmac(...).update(raw).digest('hex') — HMAC саме по сирому тілу; await new Promise(r => setTimeout(r, 100)) — мінімальна пауза без бібліотек, усередині polling-циклу.
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.
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.
// ── 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.
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);
conditional request — If-None-Match для читання і If-Match для запису; optimistic concurrency — конфлікт ловиться не блокуванням, а порівнянням версій уже на записі, звідси 412; cache-buster — довільний параметр у query, який робить URL унікальним і тим самим пробиває будь-який кеш по дорозі.
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.
Найкоротша відповідь: 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”.
// ── 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.
# 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.
same-origin policy — базове правило браузера, CORS лише керовано його послаблює; preflight — попередній OPTIONS-запит, який іде без креденшлів; Access-Control-Max-Age — кешування дозволу, щоб не питати перед кожним викликом; curl -i — показати заголовки відповіді разом із тілом.
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.
Очевидний підхід — цикл на тисячу запитів, поки не прилетить 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.
// На що рахується ліміт?
//
// ┌── на токен ──────► окремий технічний токен для тесту
// │ вибух локальний → можна бити чесно
// │
// ├── на користувача ► окремий тестовий користувач, те саме
// │
// ├── на 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.
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.
for замість Promise.all — детермінований лічильник замість гонки; expect(x, 'повідомлення') — другий аргумент показує, що саме не сталось, коли тест падає через півроку; await new Promise(r => setTimeout(r, ms)) — пауза, розрахована з Retry-After, а не константа; окремий токен з process.env — межа вибуху проходить по ньому.
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.