Skip to content

feat!: применять авторизацию и права по спеке, закрыть 5xx и дыры доступа - #18

Merged
mokevnin merged 14 commits into
mainfrom
fix/spec-driven-auth-and-automation
Aug 23, 2026
Merged

feat!: применять авторизацию и права по спеке, закрыть 5xx и дыры доступа#18
mokevnin merged 14 commits into
mainfrom
fix/spec-driven-auth-and-automation

Conversation

@mokevnin

Copy link
Copy Markdown
Contributor

Зачем

В репозитории контракт первичен, но часть того, что спека уже знает, код
повторял руками — и руками написанная копия разошлась. Все находки ниже
воспроизведены до правки и проверены после.

было стало
GET/PUT/DELETE /users без токена 200/200/204 401
GET /users/999999 200, пустое тело 404
/tokens с неизвестным email 500 401
/tokens с любым паролем 201 401
Удаление чужого курса 204 403
POST /users с fullName: "A" 500 400
GET /courses?page=-1.79e+308 500 400
PUT с телом {} 500 200
Удаление автора курсов 500 204
Токен удалённого пользователя 500 401

Первые пять строк — из чтения кода, последние пять нашёл schemathesis: их не
видели ни tsc, ни валидаторы fastify. На первом прогоне 23 падения, сейчас 0
на 1000+ кейсов при разных seed.

Как это больше не повторится

  • @useAuth из спеки применяет fastify-openapi-glue через securityHandlers
    jwtVerify() в обработчиках больше нет, забыть его негде.
  • ensure() бросает ошибку (раньше createError только создавал её, а
    сигнатура asserts заставляла tsc за это ручаться).
  • make contract-test — schemathesis по спеке, отдельным workflow.
  • make lint линтует контракт: 21 ошибка → 0. Заодно нашёлся настоящий разрыв
    — пять защищённых операций могли ответить 401, которого не было в спеке.
  • make migration-check — схема не менялась без миграции.
  • Покрытие с порогами. Раньше оно показывало 58%, но это был артефакт замера:
    приложение грузилось через хелпер fastify-cli в обход vite. Реальные цифры —
    100% строк, 86% ветвей.
  • release-please — вторая половина того, что уже было настроено: pr-title.yml
    требовал conventional commit именно под него.
  • lefthook гоняет формат и линт до коммита.
  • Тесты ходят сгенерированным из спеки клиентом; запросы, нарушающие контракт,
    остались на app.inject() — клиент типизирован и выразить их не даёт.

Тестов 15 → 45.

Решения, которые стоит обсудить

  1. v1 сломан намеренно. password стал обязательным в UserCreateDTO,
    page и idint32 с minValue(1) (page=1.5 давал 200, теперь 400).
    AuthInfo и так обещал пароль, которого не существовало, а
    @added(Versions.v2) сделал бы фичу мёртвой: приложение работает на v1.
  2. ON DELETE CASCADE. Удаление пользователя удаляет его курсы и их уроки.
    Это делает обещанный контрактом 204 честным, но альтернатива —
    задокументированный 409.
  3. Проверка существования пользователя в securityHandlers стоит одного
    запроса к базе на каждый авторизованный запрос.

Прочее

  • Локально нужен .env: echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env.
    Без секрета от 32 символов приложение осознанно не поднимается.
  • serializers/UserSerializer.ts остался мёртвым — его работу забрал
    db/projections.ts. Не удалял: каталог похож на часть задуманной
    архитектуры.
  • oasdiff показывает ломающие правки, но сборку не роняет: v1 нигде не
    опубликован.

Локально зелёные make lint, generate-check, migration-check,
test-coverage, contract-test. CI-файлы (contract.yml, openapi-diff.yml,
release-please.yml) в этом PR выполняются впервые.

🤖 Generated with Claude Code

Правки из рабочего дерева, не связанные с фиксами ниже: мёртвый root.test.js
(vitest его не запускал — include только .ts, а маршрута / нет), неиспользуемый
openapi() с импортом v2, `make routes` на несуществующий файл, .PHONY, outDir
без emit.
@useAuth(BearerAuth) стоял у пяти операций /users, но jwtVerify() в
routes/api/users.ts не вызывался ни разу: список, правка и удаление любого
пользователя работали без токена (проверено — 200/200/200/204).

Спека уже знает, где нужна авторизация, поэтому её применяет glue через
securityHandlers: имя обработчика совпадает с именем схемы безопасности.
Ручные jwtVerify() из courses и lessons убраны — забыть их больше негде.

Заодно удалён декоратор authenticate: он не имел ни одного вызова.

Тест перечисляет защищённые операции и проверяет 401 без токена и с битым
токеном, а публичные — что они остались доступны.
Две правки в одном коммите намеренно: по отдельности получается состояние, где
/tokens отдаёт статус, которого нет в контракте.

ensure() вызывал httpErrors.createError, который ошибку только создаёт. 404 не
наступал никогда: показ отсутствующей записи отдавал 200 с пустым телом,
удаление — 204, а /tokens с неизвестным email падал в 500 при чтении поля у
undefined. Сигнатура asserts при этом заставляла tsc ручаться за проверку,
которой не происходило. Теперь ensure() бросает, а параметр reply ушёл:
httpErrors берётся из @fastify/sensible напрямую.

Пароль в /tokens не проверялся вовсе — токен выдавался на любой пароль, потому
что колонки для него не было в схеме. Добавлены password_digest, хеширование
scrypt из node:crypto (без новых зависимостей) и сверка при выдаче токена.
Ответ на неизвестный email и на неверный пароль совпадает, иначе эндпоинт
превращается в перебор зарегистрированных адресов.

Хеш не должен уезжать в ответ: у User в контракте нет
additionalProperties: false, поэтому схема ответа лишнее поле не отсечёт.
Запросы к users ограничены публичной проекцией из db/projections.ts.

В контракте: password в UserCreateDTO/UserEditDTO (@secret, minLength 8) и
UnauthorizedError у tokensCreate. Это ломающее изменение v1 — версия нигде не
опубликована, а AuthInfo и так обещал пароль, которого не существовало.

Обработчик ошибок теперь рендерит problem+json для всех HTTP-ошибок, а не
только для ZodError. Текст 5xx наружу не уходит.

Побочно: seed() был асинхронным, но не ожидался в plugins/drizzle.ts — с
хешированием это уже настоящая гонка.

Миграция добавляет NOT NULL без DEFAULT: на пустой базе это работает, на живой
понадобится трёхшаговая (добавить nullable, заполнить, ужесточить).
Обработчики требовали токен, но не смотрели, чей это токен: любой
аутентифицированный пользователь правил и удалял чужие курсы и дописывал в них
уроки (проверено — 204 на чужом курсе). policies/CoursePolicy при этом был
пустым классом без единого вызова.

Правка и удаление теперь читают курс до изменения — иначе проверять владельца
не на чем, — и отвечают 403. Создание урока проверяет тот же признак по курсу:
урок меняет чужой курс. Несуществующий курс там раньше упирался в ограничение
внешнего ключа и давал 500, теперь это 404.

В контракте у coursesUpdate/coursesDestroy/coursesLessonsCreate появился
ForbiddenError — модель была описана в main.tsp, но не использовалась.
updatedAt не обновлялся никогда: обработчики его не писали, а DEFAULT
срабатывает только на INSERT. Теперь его ведёт $onUpdate, и он есть у всех трёх
таблиц, а не у одних users.

createdAt был text с default (unixepoch()), то есть в текстовую колонку клалось
число. Тип сменён на integer с mode: timestamp — наружу теперь уходит дата, а
не строка вида "1787349516".

SELECT'ы в миграции поправлены руками: drizzle-kit сгенерировал перенос
updated_at из courses и course_lessons, где такой колонки не было, и миграция
падала. Ровно поэтому миграции и не входят в generate-check.
Боевые параметры scrypt — ~230 мс на хеш, а сиды заводят трёх пользователей на
каждый build(). Прогон тестов от этого вырос на десятки секунд.

Стоимость читается из SCRYPT_COST и хранится внутри дайджеста
(scrypt$N$salt$hash), поэтому её смена не обесценивает уже выданные хеши —
проверка берёт параметры из самой строки. В vitest выставлено 1024.

Заодно include в vitest расширен на .js: раньше .js-тест молча не запускался.
…раница

JWT-секрет был зашит в код строкой "supersecret". Теперь конфиг проверяется
схемой @fastify/env на старте: без JWT_SECRET длиной от 32 символов
приложение не поднимается. plugins/jwt объявляет зависимость от env явно, а не
рассчитывает на алфавитный порядок autoload.

Добавлены штатные плагины: helmet, cors и rate-limit — все три настраиваются из
того же конфига. CSP у helmet выключен: страницу документации он ломает.

Документация — @scalar/fastify-api-reference на /docs поверх /openapi.json,
который отдаётся из той же спеки, по которой glue регистрирует маршруты.

pino-pretty переехал в devDependencies: test/helper.ts настраивает его
транспортом, а в package.json его не было — работал только потому, что всплыл
в node_modules из fastify-cli.

Локально нужен .env, шаблон в .env.example.
release-please — вторая половина того, что уже было настроено: pr-title.yml
требовал conventional commit в заголовке PR именно затем, чтобы по нему
определялся разряд версии, но выпускать было нечему.

make migration-check ловит изменённую схему без миграции: перегенерирует и
падает, если что-то появилось. drizzle-kit check вызывается флагами, а не через
конфиг — читая drizzle.config.ts, он принимает dialect за параметр AWS Data API
и падает.

Покрытие с порогами 95/95/80/95 при фактических 100/100/85/97.

Тесты собирают приложение напрямую вместо helper из fastify-cli. Тот грузил
app.ts в обход трансформации vite, из-за чего app в тестах был any, а покрытие
показывало 28% по tokens.ts при четырёх прицельных тестах на него. Реальные
цифры видны только теперь.

concurrency отменяет прошлый прогон ветки: пуш в ветку с открытым PR запускал
сборку дважды. На main не отменяет.

deps-update вызывает ncu через pnpm exec: пакет в devDependencies, а npx при
его отсутствии тянул бы из сети другую версию.
…ммита

redocly lint встроен в make lint. Он нашёл настоящий разрыв: пять защищённых
операций (все users и coursesCreate) могут ответить 401, но в контракте этого
не было — фикс авторизации сделал разрыв наблюдаемым. Добавлены
UnauthorizedError, summary у всех четырнадцати операций, сервер и лицензия.
Осталось 0 ошибок против 21.

Отключения правил лежат поимённо в redocly.yaml с причинами, а не в
ignore-файле: тот замораживает конкретные находки, и новые такие же проходят
молча. security-defined снят — публичные эндпоинты здесь осознанный дизайн.

oasdiff показывает ломающие правки контракта в PR, но сборку не роняет: v1
нигде не опубликован, и жёсткий гейт обещал бы совместимость, которой
репозиторий не даёт.

lefthook гоняет oxfmt и oxlint по staged-файлам до коммита и tsc перед пушем.
Отдельный nano-staged не нужен — у lefthook есть {staged_files}.
make contract-test поднимает приложение и гоняет по нему schemathesis: тот сам
генерирует запросы из OpenAPI и сверяет ответы со спекой. На первом же прогоне
23 падения, все настоящие.

Пять 500 на краевых входах:

- POST /users с fullName в один символ. Запрос такое принимал, а модель ответа
  требует от 2 до 100 символов, и валидатор ответа ронял запрос уже после
  записи в базу. Границы добавлены в UserCreateDTO и UserEditDTO.
- GET /courses?page=-1.79e+308. page был numeric без границ, и (page - 1) *
  perPage переполнялся. Теперь int32 с minValue(1) — заодно перестали
  приниматься дробные страницы. Идентификаторы в путях тоже int32.
- PUT с пустым телом: все поля EditDTO необязательные, а drizzle на пустом set
  бросает «No values to set». Теперь запись возвращается без изменений.
- DELETE пользователя, у которого есть курсы, и курса, у которого есть уроки, —
  нарушение внешнего ключа. Добавлен каскад: контракт обещает у DELETE только
  204.
- POST /courses с валидным токеном удалённого пользователя. Токен остаётся
  подписанным и не истёкшим, запрос доходил до обработчика и падал на внешнем
  ключе. Проверка существования пользователя добавлена в securityHandlers —
  туда же, где применяется сама авторизация.

Плюс 400 и 429 не были описаны ни у одной операции, хотя их отдают валидация
запроса и ограничитель частоты. Добавлены как CommonErrors.

На каждую находку есть тест. После правок четыре прогона подряд с разными
seed — 1000+ кейсов, ноль падений.
openapi-ts из той же спеки генерирует не только типы обработчиков и zod-схемы,
но и типизированный клиент. CRUD-тесты по курсам, пользователям, урокам и
токенам переведены на него: URL, методы и формы тел больше не переписываются в
тестах руками и не могут разойтись с контрактом.

Клиент ходит через fetch, поэтому buildClient() поднимает настоящий сокет на
случайном порту. Покрытие от этого не пострадало (100% строк, ветки даже чуть
выросли — 86% против 85%), пять прогонов подряд стабильны и в один поток, и с
параллелизмом.

Тесты на нарушения контракта — страница вне диапазона, слишком короткое имя,
запрос без токена — остались на app.inject(): клиент типизирован по спеке и
выразить такие запросы не даёт, а проверять их надо. Правило записано в
test/helper.ts.
Оба описывали состояние до правок: app.js вместо app.ts, node --test вместо
vitest, совет вынести JWT-секрет в окружение, которого уже нет в коде. Не
хватало половины целей Makefile, шага с .env и правила, по которому тесты
разделены между сгенерированным клиентом и app.inject().

Заодно сужен glob форматтера в lefthook: oxfmt форматирует только ts/js и
падает, если в наборе staged-файлов подходящих не осталось — коммит из одних
markdown-файлов не проходил.
У всех, у кого .env уже есть, эта команда затирает его содержимое. Добавление
строки безопаснее и решает ту же задачу.
Шаг был зелёным только из-за continue-on-error, а на деле падал с «failed to
load base spec: /tmp/openapi.base.json: no such file or directory». oasdiff
запускается docker-действием, и внутрь контейнера монтируется только workspace
— файл из /tmp раннера там не виден.

Заодно test -s: без него редирект создавал пустой файл даже при неудачном
git show, и fallback не срабатывал.
@mokevnin
mokevnin merged commit 12322a4 into main Aug 23, 2026
4 checks passed
@mokevnin
mokevnin deleted the fix/spec-driven-auth-and-automation branch August 23, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant