JavaRush /Курсы /Spring Security /jwt() в MockMvc

jwt() в MockMvc

Spring Security
27 уровень , 2 лекция
Открыта

1. Роль jwt() в MockMvc

Если вы однажды пробовали писать тесты «по-настоящему» — генерировать access token, подписывать его секретом, пихать в Authorization: Bearer ... и надеяться, что всё совпадёт с текущей конфигурацией — вы уже знаете, что это похоже на попытку тестировать дверной замок, каждый раз отливая новый ключ из металла. Вроде бы честно, но быстро становится больно, долго и ломко.

В security-тестах нам очень важно разделить две задачи. Первая задача — удостовериться, что правила доступа работают: editor-endpoint действительно доступен только редактору, admin-endpoint действительно доступен только админу, а /api/me не открывается анонимному запросу. Вторая задача — удостовериться, что токен реально валиден: подпись правильная, exp не истёк, формат не сломан. Это разные типы проверок, и если смешать их в одном стиле тестов, вы получите тест-набор, который либо медленный, либо хрупкий, либо и то и другое.

jwt() в spring-security-test даёт нам «тестовый пропуск» (как бейджик на конференции): он позволяет смоделировать уже аутентифицированный запрос с JWT-контекстом, чтобы вы проверяли именно авторизацию и бизнес-ограничения.

2. Что делает jwt() и его границы

Чтобы jwt() не воспринимался как «магическая наклейка», полезно держать в голове картинку из реального runtime. В продакшене запрос приходит с заголовком Authorization, дальше где-то в filter chain происходит извлечение bearer token, затем его валидация (подпись/TTL/claims), затем создаётся Authentication, и только после этого срабатывают ваши правила authorizeHttpRequests, @PreAuthorize и прочая «проверка прав».

В MockMvc-тесте с jwt() мы сознательно сокращаем маршрут. Мы как будто говорим Spring Security: «представь, что bearer token уже разобран и валиден, вот тебе готовый Jwt, вот authorities, продолжай работу с этой точки». И дальше уже проверяем, что правила доступа и бизнес-ограничения сработали так, как задумано.

flowchart TD
    A["MockMvc request"] --> B["SecurityFilterChain"]
    
    subgraph "Runtime (реальный запрос)"
      B --> C["Bearer token parsing"]
      C --> D["JwtDecoder / custom validation"]
      D --> E["Authentication создан"]
    end

    subgraph "MockMvc + jwt() (тестовый запрос)"
      B --> E
    end

    E --> F["authorizeHttpRequests / @PreAuthorize"]
    F --> G["Controller / Service"]

Здесь и лежит главный смысл: jwt() помогает тестировать authorization-решения, но не подходит для тестирования «сломанных токенов», «непарсируемых токенов», «неверной подписи» и прочих историй, где важен именно путь парсинга и валидации bearer token.

3. Базовый happy path с jwt()

Когда проект уже имеет stateless/JWT-ветку, самый базовый тест, который полезно написать, звучит почти скучно: «JWT-пользователь может вызвать protected endpoint». Но это скука правильная — она фиксирует baseline. Если такой тест внезапно перестал проходить после ваших изменений в конфигурации, у вас ломается весь stateless-контур.

Ниже пример, где мы просто проверяем, что /api/me доступен аутентифицированному запросу с JWT-контекстом:

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void meWhenJwtThenOk() throws Exception {
    mvc.perform(
                    // jwt() НЕ добавляет Authorization header,
                    // он поднимает SecurityContext с уже "готовым" Jwt-аутентифицированным пользователем
                    get("/api/me").with(jwt())
            )
            // Проверяем именно правило доступа: endpoint требует аутентификацию
            .andExpect(status().isOk());
}

В этом тесте важно понимать две вещи. Во-первых, jwt() не «добавляет заголовок», он добавляет security-контекст. Во-вторых, это тест не на токен, а на правило «этот endpoint требует аутентификации». Если вы хотите проверять правила по ролям/правам — вы должны добавить authorities (и это как раз следующий шаг).

4. Authorities: роли и права

Как только вы переходите от «просто аутентифицирован» к «разные зоны проекта», вам нужно научиться задавать authorities правильно. И здесь у новичков обычно случается классический момент: «я вроде дал роль EDITOR, но доступ всё равно запрещён». Причина почти всегда одна и та же — путаница роли и authority, плюс забытый ROLE_-префикс.

Если в конфигурации или в @PreAuthorize вы используете hasRole("EDITOR"), то под капотом это проверка наличия authority ROLE_EDITOR. То есть на уровне GrantedAuthority реальная строка будет с ROLE_. В тестах через jwt() вы чаще всего задаёте именно authorities напрямую, поэтому строка должна быть точной.

Пример: endpoint /api/editor/review-queue доступен только редактору. В тесте это выглядит так:

import org.junit.jupiter.api.Test; 
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void reviewQueueWhenEditorRoleThenOk() throws Exception {
    mvc.perform(get("/api/editor/review-queue")
            // ВАЖНО: hasRole("EDITOR") под капотом ждёт authority "ROLE_EDITOR"
            .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_EDITOR"))))
            // Если authority задана правильно — доступ должен открыться
            .andExpect(status().isOk());
}

Обратите внимание: мы не пишем .roles("EDITOR"), потому что это удобный API у user(). У jwt() вы обычно оперируете authority-строками напрямую.

Теперь про «права» (permissions). В нашем проекте есть authority-модель вида draft:review, user:manage и т.д. Если правило проверяет authority через hasAuthority("draft:review"), то вы должны выдать ровно эту строку, без ROLE_.

Пример, если endpoint или метод защищён правом draft:review:

import org.junit.jupiter.api.Test; 
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void reviewQueueWhenDraftReviewAuthorityThenOk() throws Exception {
    mvc.perform(get("/api/editor/review-queue")
            // Здесь "ROLE_" НЕ нужен: проверка идёт через hasAuthority("draft:review")
            .with(jwt().authorities(new SimpleGrantedAuthority("draft:review"))))
            .andExpect(status().isOk());
}

Да, выглядит чуть «строково», но это честное отражение того, как Spring Security принимает решение: он сравнивает строки authorities. Поэтому аккуратное именование прав в проекте — это не «косметика», а часть работоспособности security-модели.

5. Claims: subject и custom поля

Почти любой проект в какой-то момент начинает зависеть не только от ролей, но и от того, кто именно пользователь. Например, /api/me обычно возвращает профиль текущего пользователя, а owner-based правила сравнивают владельца объекта с текущим principal. В JWT-мире «кто ты» чаще всего выражается через claim sub (subject). И это напрямую влияет на то, что будет в authentication.getName().

Если вы не задаёте subject явно, jwt() создаёт какой-то минимальный дефолтный Jwt. Для теста «просто 200 OK» это нормально. Но как только вы хотите стабильные и осмысленные проверки, лучше явно выставлять subject.

Ниже пример: мы подменяем subject на maria:

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void meWhenSubjectMariaThenOk() throws Exception {
    mvc.perform(get("/api/me")
            // Подкладываем нужный sub, чтобы principal был предсказуемым
            .with(jwt().jwt(jwt -> jwt.subject("maria"))))
            .andExpect(status().isOk());
}

Если ваш endpoint /api/me возвращает имя пользователя, можно сделать тест ещё более содержательным: вы уже проверяете не только «доступ есть», но и «контекст пользователя действительно тот, который я смоделировал». Например, если ответ содержит поле username, тест может выглядеть так (предполагаем такой контракт ответа):

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void meWhenMariaJwtThenReturnsMaria() throws Exception {
    mvc.perform(get("/api/me")
            // Здесь мы проверяем не только доступ, но и то, что "кто я" в контексте — действительно maria
            .with(jwt().jwt(j -> j.subject("maria"))))
            .andExpect(status().isOk())
            // Контрактная проверка: приложение реально читает principal/claims и формирует ответ корректно
            .andExpect(jsonPath("$.username").value("maria"));
}

Да, здесь появляется jsonPath, и тест становится чуть «контрактнее», но зато он начинает ловить очень реальную проблему: когда вы вроде «аутентифицированы», но principal неожиданно не тот, или claim не туда положили, или где-то в конвертации JWT → principal допущена ошибка.

Кстати, вы точно так же можете добавлять любые custom claims, которые реально используются вашими правилами (только не превращайте тестовый JWT в сериал «Санта-Барбара: 300 серий claims»). Например, если вам нужен userId в claim uid:

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void meWhenUidClaimPresentThenOk() throws Exception {
    mvc.perform(get("/api/me")
            // Кладём кастомный claim, если он реально нужен вашему коду/правилам
            .with(jwt().jwt(j -> j.claim("uid", 42L))))
            .andExpect(status().isOk());
}

Смысл здесь не в том, что endpoint обязан читать uid. Смысл в подходе: кладём в тестовый JWT только те claims, которые реально участвуют в решении доступа или формировании ответа.

6. jwt() и ваш код: rules и principal

Когда вы используете jwt() в тестах, вы тестируете приложение примерно в том виде, как оно живёт в stateless-ветке: текущий пользователь приходит «из токена». Поэтому jwt() хорошо дружит и с request-level правилами (authorizeHttpRequests), и с method-level правилами (@PreAuthorize), потому что оба уровня принимают решения на основе Authentication и его authorities.

Кроме доступа, jwt() полезен и как «топливо» для тех мест, где вы читаете текущего пользователя в коде. Самый понятный вариант в stateless-ветке — читать Jwt как principal через @AuthenticationPrincipal. Это даёт предсказуемые claims и уменьшает количество самодельных «достань что-то из SecurityContextHolder».

Мини-пример контроллера (кусочек, не весь класс), который возвращает subject пользователя:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;

@GetMapping("/api/me")
MeResponse me(
        // В stateless JWT-сценарии principal часто именно Jwt, а не "UserDetails"
        @AuthenticationPrincipal Jwt jwt
) {
    // Берём "кто я" из sub (subject)
    return new MeResponse(jwt.getSubject());
}

Теперь вы понимаете, почему в тестах мы так настойчиво настраивали sub: иначе вы тестируете «какого-то дефолтного пользователя», а не реального актора вашей системы.

Ещё одна полезная мысль: @WithMockUser и .with(user("...")) создают Authentication другого типа (обычно UsernamePasswordAuthenticationToken). Это удобно и быстро, но если ваш код ожидает именно Jwt principal (например, в аргументе метода), то @WithMockUser может дать вам «не тот» principal. В таких местах jwt() — не прихоть, а способ тестировать именно тот контракт, который вы реально используете в stateless-ветке.

7. Типичные ошибки при работе с jwt() в MockMvc

Ошибка №1: пытаться через jwt() протестировать «сломанный токен».
jwt() не проверяет реальный парсинг строки bearer token, не валидирует подпись и не воспроизводит ошибки формата. Он моделирует уже готовый Jwt в контексте. Поэтому тесты вида «а что будет, если токен битый» должны строиться иначе — через реальный Authorization header и реальную обработку bearer token в filter chain (и это отдельный класс сценариев).

Ошибка №2: забыть, что jwt() — это про stateless-ветку, а не универсальный ключ от всех дверей.
Если вы тестируете session-based поведение (formLogin, logout, CSRF), то jwt() тут не поможет и только запутает. Для stateful-ветки используйте formLogin(), user() и csrf(). Для stateless-ветки — jwt() и/или bearer-header сценарии.

Ошибка №3: перепутать роль и authority, а потом полдня спорить с ROLE_.
Если где-то стоит hasRole("EDITOR"), то authority должна быть ROLE_EDITOR. Если где-то стоит hasAuthority("draft:review"), то должна быть ровно эта строка. В тестах это особенно заметно, потому что вы руками задаёте authorities. Самая частая проблема — написать EDITOR вместо ROLE_EDITOR и удивляться, почему доступ не открывается.

Ошибка №4: оставлять дефолтный subject и потом «случайно» не тестировать реальные owner-rules.
Когда subject остаётся дефолтным, вы проверяете доступ абстрактного пользователя и легко пропускаете ошибку, где реальный username/userId не попадает в principal. Как только в проекте появляется логика «свой/чужой», дефолтный subject превращается в мину замедленного действия. Лучше с самого начала задавать sub осмысленно там, где это важно.

Ошибка №5: в тесте на доступ забыть выдать authority, от которой реально зависит правило.
Иногда тест выглядит так: with(jwt()), ожидание 200, а endpoint на самом деле требует ROLE_ADMIN или draft:publish. Такой тест либо будет падать и раздражать, либо (если endpoint случайно стал открыт шире) будет проходить и маскировать дырку. Перед тестом полезно проговорить человеческую формулу: «кто делает запрос, куда, и какое право нужно».

1
Задача
Spring Security, 27 уровень, 2 лекция
Недоступна
JWT-доступ к `/api/me` и editor-зоне
JWT-доступ к `/api/me` и editor-зоне
1
Задача
Spring Security, 27 уровень, 2 лекция
Недоступна
Текущий пользователь из `Jwt` principal
Текущий пользователь из `Jwt` principal
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ