1. Разница между 401 и 403
Когда начинаешь тестировать безопасность, кажется, что всё просто: запрос не должен пройти — значит ждём «какую-то ошибку». На практике это как в жизни: «не пустили в клуб» бывает по разным причинам, и вежливый охранник (то есть наш API) обязан объяснить это корректно. Иначе клиент (и тест) начнут гадать, что произошло: пароль не тот, роли нет, или ресурс «не твой».
Семантически 401 Unauthorized и 403 Forbidden — это разные классы отказа. И для тестов это очень полезно, потому что статус становится «быстрым диагнозом» того, на каком шаге вас остановили.
Ниже — короткая «шпаргалка» в виде таблицы. Её удобно держать рядом, пока пишете security-матрицу для ContentHub.
| Статус | Что это означает человеческим языком | Типичная причина | Что клиенту делать дальше | Что полезно проверять в тесте |
|---|---|---|---|---|
| 401 Unauthorized | «Ты не представился» | Нет credentials или они неверные | Войти / передать корректные credentials | status=401, часто WWW-Authenticate header (для Basic), иногда JSON error payload (если у вас единый контракт) |
| 403 Forbidden | «Ты представился, но тебе нельзя» | Роль не подходит или правило доступа не выполнено (например, не владелец) | Не пытаться повторять запрос, менять пользователя/роль/права | status=403 + при наличии единого error contract — errorCode=ACCESS_DENIED |
Важный нюанс для HTTP Basic: 401 обычно сопровождается заголовком WWW-Authenticate. Это не «декорация», а подсказка клиенту: «какой именно способ аутентификации ждёт сервер». В тестах это иногда помогает убедиться, что мы реально попали в ветку аутентификации, а не в какую-то случайную 403 от CSRF или фильтра.
2. Мини-модель 401/403 в цепочке
Чтобы тесты были осмысленными, полезно иметь простую mental model: где именно в приложении принимается решение «пустить / не пустить». Иначе очень легко написать тест, который вроде бы «проверяет доступ», но на самом деле упал по другой причине — например, потому что данных в базе нет, или запрос не прошёл валидацию.
Представьте запрос как поход по коридору с несколькими дверями. Первая дверь — «аутентификация» (ты вообще кто?). Вторая — «авторизация» (тебе можно сюда?). Только после этого вы попадаете к контроллеру и бизнес-логике. Схематично это удобно держать в голове так:
flowchart TD
R["HTTP request"] --> A{Есть аутентификация?}
A -- нет --> U["401 Unauthorized"]
A -- да --> B{Есть нужная роль/authority?}
B -- нет --> F["403 Forbidden"]
B -- да --> O{Owner-based правило выполнено?}
O -- нет --> F
O -- да --> C["Controller + validation"]
C --> S["Service/business rules"]
S --> OK["200/201/..."]
S --> BE["404/409/... бизнес-ошибки"]
Эта схема объясняет два практических эффекта, которые часто удивляют новичков.
Во-первых, security-ошибки «перебивают» почти всё остальное. Если пользователь не вошёл, вы не увидите ARTICLE_NOT_FOUND, потому что до поиска статьи дело даже не дошло. Запрос закончится на первой двери.
Во-вторых, owner-based доступ часто требует существующего ресурса, иначе вы просто не сможете сравнить владельца. Это влияет на тесты: в role-based проверках можно смело использовать id=42 «с потолка» — безопасность отрежет раньше контроллера. А вот в owner-based сценарии вам, как правило, нужно создать статью в фикстурах, иначе тест превратится в проверку 404, а не 403.
3. Тесты на 401: anonymous и неверный пароль
Начнём с самого простого и, честно говоря, самого терапевтичного сценария: анонимный пользователь лезет туда, где ему не рады. Проверка 401 полезна тем, что она защищает систему от «случайно открыли редактирование наружу», и при этом тест часто можно написать без подготовки данных, без тела запроса и без сложных моков.
Для ContentHub это выглядит так: любой /api/editor/** и /api/admin/** должен требовать аутентификацию. Поэтому anonymous → туда → 401.
В integration-режиме (полный контекст + MockMvc) такой тест получается очень коротким:
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void anonymousGets401OnEditorList() throws Exception {
// Важно: идём без пользователя (anonymous), чтобы проверить именно ветку аутентификации -> 401.
mockMvc.perform(get("/api/editor/articles").with(anonymous()))
// Ожидаем 401: «не представился».
.andExpect(status().isUnauthorized());
}
Обратите внимание на хитрость выбора endpoint. Я взял GET, потому что он меньше «конфликтует» с типичными настройками безопасности. Если вы берёте POST/PUT/DELETE, в некоторых конфигурациях вы можете неожиданно получить 403 из‑за CSRF, и будете полчаса спорить с монитором, кто виноват: вы или интернет. GET в этом плане спокойнее: для него CSRF обычно не требуется.
Если в вашем API используется HTTP Basic (как в учебном проекте), то 401 часто сопровождается challenge-заголовком. Проверить его можно так:
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void anonymousGets401AndBasicChallenge() throws Exception {
mockMvc.perform(get("/api/editor/articles").with(anonymous()))
// 401 подтверждает, что аутентификация не пройдена.
.andExpect(status().isUnauthorized())
// Для Basic-аутентификации сервер должен подсказать challenge через WWW-Authenticate.
.andExpect(header().exists(HttpHeaders.WWW_AUTHENTICATE));
}
Теперь второй вариант 401: credentials есть, но они неправильные. Это уже ближе к реальной жизни, потому что мы тестируем не просто «пустим/не пустим», а «реальная аутентификация должна отстрелить неверный пароль».
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void wrongPasswordGets401() throws Exception {
// Передаём реальный заголовок Authorization: Basic ..., а не "рисуем" SecurityContext.
mockMvc.perform(get("/api/editor/articles")
.with(httpBasic("alice", "wrong-password")))
// Ожидаем 401, потому что пароль неверный.
.andExpect(status().isUnauthorized());
}
Здесь важно не спутать смысл httpBasic() и @WithMockUser. @WithMockUser — это «в тесте уже есть аутентификация», то есть проверка авторизации. Он не тестирует пароль. httpBasic() — это «я реально положил заголовок Authorization в запрос», и дальше приложение решает, верить ему или нет. Для сценариев «неправильный пароль» нужен именно httpBasic().
4. Тесты на 403: роль не та
Когда 401 вы уже поймали и закрепили, хочется перейти к более «вкусному»: пользователь вошёл, но делать это действие не должен. Здесь начинается самая частая регрессия в реальных проектах: случайно разрешили EDITOR делать admin-вещи, или разрешили всем аутентифицированным вызывать admin endpoint. И это выглядит как «да ладно, это же просто один matcher…». Да-да. Именно так и рождаются баги, которые потом чинят в пятницу вечером.
Для role-based сценария тест обычно очень простой: задаём пользователя и ждём 403. Выбирайте endpoint, где роль действительно различается. Например, admin-listing:
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@WithMockUser(username = "alice", roles = "EDITOR")
void editorGets403OnAdminList() throws Exception {
// Пользователь аутентифицирован, но роль не подходит для admin endpoint.
mockMvc.perform(get("/api/admin/articles"))
// Ожидаем 403: «вошёл, но нельзя».
.andExpect(status().isForbidden());
}
Если у вас в проекте (как в ContentHub) есть единый error contract ApiProblem и вы приводите security-ошибки к нему, то можно усилить тест: кроме статуса, проверить и смысл ошибки. Это полезно, потому что статус 403 может «прилететь» по разным причинам (не только роль), а errorCode фиксирует именно класс проблемы.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
@WithMockUser(username = "alice", roles = "EDITOR")
void editorGetsAccessDeniedProblemOnAdminList() throws Exception {
mockMvc.perform(
// Явно просим JSON, чтобы не получить "случайный" HTML (например, от form-login).
get("/api/admin/articles").accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isForbidden())
// Фиксируем семантику ошибки на уровне контракта API.
.andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
}
Обратите внимание, что я добавил accept(MediaType.APPLICATION_JSON). Это маленькая дисциплина, которая помогает тесту быть честным: мы явно говорим «мы API-клиент, мы хотим JSON». И если вдруг что-то начнёт возвращать HTML (классический привет от form-login в приложении, которое «вроде API»), тест это обнаружит гораздо раньше, чем ваш фронтендер в чате.
Иногда полезно держать рядом «контрастный» позитивный кейс: тот же endpoint, но с admin-ролью. Даже если вы проверяете только статус, это делает тестовую документацию понятнее: «вот кому можно, вот кому нельзя».
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminCanAccessAdminList() throws Exception {
// Позитивный кейс: роль подходит, значит security не должен блокировать запрос.
mockMvc.perform(get("/api/admin/articles"))
.andExpect(status().isOk());
}
Если у вас вдруг этот тест падает не на security, а «внутри бизнес-логики» (например, база не поднялась или данные не готовы), это сигнал, что вы не удержали тест в границе «проверка доступа». Тогда имеет смысл либо подготовить минимум данных, либо выбрать endpoint, который стабильно отвечает и без сложных условий. Но сама идея остаётся: 403 — это про «вошёл, но нельзя».
5. Owner-based доступ: «чужая» статья
Owner-based доступ — это момент, когда роль уже не спасает. У нас два редактора, оба с ролью EDITOR, оба «вообще-то имеют право редактировать статьи». Но редактировать они могут не любые статьи, а только свои. Это очень жизненный сценарий, и он ломается тоже очень жизненно: кто-то забыл проверить владельца в одном из методов, и вдруг «можно редактировать чужие черновики».
Для теста здесь критичны два условия: статья должна существовать, и она должна принадлежать другому пользователю. Иначе вы не проверите owner-based отказ. Если статьи нет — вы получите 404 (или другую ошибку поиска), и тест будет не про безопасность.
Один из самых компактных способов зафиксировать существование статьи — использовать @Sql с маленьким seed-скриптом. Допустим, мы вставляем в БД черновик статьи с id=100 и владельцем alice. Тогда bob должен получить 403 при попытке прочитать её через editor endpoint.
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.jdbc.Sql;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
@Sql("/db/testdata/article_draft_owned_by_alice.sql")
@WithMockUser(username = "bob", roles = "EDITOR")
void bobGets403WhenReadingAlicesDraft() throws Exception {
// Важно: ресурс существует (через @Sql), иначе вместо 403 получится 404.
mockMvc.perform(get("/api/editor/articles/100"))
.andExpect(status().isForbidden())
// Дополнительно фиксируем, что это именно запрет доступа, а не "другая" ошибка 403.
.andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
}
А теперь «контрастный» позитивный сценарий: alice как владелец должна получить доступ к этой же статье.
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.jdbc.Sql;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@Sql("/db/testdata/article_draft_owned_by_alice.sql")
@WithMockUser(username = "alice", roles = "EDITOR")
void aliceCanReadHerOwnDraft() throws Exception {
// Тот же ресурс и тот же endpoint, но username совпадает с владельцем.
mockMvc.perform(get("/api/editor/articles/100"))
.andExpect(status().isOk());
}
Сам SQL-файл (упрощённо, как идея) может выглядеть так. Здесь не нужно любить SQL — достаточно, чтобы он был коротким и детерминированным. Главное, чтобы вы явно видели владельца:
-- file: src/test/resources/db/testdata/article_draft_owned_by_alice.sql
-- Фикстура для owner-based тестов: создаём статью в статусе DRAFT, владельца видно явно.
-- Минимальная категория, чтобы статья могла сослаться на category_id.
insert into category(id, code, name)
values (1, 'java', 'Java');
-- Статья с владельцем alice: на ней и проверяем правило «владелец/не владелец».
insert into article(id, title, slug, summary, body, status, author_username, category_id, version)
values (100, 'T', 't', 'S', 'B', 'DRAFT', 'alice', 1, 0);
Да, в реальном проекте имена таблиц и набор полей могут отличаться. Смысл один: создать минимально достаточный объект в базе так, чтобы owner-check был возможен.
Теперь соберём в голове, что именно мы проверяем этой парой тестов. Мы не проверяем workflow статусов, не проверяем сериализацию DTO и даже не проверяем «а точно ли статья 100 возвращается корректно» (это уже покрыто другими слоями). Мы проверяем ровно то, что нужно: одинаковая роль EDITOR, разный username → разный доступ.
И вот тут рождается супер-полезная «мини-таблица истинности» для owner-based сценариев:
| Сценарий | Что меняем | Ожидаем |
|---|---|---|
| Anonymous → editor endpoint | нет аутентификации | 401 |
| Editor (не владелец) → editor endpoint | username другой | 403 |
| Editor (владелец) → editor endpoint | username совпадает | 200 |
| Editor → несуществующая статья | данных нет | 404 (это уже не security) |
Если держать это в голове, тесты получаются предсказуемыми, и главное — вы перестаёте радоваться «зелёному тесту», который на самом деле проверял не то.
6. Не путать security и business в тестах
Самая распространённая проблема в security-тестах — случайная подмена предмета проверки. Вы хотели проверить «анонимный не может», а проверили «в базе нет данных». Вы хотели проверить «не тот владелец», а проверили «не тот id». В итоге тест либо падает непонятно где, либо зелёный, но доказывает ровно ничего. И это, к сожалению, не редкий случай, а почти стандартный этап взросления тестов.
Здесь помогает простое правило: в security-тесте вы должны сознательно контролировать «что сломается первым». Если вы проверяете 401, выбирайте запрос, который не требует тела и не зависит от данных. Если вы проверяете 403 по роли, берите endpoint, где запрет срабатывает до бизнес-логики (обычно это так). Если вы проверяете owner-based, убедитесь, что ресурс существует, иначе вы неизбежно свалитесь в 404.
Ещё один полезный приём — держать в тесте явный фокус: «actor → action → expected». Это делает тест похожим на маленькую строку из access matrix. Например, такие имена читаются почти как документация:
- anonymousGets401OnEditorList
- editorGets403OnAdminList
- bobGets403WhenReadingAlicesDraft
И наконец, помните про «порядок фильтров/слоёв». В HTTP-запросе у вас часто есть четыре потенциальных источника отказа: security, затем валидация/биндинг в MVC, затем бизнес-правила, затем persistence/инфраструктура. В хороший security-тест вы целитесь так, чтобы точно попасть в security, а не в любой другой слой «по пути».
7. Типичные ошибки
Ошибка №1: ожидать 403, когда пользователь вообще не аутентифицирован.
Если вы не задали пользователя (ни @WithMockUser, ни httpBasic, ни user()), то сценарий почти всегда про 401. Когда в тесте написано «anonymous должен получить 403», вы, по сути, просите систему «запрети тому, кто ещё даже не вошёл». Это логически странно: сначала нужно понять, кто ты, а уже потом запрещать.
Ошибка №2: проверять 401 на запросе POST/PUT/DELETE и получать 403, после чего сомневаться в смысле жизни.
Очень часто 403 в таких тестах — это не «права не те», а CSRF-защита (если она включена). В REST API её обычно отключают или настраивают особым образом, но тесты всё равно должны быть написаны так, чтобы вы не интерпретировали 403 вслепую. Если вы хотите учить семантику 401, берите GET или убедитесь, что CSRF в вашем API-профиле действительно не мешает.
Ошибка №3: owner-based тест без фикстуры ресурса (и внезапный 404).
Если статья не создана, вы не тестируете владение. Вы тестируете отсутствие статьи. Это другой сценарий, другой статус, другое errorCode. В owner-based тесте ресурс должен существовать и принадлежать другому пользователю — только тогда 403 действительно доказывает правило доступа.
Ошибка №4: @WithMockUser там, где вы хотели проверить пароль.
@WithMockUser — это ускоритель авторизации, а не симулятор реального входа. Он не «вводит пароль», он просто кладёт в SecurityContext готовую аутентификацию. Если вы хотите проверить «неверный пароль → 401», используйте httpBasic() и полный контекст, иначе вы тестируете не аутентификацию, а свою фантазию о ней.
Ошибка №5: проверять только статус и не фиксировать смысл ошибки, когда у вас есть единый error contract.
Проверка статуса — это минимум. Но если у вас ApiProblem с errorCode, и вы хотите, чтобы клиент мог одинаково обрабатывать ошибки, полезно закреплять это тестами. Особенно в 403: иначе вы рискуете получить «403, но тело не то», и клиентская логика развалится, хотя тесты зелёные.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ