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 случайно стал открыт шире) будет проходить и маскировать дырку. Перед тестом полезно проговорить человеческую формулу: «кто делает запрос, куда, и какое право нужно».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ