Принятые решения
Schemathesis → make fuzz-api
Отдельная команда, не входит в make test. Вручную или
отдельным шагом CI.
Имеется done
pytest, pytest-asyncio, pytest-cov, httpx
Добавить
testcontainers (PostgreSQL + Redis), factory_boy + Faker,
time-machine, respx (мок httpx для HIBP)
Fuzz
Schemathesis — fuzz по OpenAPI-схеме, выявляет ответы 500 и
нарушения контракта
Тест-БД
testcontainers-python — реальный PostgreSQL + Redis,
session-scoped контейнеры + per-test rollback
Маркеры
@pytest.mark.unit, @pytest.mark.integration —
фильтрация по типу
Unit
Без БД — криптография и валидация
JWT-токены — create / validate / edge cases
Кейсы
create — claims sub, role, exp, iat, jti, iss=achilles, aud=achilles-api
create → decode валидный token → корректный payload
expired → reject exp в прошлом → ExpiredSignatureError
wrong key → reject подпись другим SECRET_KEY → ошибка
alg substitution → reject RS256 при сервере только HS256 (hardcoded algorithms)
alg=none → reject header alg: none без подписи → отвергается
kid в header ключ выбирается по kid из реестра
missing claim → reject без sub или role → ошибка
iss/aud mismatch → reject чужой issuer/audience → reject
jti uniqueness два create → разные jti
Пароли и политика — argon2id, zxcvbn, HIBP
Кейсы
hash + verify OK hash(pw) → verify = True
wrong password → False другая строка не проходит
argon2 params memory=19MB, iterations=2, parallelism=1 — в хэше
too short (<8) → reject
too long (>128) → reject
weak (zxcvbn) "password123456" ниже порога → reject
HIBP — compromised respx: найден в утечке → reject
HIBP — clean respx: не найден → accept
HIBP — unavailable timeout/500 → fail open, warning
strong password проходит длину, zxcvbn, HIBP
CSPRNG-токены — invite, refresh hash, код привязки мессенджера
Unit
Кейсы
invite token format ≥32 байта, URL-safe (base64url)
uniqueness 100 генераций → все уникальны
constant-time compare hmac.compare_digest(), не ==
refresh token — hash SHA-256 ≠ raw token
messenger link code — hash короткий CSPRNG, в БД code_hash SHA-256 ≠ raw
Integration · P0
Security invariants & core flows
Setup Wizard — первый Owner
Integration P0
Кейсы
setup available 0 users → GET /setup → 200
create owner OK POST /setup → Owner в БД, access + refresh
setup gone после Owner → 404 SETUP_UNAVAILABLE
race condition 2 параллельных POST → один Owner, второй 409 CONFLICT
invalid email → 422 VALIDATION_ERROR + errors[], Owner НЕ создан
weak password → 422 VALIDATION_ERROR + errors[], НЕ создан
Логин — выдача токенов, generic-ошибки
Integration P0
Кейсы
success access (body) + refresh (httpOnly cookie)
wrong password → 401 INVALID_CREDENTIALS generic
unknown email → 401 тот же generic — не раскрываем существование
deactivated user → 401 — не раскрываем статус
cookie flags httpOnly, Secure, SameSite=Strict, Path=/api/auth, имя с префиксом __Secure-
remember-me снят session cookie без Max-Age — гибнет при закрытии браузера
remember-me установлен persistent cookie, Max-Age = TTL refresh (30 д)
email case-insensitive вход Alice@x при учётке alice@x → успех
token claims sub=user_id, role=фактическая
timing-safe (spy) несуществующий email → dummy argon2 verify (mock/spy)
Token rotation — reuse detection
Кейсы
valid refresh → new pair новый access + новый refresh (ротация)
grace-окно (~10с) повторный refresh тем же токеном в окне → тот же новый pair, family жива (гонка вкладок)
old token → reject ротированный, вне окна → 401 TOKEN_INVALID
reuse → kill family отозванный → 401 TOKEN_INVALID, вся family
expired (30d) time-machine +31д → 401 TOKEN_EXPIRED
absolute ceiling (90d) 90д от первого выпуска → 401 TOKEN_EXPIRED
invalid signature подделка → 401 TOKEN_INVALID
Logout — завершение сессии
Integration P0
Кейсы
standard logout refresh удалён из БД, Set-Cookie очищает
post-logout refresh → 401
logout all POST /api/v1/auth/logout-all → все refresh удалены
access still valid после logout до expiry — stateless, 15 мин
свою family не убить завершение текущей session-family через управление сессиями → сервер отклоняет (не только скрыто в UI) — серверный guard
test_must_change_password.py
Принудительная смена временного пароля
Integration P0
Кейсы
вход с временным паролём must_change_password=true → токены выданы, но гейт: редирект на экран смены пароля
серверный гейт флаг взведён → запрос к произвольному защищённому эндпоинту → 403 PASSWORD_CHANGE_REQUIRED; смена пароля и logout — разрешены
смена на гейте сильный новый пароль → флаг снят, прочие refresh пользователя погашены (кроме текущего)
повторный вход без смены флаг всё ещё взведён → снова гейт смены пароля
Integration · P1
Protection & access control
Invite flow + scope boundary
Integration P1
Кейсы
owner creates invite → 201, ссылка уходит письмом (SMTP — мок)
admin creates invite → 201 (если permission)
admin invite → owner role → 403 FORBIDDEN (scope boundary)
member → 403 не может создавать invite
SMTP unconfigured создание invite → 409 SMTP_NOT_CONFIGURED
accept invite токен + имя + пароль → User с ролью
expired (48h) +49ч → 410 INVITE_EXPIRED
reuse invite → 410 INVITE_USED
duplicate email → 409 CONFLICT
duplicate (регистр) Bob@x при учётке bob@x → 409 (lower(email))
Массовый инвайт — частичный успех, дубли, лимит
Integration P1
Кейсы
частичный успех батч со смесью валидных и битых строк → 207/200 с построчным отчётом: принятые создают инвайт, отклонённые несут причину; валидные не откатываются из-за соседних ошибок
дубли внутри батча повтор одного email в батче и email уже существующего пользователя/инвайта → строка помечена дублем, второй инвайт не создаётся
невалидная строка битый email / нет обязательного поля → строка отклонена с VALIDATION_ERROR в отчёте, не общий 422 на весь батч
лимит размера батч сверх потолка строк → 422, батч не обрабатывается частично
scope-граница массово приглашающий не может выдать роль выше своей ни одной строкой батча (тот же периметр, что у единичного); нарушение → строка отклонена
идемпотентность повторной загрузки повтор того же файла не задваивает уже созданные инвайты (по email), уже принятые пропущены
Привязка мессенджера — резолв, код, relay-safety, перебор
Integration P1
Кейсы
known user → resolve DM от привязанного мессенджера → identity_mapping → user_id, кода нет
auto-match by email provisioned-email воркспейса == учётка → связь авто, без кода
auto-match off тумблер выключен → даже при совпадении email бот выдаёт код
unknown user → code нет связи и авто-матча → ссылка/код, identity_mapping не тронут
return code → bind код из DM S → identity_mapping(slack, S) ↔ выдавшая учётка, used_at
relay-safety код учётки A, возврат из Slack B → связь B↔A; чужой Slack к A не привязать без её кода
expired (15m) +16м → 410 LINK_EXPIRED
reuse code использованный → 410 LINK_EXPIRED (одноразовый)
already linked аккаунт уже в identity_mapping → 409 ALREADY_LINKED
telegram → code-only email Telegram не отдаёт → авто-матча нет, всегда код; возврат → identity_mapping(telegram, …)
mattermost → code-only в событиях email нет → авто-матча нет, всегда код; возврат → identity_mapping(mattermost, …)
code brute-force 5 неверных кодов на chat_id → попытка сожжена, нужен новый код
no self-registration код без учётки войти не даёт — привязка только к существующей
RBAC middleware — матрица ролей
Integration P1
Кейсы
no token → 401 protected без Authorization → 401
owner → owner-only → 200
admin → owner-only → 403
member → admin-only → 403
owner → all endpoints полный доступ (матрица)
admin → user mgmt users, sources → 200
member → own data /me 200, /users 403
require(permission) через ROLE_PERMISSIONS маппинг, не роль
unknown permission → 403 нет в маппинге → 403 для любой роли
admin scope boundary Members 200, не Owners/Admins 403
role escalation → 403 нельзя назначить выше своей
invalid role in token role="superadmin" → 403
deactivated + valid token v1 stateless → 200, 15-мин окно (trade-off, документируется)
stale role — критичный эндпоинт токен role=admin, в БД member; управление пользователями читает роль из БД → 403
stale status — критичный эндпоинт валидный токен, в БД status=deactivated; критичная операция читает статус из БД → 401
Владение ресурсом — защита от IDOR
Integration P1
Кейсы
own resource → 200 владелец правит свой ресурс — owner_id = user_id
foreign resource → 403 Member A правит ресурс Member B → 403 FORBIDDEN (IDOR)
permission есть, владения нет проверка owner_id поверх permission → 403
admin штатная зона управление пользователями — обход владения; данные источников — нет
зависит от ресурсовконкретные эндпоинты приходят с Agent Engine
test_brute_force.py
Защита от перебора — rate limit, delays
Кейсы
IP rate limit 21-я / 15 мин → 429 RATE_LIMITED + retry_after
exponential delay после 3 неудач → 1с / 2с / 4с
below threshold 1–2 → нет задержки (порог с 3-й)
success resets успешный логин сбрасывает счётчик
alert (слой 3) 10 неудач per-account → уведомление Owner/Admin
регистр не сбрасывает brute:account по lower(email) — смена регистра не обнуляет счётчик
IP isolation IP-A исчерпан → IP-B работает
delay cap 30s задержка ≤ 30 секунд
XFF-спуфинг поддельный X-Forwarded-For от недоверенного источника не подменяет IP — лимит не обойти
CSRF-защита — Origin whitelist
Кейсы
no cookie → 401 /refresh без httpOnly cookie → 401
origin mismatch чужой Origin → 403
valid origin → OK Origin из whitelist → пропускаем
CORS-конфигурация
Integration P1
Кейсы
allowed origin → OK whitelist → Access-Control-Allow-Origin
disallowed origin не whitelist → нет CORS-заголовков
preflight OPTIONS → Allow-Credentials true, Allow-Methods, Max-Age 3600
Конверт ошибок — Problem Details (RFC 9457)
Кейсы
problem+json shape любая ошибка → Content-Type: application/problem+json и тело с type·title·status·detail·code·request_id
request_id = заголовок request_id в теле совпадает с X-Request-Id ответа — сквозная трассировка
422 в problem-формате валидация даёт тот же конверт (не дефолтный ValidationError-shape фреймворка), code=VALIDATION_ERROR + errors[] из { field, message }
429 несёт retry_after тело содержит retry_after (сек) + заголовок Retry-After
test_api_rate_limit.py
Per-user API rate limit — тир по ролям
Кейсы
бакет исчерпан → 429 превышение per-user потолка → 429 RATE_LIMITED + Retry-After
тир по роли у Admin потолок выше, чем у Member — при одинаковой нагрузке Member упирается в лимит раньше
X-RateLimit-Remaining ответ несёт остаток корзины в заголовке
≠ рубеж перебора этот лимит per-user после аутентификации; перебор логина ⑤ — per-IP до входа (разные счётчики, не пересекаются)
Security-заголовки — проверка на ответе
Integration P1
Кейсы
HSTS Strict-Transport-Security — max-age 1 год, includeSubDomains
CSP default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
nosniff X-Content-Type-Options: nosniff
frame deny X-Frame-Options: DENY
COOP / CORP оба same-origin
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera / microphone / geolocation выключены
no-store на чувствительном Cache-Control: no-store на ответах с токенами / профилем
fingerprint скрыт Server и X-Powered-By не отдаются
Clear-Site-Data на logout ответ /api/v1/auth/logout отдаёт Clear-Site-Data (cookies, storage, cache); на прочих ответах — нет
no-referrer на токен-роутах invite-accept, reset-password и messenger-link отдают Referrer-Policy: no-referrer — ссылка-секрет не утечёт; обычные роуты сохраняют strict-origin-when-cross-origin
Смена пароля + audit
Integration P1
Кейсы
change OK current + сильный new → 200, хэш обновлён
wrong current → 401 INVALID_CREDENTIALS
wrong current ×3+ per-account задержка растёт — общий счётчик рубежа перебора с логином
weak new → 422 VALIDATION_ERROR + errors[]
same as current → 422 VALIDATION_ERROR
other sessions killed все refresh кроме текущего удалены
audit entry action=password_change
audit entry (failure) action=password_change, result=failure
CLI-команды — init-owner, reset-password
Integration P1
Кейсы
init-owner OK 0 users → Owner создан
init-owner exists есть users → ошибка, никто не создан
init-owner weak pw пароль не проходит → ошибка
reset-password OK временный пароль (CSPRNG), хэш обновлён, все refresh удалены
reset-password unknown несуществующий email → ошибка
Деактивация, сброс пароля, scope
Integration P1
Кейсы
Owner deactivates Member → 200, refresh и API-ключи пользователя отозваны
Admin deactivates Member → 200
Admin deactivates Admin → 403 FORBIDDEN (только Owner)
self-deactivation → 403 FORBIDDEN
reactivate → login OK старые токены не восстановлены
Owner resets Member pw → 200, reset-ссылка на email (без SMTP — временный пароль, refresh удалены); audit
Admin resets Owner pw → 403 (scope boundary)
deactivation + reset → audit user_deactivated, password_reset
admin «завершить все» все refresh-токены пользователя отозваны → 200; access доживает ≤15-мин окно (stateless trade-off)
Выпуск, аутентификация, отзыв
Integration P1
Кейсы
создание · показ один раз ответ содержит полный ключ (ach_ + CSPRNG) ровно раз; в БД — только SHA-256-хэш и prefix
аутентификация по ключу валидный ключ в заголовке → user_id → роль / ACL; неверный / несуществующий → 401
scope ≤ прав владельца ключ не шире роли и ACL владельца; сужение по источникам применяется поверх
read-only попытка записи через ключ → 403 (запись — территория Agent Engine)
выдача админом Owner / Admin выпускает ключ на сотрудника; scope — права сотрудника, не админа
истёкший ключ expires_at в прошлом → 401 без отдельного отзыва; бессрочный (NULL) не истекает
отзыв мгновенный is_revoked → следующий запрос 401; отзыв владельцем и Owner / Admin
каскад при деактивации деактивация владельца → все его ключи отозваны
rate limit 60 req/min на ключ → сверх лимита 429; last_used_at обновляется
audit создание и отзыв ключа пишутся в audit_log
Смена email админом
Integration P1
Кейсы
admin меняет email → 200, email обновлён; refresh пользователя погашены
email занят новый адрес уже есть (в т.ч. иной регистр, lower(email)) → 409 CONFLICT
audit entry action=email_changed, metadata old/new, actor=admin_id, target
Авто-связь identity↔users по email (мост со стороны входа)
Кейсы
accept invite → мост принятие инвайта на alice@x при существующей identity того же email → identity.user_id = новый user
case-insensitive инвайт Alice@x при identity alice@x → связь по lower(email)
identity ещё нет нет совпадающей identity → no-op, без ошибки; свяжется позже при upsert Harvester'ом
setup owner → мост первый Owner через /setup / init-owner связывается с identity того же email
смена email → переезд моста admin меняет email → мост переустанавливается на identity нового адреса, прежняя связь снимается
адрес не совпал → без моста email входа ≠ email источника → identity.user_id остаётся NULL, ждёт ручной привязки в Admin
ручная связь не перетирается закреплённая админом связь ( ) авто-связью не переписывается
Сброс по email-ссылке — «забыл пароль» и admin-reset (SMTP — мок)
Integration P1
Кейсы
forgot known email → 200, письмо со ссылкой; в БД — только хэш токена
forgot unknown email → 200, ответ неотличим от известного (anti-enumeration)
resend rate-limit частые запросы по одному адресу → 429
set password OK хэш обновлён, все refresh удалены, audit
token reuse повторное использование → 410 RESET_EXPIRED (неотличимо от истёкшего)
token expired TTL 1 ч истёк → 410 RESET_EXPIRED
new link kills old повторная отправка гасит прежний токен
no sessions killed on send отправка ссылки не трогает refresh — гасятся только при установке пароля
SMTP unconfigured self-service недоступен; admin-reset уходит в фолбэк временного пароля
Integration · P2
Business logic guards
Защита последнего Owner + hard-delete
Кейсы
delete last owner единственный → 403 LAST_OWNER_PROTECTED
deactivate last owner → 403 LAST_OWNER_PROTECTED
downgrade last owner Owner→Admin при 1 Owner → 403 LAST_OWNER_PROTECTED
delete non-last owner при 2+ → 200
delete — только Owner Admin вызывает delete → 403 FORBIDDEN; удаление необратимо, право только у Owner
hard-delete → каскад auth удаление не-последнего пользователя физически сносит строку + каскадом его refresh-токены и API-ключи
контент источников цел после удаления данные источников и audit_log не затронуты (см. test_audit_log.py — actor_id без FK)
Аудит-лог — append-only, owner-read
Кейсы
login success → entry actor, action=login, success, ip, ua
login failure → entry login_failed, failure, ip, ua, actor_id=NULL
user created → entry user_created, target=new_user_id
role changed → entry role_changed, metadata old/new
logout → entry action=logout, actor=user_id
user deactivated → entry user_deactivated, actor=admin_id, target
read access — owner only Owner 200; Admin/Member 403 FORBIDDEN
append-only SQL UPDATE/DELETE → ошибка (DB policy)
переживает удаление актора hard-delete user → строки audit_log целы (actor_id без FK)
Frontend
React — auth flows
AuthProvider + ProtectedRoute + interceptor
state, redirect, deep-link, auto-refresh
Frontend
Кейсы
login → state AuthProvider отдаёт user + role
logout → redirect state очищен, redirect /login
unauthenticated → redirect ProtectedRoute без токена → /login
deep-link → ?next возврат гость на защищённый маршрут → /login?next=… → после входа возврат на исходный
?next только локальный внешний ?next=https://evil… / //evil игнорируется → редирект на дом по роли (защита от open-redirect)
entry-gate по роли с сессией: Owner/Admin → /admin, Member → /chat
wrong role → 403 page Member на admin-only → 403
auto-refresh 401 → /refresh → retry
refresh failed → logout /refresh 401 → полный logout + redirect
concurrent 401s несколько 401 → один refresh → все retry
403 без refresh 403 по правам пропускается — не запускает refresh, не logout
retry-once (анти-цикл) флаг _retry: 401 на уже повторённом → logout без рекурсии
refresh timeout → logout таймаут Promise refresh → logout вместо зависания
setup redirect 0 users → любой route → /setup
v2
Отложенный охват
Тест-план выше покрывает scope первой версии. Зоны
ниже проектируются вместе с соответствующими v2-фичами — здесь
зафиксирован только охват, без детальных кейсов.
MFA
TOTP (окно ±1 шаг, защита от повтора кода), recovery-коды, отдельный
лимит MFA-шага, шифрование mfa_secret
SSO / OIDC
state / PKCE / nonce, автопровижининг из IdP, авто-матч только по
verified email
OAuth
интерактивный поток выдачи токена, headless-вход через
API-ключ
Мгновенный отзыв
jti в Redis blacklist при деактивации — инвалидация без
15-минутного окна
Structure
Файловая структура тестов
Приоритет (P0–P2) ортогонален каталогам и задаётся маркерами
(pytest -m p0), а не отдельными папками.
tests/backend — общий каркас
conftest.pyБД-движок, сессия, HTTP-клиент
factories/фабрики данных, кросс-модульно
auth/каталог модуля
conftest.pyфикстуры модуля
unit/без БД — крипто и валидация
integration/с БД — по доменам
session/setup · login · logout · refresh
access/rbac · ownership · invite · brute-force · csrf · cors · security-headers
account/смена пароля · CLI · lifecycle · identity-bridge
audit/last-owner · audit-log
frontend/…/auth/__tests__/рядом с кодом, по модулю