JavaRush /Курси /Spring Security /Що має перевіряти security-тест у

Що має перевіряти security-тест у MVC

Spring Security
Рівень 26 , Лекція 0
Відкрита

1. Тестування безпеки проти регресій

Якщо ви вже налаштовували SecurityFilterChain, то, напевно, ловили себе на думці: «Ну все, працює! Я ж щойно ткнув у Postman, отримав 401 там, 200 тут — отже, готово». Це цілком нормальний етап. Проблема в тому, що Postman — як кава: бадьорить тут і зараз, але не гарантує, що завтра все ще буде добре. Тести — це радше будильник, який спрацьовуватиме щоразу.

Ручна перевірка безпеки корисна тоді, коли ви лише збираєте конфігурацію і хочете відчути поведінку системи. Але щойно ви починаєте правити правила доступу, додавати нові endpoint-и, змінювати matcher-и чи вводити authorities, у вас виникає класична проблема регресії: «Я хотів лише трохи підправити, а випадково відкрив адмінку всьому світу». І так, це не жарт: найчастіше такі діри зʼявляються не тому, що розробник «поганий», а тому, що він утомився, поспішав і пересунув один рядок.

Security-тест у цьому сенсі — не розкіш і не enterprise-надмір. Це мінімальна страховка: ви змінюєте конфігурацію, змінюєте контролер, змінюєте будь-що — і за секунду тести кажуть: правила доступу не змінилися або ви їх зламали. І якщо зараз ви думаєте: «Ну я ж акуратно», — повірте, Spring Security теж акуратний, але людський фактор він не скасовує. Навіть в акуратних людей буває «пʼятниця ввечері, один matcher» — і раптом permitAll() опинився трохи вище, ніж треба.

2. Security-тест як перевірка запиту

У цій лекції важливо перевчити мозок на одну дуже практичну річ: security-тест — це тест запиту на межі застосунку. Він не зобов’язаний доводити, що ваш контролер «правильно зібрав DTO» або «правильно викликав сервіс». Його робота — перевірити, що застосунок як система коректно реагує на спробу доступу: пропускає, якщо можна, і блокує, якщо не можна.

Чому так? Тому що Spring Security стоїть «до» контролерів. І якщо ви замість HTTP-подібного запиту просто викликаєте метод контролера напряму (наприклад, controller.getMe()), ви обходите security-шар, ніби зайшли в дім через вікно, а потім гордо заявили: «Замок працює». Замок, може, і працює — але ви його не перевіряли.

Щоб це стало зовсім очевидно, уявіть схему, як ми хочемо, щоб проходив запит у тесті:

flowchart TD
    %% Важливо: тест іде через ланцюжок фільтрів, інакше це буде "перевірка замка через вікно"
    T["Метод тесту"] --> M["MockMvc.perform(...)"]
    M --> F["Ланцюжок фільтрів Spring Security"]
    F --> D["DispatcherServlet"]
    D --> C["Контролер"]
    C --> R["Відповідь"]
    R --> A["Перевірки: статус 200/401/403"]

Ця схема — наш мінімальний «контракт чесності». Якщо тест проходить через security filter chain, він справді перевіряє безпеку застосунку. Якщо ж тест минає фільтри, ви можете отримати красиві зелені галочки й абсолютно дірявий доступ. І це той випадок, коли зелений колір у CI — не радість, а потенційно дуже дорога ілюзія.

Важливо ще одне: на старті ми найчастіше перевіряємо не вміст відповіді, а факт допуску або недопуску. Тобто наш базовий сигнал — HTTP-статус. Ми свідомо не перетворюємо перші security-тести на іспит із JSONPath. Ми фіксуємо головне: «доступ дозволено» або «доступ заборонено», і чи коректно це виражається через 200 / 401 / 403.

3. Формула сценарію: «хто → куди → що очікуємо»

Коли студент уперше бачить security-тести, часто виникає відчуття, що це «магія анотацій», «якісь user()», «якісь ролі» і взагалі окрема релігія. Насправді сценарій безпеки завжди можна звести до однієї простої формули: хто робить запит → до якого endpoint-а → який результат має бути. Якщо ви можете проговорити це людськими словами, ви вже майже написали тест.

Слово «хто» в контексті курсу зазвичай означає три базові стани. Перше — анонімний запит, тобто без автентифікації. Друге — автентифікований користувач, тобто «користувач увійшов», але без спеціальних привілеїв. Третє — привілейований користувач, тобто користувач із роллю EDITOR або ADMIN (або з потрібними authorities, якщо ви їх використовуєте на цьому endpoint-і). Ми свідомо починаємо з цих трьох, тому що це фундамент матриці доступу проєкту.

Слово «куди» — це не лише шлях, а й його смислова зона: /api/public/**, /api/me/**, /api/editor/**, /api/admin/**. На перших тестах нам навіть не обов’язково перевіряти всі методи (GET/POST/PATCH/DELETE). Нам важливо закріпити саму карту зон, тому що саме вона найчастіше ламається через невдалі matcher-и.

А «що очікуємо» на старті — це найчастіше статус. Причому статус у security-тесті — не просто число. Це перевірка вашої ментальної моделі: «якщо користувач не автентифікований, це 401», «якщо користувач автентифікований, але не має права, це 403», «якщо все гаразд, це 200 (або інший 2xx)».

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

Зона проєкту Приклад endpoint-а Анонімний USER EDITOR ADMIN
Публічна GET /api/public/articles 200 200 200 200
Особиста GET /api/me 401 200 200 200
Редакторська GET /api/editor/review-queue 401 403 200 200
Адмінська GET /api/admin/users 401 403 403 200

Ця табличка, по суті, і є «сировина» для перших security-тестів. Жодної містики: ми просто беремо рядок і перетворюємо його на тести.

4. Статуси 200, 401, 403 як сигнали доступу

Коли ви починаєте писати перші security-тести, дуже хочеться одразу перевірити все: і тіло відповіді, і заголовки, і те, що JSON гарний, і те, що помилки суворо відповідають вашому контракту. Але в цей момент корисно зупинитися й запитати себе: «Що зараз найкрихкіше й найважливіше?» Відповідь майже завжди одна: межі доступу. Тому перші тести — це статус-коди. Це як індикатори на панелі приладів: вони не розповідають усю історію двигуна, але одразу кричать, що щось не так.

У нашому курсі ми вже домовилися про коректну REST-семантику: анонімний запит у приватну або привілейовану зону має отримувати 401 Unauthorized, а не HTML-сторінку входу і не редирект. Це чудово тестується найпростішим очікуванням статусу.

Ось приклад найпростішого тесту: public endpoint має бути доступним навіть для анонімного користувача. Усередині тесту ми не робимо жодної автентифікації, а просто виконуємо запит і чекаємо 200 OK.

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;

@Test
void publicArticlesAreAccessibleForAnonymous() throws Exception {
    // Перевіряємо саме правило доступу: анонімний запит має проходити в public-зону
    mockMvc.perform(get("/api/public/articles"))
            // Якщо тут раптом стане 401/403 — значить "public" випадково перестав бути public
            .andExpect(status().isOk());
}

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

Тепер зворотний бік: /api/me — особиста зона, і анонімного запиту там бути не має. У правильній моделі це 401.

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;

@Test
void meEndpointRequiresAuthentication() throws Exception {
    // Анонімний запит без автентифікації не має потрапляти в особисту зону
    mockMvc.perform(get("/api/me"))
            // Сигнал «потрібно увійти»
            .andExpect(status().isUnauthorized());
}

І ось тут починається дисципліна: ми не перевіряємо «що саме в body», не перевіряємо «яка помилка», ми фіксуємо факт, що доступ без автентифікації заборонено і виражається коректним статусом. Так, пізніше ви можете захотіти перевіряти ще й JSON error contract — але на старті нам важливо прибити до підлоги саме правило доступу.

Нарешті, щоб побачити різницю 401 і 403, корисно мати хоча б один приклад сценарію «користувач є, але прав не вистачає». У таблиці вище це USER → адмінська зона. Нижче нам уперше трапиться user() — це test-only helper, який каже: «вважай, що запит надійшов від уже автентифікованого користувача». Поки що нам від нього потрібен лише сам факт: це вже не анонімний сценарій, отже система має відповісти 403, а не 401.

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 adminZoneIsForbiddenForOrdinaryUser() throws Exception {
    // Автентифікація є (користувача змодельовано), але прав на admin-зону немає
    mockMvc.perform(get("/api/admin/users")
            // Роль USER => очікуємо заборону (403), а не «потрібно увійти» (401)
            .with(user("anna").roles("USER")))
            .andExpect(status().isForbidden());
}

Якщо ви в цьому місці думаєте: «А навіщо мені взагалі розрізняти 401 і 403, можна ж усім відповідати 403 і не мучитися?» — це дуже зрозуміла думка новачка, але погана інженерна звичка. Клієнт (і ви самі через місяць) має розуміти, що сталося: «потрібно увійти» або «увійшов, але не можна». І тести допомагають закріпити саме цю різницю.

5. Що не перевіряють у перших security-тестах

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

На цьому рівні ми зазвичай не перевіряємо конкретне тіло JSON успішної відповіді. Причина проста: бізнес-відповідь змінюється значно частіше, ніж правила доступу. Ви можете додати поле в DTO, змінити порядок полів, змінити текст заголовка — і тести мають продовжувати працювати, якщо правила доступу не змінилися. Інакше ви постійно лагодитимете security-тести через «зайвий пробіл у JSON», а це морально ламає дисципліну тестування.

Ми також зазвичай не перевіряємо, як саме контролер відпрацював усередині, тому що це вже не security-тест, а тест бізнес-логіки. У security-тесті нам важливо, що запит не був заблокований або був заблокований коректно. Це, до речі, ще й чудова звичка для мислення: ви вчитеся бачити шари окремо. У безпеки — свій набір перевірок, у бізнесу — свій.

І ще одне: на перших тестах ми не намагаємося покрити весь світ, усі endpoint-и й усі варіанти. Нормальна стратегія — взяти кілька найважливіших правил (публічна зона доступна, особиста зона закрита для анонімних, адмінська зона недоступна звичайному користувачеві) і закріпити їх автоматикою. Це вже дає «скелет» регресійного захисту: якщо ви зламали щось велике — тести заволають.

Щоб відчути різницю, уявіть два підходи. У першому ви намагаєтеся перевірити «правильність усієї відповіді»:

// Погана ідея для найпершого security-рівня:
// тест ламатиметься від будь-якої зміни DTO.
//
// З погляду безпеки тут важливо лише те, що статус 200 (доступ дозволено),
// а не те, що JSON збігся «до пробілу».
mockMvc.perform(get("/api/public/articles"))
        .andExpect(status().isOk())
        .andExpect(content().json("""
            [{"title":"...","body":"..."}]
        """));

У другому ви перевіряєте саме безпеку — доступ:

// Гарний старт: перевіряємо правило доступу, а не всю бізнес-форму відповіді.
// Цей тест має падати лише тоді, коли public-зона перестала бути public.
mockMvc.perform(get("/api/public/articles"))
        .andExpect(status().isOk());

Перший варіант може бути корисним, але пізніше і в іншому шарі тестів. У другому варіанті тест короткий, стабільний і робить рівно те, що має: перевіряє, що public-зона не «закрилася».

6. Перенесення матриці доступу в тести

Коли ви починаєте переносити матрицю доступу в тести, головна небезпека — написати один величезний тест «на все», де всередині десять запитів, п’ять ролей, купа очікувань, і посередині ще коментар «тут має бути 403, але чомусь 401, розберемося потім». Такий тест зазвичай живе недовго: він складний, погано читається і ламається так, що незрозуміло, де саме.

Практичний принцип тут дуже простий: один сценарій доступу — один тест. Якщо ви хочете перевірити «анонімний не може відкрити /api/me», це окремий тест. Якщо ви хочете перевірити «USER не може відкрити /api/admin/users», це інший тест. Так, тестів стане більше, але вони будуть короткими й зрозумілими. А коли тест падає, ви одразу бачите, яке правило доступу зламалося.

Другий принцип — назвати тест так, щоб він читався як людське правило. Це кумедно, але це справді працює. Гарна назва тесту — це майже документація вашої безпеки. Наприклад, meEndpointRequiresAuthentication або publicArticlesAreAccessibleForAnonymous — ви читаєте це, і у вас у голові автоматично спливає правило доступу.

Третій принцип — тримати перший шар тестів тонким. На цьому етапі ми не будуємо тестову архітектуру, не пишемо тестовий DSL, не вигадуємо абстракції. Ми просто беремо правило й фіксуємо його. Якщо через два дні ви захочете винести спільні частини — чудово, але перший крок має бути простим, інакше є ризик, що ви потонете в інфраструктурі та забудете, навіщо взагалі прийшли.

Щоб не втратити зв’язок із реальним застосунком, корисно в голові тримати, що кожен тест — це буквально рядок із матриці доступу проєкту, перетворений на код. Якщо в матриці написано: «анонімний не може /api/me», тест має бути саме про це, а не про те, що /api/me повертає displayName завдовжки 20 символів.

7. Типові помилки в security-тестах

Помилка №1: «Я протестував безпеку, викликавши метод контролера напряму».
Це найчастіша пастка, тому що вона виглядає логічно: «контролер же і є мій API». Але security у Spring Security сидить не в контролері, а в filter chain. Прямий виклик методу — це обхід усього механізму автентифікації та авторизації. Симптом зазвичай такий: тести зелені, але при реальному запиті ви отримуєте 401/403 і дивуєтеся, чому «в тестах усе працювало».

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

Помилка №3: перевіряють занадто багато деталей відповіді там, де хотіли перевірити лише доступ.
Коли ви в security-тесті жорстко порівнюєте весь JSON успішної відповіді, тест починає падати через будь-які бізнес-зміни. У результаті у вас зʼявляється звичка «пофіксити тести» замість «зафіксувати безпеку». Симптом: ви змінюєте DTO, і раптом «зламалася безпека», хоча зламався просто JSON snapshot у тесті.

Помилка №4: плутають 401 і 403, а потім лагодять конфігурацію не в той бік.
Якщо endpoint повертає 401, значить, запит прийшов без автентифікації (або система не змогла її відновити). Якщо повертає 403, значить, автентифікація є, але прав не вистачає. Коли ці два статуси плутають, починаються дивні «лагодження»: додають permitAll() туди, де потрібно було правильно змоделювати користувача, або навпаки — затягують правила, де проблема була в тому, що запит анонімний. Симптом: ви змінюєте правила доступу, а ситуація стає тільки гіршою.

Помилка №5: намагаються довести тестами «що security увімкнено», не перевіряючи конкретних правил.
Іноді роблять тест виду «будь-який запит дає не 500 — отже security працює». Це приблизно як перевіряти сигналізацію тим, що на ній горить лампочка. Security-тест цінний тоді, коли він фіксує конкретне правило: публічне залишилося публічним, приватне залишилося приватним, межі editor/admin не попливли. Симптом: тести зелені, але ви все одно боїтеся змінювати security-конфігурацію, тому що тести насправді нічого не гарантують.

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