Перейти к основному содержимому

ADR 0002 — Нормализация ошибок GitProvider

Контекст​

Контрактной suite GitProviderContract (см. 0001) нужны нормализованные и стабильные ошибки для 8 операций — в частности для 3 сценариев, названных в исходном запросе (репозиторий уже существует, ветка не существует, доступ запрещён), плюс случай отсутствующей capability, который никогда не может быть сырым crash.

В остальном коде уже есть соглашение об ошибках домена: каждая ошибка — отдельный class X extends Error, с явно заданным this.name и типизированными полями контекста (никогда общий enum code) — см. apps/api/src/domain/git/git-provider-errors.ts (GitProviderAuthError, InvalidOauthStateError, сосредоточенные на OAuth) и ошибки машин состояний сессии/действия.

Решение​

Без общего базового класса. Рассматривалось введение абстрактного GitError extends Error, к которому сошлись бы 6 новых классов (облегчило бы будущий единый @Catch(GitError)), и отвергнуто: в этой сессии не регистрируется ни одного нового HTTP-фильтра (ни один endpoint пока не открывает 8 операций — это пункты 4-6 Фазы 2, будущие), так что общая база не имеет немедленного применения и выбивалась бы из соглашения "без базы", уже установленного во всём остальном домене, без конкретной причины сейчас.

Шесть отдельных классов в apps/api/src/domain/git/git-errors.ts (новый файл, отдельный от git-provider-errors.ts для OAuth):

  • GitRepoAlreadyExistsError(repoId)
  • GitRepoNotFoundError(repoId)
  • GitBranchNotFoundError(repoId, ref)
  • GitBranchAlreadyExistsError(repoId, branchName) — получается даром из семантики compare-and-swap git update-ref, используемой в createBranch; не входил в исходный список из 3 сценариев, но это естественное поведение отказа в перезаписи существующей ветки.
  • GitPermissionDeniedError(path)
  • GitNotSupportedError(provider, operation)

Тест отказа в доступе и контейнеры, работающие от root. Dev-контейнеры api работают от root (docker/api/Dockerfile, без USER). Root игнорирует проверку прав Unix (DAC), поэтому тест, который делает chmod(dir, 0o000) и ожидает EACCES, ничего реального не воспроизводит при работе от root — тест "прошёл" бы, не задействовав ни одного кода обработки ошибок, что хуже, чем отсутствие теста. Рассматривалось (и отложено как непропорциональное для области этой сессии) использование опций uid/gid из child_process Node, чтобы намеренно сбросить привилегии перед попыткой операции, — это потребовало бы предполагать конкретный непривилегированный uid (nobody или подобный), присутствующий в каждом окружении, где запускается suite, и добавило бы путь переопределения идентичности лишь ради тестов. Решение: suite обнаруживает process.getuid?.() === 0 и пропускает (it.skipIf) тест отказа в доступе при работе от root, с явным комментарием в коде — никогда не притворяется, что прошёл.

Последствия​

  • В этой сессии не добавляется ни одного HTTP-фильтра (@Catch(...)) — это остаётся на тот момент, когда будущая сессия откроет 8 операций через endpoint (пункты 4-6 Фазы 2). До тех пор эти 6 классов циркулируют лишь внутри процесса api (прямые вызовы provider, тесты).
  • В окружениях, где тесты запускаются не от root (напр.: CI, настроенный без root, или будущий образ dev с не-root USER), тест отказа в доступе начинает действительно задействоваться — никаких изменений кода для этого не нужно, только окружение.