1. Рост security-тестов
Security-тесты — чемпионы по размножению. Одна защищённая операция и так требует проверки happy path, а тут мы внезапно умножаем сценарии на «кто делает запрос или вызывает метод»: anonymous, editor, admin, плюс «чей ресурс», плюс разные коды ошибок. Если писать всё копипастой, тесты быстро превращаются в сериал на 12 сезонов, где уже никто не помнит, что было в первой серии.
Самая частая причина «адского» security-suite — не сама безопасность, а то, что мы забываем: тест — это тоже код, и его читают люди. Поддерживаемый security-тест должен отвечать на три вопроса буквально в первые две секунды просмотра: кто делает запрос или вызывает операцию, что он пытается сделать (endpoint на HTTP-границе или сервисный метод внутри приложения), и что ожидается (допуск/отказ и почему). Если эти три вещи спрятаны среди повторяющихся строк with(user("alice")...), contentType(...), accept(...), andExpect(...), то тест начинает выполнять роль не документации, а шифровки.
Давайте честно посмотрим на типичный «вчера написанный» тест, который через неделю уже выглядит как чужой (даже если писал его ты сам — классика жанра). В примерах дальше используется JUnit 6:
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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AdminApproveSecurityTest {
@Test
void editorCannotApprove() throws Exception {
mockMvc.perform(post("/api/admin/articles/42/approve")
// Актор сценария: запрос делает editor (именно это и есть предмет проверки)
.with(user("alice").roles("EDITOR")))
// Ожидаем отказ по правам: editor не может делать admin-действие
.andExpect(status().isForbidden());
}
}
Пока это один тест — он выглядит нормально. Проблема начинается, когда таких тестов становится 40, и в каждом повторяется «alice», «EDITOR», «admin», «password», одни и те же URL, и ещё полстраницы одинаковых ожиданий. В этот момент security-suite начинает жить по правилам старого фольклора: «Если тесты трогать — они могут обидеться и сломаться».
Поддерживаемость в этом месте — не про «красиво», а про то, что завтра у вас поменяется правило доступа, и вы захотите быстро понять: это одна правка в одном месте или «вручную в 25 тестах». Хороший security-suite делает изменения локальными и предсказуемыми, а не драматическими.
Здесь важно не схлопнуть всё обратно в MockMvc. На практике security-тесты — это не один жанр, а как минимум три семейства, и поддерживаемость нужна всем трём.
- HTTP-граница + mock actor — @WithMockUser, user(...), anonymous(...), когда мы быстро проверяем правило доступа к endpoint-у.
- HTTP-граница + реальные credentials — httpBasic(...) или обычный Authorization header, когда важно пройти через настоящий механизм входа.
- Method security + прямой вызов Spring-managed bean — когда правило висит на сервисном методе, и тест ждёт уже не HTTP-статус, а исключение вроде AccessDeniedException.
Дальше основной фокус будет на первом семействе, потому что именно там быстрее всего разрастается матрица actor → endpoint → result. Но почти все организационные привычки из этой лекции переносятся и на остальные два пути: осмысленные имена акторов, компактные fixtures, читаемая группировка тестов. Не переносится только форма проверок: status helpers хороши для HTTP, а прямой вызов сервиса должен оставаться прямым вызовом сервиса.
2. Группировка тестов и матрица доступа
На HTTP-границе security-тесты и правда часто выглядят как матрица «актор → действие → ожидаемый статус». Поэтому логично, чтобы структура тестового кода эту матрицу отражала. Иначе вы получаете набор разрозненных проверок, где половина тестов про editor, половина про admin, и всё перемешано как провода в ящике, который «я потом разберу». Спойлер: никто его не разбирает.
Есть два здоровых способа группировки, и оба можно применять в одном проекте. Первый — группировка по access zone: отдельные тестовые классы под public/editor/admin API. Это особенно удобно в ContentHub, потому что зоны API уже отражены в URL (/api/public/..., /api/editor/..., /api/admin/...). Второй — группировка по актору внутри класса: nested-классы AsAnonymous, AsEditor, AsAdmin. Такой подход делает «кто выполняет запрос» видимым не только в коде, но и в структуре тестов.
Мини-скелет выглядит так:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@DisplayName("Security: /api/admin/articles")
class AdminArticleSecurityTest {
@Nested
@DisplayName("as ANONYMOUS")
// Вложенные классы — это «папки» для акторов: так матрица доступа видна прямо по дереву тестов
class AsAnonymous {
// Здесь будут тесты, где запрос делает anonymous
}
}
Почему это полезно именно для начинающих? Потому что у вас не будет ощущения, что security — это «магия где-то внутри Spring». Вы глазами видите: вот проверки анонимного, вот проверки редактора, вот администратора. И когда какой-то тест падает, вы уже по дереву тестов понимаете контекст.
Схематично (очень грубо, но помогает мозгу):
flowchart TD
A["AdminArticleSecurityTest"] --> B["AsAnonymous"]
A --> C["AsEditor"]
A --> D["AsAdmin"]
B --> B1["GET/POST -> 401"]
C --> C1["approve -> 403"]
D --> D1["approve -> 2xx"]
Тот же приём работает и для method-security тестов: можно группировать их по защищаемому сервису и внутри разводить AsEditor/AsAdmin. Просто в матрице вместо URI будет имя метода, а вместо 401/403 — допуск или AccessDeniedException.
Обратите внимание на тонкий, но важный момент: группировка должна усиливать читабельность, а не добавлять бюрократию. Если вы сделаете 10 уровней вложенности (AsEditor → WhenArticleIsDraft → WhenModerationOk → WhenMercuryIsInRetrograde), то тесты станут похожи на лабиринт, а не на документацию.
Очень хороший практический баланс для security-suite выглядит так: один тестовый класс покрывает одну зону или один набор endpoint-ов, а внутри этого класса @Nested помогает держать рядом проверки разных акторов. Так матрица доступа становится «видимой» прямо по файлу — без прокрутки на 800 строк вниз.
3. User fixtures: меньше copy-paste
В HTTP security-тестах почти всегда повторяются одни и те же акторы: alice как editor, bob как «другой editor» для owner-based сценариев, и admin. Если вы пишете эти строки руками в каждом тесте, вы одновременно создаёте две проблемы: во‑первых, тесты раздуваются, во‑вторых, у вас появляются расхождения вроде roles("Admin") в одном месте и roles("ADMIN") в другом (а потом вы грустите и ищете, почему это вдруг 403).
Решение простое: сделать маленький набор user fixtures. Не «магический базовый класс на 500 строк», а буквально один компактный helper, который создаёт акторов одинаково во всём suite. В MVC-тестах самый удобный формат — RequestPostProcessor, который вы передаёте через .with(...) в MockMvc.
Минимальный вариант TestUsers может быть таким:
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
public final class TestUsers {
private TestUsers() {
// Утилитарный класс: экземпляры не нужны
}
public static RequestPostProcessor editor(String username) {
// Фикстура акторов: единый способ создать editor-пользователя для всех тестов
return user(username).roles("EDITOR");
}
}
И сразу добавим туда администратора и анонимного пользователя. Главное — не усложнять, а сделать ровно то, что экономит шум:
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
public final class TestUsers {
private TestUsers() {
// Утилитарный класс: только static-методы
}
public static RequestPostProcessor editor(String username) {
// Editor оставляем в том же helper, чтобы все основные акторы были собраны в одном месте
return user(username).roles("EDITOR");
}
public static RequestPostProcessor admin() {
// Админ фиксируем явно: одно место, один username, одна роль
return user("admin").roles("ADMIN");
}
public static RequestPostProcessor anonymousUser() {
// Явно показываем «анонимного» как отдельного актора, а не как null
return anonymous();
}
}
Да, метод admin() в одну строку выглядит немного вызывающе. Но это как раз тот редкий случай, когда «вызывающе коротко» — хорошо: мы экономим шум и не прячем смысл. Тест должен выглядеть как история: «bob делает PUT туда-то — ожидаем forbidden». И TestUsers.editor("bob") отлично читается как фраза.
Использование в тесте тоже получается очень «человеческим»:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AdminApproveSecurityTest {
@Test
void editorGets403OnAdminApprove() throws Exception {
mockMvc.perform(post("/api/admin/articles/42/approve")
// Не копипастим роли по всему suite — берём из фикстуры
.with(TestUsers.editor("alice")))
.andExpect(status().isForbidden());
}
}
Здесь важная методическая мысль: мы вынесли в helper только то, что повторяется и не добавляет смысла (создание пользователя), но не спрятали ключевые элементы сценария (endpoint и ожидаемый статус). Это очень тонкая грань, и на ней легко ошибиться.
Иногда команде хочется идти не через post-processors, а через @WithMockUser. Это тоже окей, особенно если вы группируете тесты по актору через @Nested. Тогда можно поставить аннотацию на nested-класс и убрать повторение:
import org.junit.jupiter.api.Nested;
import org.springframework.security.test.context.support.WithMockUser;
class AdminArticleSecurityTest {
@Nested
@WithMockUser(username = "admin", roles = "ADMIN")
// Аннотацию можно повесить на nested-класс, чтобы все тесты внутри выполнялись «как admin»
class AsAdmin {
}
}
Если вы используете @WithMockUser, то очень полезно держать имена пользователей осмысленными. alice и bob в owner-based сценариях работают как персонажи в книге: сразу понятно, кто кому «не владелец». Если написать user1 и user2, мозгу придётся делать лишнюю работу — а мы в тестах как раз стараемся экономить мозг.
Для method-security path принцип тот же: актор может задаваться аннотацией на nested-классе, но defaultUser так же вреден, как и в MockMvc-тестах.
4. Owner-based сценарии: пользователь и данные
Owner-based доступ — это место, где начинающие чаще всего пишут «вроде правильный тест», но он падает (или проходит) по вообще другой причине. Например, вы хотели проверить «bob не может читать черновик alice», но статья не была создана — и вы получили 404. Формально это тоже «не пустили», но смысл другой: это уже не owner-based отказ, а отсутствие ресурса. Такой тест вводит в заблуждение, потому что проверяет не то, что написано в названии.
Поэтому в owner-based тестах фикстура данных — такая же важная часть, как фикстура пользователя. Вам нужно явно создать статью, явно закрепить за ней владельца, и только потом делать запрос «чужим» пользователем. Идея звучит очевидно, но именно здесь чаще всего начинается хаос: «а давайте возьмём id 42, он же где-то есть» (а он где-то перестал быть).
Самый простой способ стабилизировать owner-based сценарии — держать отдельный test helper для подготовки статьи, чтобы в тесте оставался смысл, а не детали создания сущности. Например, маленькая фабрика TestArticles (в тестовом коде) может создавать «минимально валидную» статью:
public final class TestArticles {
private TestArticles() {
// Утилитарный класс: фабрика тестовых сущностей
}
public static Article draftOwnedBy(String username, Category category) {
Article a = new Article();
// Минимально валидные поля: чтобы тест падал по security, а не по валидации/БД-ограничениям
a.setTitle("Test title");
a.setSummary("Test summary");
a.setBody("Test body");
a.setStatus(ArticleStatus.DRAFT);
// Ключевая часть owner-based фикстуры: кому принадлежит ресурс
a.setAuthorUsername(username);
a.setCategory(category);
return a;
}
}
Да, это уже не security-специфичный код, но он решает проблему owner-based тестов: данные становятся понятными, а не случайными.
А вот так может выглядеть сам тест (идея важнее конкретных названий репозиториев/полей):
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;
class EditorArticleOwnerSecurityTest {
@Test
void bobGets403ForAlicesDraft() throws Exception {
Category tech = categoryRepository.findByCode("TECH").orElseThrow();
// Фиксируем владельца ресурса: статья принадлежит alice
Article a = articleRepository.save(TestArticles.draftOwnedBy("alice", tech));
mockMvc.perform(get("/api/editor/articles/" + a.getId())
// Фиксируем актора: запрос делает bob (не владелец)
.with(TestUsers.editor("bob")))
.andExpect(status().isForbidden());
}
}
Обратите внимание, что тут две фиксации контекста. Первая — в данных: статья принадлежит alice. Вторая — в акторе: запрос выполняет bob. И только при таком раскладе результат 403 будет действительно означать «owner-based запрет», а не «случайно не нашли статью».
Если у вас в проекте принята SQL-подготовка данных (а мы уже умеем работать с @Sql из data/integration блока курса), то owner-based сценарии можно сделать ещё более «схематичными»: зафиксировать конкретный id статьи (например, 42L) и владельца прямо в SQL-файле. Это иногда даже понятнее, чем создавать сущность в Java, потому что читатель теста видит «вот конкретная строка в БД» и понимает, на чём стоит сценарий.
Главное правило остаётся тем же: owner-based тест должен падать по причине «не владелец», а не по причине «не существует» или «не тот статус».
5. Мини-DSL: helpers без магии
Когда вы уже сделали user fixtures, появляется соблазн «пойти дальше» и написать мини-фреймворк для тестов: performAsEditor().approve().expectDenied(). Это сладкий соблазн, потому что кажется, что кода станет меньше. Но тут легко потерять главное: тест перестаёт быть простым чтением и превращается в чтение чужого DSL (который ещё и вы сами же поддерживаете).
Хорошая золотая середина — helpers, которые убирают технический шум, но оставляют ключевые элементы сценария на виду. Например, в security-тестах часто повторяется accept(MediaType.APPLICATION_JSON) и постоянная связка .with(actor). Это можно вынести в пару методов внутри тестового класса (или в небольшой utility), не скрывая URL и ожидаемый статус.
Пример «здорового» helper-а:
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
ResultActions getAs(RequestPostProcessor actor, String url) throws Exception {
// Helper убирает техшум (accept + with), но не прячет URL и не прячет ожидания (они остаются в тесте)
return mockMvc.perform(get(url).with(actor).accept(MediaType.APPLICATION_JSON));
}
Теперь тест читается проще, но всё ещё буквально и прозрачно:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EditorArticleSecurityTest {
@Test
void anonymousGets401OnEditorRead() throws Exception {
// Здесь важно явно видеть: anonymous вызывает editor-endpoint => ожидаем 401
getAs(TestUsers.anonymousUser(), "/api/editor/articles/42")
.andExpect(status().isUnauthorized());
}
}
Заметьте, что мы не спрятали сам endpoint. Это важно: в HTTP security-тестах endpoint — часть «контракта доступа», и он должен быть видимым.
Есть полезный приём, который особенно хорошо работает в ContentHub: выносить URL-шаблоны в константы. Не для того, чтобы «вынести всё в константы, потому что так принято», а чтобы не ловить опечатки и не размазывать знания о маршрутах по всему suite:
private static final String EDITOR_ARTICLE_BY_ID = "/api/editor/articles/{id}";
private static final String ADMIN_APPROVE = "/api/admin/articles/{id}/approve";
Тогда тесты становятся ещё понятнее: вы не читаете «строки URL», вы читаете «имена действий».
Чтобы не скатиться в «магический DSL», удобно держать в голове простую табличку (её можно воспринимать как внутренний «чек»):
| Что выносить в helper | Почему это нормально | Что лучше не выносить | Почему это опасно |
|---|---|---|---|
| Создание пользователей (TestUsers.admin()) | повторяется, не добавляет смысла | ожидаемый статус | читатель должен видеть 401/403/200 прямо в тесте |
| accept(MediaType.APPLICATION_JSON) / contentType(MediaType.APPLICATION_JSON) | технический шум | полный сценарий «create + submit + approve» | это уже бизнес-flow и вы теряете фокус security |
| генерация минимально валидной статьи для owner-based | иначе тест случайный | «универсальный perform()» со скрытым URL | потом невозможно понять, что именно проверяется |
Если вы всё же хотите чуть уменьшить дублирование проверок ApiProblem (например, errorCode = ACCESS_DENIED), делайте это осторожно. Хороший компромисс — маленький helper, который добавляет одну читаемую проверку, но не превращает тест в «чёрный ящик». Например, метод expectAccessDenied(...), который проверяет 403 и errorCode, но вызывается прямо в тесте и не скрывает контекст.
6. Типичные ошибки при организации security-тестов
Ошибка №1: «волшебный» базовый класс для всех security-тестов.
Иногда хочется сделать AbstractSecurityTest, который будет настраивать всё подряд: пользователей, данные, helper-методы, общие assertions, и ещё «удобную» магию поверх MockMvc. Проблема в том, что через пару дней вы получаете не поддержку, а второе приложение внутри тестов. Когда тест падает, вы сначала дебажите базовый класс, потом тест. Для Junior-уровня и для учебного проекта это почти всегда лишняя сложность. Лучше маленькие утилиты и локальные helpers внутри конкретного тестового класса.
Ошибка №2: helper вида loginAsDefaultUser() без роли и username.
Такой helper экономит две строки, но ломает смысл. В security-тесте важно явно видеть актор. Если метод скрывает, кто именно «залогинен», тест перестаёт быть документацией матрицы доступа и превращается в угадайку. В результате вы читаете тест и думаете: «Так, а кто тут вообще запрос делает?»
Ошибка №3: owner-based тест без фикстуры владельца.
Если вы проверяете «bob не может читать статью alice», но статья не создана, либо создана без автора, либо автор не соответствует username, то тест либо упадёт с 404, либо будет проходить случайно. В обоих случаях вы теряете смысл: тест не доказывает owner-based правило. Owner-based сценарий обязан держаться на чётких данных: ресурс существует и принадлежит другому пользователю.
Ошибка №4: смешивание причин отказа в одном тесте.
Когда в одном тесте вы проверяете и доступ (security), и бизнес-переход статуса (workflow), и формат ошибки, тест превращается в «всё и сразу». Он становится хрупким: поменяли бизнес-правило — упал security-тест, и вы начинаете сомневаться, что сломалось. Лучше держать security-тест как проверку «пустили/не пустили», а бизнес-логику — в своих слоях (unit/integration), как мы уже делали раньше.
Ошибка №5: копипаста строк ролей и URL по всему suite.
Сегодня вы написали "EDITOR" в десяти местах — и всё зелёное. Завтра вы переименовали роль, или у вас появилась новая, или поменялся URL, и вы ищете строковые литералы по проекту. Это не «катастрофа», но это потеря времени и нервов. User fixtures и константы URL решают проблему очень дёшево, и именно поэтому они окупаются почти сразу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ