JavaRush /Курсы /Spring Security /Security-матрица тестов для API

Security-матрица тестов для API

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

1. Пара тестов — не стратегия

Когда проект маленький, кажется, что тесты безопасности можно писать по настроению: сегодня проверим /api/me, завтра — /api/admin/users, послезавтра — что-нибудь ещё. Но как только в приложении появляются разные зоны (public, me, drafts, editor, admin), разные модели аутентификации (session-based и JWT), плюс отдельные механики вроде CSRF, тесты начинают вести себя как носки после стирки: вроде все были парные, а потом внезапно каждый сам по себе и в неожиданных местах.

Главная практическая проблема не в том, что тестов мало, а в том, что у нас исчезает обзор. Мы перестаём понимать, какие сценарии уже защищены тестами, какие — только “проверяли руками”, а какие вообще никто не трогал и надеется на удачу. Именно поэтому мы вводим матрицу: это способ сделать security-проверки системными, чтобы по набору тестов было видно, что приложение действительно соответствует своей access-модели и что регрессии ловятся не случайно, а по плану.

Ментальная модель “матрицы”: что мы покрываем

Матрица security-тестов — это не магическая таблица, которую нужно “заполнить полностью”. Это скорее координатная сетка, которая помогает вам не забыть важные оси: какую фичу проверяем, каким актором, какую операцию, в какой auth-модели и какой статус ожидаем. Если в голове держать эту схему, тесты начинают писаться не “как получится”, а как аккуратное отражение вашей access matrix.

Нам удобно мыслить так: у каждого endpoint’а есть “граница”, на которой Spring Security принимает решение. И почти всегда полезно иметь хотя бы один позитивный сценарий (когда доступ должен быть) и один осмысленный негативный (когда доступ запрещён). Для state-changing операций в session-based ветке часто добавляется отдельная пара негативных сценариев по CSRF: “нет токена” и “токен неправильный”. Для owner-based сценариев нужен отдельный негативный тест “чужой объект”, потому что роль USER сама по себе эту разницу не выражает.

Чтобы картинка была чуть более “осязаемой”, вот простая схема, как access matrix превращается в test matrix:

flowchart TD
    A["Access matrix проекта public / me / drafts / editor / admin"] --> B["Выбираем endpoint"]
    B --> C["Выбираем actor anonymous / user(owner) / user(foreign) / editor / admin"]
    C --> D["Определяем модель session+CSRF или JWT"]
    D --> E["Фиксируем ожидаемый статус 200/201/204, 401, 403, 3xx"]
    E --> F["Пишем короткий тест одна причина отказа"]

Матрица — это дисциплина. Она не заставляет вас писать тысячу тестов, но гарантирует, что вы не забудете про самые неприятные дырки, которые любят появляться “вроде бы после маленького рефакторинга”.

3. Оси матрицы: зона, актор, операция, ожидание

Если пытаться покрыть всё “на 100%”, легко утонуть. Поэтому для учебного проекта мы фиксируем минимально полезные оси и держим их стабильными. В Secure Content Platform API естественные оси такие: feature-зона (public, profile/me, drafts, editor, admin, auth), актор (anonymous, USER, EDITOR, ADMIN, плюс различие owner vs foreign), тип операции (read vs state-changing), и ожидаемый результат (200/201/204, 401, 403, иногда 3xx для form login/logout).

Ниже — пример “скелета” матрицы не в виде бесконечной таблицы на 200 строк, а как компактная подсказка, что мы вообще обязаны помнить. Это не единственный правильный вариант, но он хорошо ложится на наш курс:

Feature-зона Пример endpoint’а Актор “должно быть можно” Минимальный deny-case Особый deny-case
public GET /api/public/articles anonymous → 200 почти не нужен
me/profile GET /api/me authenticated → 200 anonymous → 401
me/profile (state-change) PATCH /api/me/profile USER + CSRF → 200 anonymous (c CSRF) → 401 или USER без CSRF → 403 invalid CSRF → 403
drafts (owner) GET /api/drafts/{id} USER(owner) → 200 anonymous → 401 USER(foreign) → 403
editor GET /api/editor/review-queue EDITOR → 200 anonymous → 401 USER → 403
admin GET /api/admin/users ADMIN → 200 anonymous → 401 EDITOR → 403
auth (session) POST /login,
POST /logout
valid/invalid login → 3xx logout без CSRF → 403
auth (jwt) POST /api/auth/login valid creds → 200 wrong creds → 401

Обратите внимание на важную штуку: в матрице мы стараемся, чтобы у deny-case была понятная причина. Если вы хотите проверить “аноним не может менять профиль”, но забыли добавить csrf(), Spring Security может вернуть 403 из-за CSRF раньше, чем доберётся до “аноним”. Это не ошибка security как таковой, это ошибка теста — он проверяет не то, что написано в названии.

Как выбрать helper под цель теста

Матрица полезна только тогда, когда у каждой клетки есть правильный инструмент. Самая частая путаница здесь не в статусах, а в выборе helper’а: user() используют там, где нужно проверить реальный login flow, jwt() — там, где должен ломаться bearer token, а про csrf() вспоминают уже после странного 403.

Что хотим проверить Чем моделировать Почему именно так
Быстрая проверка request-level или method-level доступа для обычного principal user() или @WithMockUser быстро создают аутентифицированный контекст без реального login flow
State-changing запрос в session/stateful-ветке user() / @WithMockUser + csrf() оставляют в тесте именно CSRF-барьер, а не случайный отказ по anonymous
Реальный browser-oriented login/logout flow formLogin() и POST /logout прогоняют приложение через настоящую username/password и session-семантику
Stateless authorization, claims и Jwt principal jwt() моделирует уже валидный JWT-контекст и даёт тестировать права и claims
Malformed / expired / invalid bearer token реальный Authorization: Bearer ... header через активный JWT runtime path (custom filter или built-in resource-server path) здесь важен сам разбор и валидация токена, а не готовый Authentication

Если упростить до одного правила, оно звучит так: сначала реши, что именно ты сейчас доказываешь — login flow, access rule, CSRF-барьер или bearer-processing. Потом бери самый короткий инструмент, который оставляет в тесте именно эту причину успеха или отказа. Как только в тест попадает helper “из другого мира”, матрица начинает врать.

4. Структура тестового набора в коде

Когда тестов становится больше десятка, их структура начинает влиять на качество не меньше, чем сами проверки. Если все security-тесты лежат в одном классе SecurityTest на 800 строк, вы получите классический эффект: “никто туда не хочет заходить”. Это как серверная UserService на 2000 строк — формально работает, но трогать страшно.

Для нашего проекта очень хорошо подходит правило: группируй тесты по feature, а не по технике. То есть мы не делаем классы CsrfTests, JwtTests, NegativeTests как главный каркас. Это удобно для учебного дня, но плохо для живого проекта. В живом проекте вы хотите открыть пакет content и увидеть там тесты, которые объясняют безопасность drafts и public articles, а не бегать по папкам “csfr/jwt”.

Пример разумной структуры src/test/java (без фанатизма) может выглядеть так же “по фичам”, как и main-код:

src/test/java/com/example/securecontent
├─ profile
│  ├─ ProfileSecuritySessionTest.java
│  └─ ProfileSecurityJwtTest.java
├─ content
│  ├─ DraftSecurityTest.java
│  └─ EditorSecurityTest.java
├─ admin
│  └─ AdminUsersSecurityTest.java
└─ auth
   └─ FormLoginSecurityTest.java

Да, иногда возникает вопрос: “а куда девать общие вещи?”. И вот тут мы вспоминаем правило из плана дня: helper’ы допустимы, пока они не скрывают смысл правила. То есть можно вынести “актёров” (как создавать admin JWT), но нельзя вынести “само правило” так, чтобы из теста пропало понимание, что именно проверяется.

5. Пары allow/deny в матрице

После selector-а выше allow/deny-пары читать проще: мы уже знаем, чем моделировать актора, и теперь нам важно держать рядом обе стороны границы доступа. Большинство полезной security-регрессии строится именно на таких парных проверках. Представьте турникет в метро: нам важно убедиться, что он пропускает тех, у кого есть билет, и не пропускает тех, у кого билета нет. Проверять 17 оттенков “почти билет” тоже можно, но это уже следующий слой. Для учебного (и часто для реального) бэкенда база — это пары.

Вот пример такой “парности” для admin-зоны, уже в стиле матрицы: один тест — базовый deny-case, второй — allow-case. Это сразу превращает тесты в документацию: открываешь файл и видишь границу доступа.

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

import org.junit.jupiter.api.Test;

@Test
void listUsersWhenAnonymousThenUnauthorized() throws Exception {
    // Анонимный запрос: нет JWT/сессии -> ожидаем 401 Unauthorized
    mvc.perform(get("/api/admin/users"))
            .andExpect(status().isUnauthorized());
}

И рядом:

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;

import org.junit.jupiter.api.Test;

@Test
void listUsersWhenAdminJwtThenOk() throws Exception {
    // Добавляем JWT и явно подставляем роль администратора
    mvc.perform(get("/api/admin/users").with(jwt().authorities(() -> "ROLE_ADMIN")))
            // Если endpoint реально admin-only, тут должно быть 200 OK
            .andExpect(status().isOk());
}

Да, это всего два теста. Но они очень “громкие”: они фиксируют, что endpoint не публичный, и что доступ — именно admin-only. Если кто-то случайно поменяет matcher’ы или перепутает hasRole, эти два теста упадут первыми и максимально понятно.

6. Owner-based часть матрицы: “свой / чужой”

Owner-based безопасность — это то место, где матрица перестаёт быть просто списком ролей. Для /api/drafts/{id} мало пары “USER может / anonymous не может”: нужен ещё отдельный deny-case “тот же USER, но foreign”. Иначе вы фиксируете только факт, что роль существует, но не фиксируете правило “это мой объект или чужой”.

Поэтому для owner-based endpoint’ов полезно держать рядом не разрозненные тесты, а маленькую тройку на один и тот же id ресурса:

Endpoint Actor Ожидание
/api/drafts/15 anonymous 401
/api/drafts/15 maria как owner 200
/api/drafts/15 anna как foreign user 403

Сам код этих сценариев уже знаком по owner-based negative paths. Здесь важнее другое: в test data должно быть стабильно известно, что draft 15 принадлежит maria, а строка “foreign user → 403” должна лежать в матрице так же явно, как и allow-case владельца. Тогда owner-check перестаёт быть надеждой на сервисный код и становится частью regression pack’а.

7. Stateful и JWT ветки без путаницы

В проекте полезно развести stateful и JWT-ветки не только в голове, но и по тестовым классам. У них разные причины отказов и разные инструменты из selector-а выше, поэтому один большой класс “про всё” быстро смешивает browser-flow, CSRF и bearer-token сценарии.

Ветка Что фиксируем Инструмент из selector-а Минимальный pair
Session, state-changing endpoints изменение состояния проходит только с CSRF user() / @WithMockUser + csrf() valid CSRF → 200, missing/invalid CSRF → 403
Session, auth flow логин и logout действительно живут как browser-flow formLogin() и POST /logout login success → 3xx + authenticated, logout success → 3xx
JWT, access rules role/authority/claim дают нужный доступ jwt() allow-case → 200, wrong authority/role → 403
JWT, broken token path токен не проходит runtime validation реальный Authorization header malformed/expired/invalid → 401

Есть и практический нюанс для матрицы: если вы хотите проверить 401 для anonymous на state-changing session-endpoint, добавьте csrf() даже туда. Иначе вас раньше остановит CSRF-фильтр, и тест окажется не про отсутствие аутентификации, а про другое.

8. Минимальные helper’ы: сокращаем шум, но не прячем смысл

Очень легко перейти грань и превратить тесты в набор “магических вызовов”: givenAdmin().whenCallEndpoint().thenOk(). С одной стороны — красиво, с другой — через две недели непонятно, почему оно так работает и что вообще проверяется.

Компромисс в нашем курсе такой: helper допустим, если он описывает актора, а не правило. То есть хорошо иметь adminJwt(), но опасно иметь shouldAllowAdminAccessToAnything().

Пример маленького helper’а, который делает тесты короче, но не убирает смысл:

import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

private RequestPostProcessor adminJwt() {
    // Создаём JWT, который выглядит как запрос от администратора (ROLE_ADMIN)
    return jwt().authorities(() -> "ROLE_ADMIN");
}

Тогда allow-case читается почти как предложение: get("/api/admin/users").with(adminJwt()). И в тесте всё ещё видно главное — какой endpoint проверяем, каким актором и какой статус ждём.

9. Матрица по фиче: drafts одним классом

В больших проектах часто помогает @Nested, чтобы код читался как сценарий. Это не обязательно, но для матрицы удобно: вы группируете тесты по контексту (anonymous, owner, foreign, editor), и файл перестаёт быть “простынёй”.

Небольшой пример формы (идея, а не единственно правильный стиль):

import org.junit.jupiter.api.Nested;
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.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Nested
class ReadDraft {
    @Test
    void whenOwnerThenOk() throws Exception {
        // Контекст "owner": владелец должен иметь доступ к своему draft
        mvc.perform(get("/api/drafts/15").with(user("maria").roles("USER")))
                .andExpect(status().isOk());
    }
}

Вы можете добавить рядом другой @Nested-класс для foreign user и для anonymous. В итоге матрица “живёт” в структуре кода: на одном экране видно, какие акторы проверены, и почему именно так.

10. Достаточная матрица: критерии

Когда выбор helper-а уже понятен, главный вопрос перестаёт звучать как “чем писать тест?” и превращается в “какие клетки матрицы нам реально нужны”. Есть тонкая грань между “мы проверили безопасность” и “мы написали 400 тестов, которые никто не запускает локально, потому что это занимает вечность”. В нашем курсе мы нацелены на достаточную матрицу: такую, которая ловит самые опасные регрессии и подтверждает контракт доступа.

Практический критерий “достаточности” для учебного проекта звучит так: по каждой чувствительной зоне (/api/me, drafts, editor, admin) у нас есть базовая пара allow/deny, а для owner-based — пара owner/foreign, для CSRF — минимум happy path и один осмысленный негативный (missing или invalid), для JWT — минимум один allow-case и один deny-case по authority. После этого матрица уже работает как регрессия: случайно открыть admin или сломать owner-check станет сложно.

Если вы хотите усилить матрицу, делайте это не количеством тестов, а качеством отрицательных сценариев. Один правильный “чужой объект” тест стоит десяти копий “admin ok”.

11. Типичные ошибки при построении полной security-матрицы тестов

Ошибка №1: группировать тесты по технике (CsrfTests, JwtTests), а не по фиче.
Так удобно в момент обучения, но быстро становится неудобно в проекте. Через месяц вы захотите посмотреть “всю безопасность drafts” и будете вынуждены прыгать по классам. Когда тесты сгруппированы по feature (profile, content, admin), они начинают работать как документация к конкретной части API.

Ошибка №2: один тест — много причин отказа.
Если в тесте на “403 для USER в editor-зоне” вы забыли аутентификацию, вы получите 401 и будете долго спорить с монитором, кто виноват. Если вы тестируете CSRF, сначала дайте корректного пользователя. Если вы тестируете 401, добавьте csrf() туда, где иначе сработает CSRF-фильтр. Один тест — одна основная причина, иначе матрица начинает “врать” и теряет смысл.

Ошибка №3: отсутствие пары “owner / foreign” для owner-based endpoint’ов.
Роль USER слишком широкая, и это нормально. Но именно поэтому owner-based правило должно проверяться отдельной парой тестов. Если у вас есть тест “владелец может”, но нет теста “чужой не может”, то вы фактически не тестируете ownership — вы тестируете только “USER существует”.

Ошибка №4: helper’ы превращают тесты в загадку.
Небольшие helper’ы для акторов — полезны. Но когда в тесте остаётся perform(callEndpointAsSomeActor()), смысл правила исчезает. Хороший тест читается как фраза: publishDraftWhenUserRoleThenForbidden. Если вы вынесли половину смысла в абстрактные методы, вы сломали читаемость — а значит сломали главный бонус матрицы: быть живой документацией.

Ошибка №5: матрица не проверяет негативные пути, потому что “и так работает”.
В безопасности “и так работает” обычно означает “и так работает до первого инцидента”. Большинство регрессий — это не падение сервиса, а тихое расширение доступа: где-то permitAll, где-то слишком широкий matcher, где-то забыли owner-check. Негативные тесты — это не пессимизм, а способ не проснуться однажды в мире, где /api/admin/users внезапно стал public-эндпоинтом.

1
Задача
Spring Security, 27 уровень, 4 лекция
Недоступна
Матрица доступа для moderation API
Матрица доступа для moderation API
1
Задача
Spring Security, 27 уровень, 4 лекция
Недоступна
Session-based матрица для profile API
Session-based матрица для profile API
1
Опрос
Security Тесты, 27 уровень, 4 лекция
Недоступен
Security Тесты
CSRF, JWT и логин
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ