1. Зростання security-тестів
Security-тести — чемпіони зі стрімкого розмноження. Одна захищена операція й так потребує перевірки успішного сценарію, а тут ми раптово множимо сценарії на «хто виконує запит або викликає метод»: анонімний користувач, редактор, адміністратор, плюс «чий це ресурс», плюс різні коди помилок. Якщо писати все копіпастом, тести швидко перетворюються на серіал на 12 сезонів, де вже ніхто не пам’ятає, що було в першій серії.
Найчастіша причина «пекельного» security-suite — не сама безпека, а те, що ми забуваємо: тест — це теж код, і його читають люди. Підтримуваний security-тест має відповідати на три запитання буквально в перші дві секунди перегляду: хто виконує запит або викликає операцію, що саме він намагається зробити (кінцева точка на HTTP-межі або сервісний метод усередині застосунку), і що очікується (дозвіл або відмова, і чому). Якщо ці три речі сховані серед повторюваних рядків with(user("alice")...), contentType(...), accept(...), andExpect(...), то тест перестає бути документацією і перетворюється на шифровку.
Давайте чесно подивимося на типовий «вчора написаний» тест, який за тиждень уже виглядає як чужий, навіть якщо писали його ви самі — класика жанру. У наведених далі прикладах ми використовуємо JUnit 6:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AdminApproveSecurityTest {
@Test
void editorCannotApprove() throws Exception {
mockMvc.perform(post("/api/admin/articles/42/approve")
// Актор сценарію: запит виконує editor — саме це перевіряємо
.with(user("alice").roles("EDITOR")))
// Очікуємо відмову за правами: editor не може виконати admin-дію
.andExpect(status().isForbidden());
}
}
Поки це один тест — він виглядає нормально. Проблема починається, коли таких тестів стає 40, і в кожному повторюються «alice», «EDITOR», «admin», «password», одні й ті самі URL, і ще пів сторінки однакових очікувань. У цей момент security-suite починає жити за правилами старого фольклору: «Якщо тести чіпати — вони можуть образитися і зламатися».
Підтримуваність у цьому місці — не про «красиво», а про те, що завтра у вас зміниться правило доступу, і ви захочете швидко зрозуміти: це одна правка в одному місці чи все ж ручне редагування 25 тестів. Хороший security-suite робить зміни локальними й передбачуваними, а не драматичними.
Тут важливо не звести все назад лише до MockMvc. На практиці security-тести — це не один жанр, а щонайменше три сімейства, і підтримуваність потрібна всім трьом.
- HTTP-межа + імітований актор — @WithMockUser, user(...), anonymous(...), коли ми швидко перевіряємо правило доступу до кінцевої точки.
- HTTP-межа + реальні облікові дані — httpBasic(...) або звичайний Authorization header, коли важливо пройти через справжній механізм входу.
- Method security + прямий виклик Spring-managed bean — коли правило висить на сервісному методі, і тест очікує вже не HTTP-статус, а виняток на кшталт AccessDeniedException.
Далі основний фокус буде на першому сімействі, тому що саме там найшвидше розростається матриця actor → endpoint → result. Але майже всі організаційні звички з цієї лекції переносяться і на інші два шляхи: осмислені імена акторів, компактні фікстури, читабельне групування тестів. Не переноситься лише форма перевірок: status helpers хороші для HTTP, а прямий виклик сервісу має залишатися прямим викликом сервісу.
2. Групування тестів і матриця доступу
На HTTP-межі security-тести й справді часто виглядають як матриця «актор → дія → очікуваний статус». Тому логічно, щоб структура тестового коду цю матрицю відображала. Інакше ви отримуєте набір розрізнених перевірок, де половина тестів про editor, половина про admin, і все перемішано як дроти в ящику, який «я потім розберу». Спойлер: ніхто його не розбирає.
Є два здорові способи групування, і обидва можна застосовувати в одному проєкті. Перший — групування за зоною доступу: окремі тестові класи під public/editor/admin API. Це особливо зручно в ContentHub, тому що зони API вже відображені в URL (/api/public/..., /api/editor/..., /api/admin/...). Другий — групування за актором усередині класу: nested-класи AsAnonymous, AsEditor, AsAdmin. Такий підхід робить «хто виконує запит» видимим не лише в коді, а й у структурі тестів.
Мініскелет виглядає так:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@DisplayName("Security: /api/admin/articles")
class AdminArticleSecurityTest {
@Nested
@DisplayName("as ANONYMOUS")
// Вкладені класи — це «папки» для акторів: так матриця доступу видна прямо по дереву тестів
class AsAnonymous {
// Тут будуть тести, де запит виконує anonymous
}
}
Чому це корисно саме для початківців? Тому що у вас не буде відчуття, що security — це «магія десь усередині Spring». Ви очима бачите: ось перевірки анонімного користувача, ось перевірки редактора, ось адміністратора. І коли якийсь тест падає, ви вже по дереву тестів розумієте контекст.
Схематично, дуже грубо, але мозку це допомагає:
flowchart TD
A["AdminArticleSecurityTest"] --> B["Анонімний"]
A --> C["Редактор"]
A --> D["Адміністратор"]
B --> B1["GET/POST → 401"]
C --> C1["approve → 403"]
D --> D1["approve → 2xx"]
Такий самий прийом працює і для method-security тестів: можна групувати їх за захищуваним сервісом і всередині розводити AsEditor/AsAdmin. Просто в матриці замість URI буде ім’я методу, а замість 401/403 — дозвіл або AccessDeniedException.
Зверніть увагу на тонкий, але важливий момент: групування має посилювати читабельність, а не додавати бюрократію. Якщо ви зробите 10 рівнів вкладеності (AsEditor → WhenArticleIsDraft → WhenModerationOk → WhenMercuryIsInRetrograde), то тести стануть схожими на лабіринт, а не на документацію.
Дуже хороший практичний баланс для security-suite виглядає так: один тестовий клас покриває одну зону або один набір кінцевих точок, а всередині цього класу @Nested допомагає тримати поруч перевірки різних акторів. Так матриця доступу стає «видимою» прямо по файлу — без прокручування на 800 рядків униз.
3. User fixtures: менше copy-paste
В HTTP security-тестах майже завжди повторюються одні й ті самі актори: alice як editor, bob як «інший editor» для сценаріїв за власником, і admin. Якщо ви пишете ці рядки вручну в кожному тесті, ви одночасно створюєте дві проблеми: по-перше, тести роздуваються, по-друге, у вас з’являються розбіжності на кшталт roles("Admin") в одному місці та roles("ADMIN") в іншому, а потім ви сумно шукаєте, чому це раптом 403.
Рішення просте: зробити маленький набір user fixtures. Не «магічний базовий клас на 500 рядків», а буквально один компактний helper, який створює акторів однаково в усьому suite. У MVC-тестах найзручніший формат — RequestPostProcessor, який ви передаєте через .with(...) у MockMvc.
Мінімальний варіант TestUsers може бути таким:
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
public final class TestUsers {
private TestUsers() {
// Утилітарний клас: екземпляри не потрібні
}
public static RequestPostProcessor editor(String username) {
// Фікстура актора: єдиний спосіб створити користувача-редактора для всіх тестів
return user(username).roles("EDITOR");
}
}
І одразу додамо туди адміністратора та анонімного користувача. Головне — не ускладнювати, а зробити рівно те, що економить шум:
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
public final class TestUsers {
private TestUsers() {
// Утилітарний клас: лише static-методи
}
public static RequestPostProcessor editor(String username) {
// Редактора залишаємо в тому самому helper, щоб усі основні актори були зібрані в одному місці
return user(username).roles("EDITOR");
}
public static RequestPostProcessor admin() {
// Адміністратора фіксуємо явно: одне місце, одне ім'я користувача, одна роль
return user("admin").roles("ADMIN");
}
public static RequestPostProcessor anonymousUser() {
// Явно показуємо «анонімного» як окремого актора, а не як null
return anonymous();
}
}
Так, метод admin() в один рядок виглядає трохи зухвало. Але це якраз той рідкісний випадок, коли «зухвало коротко» — добре: ми економимо шум і не ховаємо сенс. Тест має виглядати як історія: «bob виконує PUT туди-то — очікуємо forbidden». І TestUsers.editor("bob") чудово читається як фраза.
Використання в тесті теж виходить дуже «людським»:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AdminApproveSecurityTest {
@Test
void editorGets403OnAdminApprove() throws Exception {
mockMvc.perform(post("/api/admin/articles/42/approve")
// Не копіпастимо ролі по всьому suite — беремо з фікстури
.with(TestUsers.editor("alice")))
.andExpect(status().isForbidden());
}
}
Тут важлива методична думка: ми винесли в helper лише те, що повторюється і не додає сенсу, тобто створення користувача, але не сховали ключові елементи сценарію — кінцеву точку й очікуваний статус. Це дуже тонка межа, і на ній легко помилитися.
Іноді команді хочеться йти не через post-processors, а через @WithMockUser. Це теж нормально, особливо якщо ви групуєте тести за актором через @Nested. Тоді можна поставити анотацію на nested-клас і прибрати повторення:
import org.junit.jupiter.api.Nested;
import org.springframework.security.test.context.support.WithMockUser;
class AdminArticleSecurityTest {
@Nested
@WithMockUser(username = "admin", roles = "ADMIN")
// Анотацію можна повісити на nested-клас, щоб усі тести всередині виконувалися «як admin»
class AsAdmin {
}
}
Якщо ви використовуєте @WithMockUser, то дуже корисно тримати імена користувачів осмисленими. alice і bob у сценаріях за власником працюють як персонажі в книжці: одразу зрозуміло, хто кому «не власник». Якщо написати user1 і user2, мозку доведеться робити зайву роботу — а ми в тестах якраз намагаємося економити мозок.
Для method-security шлях той самий: актор може задаватися анотацією на nested-класі, але defaultUser так само шкідливий, як і в MockMvc-тестах.
4. Сценарії за власником: користувач і дані
Сценарії за власником — це місце, де початківці найчастіше пишуть «ніби правильний тест», але він падає або проходить з абсолютно іншої причини. Наприклад, ви хотіли перевірити, що «bob не може читати чернетку alice», але стаття не була створена — і ви отримали 404. Формально це теж «не пустили», але сенс уже інший: це не відмова за правилом власника, а просто відсутність ресурсу. Такий тест вводить в оману, тому що перевіряє не те, що написано в назві.
Тому в сценаріях за власником фікстура даних — така сама важлива частина, як і фікстура користувача. Вам потрібно явно створити статтю, явно закріпити за нею власника, і лише потім виконувати запит «чужим» користувачем. Ідея звучить очевидно, але саме тут найчастіше починається хаос: «а давайте візьмемо id 42, він же десь є» — а він десь раптом перестав бути.
Найпростіший спосіб стабілізувати сценарії за власником — тримати окремий test helper для підготовки статті, щоб у тесті залишався сенс, а не деталі створення сутності. Наприклад, маленька фабрика TestArticles (у тестовому коді) може створювати «мінімально валідну» статтю:
public final class TestArticles {
private TestArticles() {
// Утилітарний клас: фабрика тестових сутностей
}
public static Article draftOwnedBy(String username, Category category) {
Article a = new Article();
// Мінімально валідні поля: щоб тест падав через security, а не через валідацію чи обмеження БД
a.setTitle("Тестовий заголовок");
a.setSummary("Тестове резюме");
a.setBody("Тестовий вміст");
a.setStatus(ArticleStatus.DRAFT);
// Ключова частина фікстури для сценарію за власником: кому належить ресурс
a.setAuthorUsername(username);
a.setCategory(category);
return a;
}
}
Так, це вже не security-специфічний код, але він вирішує проблему сценаріїв за власником: дані стають зрозумілими, а не випадковими.
А ось так може виглядати сам тест, ідея тут важливіша за конкретні назви репозиторіїв чи полів:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EditorArticleOwnerSecurityTest {
@Test
void bobGets403ForAlicesDraft() throws Exception {
Category tech = categoryRepository.findByCode("TECH").orElseThrow();
// Фіксуємо власника ресурсу: стаття належить alice
Article a = articleRepository.save(TestArticles.draftOwnedBy("alice", tech));
mockMvc.perform(get("/api/editor/articles/" + a.getId())
// Фіксуємо актора: запит виконує bob (не власник)
.with(TestUsers.editor("bob")))
.andExpect(status().isForbidden());
}
}
Зверніть увагу, що тут дві фіксації контексту. Перша — в даних: стаття належить alice. Друга — в акторі: запит виконує bob. І лише за такого розкладу результат 403 справді означатиме «заборона за власником», а не «випадково не знайшли статтю».
Якщо у вашому проєкті прийнята SQL-підготовка даних, а ми вже вміємо працювати з @Sql із data/integration блоку курсу, то сценарії за власником можна зробити ще більш «схематичними»: зафіксувати конкретний id статті, наприклад 42L, і власника прямо в SQL-файлі. Це іноді навіть зрозуміліше, ніж створювати сутність у Java, тому що читач тесту бачить «ось конкретний рядок у БД» і розуміє, на чому тримається сценарій.
Головне правило залишається тим самим: сценарій за власником має падати з причини «не власник», а не з причини «не існує» або «не той статус».
5. Міні-DSL: helpers без магії
Коли ви вже зробили user fixtures, з’являється спокуса «піти далі» і написати мініфреймворк для тестів: performAsEditor().approve().expectDenied(). Це солодка спокуса, тому що здається, ніби коду стане менше. Але тут легко втратити головне: тест перестає бути простим читанням і перетворюється на читання чужого DSL, який ще й ви самі ж підтримуєте.
Хороша золота середина — helpers, які прибирають технічний шум, але залишають ключові елементи сценарію на видноті. Наприклад, у security-тестах часто повторюється accept(MediaType.APPLICATION_JSON) і постійний зв’язок .with(actor). Це можна винести в пару методів усередині тестового класу або в невеликий utility, не ховаючи URL і очікуваний статус.
Приклад «здорового» helper-а:
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
ResultActions getAs(RequestPostProcessor actor, String url) throws Exception {
// Helper прибирає техшум (accept + with), але не ховає URL і не ховає очікування (вони залишаються в тесті)
return mockMvc.perform(get(url).with(actor).accept(MediaType.APPLICATION_JSON));
}
Тепер тест читається простіше, але все ще буквально і прозоро:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EditorArticleSecurityTest {
@Test
void anonymousGets401OnEditorRead() throws Exception {
// Тут важливо явно бачити: anonymous виконує editor-кінцеву точку => очікуємо 401
getAs(TestUsers.anonymousUser(), "/api/editor/articles/42")
.andExpect(status().isUnauthorized());
}
}
Зверніть увагу, що ми не сховали саму кінцеву точку. Це важливо: в HTTP security-тестах кінцева точка — частина «контракту доступу», і вона має бути видимою.
Є корисний прийом, який особливо добре працює в ContentHub: виносити URL-шаблони в константи. Не для того, щоб «винести все в константи, бо так прийнято», а щоб не ловити помилки в написанні та не розмазувати знання про маршрути по всьому suite:
private static final String EDITOR_ARTICLE_BY_ID = "/api/editor/articles/{id}";
private static final String ADMIN_APPROVE = "/api/admin/articles/{id}/approve";
Тоді тести стають ще зрозумілішими: ви читаєте не «рядки URL», а «назви дій».
Щоб не скотитися в «магічний DSL», зручно тримати в голові просту табличку. Її можна сприймати як внутрішній чек:
| Що виносити в helper | Чому це нормально | Що краще не виносити | Чому це небезпечно |
|---|---|---|---|
| Створення користувачів (TestUsers.admin()) | повторюється, не додає сенсу | очікуваний статус | читач має бачити 401/403/200 прямо в тесті |
| accept(MediaType.APPLICATION_JSON) / contentType(MediaType.APPLICATION_JSON) | технічний шум | повний сценарій «create + submit + approve» | це вже бізнес-flow, і ви втрачаєте фокус на security |
| генерація мінімально валідної статті для сценарію за власником | інакше тест випадковий | «універсальний perform()» зі схованим URL | потім неможливо зрозуміти, що саме перевіряється |
Якщо ви все ж хочете трохи зменшити дублювання перевірок ApiProblem (наприклад, errorCode = ACCESS_DENIED), робіть це обережно. Хороший компроміс — маленький helper, який додає одну читабельну перевірку, але не перетворює тест на «чорну скриньку». Наприклад, метод expectAccessDenied(...), який перевіряє 403 і errorCode, але викликається прямо в тесті й не ховає контекст.
6. Типові помилки під час організації security-тестів
Помилка №1: «магічний» базовий клас для всіх security-тестів.
Іноді хочеться зробити AbstractSecurityTest, який налаштовуватиме все підряд: користувачів, дані, helper-методи, спільні assertions і ще «зручну» магію поверх MockMvc. Проблема в тому, що за кілька днів ви отримуєте не підтримку, а другий застосунок усередині тестів. Коли тест падає, ви спочатку дебажите базовий клас, а потім сам тест. Для рівня Junior і для навчального проєкту це майже завжди зайва складність. Краще маленькі утиліти та локальні helpers усередині конкретного тестового класу.
Помилка №2: helper на кшталт loginAsDefaultUser() без ролі та username.
Такий helper економить два рядки, але ламає сенс. У security-тесті важливо явно бачити актора. Якщо метод приховує, хто саме «увійшов», тест перестає бути документацією матриці доступу і перетворюється на вгадування. У результаті ви читаєте тест і думаєте: «Так, а хто тут узагалі виконує запит?»
Помилка №3: сценарій за власником без фікстури власника.
Якщо ви перевіряєте, що «bob не може читати статтю alice», але стаття не створена, або створена без автора, або автор не відповідає username, то тест або впаде з 404, або буде проходити випадково. В обох випадках ви втрачаєте сенс: тест не доводить правило за власником. Сценарій за власником має триматися на чітких даних: ресурс існує і належить іншому користувачу.
Помилка №4: змішування причин відмови в одному тесті.
Коли в одному тесті ви перевіряєте і доступ, і бізнес-переходи статусу, і формат помилки, тест перетворюється на «все й одразу». Він стає крихким: змінили бізнес-правило — впав security-тест, і ви починаєте сумніватися, що саме зламалося. Краще тримати security-тест як перевірку «пустили/не пустили», а бізнес-логіку — у своїх шарах (unit/integration), як ми вже робили раніше.
Помилка №5: копіпаст рядків ролей і URL по всьому suite.
Сьогодні ви написали "EDITOR" у десяти місцях — і все зелене. Завтра ви перейменували роль, або у вас з’явилася нова, або змінився URL, і ви шукаєте рядкові літерали по проєкту. Це не «катастрофа», але це втрата часу й нервів. User fixtures і константи URL вирішують проблему дуже дешево, і саме тому вони окуповуються майже одразу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ