JavaRush /Курсы /Spring Test /Method security на service-layer

Method security на service-layer

Spring Test
23 уровень , 3 лекция
Открыта

1. Когда нужен method security

HTTP-граница уже показала нам, кто получает 401, кто — 403, и где всплывает owner-based отказ. Но не все правила доступа живут на URL: часть из них сидит прямо на бизнес-операции.

Если смотреть на приложение глазами новичка (и иногда даже глазами уставшего разработчика в пятницу вечером), кажется логичным: «Ну раз все запросы идут через контроллеры, и там Spring Security стоит на входе, значит дальше внутри всё безопасно». Эта логика похожа на мысль «если на входе в офис охранник, значит сейф можно не закрывать». Иногда это правда… пока не наступает реальность.

В Spring Boot приложении service-layer — это не «внутренности, куда никто кроме контроллера не дотронется». Со временем появляются новые входы в тот же use case: другой контроллер, админская ручка, batch-операция, командный endpoint для поддержки, обработчик события, иногда даже просто «временный» внутренний вызов из другого сервиса. Если правило доступа живёт только на web-границе, вы рискуете получить ситуацию, где один вход защищён, а другой (случайно) нет.

Тут и появляется смысл method security: вы ставите правило не на «дверь в здание», а на «дверь в комнату». Тогда неважно, кто именно вас привёл в эту комнату — контроллер, scheduler или коллега-энтузиаст, который «всего лишь хотел переиспользовать сервис».

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

И важная оговорка: method security не означает «давайте везде продублируем все проверки дважды». Если вы начнёте дублировать всё и на endpoint-уровне, и на методах, вы получите тесты, которые проверяют одно и то же, но в двух разных местах. Такой suite будет зелёным, но не обязательно полезным. Мы всё ещё живём по правилу минимально достаточного теста.

2. Как работает method security в Spring

Перед тем как писать тесты, полезно понять механику на уровне «что вообще происходит». Method security в Spring — это не «магия внутри метода». Это обёртка вокруг бина, обычно через прокси. Вы autowired-ите сервис — и на самом деле получаете объект-прокси. Когда вы вызываете метод, прокси сначала спрашивает: «а можно ли этому пользователю сюда?», и только потом (если можно) вызывает реальную реализацию.

Схематично это удобно представлять так:

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 security тест отвечает на вопрос: «может ли этот актор войти в endpoint и получить ответ?»
Method security тест отвечает на вопрос: «может ли этот актор вызвать бизнес-операцию, даже если он уже “внутри” приложения?»

Чтобы не дублировать тесты без нужды, удобно держать в голове такую таблицу решений:

Если ваша проверка… Где она живёт в коде Где её тестировать
«/api/admin/** доступно только ADMIN» SecurityFilterChain (request matcher) MVC/integration тестом через HTTP (MockMvc, RestTestClient)
«approve — это админская операция» @PreAuthorize на ArticleWorkflowService.approve(...) method security тестом прямым вызовом сервиса
«editor может редактировать только свою статью» часто @PreAuthorize + owner check (через 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, случайно вызовет сервис не там и не так, и получит «admin действие» через роль 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 без веб-сервера, берём 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() {
        // Не "какая-то статья", а минимально валидная fixture для approve-сценария
        Article article = TestArticles.approvableArticle();
        given(articleRepository.findById(42L)).willReturn(Optional.of(article));

        // Здесь мы проверяем только одно: security не режет ADMIN при валидных доменных предусловиях
        assertThatCode(() -> articleWorkflowService.approve(42L))
                .doesNotThrowAnyException();
    }
}

Если ваш approve() при валидном доступе всё равно может бросить доменное исключение, формулируйте ожидание уже под свою модель. Здесь нам важна сама граница: при корректной роли и валидной fixture тест не должен падать именно на security-слое.

Если вы замечаете, что для того чтобы approve «не упал», вам нужно замокать полпроектa, это сигнал. Либо вы пытаетесь сделать тест слишком «позитивным», и лучше доказать допуск другим способом (например, ожидать бизнес-исключение, но не access-denied), либо метод слишком сложный и вам нужно выбрать более узкую точку проверки. Method security тест должен быть маленьким, иначе он теряет смысл.

7. Owner-based правило в @PreAuthorize

Owner-based правила — самый частый источник путаницы, потому что роль 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 тесте мы хотим доказать, что связка «роль + владелец» реально работает. При этом нам не всегда хочется поднимать настоящую БД только ради owner-check. В учебном подходе можно замокать 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.

Практическая дисциплина здесь такая: если owner-check живёт в аннотации, тестируйте, что аннотация работает. Если owner-check живёт в коде сервиса, тестируйте поведение сервиса. Не нужно проверять одно и то же тремя способами, иначе 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 сломался», это «мы обошли прокси». В таких местах либо меняют структуру кода, либо явно проектируют границу бинов.

1
Задача
Spring Test, 23 уровень, 3 лекция
Недоступна
Ролевая method security на Spring bean
Ролевая method security на Spring bean
1
Задача
Spring Test, 23 уровень, 3 лекция
Недоступна
Owner-based правило в `@PreAuthorize` на service-layer
Owner-based правило в `@PreAuthorize` на service-layer
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ