← Auth / Security

Тест-кейсы

auth / security · workzone
Принятые решения
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 Без БД — криптография и валидация
test_jwt.py
JWT-токены — create / validate / edge cases
Кейсы
create — claimssub, role, exp, iat, jti, iss=achilles, aud=achilles-api
create → decodeвалидный token → корректный payload
expired → rejectexp в прошлом → ExpiredSignatureError
wrong key → rejectподпись другим SECRET_KEY → ошибка
alg substitution → rejectRS256 при сервере только HS256 (hardcoded algorithms)
alg=none → rejectheader alg: none без подписи → отвергается
kid в headerключ выбирается по kid из реестра
missing claim → rejectбез sub или role → ошибка
iss/aud mismatch → rejectчужой issuer/audience → reject
jti uniquenessдва create → разные jti
test_password.py
Пароли и политика — argon2id, zxcvbn, HIBP
Кейсы
hash + verify OKhash(pw)verify = True
wrong password → Falseдругая строка не проходит
argon2 paramsmemory=19MB, iterations=2, parallelism=1 — в хэше
too short (<8)→ reject
too long (>128)→ reject
weak (zxcvbn)"password123456" ниже порога → reject
HIBP — compromisedrespx: найден в утечке → reject
HIBP — cleanrespx: не найден → accept
HIBP — unavailabletimeout/500 → fail open, warning
strong passwordпроходит длину, zxcvbn, HIBP
test_tokens.py
CSPRNG-токены — invite, refresh hash, код привязки мессенджера
Unit
Кейсы
invite token format≥32 байта, URL-safe (base64url)
uniqueness100 генераций → все уникальны
constant-time comparehmac.compare_digest(), не ==
refresh token — hashSHA-256 ≠ raw token
messenger link code — hashкороткий CSPRNG, в БД code_hash SHA-256 ≠ raw
Integration · P0 Security invariants & core flows
test_setup.py
Setup Wizard — первый Owner
IntegrationP0
Кейсы
setup available0 users → GET /setup → 200
create owner OKPOST /setup → Owner в БД, access + refresh
setup goneпосле Owner → 404 SETUP_UNAVAILABLE
race condition2 параллельных POST → один Owner, второй 409 CONFLICT
invalid email→ 422 VALIDATION_ERROR + errors[], Owner НЕ создан
weak password→ 422 VALIDATION_ERROR + errors[], НЕ создан
test_login.py
Логин — выдача токенов, generic-ошибки
IntegrationP0
Кейсы
successaccess (body) + refresh (httpOnly cookie)
wrong password→ 401 INVALID_CREDENTIALS generic
unknown email→ 401 тот же generic — не раскрываем существование
deactivated user→ 401 — не раскрываем статус
cookie flagshttpOnly, 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 claimssub=user_id, role=фактическая
timing-safe (spy)несуществующий email → dummy argon2 verify (mock/spy)
test_refresh.py
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
test_logout.py
Logout — завершение сессии
IntegrationP0
Кейсы
standard logoutrefresh удалён из БД, Set-Cookie очищает
post-logout refresh→ 401
logout allPOST /api/v1/auth/logout-all → все refresh удалены
access still validпосле logout до expiry — stateless, 15 мин
свою family не убитьзавершение текущей session-family через управление сессиями → сервер отклоняет (не только скрыто в UI) — серверный guard
test_must_change_password.py
Принудительная смена временного пароля
IntegrationP0
Кейсы
вход с временным паролёмmust_change_password=true → токены выданы, но гейт: редирект на экран смены пароля
серверный гейтфлаг взведён → запрос к произвольному защищённому эндпоинту → 403 PASSWORD_CHANGE_REQUIRED; смена пароля и logout — разрешены
смена на гейтесильный новый пароль → флаг снят, прочие refresh пользователя погашены (кроме текущего)
повторный вход без сменыфлаг всё ещё взведён → снова гейт смены пароля
Integration · P1 Protection & access control
test_invite.py
Invite flow + scope boundary
IntegrationP1
Кейсы
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))
test_bulk_invite.py
Массовый инвайт — частичный успех, дубли, лимит
IntegrationP1
Кейсы
частичный успехбатч со смесью валидных и битых строк → 207/200 с построчным отчётом: принятые создают инвайт, отклонённые несут причину; валидные не откатываются из-за соседних ошибок
дубли внутри батчаповтор одного email в батче и email уже существующего пользователя/инвайта → строка помечена дублем, второй инвайт не создаётся
невалидная строкабитый email / нет обязательного поля → строка отклонена с VALIDATION_ERROR в отчёте, не общий 422 на весь батч
лимит размерабатч сверх потолка строк → 422, батч не обрабатывается частично
scope-граница массовоприглашающий не может выдать роль выше своей ни одной строкой батча (тот же периметр, что у единичного); нарушение → строка отклонена
идемпотентность повторной загрузкиповтор того же файла не задваивает уже созданные инвайты (по email), уже принятые пропущены
test_messenger_link.py
Привязка мессенджера — резолв, код, relay-safety, перебор
IntegrationP1
Кейсы
known user → resolveDM от привязанного мессенджера → identity_mappinguser_id, кода нет
auto-match by emailprovisioned-email воркспейса == учётка → связь авто, без кода
auto-match offтумблер выключен → даже при совпадении email бот выдаёт код
unknown user → codeнет связи и авто-матча → ссылка/код, identity_mapping не тронут
return code → bindкод из DM Sidentity_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-onlyemail Telegram не отдаёт → авто-матча нет, всегда код; возврат → identity_mapping(telegram, …)
mattermost → code-onlyв событиях email нет → авто-матча нет, всегда код; возврат → identity_mapping(mattermost, …)
code brute-force5 неверных кодов на chat_id → попытка сожжена, нужен новый код
no self-registrationкод без учётки войти не даёт — привязка только к существующей
test_rbac.py
RBAC middleware — матрица ролей
IntegrationP1
Кейсы
no token → 401protected без Authorization → 401
owner → owner-only→ 200
admin → owner-only→ 403
member → admin-only→ 403
owner → all endpointsполный доступ (матрица)
admin → user mgmtusers, sources → 200
member → own data/me 200, /users 403
require(permission)через ROLE_PERMISSIONS маппинг, не роль
unknown permission → 403нет в маппинге → 403 для любой роли
admin scope boundaryMembers 200, не Owners/Admins 403
role escalation → 403нельзя назначить выше своей
invalid role in tokenrole="superadmin" → 403
deactivated + valid tokenv1 stateless → 200, 15-мин окно (trade-off, документируется)
stale role — критичный эндпоинттокен role=admin, в БД member; управление пользователями читает роль из БД → 403
stale status — критичный эндпоинтвалидный токен, в БД status=deactivated; критичная операция читает статус из БД → 401
test_ownership.py
Владение ресурсом — защита от IDOR
IntegrationP1
Кейсы
own resource → 200владелец правит свой ресурс — owner_id = user_id
foreign resource → 403Member A правит ресурс Member B → 403 FORBIDDEN (IDOR)
permission есть, владения нетпроверка owner_id поверх permission → 403
admin штатная зонауправление пользователями — обход владения; данные источников — нет
зависит от ресурсовконкретные эндпоинты приходят с Agent Engine
test_brute_force.py
Защита от перебора — rate limit, delays
Кейсы
IP rate limit21-я / 15 мин → 429 RATE_LIMITED + retry_after
exponential delayпосле 3 неудач → 1с / 2с / 4с
below threshold1–2 → нет задержки (порог с 3-й)
success resetsуспешный логин сбрасывает счётчик
alert (слой 3)10 неудач per-account → уведомление Owner/Admin
регистр не сбрасываетbrute:account по lower(email) — смена регистра не обнуляет счётчик
IP isolationIP-A исчерпан → IP-B работает
delay cap 30sзадержка ≤ 30 секунд
XFF-спуфингподдельный X-Forwarded-For от недоверенного источника не подменяет IP — лимит не обойти
test_csrf.py
CSRF-защита — Origin whitelist
Кейсы
no cookie → 401/refresh без httpOnly cookie → 401
origin mismatchчужой Origin → 403
valid origin → OKOrigin из whitelist → пропускаем
test_cors.py
CORS-конфигурация
IntegrationP1
Кейсы
allowed origin → OKwhitelist → Access-Control-Allow-Origin
disallowed originне whitelist → нет CORS-заголовков
preflightOPTIONS → Allow-Credentials true, Allow-Methods, Max-Age 3600
test_error_envelope.py
Конверт ошибок — 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 до входа (разные счётчики, не пересекаются)
test_security_headers.py
Security-заголовки — проверка на ответе
IntegrationP1
Кейсы
HSTSStrict-Transport-Security — max-age 1 год, includeSubDomains
CSPdefault-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
nosniffX-Content-Type-Options: nosniff
frame denyX-Frame-Options: DENY
COOP / CORPоба same-origin
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera / 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
test_password_change.py
Смена пароля + audit
IntegrationP1
Кейсы
change OKcurrent + сильный 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 entryaction=password_change
audit entry (failure)action=password_change, result=failure
test_cli.py
CLI-команды — init-owner, reset-password
IntegrationP1
Кейсы
init-owner OK0 users → Owner создан
init-owner existsесть users → ошибка, никто не создан
init-owner weak pwпароль не проходит → ошибка
reset-password OKвременный пароль (CSPRNG), хэш обновлён, все refresh удалены
reset-password unknownнесуществующий email → ошибка
test_user_lifecycle.py
Деактивация, сброс пароля, scope
IntegrationP1
Кейсы
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 → audituser_deactivated, password_reset
admin «завершить все»все refresh-токены пользователя отозваны → 200; access доживает ≤15-мин окно (stateless trade-off)
test_api_keys.py
Выпуск, аутентификация, отзыв
IntegrationP1
Кейсы
создание · показ один разответ содержит полный ключ (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 limit60 req/min на ключ → сверх лимита 429; last_used_at обновляется
auditсоздание и отзыв ключа пишутся в audit_log
test_email_change.py
Смена email админом
IntegrationP1
Кейсы
admin меняет email→ 200, email обновлён; refresh пользователя погашены
email занятновый адрес уже есть (в т.ч. иной регистр, lower(email)) → 409 CONFLICT
audit entryaction=email_changed, metadata old/new, actor=admin_id, target
test_identity_bridge.py
Авто-связь 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
ручная связь не перетираетсязакреплённая админом связь () авто-связью не переписывается
test_password_reset.py
Сброс по email-ссылке — «забыл пароль» и admin-reset (SMTP — мок)
IntegrationP1
Кейсы
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 expiredTTL 1 ч истёк → 410 RESET_EXPIRED
new link kills oldповторная отправка гасит прежний токен
no sessions killed on sendотправка ссылки не трогает refresh — гасятся только при установке пароля
SMTP unconfiguredself-service недоступен; admin-reset уходит в фолбэк временного пароля
Integration · P2 Business logic guards
test_last_owner.py
Защита последнего Owner + hard-delete
IntegrationP2 → Удаление
Кейсы
delete last ownerединственный → 403 LAST_OWNER_PROTECTED
deactivate last owner→ 403 LAST_OWNER_PROTECTED
downgrade last ownerOwner→Admin при 1 Owner → 403 LAST_OWNER_PROTECTED
delete non-last ownerпри 2+ → 200
delete — только OwnerAdmin вызывает delete → 403 FORBIDDEN; удаление необратимо, право только у Owner
hard-delete → каскад authудаление не-последнего пользователя физически сносит строку + каскадом его refresh-токены и API-ключи
контент источников целпосле удаления данные источников и audit_log не затронуты (см. test_audit_log.pyactor_id без FK)
test_audit_log.py
Аудит-лог — append-only, owner-read
IntegrationP2 → Аудит-лог
Кейсы
login success → entryactor, action=login, success, ip, ua
login failure → entrylogin_failed, failure, ip, ua, actor_id=NULL
user created → entryuser_created, target=new_user_id
role changed → entryrole_changed, metadata old/new
logout → entryaction=logout, actor=user_id
user deactivated → entryuser_deactivated, actor=admin_id, target
read access — owner onlyOwner 200; Admin/Member 403 FORBIDDEN
append-onlySQL 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 → stateAuthProvider отдаёт user + role
logout → redirectstate очищен, redirect /login
unauthenticated → redirectProtectedRoute без токена → /login
deep-link → ?next возвратгость на защищённый маршрут → /login?next=… → после входа возврат на исходный
?next только локальныйвнешний ?next=https://evil… / //evil игнорируется → редирект на дом по роли (защита от open-redirect)
entry-gate по ролис сессией: Owner/Admin → /admin, Member → /chat
wrong role → 403 pageMember на admin-only → 403
auto-refresh401 → /refresh → retry
refresh failed → logout/refresh 401 → полный logout + redirect
concurrent 401sнесколько 401 → один refresh → все retry
403 без refresh403 по правам пропускается — не запускает refresh, не logout
retry-once (анти-цикл)флаг _retry: 401 на уже повторённом → logout без рекурсии
refresh timeout → logoutтаймаут Promise refresh → logout вместо зависания
setup redirect0 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__/рядом с кодом, по модулю