JavaRush /Курси /Spring Test /Method security на сервісному шарі

Method security на сервісному шарі

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

1. Коли потрібен method security

HTTP-границя вже показала нам, хто отримує 401, хто — 403, і де спливає відмова за власником. Але не всі правила доступу привʼязані до URL: частина з них живе просто в бізнес-операції.

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

У Spring Boot застосунку service-layer — це не «внутрішність, куди ніхто, окрім контролера, не доторкнеться». З часом зʼявляються нові входи в той самий сценарій використання: інший контролер, адмінська ручка, batch-операція, службовий endpoint для підтримки, обробник події, а інколи навіть просто «тимчасовий» внутрішній виклик із іншого сервісу. Якщо правило доступу живе тільки на web-границі, ви ризикуєте отримати ситуацію, де один вхід захищений, а інший — випадково ні.

Ось тут і зʼявляється сенс method security: ви ставите правило не на «двері в будівлю», а на «двері в кімнату». Тоді неважливо, хто саме привів вас у цю кімнату — контролер, scheduler чи колега-ентузіаст, який «лише хотів повторно використати сервіс».

Ще одна причина — архітектурна дисципліна. Контролери в ContentHub (як і в більшості нормальних MVC-сервісів) задумано тонкими: вони перетворюють HTTP на виклик сервісу, а сервіс виконує бізнес-операцію. Якщо правило доступу є частиною бізнес-операції (наприклад, approve/reject — це саме адміністративна дія), логічно тримати його поруч із цією операцією, а не розмазувати по кількох контролерах.

І важливе застереження: method security не означає «давайте всюди продублюємо всі перевірки двічі». Якщо ви почнете дублювати все і на рівні endpoint, і на методах, отримаєте тести, які перевіряють одне й те саме, але в двох різних місцях. Такий набір тестів буде зеленим, але не обов’язково корисним. Ми й досі живемо за правилом мінімально достатнього тесту.

2. Як працює method security у Spring

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

Схематично це зручно уявляти так:

flowchart TD
    Caller["Код (контролер / інший сервіс / тест)"] --> Proxy["Проксі Spring (method security)"]
    Proxy --> Decision["Перевірка @PreAuthorize / @PostAuthorize"]
    Decision -->|дозволено| Target["Реальний ArticleWorkflowService"]
    Decision -->|заборонено| Denied["Виняток: AccessDeniedException"]
    Target --> Repo["Repository / інші залежності"]

Ключовий практичний висновок із цієї схеми дуже приземлений: якщо ви створите сервіс вручну через new ArticleWorkflowService(...), то обійдете проксі. А отже, жоден @PreAuthorize не спрацює, тест буде «зеленим», і ви будете щасливі рівно до першого security-інциденту. Тому method security тестують лише на Spring-managed bean.

Далі — про те, що саме «летить» при забороні. На web-layer ми звикли до статусів 401 і 403. На service-layer HTTP-статусів немає — там є винятки. І тут корисно заздалегідь домовитися: у тесті method security ви найчастіше перевіряєте тип винятку, а не текст повідомлення.

Зазвичай трапляються дві основні ситуації:

Контекст Що в SecurityContext Що зазвичай відбувається на @PreAuthorize
Користувача взагалі не задано Authentication відсутній часто буде AuthenticationCredentialsNotFoundException
Користувача задано, але прав не вистачає є Authentication (anonymous/editor/…) буде AccessDeniedException

Щоб не ловити сюрпризи, у тестах краще робити актора явним: або через @WithMockUser, або через @WithAnonymousUser. Так ви самі контролюєте, яку гілку перевіряєте, а не сподіваєтеся на «яку там аутентифікацію Spring підставить за замовчуванням».

3. Вибір рівня перевірки безпеки

Після перших пояснень у вас могло виникнути відчуття: «Гаразд, ми перевіряємо 401/403 через MockMvc — отже безпеку протестовано». Це правда, але лише для тієї частини правил, яка реально живе на HTTP-границі: у SecurityFilterChain, у правилах на URL, у налаштуваннях basic auth, у фільтрах і в обробниках відмови.

Method security вирішує інше питання. Якщо сформулювати прямо, виходить дуже проста пара:

Тест безпеки web-layer відповідає на питання: «Чи може цей актор увійти в endpoint і отримати відповідь?»
Тест method security відповідає на питання: «Чи може цей актор викликати бізнес-операцію, навіть якщо він уже “всередині” застосунку?»

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

Якщо ваша перевірка… Де вона живе в коді Де її тестувати
«/api/admin/** доступно лише ADMIN» SecurityFilterChain (request matcher) MVC/integration тестом через HTTP (MockMvc, RestTestClient)
«approve — це адмінська операція» @PreAuthorize на ArticleWorkflowService.approve(...) тестом method security через прямий виклик сервісу
«editor може редагувати лише свою статтю» часто @PreAuthorize + перевірка за власником (через bean/SpEL) або явна перевірка в сервісі або тестом method security (якщо правило в анотації), або unit/service тестом (якщо правило в коді)
«якщо не увійшов — отримай 401» web boundary (entry point) web-layer тестом (бо 401 — це HTTP-семантика)

Тут важливо не переплутати: 401 і 403 — це розмова про HTTP, і вона доречна на рівні контролера чи інтеграції. На method-level тесті ви доводите не статуси, а факт заборони або допуску через винятки.

4. Приклад @PreAuthorize у сервісі

Щоб тестувати method security, у проєкті має бути місце, де method security реально використовується. У ContentHub це зазвичай виглядає як сервісний метод, захищений анотацією @PreAuthorize. Для прикладу візьмемо «approve статті» як суто адмінську операцію.

Ось спрощений фрагмент сервісу:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class ArticleWorkflowService {

    // Правило доступу живе поруч із бізнес-операцією, а не лише на рівні контролера
    @PreAuthorize("hasRole('ADMIN')")
    public void approve(long articleId) {
        // Тут була б реальна бізнес-логіка approve (зміна статусу, аудит, події тощо)
    }
}

І контролер, який просто передає виклик далі. Контролер залишається «тонким», як і задумано:

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class AdminArticleController {

    private final ArticleWorkflowService articleWorkflowService;

    AdminArticleController(ArticleWorkflowService articleWorkflowService) {
        // У контролері немає бізнес-логіки — лише делегування в сервіс
        this.articleWorkflowService = articleWorkflowService;
    }

    @PostMapping("/api/admin/articles/{id}/approve")
    void approve(@PathVariable long id) {
        // Контролер лише викликає сервіс, а перевірка прав відбувається на method-level
        articleWorkflowService.approve(id);
    }
}

Сенс method security тут у тому, що правило «approve робить лише ADMIN» привʼязано до самої операції approve. Це зменшує ризик, що хтось створить новий endpoint, випадково викличе сервіс не там і не так, а потім отримає «адмінську дію» через роль editor. Web-границя важлива, але method security — це страхувальний шар усередині.

І так, щоб method security взагалі працював, його треба ввімкнути. У реальному проєкті це зазвичай частина загальної security-конфігурації. У навчальному прикладі це можна уявити так:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity // Увімкнути обробку @PreAuthorize/@PostAuthorize через проксі
class MethodSecurityConfig {
}

Ми не пишемо конфігурацію «з нуля» (це не завдання дня), але важливо пам’ятати: якщо @EnableMethodSecurity забути, @PreAuthorize стане просто красивим коментарем. З точки зору безпеки — поганим коментарем.

5. Тест заборони доступу (не та роль)

З негативних кейсів починати навіть приємніше: вони найчастіше простіші. Нам не потрібно готувати дані, не потрібно, щоб approve реально відпрацював; нам потрібно довести, що неправильний актор не може викликати метод. І тут method security особливо зручний: перевірка відбувається до тіла методу, отже ми можемо тестувати заборону майже без інфраструктури.

Мінімальний каркас тесту зазвичай виглядає так: піднімаємо Spring context без web-сервера, беремо Spring-managed сервіс, задаємо актора через @WithMockUser і перевіряємо виняток.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Піднімаємо контекст без web-рівня
class ArticleWorkflowServiceMethodSecurityTest {

    @Autowired
    ArticleWorkflowService articleWorkflowService; // Беремо саме Spring-managed bean (важливо для проксі)

    @Test
    @WithMockUser(username = "alice", roles = "EDITOR") // Явно задаємо роль: перевіряємо заборону для EDITOR
    void editorCannotApprove() {
        // Перевірка відбувається ДО входу в метод approve()
        assertThatThrownBy(() -> articleWorkflowService.approve(42L))
                .isInstanceOf(AccessDeniedException.class);
    }
}

Зверніть увагу на дві речі. По-перше, ми явно задали «alice як EDITOR». Це робить тест читабельним: він не про «якогось користувача», а про зрозумілу персонажку домену. По-друге, ми перевіряємо саме AccessDeniedException. Тобто кажемо: «користувач був, але доступу не вистачило». Це смисловий аналог 403 на рівні методу.

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

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.then;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Контекст без web-рівня
class ArticleWorkflowServiceMethodSecurityNoSideEffectsTest {

    @Autowired ArticleWorkflowService articleWorkflowService; // Spring-managed сервіс під проксі

    @MockitoBean ArticleRepository articleRepository; // Мокаємо залежність, щоб перевірити відсутність side effects

    @Test
    @WithMockUser(roles = "EDITOR") // Роль, яка не має доступу до approve
    void forbiddenCallDoesNotTouchRepository() {
        // Доступ має бути заборонено ще до будь-яких звернень до репозиторію
        assertThatThrownBy(() -> articleWorkflowService.approve(42L))
                .isInstanceOf(AccessDeniedException.class);

        // Якщо security спрацював правильно — репозиторій ніхто не чіпав
        then(articleRepository).shouldHaveNoInteractions();
    }
}

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

6. Тест допуску доступу (правильна роль)

Позитивний кейс виглядає невинно: «admin може approve». Але тут є пастка. Коли доступ дозволено, метод реально виконується, і він може впасти з причин, не пов’язаних із безпекою: статтю не знайдено, статус неправильний, політика переходів не дозволяє approve, ще не заповнені обов’язкові поля, база недоступна… У підсумку тест, який мав би перевіряти security, перетворюється на тест усього світу одразу.

Тут допомагає дуже проста стратегія: у позитивному тесті method security ми готуємо мінімально валідний стан саме для цієї операції. Не «якусь статтю», а статтю в стані, з якого approve взагалі дозволений. Інакше security нас уже пропустить, а тест завалиться на бізнес-помилці, після чого дуже легко переплутати причину падіння. Для цього часто використовують @MockitoBean на залежностях сервісу, щоб не піднімати справжню БД і не перетворювати тест випадково на інтеграційний.

Можливий варіант (спрощений, ідея важливіша за конкретні поля Article):

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.BDDMockito.given;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Не піднімаємо сервер: тестуємо method-level security
class ArticleWorkflowServiceMethodSecurityAllowedTest {

    @Autowired ArticleWorkflowService articleWorkflowService; // Важливо: отримуємо bean із контексту

    @MockitoBean ArticleRepository articleRepository; // Мокаємо інфраструктуру, щоб тест не став інтеграційним

    @Test
    @WithMockUser(roles = "ADMIN") // Роль, якій доступ дозволено
    void adminCanApprove() {
        // Не "якась стаття", а мінімально валідний набір даних для approve-сценарію
        Article article = TestArticles.approvableArticle();
        given(articleRepository.findById(42L)).willReturn(Optional.of(article));

        // Тут ми перевіряємо лише одне: security не зупиняє ADMIN за валідних доменних передумов
        assertThatCode(() -> articleWorkflowService.approve(42L))
                .doesNotThrowAnyException();
    }
}

Якщо ваш approve() за валідного доступу все одно може кидати доменний виняток, формулюйте очікування вже під свою модель. Тут нам важлива сама межа: за коректної ролі та валідного набору даних тест не повинен падати саме на security-шарі.

Якщо ви помічаєте, що для того, щоб approve «не впав», вам треба замокати півпроєкту, це сигнал. Або ви намагаєтеся зробити тест занадто «позитивним», і краще довести допуск іншим способом (наприклад, очікувати бізнес-виняток, але не access denied), або метод занадто складний і вам потрібно обрати вужчу точку перевірки. Test method security має бути маленьким, інакше він втрачає сенс.

7. Правило за власником у @PreAuthorize

Перевірки за власником — найчастіше джерело плутанини, тому що роль EDITOR виглядає як «пропуск у редакторську зону», але не відповідає на питання: «Це ваша стаття чи чужа?» Іноді таке правило роблять у тілі сервісу, інколи — просто в @PreAuthorize через виклик спеціального компонента (умовного ArticleAccess).

Спрощений приклад виглядає так:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class EditorArticleService {

    // Перевіряємо і роль, і "володіння" статтею через окремий компонент (bean)
    @PreAuthorize("hasRole('EDITOR') and @articleAccess.isOwner(#articleId, authentication)")
    public void updateDraft(long articleId) {
        // Тут була б бізнес-логіка оновлення чернетки
    }
}

І компонент, який вирішує, власник чи ні:

import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component
class ArticleAccess {

    boolean isOwner(long articleId, Authentication authentication) {
        // У реальності тут зазвичай запит у репозиторій і порівняння ownerId/username
        return false;
    }
}

Як це тестувати? У тесті method security ми хочемо довести, що зв’язка «роль + власник» реально працює. При цьому нам не завжди хочеться піднімати справжню БД лише заради перевірки власника. У навчальному підході можна замокати ArticleAccess і керувати його відповіддю: в одному кейсі він каже «не власник», і виклик має бути заборонений; в іншому — «власник», і метод має пройти security.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Контекст без web-рівня
class EditorArticleServiceOwnerMethodSecurityTest {

    @Autowired EditorArticleService editorArticleService; // Викликаємо метод напряму (service-layer)

    @MockitoBean ArticleAccess articleAccess; // Мокаємо owner-check, щоб керувати умовою в @PreAuthorize

    @Test
    @WithMockUser(username = "bob", roles = "EDITOR") // Роль є, але власник буде "false"
    void editorCannotUpdateForeignDraft() {
        // Кажемо security-правилу: користувач НЕ є власником статті
        given(articleAccess.isOwner(eq(42L), any())).willReturn(false);

        // Тоді доступ має бути заборонено на рівні @PreAuthorize
        assertThatThrownBy(() -> editorArticleService.updateDraft(42L))
                .isInstanceOf(AccessDeniedException.class);
    }
}

Ця техніка виглядає як «ми замокали те, що перевіряє власника, отже owner-check не тестуємо». І це нормально, якщо ви розділяєте відповідальність. Логіку ArticleAccess — яка, по суті, є звичайною прикладною перевіркою — можна тестувати окремо: хоч unit-тестом без Spring, хоч data-тестом із репозиторієм, залежно від реалізації. А тестом method security ви фіксуєте, що @PreAuthorize реально підключено, реально викликає потрібний компонент і реально забороняє виконання за false.

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

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

У тестах method security дуже легко отримати зелені тести, які «нібито є», але нічого не доводять. Найчастіше проблема не в Spring Security, а в тому, що ми випадково обійшли механізм проксі або змішали в один тест занадто багато причин падіння.

Помилка №1: тестувати @PreAuthorize на обʼєкті, створеному через new.
Method security зав’язаний на Spring-managed проксі. Щойно ви зробили new ArticleWorkflowService(...), ви перевіряєте не безпеку, а лише те, що Java вміє викликати методи. Такий тест може бути зеленим навіть за повністю зламаної security-конфігурації.

Помилка №2: забути ввімкнути method security і радіти зеленому suite.
Якщо в конфігурації немає @EnableMethodSecurity, анотації @PreAuthorize не працюватимуть. Найпідступніше те, що тести «на бізнес-логіку» при цьому можуть продовжувати проходити, і ви дізнаєтеся про проблему занадто пізно. Тест method security якраз корисний тим, що ловить таку конфігураційну дірку.

Помилка №3: намагатися шукати 401 і 403 у method-level тесті.
На рівні виклику сервісу немає HTTP. Там немає статусів — там є винятки. Перевіряти потрібно AccessDeniedException (і інколи AuthenticationCredentialsNotFoundException), а статусні смисли залишати для web-layer.

Помилка №4: позитивний тест падає через бізнес-логіку, і ви сприймаєте це як «security не працює».
Коли доступ дозволено, метод починає виконувати реальну роботу. Якщо не підготувати мінімальний стан або не замокати важкі залежності, тест падатиме «тому що статтю не знайдено» або «статус не той». Це не security regression, а просто інший ризик. Або підготуйте стан, або формулюйте очікування інакше (наприклад, «не access denied»).

Помилка №5: self-invocation і «чому анотація не спрацювала».
Якщо всередині одного сервісу метод A() викликає метод B() того самого класу, то виклик часто йде повз проксі — це класична проблема self-invocation у proxy-based AOP. У підсумку @PreAuthorize на B() може не спрацювати. Це не «Spring зламався», а «ми обійшли проксі». У таких місцях або змінюють структуру коду, або явно проєктують межу біна.

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