JavaRush /Курси /Spring Test /spring-security-test...

spring-security-test у тестах

Spring Test
Рівень 23 , Лекція 1
Відкрита

1. spring-security-test для security-тестів

Матриця доступу сама себе не перевірить: у тесті потрібно вміти явно задавати, хто саме робить запит.

Коли ви вперше вмикаєте Spring Security у Spring Boot-застосунку, відбувається магія, яка спочатку тішить, а потім трохи дратує. Тішить — тому що «ніби все захищено». Дратує — тому що раптово майже будь-який запит у тестах перетворюється на 401 або 403, і ви сидите й думаєте: «Я тестую контролер… чи раптом усю цивілізацію безпеки?». У цей момент багато хто починає або вимикати фільтри «щоб тести проходили», або намагатися вручну заповнити SecurityContextHolder. Саме тут і зʼявляється spring-security-test: бібліотека, яка дає вам офіційний і зручний спосіб моделювати користувача в тестах — так, щоб тест залишався читабельним.

У нашому курсі це особливо важливо, тому що ми вже вміємо тестувати MVC-шар та інтеграційні сценарії, а тепер просто додаємо ще одну вісь: «хто саме виконує запит». spring-security-test допомагає зробити цю вісь явною в коді тесту: або анотацією @WithMockUser, або налаштуванням конкретного запиту через request post-processors (user(...), anonymous(...), httpBasic(...)).

Якщо дивитися прагматично, ця залежність заощаджує вам години часу: замість того, щоб гадати, чому запит упав і як «підсунути користувача», ви пишете прямо в тесті зрозумілу фразу: «виконати запит як admin». І далі тест знову стає тим, чим він має бути: документацією поведінки.

Тут є три передумови, щоб далі не ловити примарну магію. По-перше, spring-security-test — це окрема test-dependency org.springframework.security:spring-security-test, а не безплатний бонус, який сам собою раптово зʼявився в проєкті. По-друге, усі приклади нижче спираються на вже наявну security-конфігурацію ContentHub: ролі EDITOR/ADMIN, публічну зону та робочий спосіб автентифікації в застосунку. І по-третє, @WithMockUser та user() живуть у server-side Spring Test path: вони доречні в @WebMvcTest, у @SpringBootTest + @AutoConfigureMockMvc і в method-security тестах на Spring-managed bean. Якщо ви піднімаєте живий сервер і звертаєтеся до нього клієнтом, тоді вже потрібні реальні credentials та звичайний заголовок Authorization.

2. Де живе користувач

Якщо ви новачок, є одна річ, яку важливо прийняти: потім жити буде легше. У звичайному запиті користувач не потрапляє в контролер напряму. Спочатку запит проходить через security filter chain, і лише якщо ланцюжок вирішує, що все гаразд, керування доходить до MVC і вашого @RestController. Тобто з погляду Spring Security головним обʼєктом є не ваш DTO і навіть не ваш контролер, а Authentication у SecurityContext.

Усередині Spring Security це виглядає приблизно так: фільтри намагаються зʼясувати, хто ви, формують обʼєкт Authentication (де є username і список прав), кладуть його в SecurityContext, а далі механізм авторизації вирішує, чи можна вам у цей endpoint або метод. Якщо ви взагалі не автентифіковані — контекст порожній або містить “anonymous authentication” (залежно від конфігурації). Якщо ви ввійшли — у контексті є Authentication з вашими ролями/authorities.

У тестах ми хочемо керувати цим без зайвих маніпуляцій. І тут є два принципово різні підходи:

1) Ми можемо симулювати вже автентифікованого користувача, не перевіряючи, як саме він увійшов. Це шлях @WithMockUser і user(). Він добрий, коли нас цікавить авторизація (доступ за ролями/правами), а не механізм входу.

2) Ми можемо пройти через реальний механізм входу, наприклад через HTTP Basic, тобто додати до запиту заголовок Authorization: Basic ... і дати фільтрам самостійно все обробити. Це шлях httpBasic(). Він корисний, коли ви хочете переконатися, що ваша security-конфігурація справді працює так, як задумано, а не лише «у тестовій уяві».

Ця модель — головний орієнтир для вибору інструмента в тесті. Якщо ви її тримаєте в голові, далі все стає доволі логічним, а не «магією анотацій».

3. @WithMockUser: швидкий мок-актор

@WithMockUser — це, по суті, офіційний «чит-код» для тесту. Але не в поганому сенсі, а в інженерному. Він дає змогу виконати тест так, ніби в SecurityContext уже лежить автентифікований користувач із потрібним username та ролями. При цьому жодні реальні паролі не перевіряються, жодна база користувачів не потрібна, і ваш тест не перетворюється на курс «як влаштований вхід».

На практиці @WithMockUser чудово підходить для MVC-тестів (і slice, і full-context), коли ви хочете перевірити саме правило доступу: хто може потрапити в endpoint. Особливо добре він лягає на сценарії, де весь тест виконується від імені одного актора: «цей тест — як editor», «цей — як admin». У такому режимі код читається майже як речення українською.

Мініприклад: запит до editor endpoint від імені editor-користувача.

import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
// Кладемо в SecurityContext користувача з потрібними правами, оминаючи реальний вхід
@WithMockUser(username = "alice", roles = "EDITOR")
void createDraft_asEditor_isOk() throws Exception {
    // Важливо: сам запит звичайний, а "магія" відбувається в security-тестовій інфраструктурі
    mockMvc.perform(post("/api/editor/articles"))
            // Перевіряємо, що авторизація пропускає такого користувача
            .andExpect(status().is2xxSuccessful());
}

Зверніть увагу на два моменти. По-перше, username тут не декоративний. Якщо у вас десь у правилах доступу є логіка «свій/чужий ресурс», то без осмисленого імені ви просто не зможете нормально перевіряти сценарії. По-друге, EDITOR виглядає майже як бізнес-термін нашого домену, і це дуже добре: тест не має бути написаний мовою «ROLE_технічні_букви_внутрішніх деталей».

4. roles і authorities: про ROLE_

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

У Spring Security історично склалося так: role — це приватний випадок authority, просто з префіксом ROLE_. Тобто роль ADMIN у реальності зберігається як authority ROLE_ADMIN. Саме тому @WithMockUser(roles = "ADMIN") автоматично створить authority ROLE_ADMIN. І саме тому писати roles = "ROLE_ADMIN" майже завжди помилка: ви отримаєте ROLE_ROLE_ADMIN, а це вже звучить як начальник начальників, але, на жаль, не працює.

Якщо ж ви хочете задати права точніше (наприклад, не роль, а конкретне право), тоді ви використовуєте authorities. У межах ContentHub нам базово вистачає ролей EDITOR і ADMIN, але корисно розуміти різницю хоча б на рівні «я знаю, чому воно так».

Мініприклад із authorities, щоб відчути різницю:

import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;

@Test
// Тут задаємо окреме право напряму, без ROLE_-префікса і без ролей
@WithMockUser(username = "admin", authorities = "ARTICLE_APPROVE")
void approve_withAuthority_isOk() throws Exception {
    // Такий тест перевіряє саме правило доступу — авторизацію за authority
    mockMvc.perform(post("/api/admin/articles/42/approve"))
            .andExpect(status().is2xxSuccessful());
}

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

5. Post-processors для MockMvc

Анотація @WithMockUser зручна, але вона працює на рівні тестового методу (або класу). А що, якщо ви пишете один тест, який робить два запити, і другий запит має бути від іншого користувача? Або ви будуєте невелику «матрицю» перевірок і хочете, щоб актор був видимий просто в рядку із запитом? Тоді набагато зручніше використовувати request post-processors.

Request post-processor — це штука, яка «донастроює» запит перед виконанням. Для MockMvc їх надає Spring Security Test у вигляді набору методів, які зазвичай імпортуються статично. Найважливіші для нас сьогодні: anonymous(), user(...), httpBasic(...).

Приклад: явно виконати запит як anonymous — це корисно навіть тоді, коли за замовчуванням тести можуть виконуватися без користувача, бо ви робите свій намір явним.

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 publicEndpoint_asAnonymous_isOk() throws Exception {
    // Явно задаємо анонімного користувача, тобто без автентифікації
    mockMvc.perform(get("/api/public/articles").with(anonymous()))
            // Очікуємо, що публічна зона доступна без входу
            .andExpect(status().is2xxSuccessful());
}

Приклад: виконати запит як конкретний editor-користувач, але не через анотацію, а прямо в запиті.

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.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void editorReadsOwnEndpoint_asUserPostProcessor() throws Exception {
    // Користувача задаємо на рівні конкретного запиту — зручно, коли в тесті кілька акторів
    mockMvc.perform(get("/api/editor/articles").with(user("alice").roles("EDITOR")))
            // Тут і далі перевіряємо авторизацію, а не реальний механізм входу
            .andExpect(status().is2xxSuccessful());
}

І третій важливий інструмент — httpBasic(). Він відрізняється за змістом: user() і @WithMockUser «підкладають» користувача напряму в контекст тесту, а httpBasic() робить запит більш схожим на реальний, тому що додає HTTP Basic credentials у запит і дає фільтрам відпрацювати вхід.

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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void adminApproves_asHttpBasic_isOk() throws Exception {
    // Передаємо реальні credentials у запит (Authorization: Basic ...)
    mockMvc.perform(post("/api/admin/articles/42/approve").with(httpBasic("admin", "password")))
            // Якщо basic auth, користувач і пароль налаштовані, запит має пройти
            .andExpect(status().is2xxSuccessful());
}

Якщо у вашому застосунку справді налаштовані in-memory users для курсу (а в ContentHub вони якраз мають бути), такий тест уже починає перевіряти не лише «чи пускають роль ADMIN», а й те, що admin взагалі існує як користувач, що пароль читається, і що HTTP Basic увімкнено. Це інший рівень впевненості, і інколи він дуже корисний.

6. Вибір способу задати користувача

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

Нижче — невелика таблиця-навігатор. Вона не замінює мислення, але допомагає не заблукати.

Спосіб задати користувача Що ви насправді перевіряєте Коли особливо зручно Головний ризик/обмеження
@WithMockUser авторизація (правила доступу), без реального входу коли весь тест — «під одним актором» легко забути, що пароль і вхід узагалі не перевіряються
.with(user("...")) авторизація на рівні конкретного запиту коли в одному тесті різні актори або коли хочете «бачити актора» поруч із URI усе ще не перевіряє реальний механізм автентифікації
.with(httpBasic("...","...")) більш реальний шлях входу через HTTP Basic коли хочете бути ближче до production-like конфігурації тест стає трохи «дорожчим» і залежить від налаштування користувачів у застосунку

Загальна практична порада для курсу ContentHub: якщо ви робите чисту матрицю «актор → endpoint → результат», вам часто достатньо @WithMockUser або user(). А ось якщо ви хочете переконатися, що ваш інтеграційний сценарій справді проходить із реальними credentials (особливо у full-context тестах), тоді httpBasic() — чудовий варіант.

7. Приклади для ContentHub

Оскільки ми вже вміємо писати і @WebMvcTest, і @SpringBootTest + @AutoConfigureMockMvc, важливо зрозуміти, що spring-security-test не «привʼязаний» до одного виду тестів. Він працює всюди, де Spring Test узагалі підіймає контекст і підключає інфраструктуру безпеки.

У MVC slice (@WebMvcTest) ви часто мокаєте сервіси контролера і фокусуєтеся на межі HTTP. У такому тесті @WithMockUser зазвичай ідеально підходить: ви явно задаєте роль, робите запит і перевіряєте, що контролер доступний або недоступний. При цьому ви не ускладнюєте тест перевіркою реального входу — тому що мета slice-тесту інша: швидко й локально перевірити вебмежу.

У full-context тесті (@SpringBootTest + @AutoConfigureMockMvc) ви ближче до «реального застосунку», і у вас є вибір. Якщо ви хочете просто перевірити доступність endpoint-а для ролі, використовуйте @WithMockUser. Якщо ж ви хочете переконатися, що ваш security setup справді «як у житті» — хай і в навчальному проєкті, — використовуйте httpBasic(). Це особливо корисно, коли security-конфігурація змінюється і ви боїтеся регресій на кшталт «ми випадково вимкнули basic auth» або «перейменували користувача в конфігу».

Мініфрагмент full-context тесту з httpBasic(), у стилі «перевіряю доступ через реальні credentials»:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

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 adminListing_asHttpBasic_isOk() throws Exception {
    // У full-context тесті це ближче до реальної поведінки: фільтри самі обробляють автентифікацію
    mockMvc.perform(get("/api/admin/articles").with(httpBasic("admin", "password")))
            .andExpect(status().is2xxSuccessful());
}

Тут немає жодної бізнес-логіки публікації. І це добре. Це не regression flow, це точкова перевірка: «щонайменше адмін може зайти в адмінську зону».

8. Типові помилки під час задання користувача в security-тестах

Помилка №1: очікувати, що @WithMockUser спрацює в тесті без Spring-контексту.
@WithMockUser — це не магія JUnit сама по собі, це інтеграція зі Spring Test. Якщо ви пишете звичайний unit-тест без @SpringBootTest, без @WebMvcTest і взагалі без участі Spring TestContext, анотація не «підчепиться», а ви будете дивитися на 401/403 як на загадку всесвіту.

Помилка №2: писати roles = "ROLE_ADMIN" і потім дивуватися, що доступ не зʼявився.
Для roles префікс ROLE_ додається автоматично. Якщо ви явно прописуєте ROLE_ADMIN, ви часто отримуєте неправильний authority (ROLE_ROLE_ADMIN). Тест виглядає правдоподібно, і від цього він особливо підступний: здається, що «все як у доках», а по факту — ні.

Помилка №3: використовувати @WithMockUser, коли ви насправді хотіли перевірити реальний спосіб входу.
Іноді команда думає, що перевірила «basic auth працює», але написала тест на @WithMockUser. Такий тест перевірив лише правила авторизації, а механізм автентифікації обійшов стороною. Якщо вам важливо переконатися, що credentials справді обробляються, використовуйте httpBasic() (або в тестах із живим сервером — реальний заголовок Authorization).

Помилка №4: ховати актора надто глибоко в helper і втратити читабельність.
Зручно зробити метод asEditor() і забути, хто там усередині. Але якщо security-логіка завʼязана на username (а доступ, привʼязаний до власника, майже завжди так і влаштований), то «безіменні» helpers перетворюють тест на ребус. Користувач у security-тесті має бути видимим, інакше тест перестає бути документацією.

Помилка №5: отримати 403 і автоматично думати «значить, роль неправильна».
403 — це просто «заборонено», але причин може бути кілька: недостатня роль, відсутність CSRF-токена (якщо він увімкнений), відсутність потрібної authority або навіть те, що запит потрапив в інший security matcher. Перш ніж виправляти тест, корисно переконатися, що ви задаєте користувача саме тим способом, який відповідає вашій конфігурації (мок-актор чи реальний basic auth).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ