1. Роль тестов login/logout
Если вы только что научились писать тесты с .with(user("maria").roles("USER")), возникает естественная мысль: «Ну всё, безопасность протестирована, можно праздновать». Но есть нюанс. Такой тест проверяет authorization-правила (кого куда пускаем), но почти не проверяет authentication flow (как человек вообще становится залогиненным). А это две разные части системы — как «пропуск на завод» и «проверка паспорта на входе».
Самый опасный сценарий для проекта выглядит так: у вас зелёные тесты на /api/me и /api/admin/users, потому что вы везде используете user() — и он честно создаёт аутентифицированный SecurityContext прямо внутри тестового запроса. Но в реальности ваш formLogin сломан: не тот PasswordEncoder, не тот UserDetailsService, неправильный login URL, поменяли параметр username на email и забыли, или вообще кто-то «оптимизировал» конфиг так, что UsernamePasswordAuthenticationFilter больше не срабатывает. В итоге настоящий пользователь в браузере не может войти… а тесты счастливо улыбаются и делают вид, что всё отлично.
Поэтому логика простая: user()-тесты — это быстрые и массовые проверки матрицы доступа, а formLogin() и logout-тесты — это несколько точечных, но очень важных “сквозных” проверок, которые гарантируют: ваш stateful login/logout действительно жив.
2. formLogin() в spring-security-test
Когда новичок впервые пытается протестировать логин вручную, он обычно делает post("/login"), кладёт параметры username и password, и… получает 403 Forbidden. Дальше начинается классическое: «Spring Security сломан», «CSRF бесит», «давайте выключим CSRF», «я видел в интернете .csrf().disable()». И вот в этот момент преподаватель грустит, а где-то в мире плачет один безопасный backend.
formLogin() — это готовый builder из spring-security-test, который собирает корректный form-based login запрос за вас. Он делает то, что в браузере обычно делает HTML-форма: отправляет POST на login processing URL, добавляет параметры с логином и паролем, выставляет нужный Content-Type, и — самое важное в нашем контексте — добавляет валидный CSRF token, потому что login — это state-changing операция.
Полезно держать в голове, что formLogin() — это не «магическая кнопка залогинь меня», а просто удобный способ не писать руками одно и то же. По смыслу он имитирует такой запрос:
| Что | Типичное значение |
|---|---|
| HTTP method | POST |
| URL | /login (по умолчанию) |
| Content-Type | application/x-www-form-urlencoded |
| Параметр логина | username=... (по умолчанию) |
| Параметр пароля | password=... (по умолчанию) |
| CSRF | присутствует (иначе будет ) |
Если вы изменили настройки formLogin (URL или имена параметров), то тест должен измениться вместе с ними — об этом поговорим чуть позже. Пока же наша цель — научиться тестировать успешный и неуспешный вход, не превращая тест в 30 строк ручной сборки запроса.
3. Каркас тестов с MockMvc
Перед тем как писать тесты на login/logout, важно, чтобы у нас был MockMvc, который реально ходит через SecurityFilterChain. Этот каркас у вас уже должен быть с прошлого дня, но я покажу его ещё раз коротко — просто чтобы код в голове не висел в воздухе, как баг в проде в пятницу вечером.
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest // Поднимаем реальный Spring-контекст, чтобы SecurityFilterChain был настоящим
@AutoConfigureMockMvc // Просим Spring сконфигурировать MockMvc поверх фильтров (включая Spring Security)
class AuthFlowTest {
@Autowired
MockMvc mvc; // MockMvc будет прогонять запросы через весь фильтр-цепочник, а не «в обход»
}
Это базовая форма: мы поднимаем контекст приложения и получаем MockMvc. Дальше важно помнить ещё одну практическую вещь: formLogin() запускает настоящую аутентификацию, то есть вашему приложению в тестовом профиле нужен реальный пользователь в user store (in-memory или DB). В отличие от user(), здесь «пользователь в голове теста» не прокатит — Spring Security будет честно искать его через UserDetailsService.
Если у вас в тестах нет подготовленного пользователя maria с паролем secret, то formLogin().user("maria").password("secret") будет не “зелёный тест”, а “красивый способ сказать BadCredentials”.
4. Успешный login в тестах
Успешный login в stateful модели — это не “получили 200 OK”. Это переход системы из состояния “anonymous” в состояние “authenticated", плюс создание/использование session, плюс стандартное browser-oriented поведение (обычно redirect). И мы хотим зафиксировать в тесте именно это поведение, а не просто факт, что сервер что-то ответил.
Самый компактный и полезный тест успешного логина обычно проверяет три вещи: статус (скорее всего 3xx), redirect URL (если вы его ожидаете), и факт аутентификации (через matcher authenticated()). Вот пример:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class AuthFlowTest {
// mvc уже внедрён через @Autowired
@Test
void loginWhenValidCredentialsThenRedirectAndAuthenticated() throws Exception {
// formLogin() имитирует браузерную отправку формы (включая CSRF), а не «логин из головы теста»
mvc.perform(formLogin().user("maria").password("secret"))
.andExpect(status().is3xxRedirection()) // Для form login обычно ожидаем редирект, а не 200
.andExpect(redirectedUrl("/")) // Типичное дефолтное поведение: после логина на главную
.andExpect(authenticated().withUsername("maria")); // Фиксируем факт аутентификации в SecurityContext
}
}
Почему здесь важно authenticated()? Потому что это проверка уровня Spring Security, а не уровня “ну редирект вроде есть”. В реальности можно получить редирект и при неудачной аутентификации (например, на /login?error). Поэтому хорошо, когда тест явно фиксирует: “пользователь действительно стал аутентифицированным”.
Если вы хотите сделать тест чуть более «физичным» (и это полезно новичкам), можно дополнительно проверить, что появилась session и что с ней теперь можно сходить в защищённый endpoint. Здесь важно не превращать тест в кинотрилогию, но один короткий сценарий “login → доступ к /api/me” даёт очень понятную картину.
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MvcResult;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void meEndpointWhenLoginThenOk() throws Exception {
// Шаг 1: логинимся «по-настоящему», чтобы Spring Security создал нам сессию
MvcResult login = mvc.perform(formLogin().user("maria").password("secret"))
.andReturn();
// Забираем сессию из запроса логина — это и есть наш «JSESSIONID в тестовом виде»
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
// Шаг 2: идём в защищённый endpoint с той же сессией (как сделал бы браузер)
mvc.perform(get("/api/me").session(session))
.andExpect(status().isOk());
}
В этом примере есть приятная «жизненная» правда: мы залогинились, получили session, и теперь /api/me доступен, потому что сервер узнаёт нас по JSESSIONID (в тесте это абстракция MockHttpSession). Такой тест особенно полезен, когда студент ещё не до конца верит, что “session — это реальная штука, а не миф из легенд о древних сервлетах”.
5. Неуспешный login в тестах
Неуспешный login — это не просто “ну бывает”. В security это обязательный сценарий. Причём он важен не только для безопасности, но и для диагностики: если вы случайно сломали PasswordEncoder, первым обычно ломается именно путь неуспешной аутентификации (и это хорошо, потому что вы узнаете об этом тестом, а не от пользователя в Telegram).
В stateful form login по умолчанию неуспешный вход обычно заканчивается redirect на /login?error. И это вполне нормальная семантика для browser-flow. Наш тест должен проверять: статус 3xx, корректный redirect, и что аутентификация не произошла (unauthenticated()).
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void loginWhenWrongPasswordThenRedirectToErrorAndUnauthenticated() throws Exception {
// Нам важен именно «неуспешный» путь аутентификации: пароль неверный
mvc.perform(formLogin().user("maria").password("wrong"))
.andExpect(status().is3xxRedirection()) // В браузерном сценарии это обычно редирект
.andExpect(redirectedUrl("/login?error")) // Дефолтный URL ошибки при form login
.andExpect(unauthenticated()); // Явно фиксируем, что SecurityContext не стал authenticated
}
Обратите внимание на психологически важный момент: здесь мы не проверяем бизнес-эндпоинты, не делаем запрос к /api/me, не пытаемся “в одном тесте проверить вообще всё”. Этот тест проверяет только поведение login flow при неправильном пароле. Чем он короче и однозначнее, тем проще его поддерживать.
Иногда полезно добавить ещё один негативный сценарий: пользователь не существует. В плане поведения для клиента это обычно выглядит так же (в целях безопасности вы не хотите сообщать “такого пользователя нет”, иначе получится user enumeration). Но для тестов это полезно как отдельная ветка, чтобы вы не перепутали “пользователь не найден” и “пароль не совпал”.
Ручной POST /login без CSRF
Сейчас будет маленькая демонстрация, которая очень хорошо лечит от желания отключать CSRF “потому что тесты красные”. Если вы попытаетесь сделать логин вручную через MockMvc, но забудете CSRF, вы получите 403 Forbidden. И это не ошибка Spring, это Spring пытается вас спасти от плохих решений.
Вот такой тест (или эксперимент) обычно заканчивается 403:
import org.junit.jupiter.api.Test; // Аннотация теста
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void loginWhenManualPostWithoutCsrfThenForbidden() throws Exception {
// Здесь мы намеренно делаем «как будто форма», но без csrf() — чтобы увидеть 403
mvc.perform(post("/login")
.contentType(MediaType.APPLICATION_FORM_URLENCODED) // Типичный Content-Type HTML-формы
.param("username", "maria") // Параметры формы логина
.param("password", "secret")) // Параметры формы пароля
.andExpect(status().isForbidden()); // CSRF защитил вас от "удобства"
}
И вот тут самое важное: formLogin() не просто “удобнее”, он ещё и помогает не забывать важные детали протокола. Поэтому в тестах на form login почти всегда разумно использовать formLogin(), а не собирать запрос вручную, если только вы специально не тестируете “что будет без CSRF”.
Logout как security-событие
С logout у новичков часто два мифа. Первый: “logout — это когда мы на фронте удалили cookie”. Второй: “logout не так важен, как login”. Оба мифа в реальном мире быстро превращаются в очень странные баги, когда пользователь “вышел”, но через минуту всё равно остаётся залогиненным, потому что session на сервере никуда не делась.
В session-based модели logout — это операция, которая меняет security-state: Spring Security должен очистить SecurityContext, обычно инвалидировать session, и (часто) попросить браузер удалить cookie, связанное с session (JSESSIONID). Именно поэтому logout — state-changing запрос, а значит, при включённом CSRF он должен быть защищён CSRF-токеном.
На уровне HTTP в типичном конфиге Spring Security logout выглядит как POST /logout с CSRF. А ответ чаще всего — redirect, например на /login?logout (это стандартный маркер, который удобно показывать на login page). И если вы тестируете stateful ветку проекта, то logout-тест должен быть в вашем регрессионном наборе так же обязательно, как и login-тест.
6. Logout: базовые тесты
Начнём с самого простого: logout-запрос с аутентифицированным пользователем и CSRF должен пройти и вернуть ожидаемую browser-семантику (обычно redirect). Мы делаем это явно через POST /logout и добавляем csrf().
import org.junit.jupiter.api.Test; // Аннотация теста
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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.*;
@Test
void logoutWhenAuthenticatedAndCsrfThenRedirect() throws Exception {
mvc.perform(post("/logout")
.with(user("maria").roles("USER")) // Указываем аутентифицированного пользователя для запроса
.with(csrf())) // Logout — state-changing операция, поэтому CSRF обязателен
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/login?logout")); // Типичный «маркер» успешного логаута
}
Этот тест полезен, но у него есть один недостаток: мы не убедились, что logout реально “закрыл дверь”. В конце концов, можно вернуть редирект и при сломанной логике (плохая идея, но технически возможно).
Поэтому следующий уровень — очень короткий сценарий “login → доступ есть → logout → доступ пропал”. Да, это уже чуть более интеграционный тест, но он отлично показывает, что session действительно перестаёт работать.
import org.junit.jupiter.api.Test; // Аннотация теста
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MvcResult;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void meEndpointAfterLogoutShouldNotBeAccessible() throws Exception {
// Шаг 1: логинимся и получаем «живую» сессию
MvcResult login = mvc.perform(formLogin().user("maria").password("secret"))
.andReturn();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); // Берём текущую сессию
// Шаг 2: проверяем, что доступ до логаута есть
mvc.perform(get("/api/me").session(session))
.andExpect(status().isOk());
// Шаг 3: делаем logout в рамках той же сессии + CSRF (как сделал бы браузер)
mvc.perform(post("/logout").session(session).with(csrf()))
.andExpect(status().is3xxRedirection());
// Шаг 4: убеждаемся, что та же сессия больше не даёт доступ к защищённому ресурсу
mvc.perform(get("/api/me").session(session))
.andExpect(status().is3xxRedirection()); // обычно redirect на /login
}
Да, тут чуть больше строк, чем “идеальные 8”. Зато в голове студента собирается цельная картинка: session жива, пока мы залогинены; logout “обнуляет” состояние; после этого тот же session уже не даёт доступ. Это именно тот тест, который спасает от “я вроде добавил logout, но он ничего не делает”.
7. Кастомизация login flow
Почти неизбежно наступает момент, когда вы перестаёте жить в “дефолтном мире”, и login flow становится кастомным. Например, вы меняете login processing URL (не /login, а что-то вроде /api/auth/login-form), или решаете, что логинимся по email, и тогда параметр формы уже не username.
И тут есть важное правило взросления: если вы поменяли контракт login flow, тесты обязаны поменяться вместе с контрактом. Это не “тесты мешают разработке”, это тесты фиксируют реальность, которую вы сами же создаёте.
Пример: вы оставили form login, но поменяли login processing URL. Тогда в тесте вы должны указать URL при построении запроса:
import org.junit.jupiter.api.Test; // Аннотация теста
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void loginWhenCustomLoginUrlThenStillWorks() throws Exception {
// Если поменяли loginProcessingUrl в конфиге — тест обязан ходить по новому URL
mvc.perform(formLogin("/api/auth/login-form")
.user("maria")
.password("secret"))
.andExpect(status().is3xxRedirection()); // Проверяем хотя бы факт успешного логина
}
А если вы поменяли имя параметра для логина, то и это отражается:
import org.junit.jupiter.api.Test; // Аннотация теста
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void loginWhenEmailParameterThenOk() throws Exception {
// userParameter(...) должен соответствовать тому, что вы реально ожидаете в фильтре аутентификации
mvc.perform(formLogin()
.userParameter("email") // Теперь логинимся не по username, а по email
.user("maria@example.com")
.password("secret"))
.andExpect(status().is3xxRedirection()); // На первых шагах фиксируем «оно аутентифицирует»
}
Здесь я специально не проверяю redirect URL — потому что при кастомизации вы могли поменять и success handler. На первых шагах полезнее зафиксировать “оно вообще аутентифицирует”, а затем уже делать тест более строгим.
И ещё один практический совет: кастомизацию лучше делать постепенно. Сначала поменяли URL — поправили тест. Потом поменяли параметр — поправили тест. И только потом, когда всё зелёное, можно придумывать красивую архитектуру “у нас login flow как в большом продукте”. Иначе вы получите продукт как в большом продукте, но почему-то без логина.
8. Типичные ошибки при formLogin() и logout
Ошибка №1: пытаться тестировать login через user() и считать это “проверкой логина”.
user() — это отличный инструмент для проверки правил доступа, но он вообще не проверяет, что ваш DaoAuthenticationProvider работает, что пароль правильно кодируется, что ваш UserDetailsService реально находит пользователя. Если вы не добавили хотя бы пару тестов на formLogin(), вы фактически не тестируете login flow.
Ошибка №2: ожидать 200 OK от успешного form login.
В browser-oriented сценарии успешный логин обычно заканчивается 302 и редиректом. Если вы в тесте ждёте 200, то вы либо тестируете не то, либо случайно попали в другое поведение (например, кастомный success handler). Лучше начинать с ожидания 3xx и authenticated(), а затем уточнять поведение под ваш конкретный контракт.
Ошибка №3: забыть, что formLogin() требует реального пользователя в user store.
Тесты с @WithMockUser прекрасно работают даже без базы и без in-memory пользователей. Но formLogin() — нет. Он честно аутентифицирует. Поэтому либо у вас должен быть подготовленный пользователь в тестовой базе/seed-данных, либо отдельная тестовая конфигурация user store, либо вы запускаете эти тесты на соответствующем checkpoint’е проекта.
Ошибка №4: тестировать logout как GET /logout или как POST без CSRF.
При включённом CSRF logout должен быть state-changing запросом с токеном. Если токена нет — нормально получить 403. И это не “мешает разработке”, это security делает свою работу. В тестах лучше быть явным: post("/logout").with(csrf()), чтобы не ловить случайные различия поведения между конфигурациями.
Ошибка №5: смешивать в одном тесте login/logout и сразу все бизнес-эндпоинты проекта.
Порой хочется написать один “гигантский” тест: логин, профиль, черновики, publish, logout… Такой тест будет падать “непонятно почему” и станет вашим персональным сериалом на 12 сезонов. Лучше держать login/logout тесты отдельными и короткими, а бизнес-эндпоинты проверять через user()-подходы — так тестовый набор остаётся поддерживаемым и предсказуемым.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ