1. Різниця між 401 і 403
Коли ви починаєте тестувати безпеку, здається, що все просто: запит не має пройти — отже, чекаємо на «якусь помилку». На практиці все, як у житті: «не пустили до клубу» буває з різних причин, а ввічливий охоронець — тобто наш API — зобов’язаний пояснити це коректно. Інакше клієнт і тест почнуть гадати, що сталося: пароль не той, ролі немає або ресурс «не ваш».
З погляду семантики 401 Unauthorized і 403 Forbidden — це різні класи відмови. Для тестів це дуже корисно, бо статус стає «швидким діагнозом» того, на якому кроці вас зупинили.
Нижче — коротка «шпаргалка» у вигляді таблиці. Її зручно тримати поруч, коли ви пишете матрицю безпеки для ContentHub.
| Статус | Що це означає людською мовою | Типова причина | Що клієнтові робити далі | Що корисно перевіряти в тесті |
|---|---|---|---|---|
| 401 Unauthorized | «Ти не представився» | Немає облікових даних або вони неправильні | Увійти / передати коректні облікові дані | status=401, часто WWW-Authenticate header (для Basic), іноді JSON error payload (якщо у вас єдиний контракт) |
| 403 Forbidden | «Ти представився, але тобі не можна» | Роль не підходить або правило доступу не виконано (наприклад, не власник) | Не намагатися повторювати запит, змінити користувача/роль/права | status=403 + за наявності єдиного error contract — errorCode=ACCESS_DENIED |
Важливий нюанс для HTTP Basic: 401 зазвичай супроводжується заголовком WWW-Authenticate. Це не «декорація», а підказка клієнтові: «який саме спосіб автентифікації очікує сервер». У тестах це інколи допомагає переконатися, що ми справді потрапили у гілку автентифікації, а не в якусь випадкову 403 від CSRF або фільтра.
2. Міні-модель 401/403 у ланцюжку
Щоб тести були змістовними, корисно мати просту ментальну модель: де саме в застосунку ухвалюється рішення «пустити / не пустити». Інакше дуже легко написати тест, який ніби перевіряє доступ, але насправді падає з іншої причини — наприклад, тому що в базі немає даних або запит не пройшов валідацію.
Уявіть запит як рух коридором із кількома дверима. Перші двері — «автентифікація» (хто ви взагалі?). Другі — «авторизація» (чи можна вам сюди?). Лише після цього ви потрапляєте до контролера та бізнес-логіки. Схематично це зручно тримати в голові так:
flowchart TD
R["HTTP-запит"] --> A{Користувач аутентифікований?}
A -- ні --> U["401 Unauthorized"]
A -- так --> B{Потрібна роль / authority?}
B -- ні --> F["403 Forbidden"]
B -- так --> O{Виконано правило доступу на основі власника?}
O -- ні --> F
O -- так --> C["Контролер + валідація"]
C --> S["Сервіс / бізнес-правила"]
S --> OK["200/201/..."]
S --> BE["404/409/... бізнес-помилки"]
Ця схема пояснює два практичні ефекти, які часто дивують новачків.
По-перше, помилки безпеки часто перекривають майже все інше. Якщо користувач не увійшов, ви не побачите ARTICLE_NOT_FOUND, тому що до пошуку статті справа навіть не дійшла. Запит завершиться на перших дверях.
По-друге, доступ на основі власника часто вимагає існуючого ресурсу, інакше ви просто не зможете порівняти власника. Це впливає на тести: у перевірках за роллю можна спокійно використовувати id=42 «зі стелі» — безпека відріже запит раніше за контролер. А от у сценарії з доступом на основі власника вам, як правило, потрібно створити статтю у фікстурах, інакше тест перетвориться на перевірку 404, а не 403.
3. Тести на 401: anonymous і неправильний пароль
Почнімо з найпростішого і, чесно кажучи, одного з найпоказовіших сценаріїв: анонімний користувач намагається потрапити туди, де його не чекають. Перевірка 401 корисна тим, що вона захищає систему від «випадково відкрили редагування назовні», і водночас такий тест часто можна написати без підготовки даних, без тіла запиту та без складних моків.
Для ContentHub це виглядає так: будь-який /api/editor/** і /api/admin/** має вимагати автентифікацію. Отже, anonymous → туди → 401.
У режимі інтеграції (повний контекст + MockMvc) такий тест виходить дуже коротким:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void anonymousGets401OnEditorList() throws Exception {
// Важливо: йдемо без користувача (anonymous), щоб перевірити саме гілку автентифікації -> 401.
mockMvc.perform(get("/api/editor/articles").with(anonymous()))
// Очікуємо 401: «не представився».
.andExpect(status().isUnauthorized());
}
Зверніть увагу на хитрість у виборі endpoint. Я взяв GET, тому що він менше конфліктує з типовими налаштуваннями безпеки. Якщо ви берете POST/PUT/DELETE, у деяких конфігураціях можете несподівано отримати 403 через CSRF і пів години сперечатися з монітором, хто винен: ви чи інтернет. GET у цьому плані спокійніший: для нього CSRF зазвичай не потрібен.
Якщо у вашому API використовується HTTP Basic (як у навчальному проєкті), то 401 часто супроводжується challenge-заголовком. Перевірити його можна так:
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void anonymousGets401AndBasicChallenge() throws Exception {
mockMvc.perform(get("/api/editor/articles").with(anonymous()))
// 401 підтверджує, що автентифікація не пройдена.
.andExpect(status().isUnauthorized())
// Для Basic-автентифікації сервер має підказати challenge через WWW-Authenticate.
.andExpect(header().exists(HttpHeaders.WWW_AUTHENTICATE));
}
Тепер другий варіант 401: облікові дані є, але вони неправильні. Це вже ближче до реального життя, тому що ми тестуємо не просто «пустити / не пустити», а «чи відкине система неправильний пароль».
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void wrongPasswordGets401() throws Exception {
// Передаємо реальний заголовок Authorization: Basic ..., а не «малюємо» SecurityContext.
mockMvc.perform(get("/api/editor/articles")
.with(httpBasic("alice", "wrong-password")))
// Очікуємо 401, тому що пароль неправильний.
.andExpect(status().isUnauthorized());
}
Тут важливо не сплутати зміст httpBasic() і @WithMockUser. @WithMockUser — це «у тесті вже є автентифікація», тобто перевірка авторизації. Він не тестує пароль. httpBasic() — це «я справді поклав заголовок Authorization у запит», а далі застосунок вирішує, вірити йому чи ні. Для сценаріїв «неправильний пароль» потрібен саме httpBasic().
4. Тести на 403: роль не та
Коли 401 уже перевірили й закріпили, хочеться перейти до більш «смачного»: користувач увійшов, але робити цю дію не має права. Тут починається найчастіша регресія в реальних проєктах: випадково дозволили EDITOR виконувати адмінські дії або дозволили всім аутентифікованим викликати admin endpoint. І це виглядає як «та ну, це ж просто один matcher…». Так-так. Саме так і народжуються баги, які потім виправляють у п’ятницю ввечері.
Для сценарію на основі ролі тест зазвичай дуже простий: задаємо користувача і чекаємо 403. Обирайте endpoint, де роль справді відрізняється. Наприклад, admin-listing:
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@WithMockUser(username = "alice", roles = "EDITOR")
void editorGets403OnAdminList() throws Exception {
// Користувач аутентифікований, але роль не підходить для admin endpoint.
mockMvc.perform(get("/api/admin/articles"))
// Очікуємо 403: «увійшов, але не можна».
.andExpect(status().isForbidden());
}
Якщо у вашому проєкті (як у ContentHub) є єдиний контракт помилок ApiProblem і ви зводите security-помилки до нього, можна посилити тест: окрім статусу, перевірити й зміст помилки. Це корисно, тому що статус 403 може «прилетіти» з різних причин (не лише через роль), а errorCode фіксує саме клас проблеми.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
@WithMockUser(username = "alice", roles = "EDITOR")
void editorGetsAccessDeniedProblemOnAdminList() throws Exception {
mockMvc.perform(
// Явно просимо JSON, щоб не отримати «випадковий» HTML (наприклад, від form-login).
get("/api/admin/articles").accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isForbidden())
// Фіксуємо семантику помилки на рівні контракту API.
.andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
}
Зверніть увагу, що я додав accept(MediaType.APPLICATION_JSON). Це маленька дисципліна, яка допомагає тесту бути чесним: ми явно кажемо «ми — API-клієнт, ми хочемо JSON». І якщо раптом щось почне повертати HTML (класичний привіт від form-login у застосунку, який начебто API), тест це виявить набагато раніше, ніж ваш фронтендер у чаті.
Іноді корисно тримати поруч «контрастний» позитивний кейс: той самий endpoint, але з admin-роллю. Навіть якщо ви перевіряєте лише статус, це робить тестову документацію зрозумілішою: «ось кому можна, ось кому не можна».
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminCanAccessAdminList() throws Exception {
// Позитивний кейс: роль підходить, отже security не має блокувати запит.
mockMvc.perform(get("/api/admin/articles"))
.andExpect(status().isOk());
}
Якщо у вас раптом цей тест падає не на перевірці безпеки, а в бізнес-логіці (наприклад, база не піднялася або дані не готові), це сигнал, що ви не втримали тест у межах перевірки доступу. Тоді має сенс або підготувати мінімум даних, або вибрати endpoint, який стабільно відповідає й без складних умов. Але сама ідея залишається: 403 — це про «увійшов, але не можна».
5. Доступ на основі власника: «чужа» стаття
Доступ на основі власника — це момент, коли роль уже не рятує. У нас два редактори, обидва з роллю EDITOR, обидва «взагалі-то мають право редагувати статті». Але редагувати вони можуть не будь-які статті, а лише свої. Це дуже життєвий сценарій, і він ламається теж цілком буденно: хтось забув перевірити власника в одному з методів, і раптом «можна редагувати чужі чорновики».
Для тесту тут критичні дві умови: стаття має існувати, і вона має належати іншому користувачеві. Інакше ви не перевірите заборону на рівні власника. Якщо статті немає — ви отримаєте 404 (або іншу помилку пошуку), і тест буде не про безпеку.
Один із найкомпактніших способів зафіксувати існування статті — використати @Sql з маленьким seed-скриптом. Припустімо, ми вставляємо в БД чорновик статті з id=100 і власником alice. Тоді bob має отримати 403 при спробі прочитати її через editor endpoint.
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.jdbc.Sql;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
@Sql("/db/testdata/article_draft_owned_by_alice.sql")
@WithMockUser(username = "bob", roles = "EDITOR")
void bobGets403WhenReadingAlicesDraft() throws Exception {
// Важливо: ресурс існує (через @Sql), інакше замість 403 вийде 404.
mockMvc.perform(get("/api/editor/articles/100"))
.andExpect(status().isForbidden())
// Додатково фіксуємо, що це саме заборона доступу, а не «інша» помилка 403.
.andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
}
А тепер «контрастний» позитивний сценарій: alice як власник має отримати доступ до цієї ж статті.
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.jdbc.Sql;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@Sql("/db/testdata/article_draft_owned_by_alice.sql")
@WithMockUser(username = "alice", roles = "EDITOR")
void aliceCanReadHerOwnDraft() throws Exception {
// Той самий ресурс і той самий endpoint, але username збігається з власником.
mockMvc.perform(get("/api/editor/articles/100"))
.andExpect(status().isOk());
}
Сам SQL-файл (спрощено, як ідея) може виглядати так. Тут не потрібно любити SQL — достатньо, щоб він був коротким і детермінованим. Головне, щоб ви явно бачили власника:
-- файл: src/test/resources/db/testdata/article_draft_owned_by_alice.sql
-- Фікстура для тестів на доступ на основі власника: створюємо статтю в статусі DRAFT, власника видно явно.
-- Мінімальна категорія, щоб стаття могла посилатися на category_id.
insert into category(id, code, name)
values (1, 'java', 'Java');
-- Стаття з власником alice: на ній і перевіряємо правило «власник/не власник».
insert into article(id, title, slug, summary, body, status, author_username, category_id, version)
values (100, 'T', 't', 'S', 'B', 'DRAFT', 'alice', 1, 0);
Так, у реальному проєкті назви таблиць і набір полів можуть відрізнятися. Сенс один: створити в базі мінімально достатній об’єкт так, щоб перевірка власника була можливою.
Тепер зберемо в голові, що саме ми перевіряємо цією парою тестів. Ми не перевіряємо workflow статусів, не перевіряємо серіалізацію DTO і навіть не перевіряємо «чи справді стаття 100 повертається коректно» (це вже покрито іншими шарами). Ми перевіряємо рівно те, що потрібно: однакова роль EDITOR, різний username → різний доступ.
І ось тут народжується суперкорисна «міні-таблиця істинності» для сценаріїв на основі власника:
| Сценарій | Що змінюємо | Очікуємо |
|---|---|---|
| Анонімний → editor endpoint | немає автентифікації | 401 |
| Редактор (не власник) → editor endpoint | username інший | 403 |
| Редактор (власник) → editor endpoint | username збігається | 200 |
| Редактор → неіснуюча стаття | даних немає | 404 (це вже не security) |
Якщо тримати це в голові, тести виходять передбачуваними, і головне — ви перестаєте радіти «зеленому тесту», який насправді перевіряв не те.
6. Не плутати безпеку й бізнес-логіку в тестах
Найпоширеніша проблема в тестах безпеки — випадкова підміна предмета перевірки. Ви хотіли перевірити «анонімний не може», а перевірили «у базі немає даних». Ви хотіли перевірити «не той власник», а перевірили «не той id». У підсумку тест або падає незрозуміло де, або зелений, але не доводить майже нічого. І це, на жаль, не рідкісний випадок, а майже стандартний етап дорослішання тестів.
Тут допомагає просте правило: у тесті на безпеку ви маєте свідомо контролювати, що зламається першим. Якщо ви перевіряєте 401, обирайте запит, який не потребує тіла і не залежить від даних. Якщо ви перевіряєте 403 за роллю, беріть endpoint, де заборона спрацьовує до бізнес-логіки (зазвичай саме так і є). Якщо ви перевіряєте доступ на основі власника, переконайтеся, що ресурс існує, інакше ви неминуче впадете в 404.
Ще один корисний прийом — тримати в тесті явний фокус: «актор → дія → очікування». Це робить тест схожим на короткий рядок із матриці доступів. Наприклад, такі назви читаються майже як документація:
- anonymousGets401OnEditorList
- editorGets403OnAdminList
- bobGets403WhenReadingAlicesDraft
І нарешті, пам’ятайте про «порядок фільтрів/шарів». У HTTP-запиті у вас часто є чотири потенційні джерела відмови: безпека, потім валідація/биндинг у MVC, потім бізнес-правила, потім persistence/інфраструктура. У хорошому тесті на безпеку ви ціляєте так, щоб точно потрапити в security, а не в будь-який інший шар «по дорозі».
7. Типові помилки
Помилка №1: чекати 403, коли користувач узагалі не автентифікований.
Якщо ви не задали користувача (ані @WithMockUser, ані httpBasic, ані user()), то сценарій майже завжди про 401. Коли в тесті написано «anonymous має отримати 403», ви, по суті, просите систему заборонити тому, хто ще навіть не увійшов. Це логічно дивно: спершу потрібно зрозуміти, хто ви, а вже потім забороняти.
Помилка №2: перевіряти 401 на запиті POST/PUT/DELETE і отримувати 403, після чого сумніватися в сенсі життя.
Дуже часто 403 у таких тестах — це не «права не ті», а захист CSRF (якщо він увімкнений). У REST API його зазвичай вимикають або налаштовують особливим чином, але тести все одно мають бути написані так, щоб ви не інтерпретували 403 навмання. Якщо ви хочете саме перевірити семантику 401, беріть GET або переконайтеся, що CSRF у вашому API-профілі справді не заважає.
Помилка №3: тест на доступ на основі власника без фікстури ресурсу (і раптовий 404).
Якщо статтю не створено, ви не тестуєте володіння. Ви тестуєте відсутність статті. Це інший сценарій, інший статус, інший errorCode. У тесті на доступ на основі власника ресурс має існувати і належати іншому користувачеві — лише тоді 403 справді доводить правило доступу.
Помилка №4: @WithMockUser там, де ви хотіли перевірити пароль.
@WithMockUser — це прискорювач авторизації, а не симулятор реального входу. Він не «вводить пароль», він просто кладе в SecurityContext готову автентифікацію. Якщо ви хочете перевірити «неправильний пароль → 401», використовуйте httpBasic() і повний контекст, інакше ви тестуєте не автентифікацію, а свою уяву про неї.
Помилка №5: перевіряти тільки статус і не фіксувати зміст помилки, коли у вас є єдиний error contract.
Перевірка статусу — це мінімум. Але якщо у вас ApiProblem з errorCode, і ви хочете, щоб клієнт міг однаково обробляти помилки, корисно закріплювати це тестами. Особливо в 403: інакше ви ризикуєте отримати «403, але тіло не те», і клієнтська логіка розсиплеться, хоча тести зелені.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ