Четыре проверки у незапертой двери
comlink-python: проверки на отказ стучались в адрес, который сервер не защищает, — и пять месяцев скрывали настоящую ошибку подписи
miki/chetyre-proverki-u-nezapertoy-dveri · v1
comlink-python: проверки на отказ стучались в адрес, который сервер не защищает, — и пять месяцев скрывали настоящую ошибку подписи
Как это должно было работать
comlink-python, интеграционные тесты подписи запросов, март — август 2026.
comlink-python — обёртка на Python к серверу Comlink, через который получают данные мобильной игры. У сервера есть защищённый режим: запрос подписывается секретным ключом, и без верной подписи сервер отвечает отказом.
Проверить такое положено с двух сторон: с верным ключом — пускает, без ключа и с чужим ключом — не пускает. Ровно так тесты и написаны.
Кадр первый: 7 марта. Четыре проверки на отказ
Приезжает коммит 5e005f5 — feat(tests): add integration tests for HMAC and async workflows, enhance CI. В нём появляются четыре проверки с говорящими именами:
Каждая устроена одинаково: создать клиента без ключа (или с неверным) и потребовать, чтобы вызов провалился.
По-человечески: «постучись без ключа и убедись, что тебя не пустили».
Кадр второй: 9 мая. Оказывается, никто не стучался
Через два месяца тот же автор открывает PR #85 — про настройки запуска проверок, скучнее не придумаешь. Но в описании стоит фраза, после которой первый кадр читается иначе:
CI workflows still triggered on the previous
2.0-developmentand1.0-maintenancenames and never ran ondevelopitself — so PRs targetingdevelopgot no checks and merges todevelophad no post-merge verification.
Настройки запуска по-прежнему ссылались на старые имена веток и на самом develop не срабатывали никогда — то есть PR в develop не получали проверок вовсе.
Ветки переименовали, а список веток, на которых запускаются проверки, — нет. Проверки существовали, показывались в репозитории, назывались правильными именами и не запускались.
Кадр третий: 10 мая. Первый же запуск
Назавтра появляется issue #88:
All four call
client.get_enums(), which is a GET request to/enums. The Comlink HMAC service does not protect GET endpoints, only POST. The server returns 200 OK regardless of authentication for GET… the tests fail withFailed: DID NOT RAISE.
Все четыре зовут get_enums() — это GET-запрос к /enums. Служба не защищает GET, только POST. На GET сервер отвечает 200 независимо от подписи, и тесты падают с «НЕ ВЫБРОСИЛО».
Проверки стучались в дверь, которая не заперта. Их не пускали ровно с той же охотой, с какой пускали всех остальных.
К записи приложен протокол опроса живого сервера — не рассуждение, а замер:
Починка приезжает в тот же день, 75feb67, и меняет одну строку в каждой из четырёх проверок:
Плюс объяснение, вписанное прямо в описание каждой проверки, — чтобы следующий не вернул как было:
Казалось бы, история закончена: дверь нашли, стучаться стали в неё.
Кадр четвёртый: 3 августа. Что было за той дверью
Проходит два с половиной месяца. Integration Tests горит красным на всех ветках, включая main, — и это первая строка PR #123:
The
Integration Testsworkflow has been red on every branch since 2026-07-26, including onmain. This fixes a real HMAC bug found along the way…
Проверки красные на всех ветках с 26 июля, включая main. Здесь чинится настоящая ошибка подписи, найденная по дороге.
Вот она:
_construct_request_headerstested payload truthiness, so an empty dict hashed""while httpx transmitted{}:code1payload={} wire=b'{}' digest_matches=FalseEvery HMAC-authenticated POST with an empty payload was rejected with HTTP 403
HMACValidationError— includingget_game_metadata()with noclient_specs, the common case.
Заголовки проверяли содержимое на «непустоту», поэтому пустой словарь подписывался как пустая строка, а по сети уходило {}. Каждый подписанный POST с пустым телом сервер отвергал — включая самый обычный вызов.
Библиотека была сломана. Не в углу, не в редком режиме: обычный вызов без параметров, с правильным ключом, получал отказ.
Правка — одно условие (_base.py:198-206):
if payload: — пустой словарь считается ложью, и подписывается пустота. if payload is not None: — подписывается то, что действительно уходит.
А следом — фраза, ради которой стоило всё это читать:
This was invisible because the two tests covering it called
get_enums(), a GET endpoint Comlink does not authenticate.
Этого не было видно потому, что две проверки, которые её покрывали, звали get_enums() — GET, который сервер не проверяет.
Слепота из третьего кадра не просто портила отчёт: за той самой дверью, в которую четыре проверки не стучались, пять месяцев лежала настоящая ошибка. Проверки на успех тоже ходили на GET — и подтверждали, что подпись работает, ни разу её не применив.
Кадр пятый: «сделать проверки способными падать»
В том же заходе появляется коммит с названием, которое хочется повесить на стену: 2e66314 — test(tests): make the integration assertions able to fail.
Он вводит четыре строки, объясняющие третий слой той же беды (test_hmac.py:27-31):
По-человечески: исключение, которого ждали, выбрасывается и тогда, когда сервер вообще не поднялся. То есть четыре проверки на отказ отчитались бы зелёным, если бы защищённой службы не существовало. Теперь они требуют, чтобы в тексте ошибки стоял код ответа 4xx: сервер запрос увидел и отказал.
Аудит в описании PR перечисляет остальное тем же ровным тоном:
- 4 HMAC rejection tests passed against an unreachable server.
- 2 HMAC "success" tests did not exercise HMAC.
- 2 context-manager tests asserted nothing about the context manager.
- Empty-value assertions tightened … each previously passed on empty.
- New coverage for
player_details_only, used in four tests but never verified.
И отдельной строкой — вывод дороже починки: образы служб прибиты к версии 4.4.1 вместо latest,
so an upstream release can no longer turn the suite red with no commit of ours.
чтобы чужой выпуск больше не мог покрасить наши проверки в красный без единого нашего коммита.
Кто в кадре
MarTrepodi — автор всех пяти кадров: и тестов, и находки, и починки. Имени в профиле нет, поэтому только ник.
Репозиторий одиночный: пять звёзд, один участник, спорить не с кем. Ход мысли восстановим не по переписке, а потому, что автор пишет его сам — в issue, в описании PR, в комментариях к коду. Замер вместо рассуждения («опросил живой сервер, вот четыре строки ответов»), карантин вместо тишины («это не наше, помечено xfail, XPASS будет сигналом, что чужие починили»), объяснение, вписанное в код рядом с правкой.
Отдельно и честно: описание PR #123 подписано 🤖 Generated with Claude Code. Разбор и формулировки — работа с помощником. Мы этим не попрекаем: сами такие. Замечаем другое — найдено это было не машиной и не человеком по отдельности, а тем, что кто-то наконец запустил проверки и не отмахнулся от красного.
Цена решения
Проверка, которая не может упасть, дороже отсутствующей. Отсутствующую видно: в отчёте о покрытии дыра, и всякий знает, что здесь не проверено. Зелёная галочка сообщает обратное — «здесь проверено», — и на неё опираются.
Пять месяцев в этой истории стоили так:
| срок | что происходило |
|---|---|
| 7 марта — 9 мая | проверки не запускались вовсе |
| 9 — 10 мая | запустились и упали в первый же день |
| 10 мая — 3 августа | стучались в верную дверь, но проходили и при выключенном сервере |
| 3 августа | найдена настоящая ошибка, которую всё это скрывало |
Как выглядит противоположный полюс — видно у trycua/cua (22 тысячи звёзд). Там проверка соответствия маршрутов и правил доступа читает три описания одного и того же множества и требует, чтобы они сошлись (route_policy_correspondence_test.go:19-42). В шапке файла записано, зачем:
The checks run in both directions on purpose. A route registered with no policy is an open endpoint; a policy naming a route nobody registers is dead authorization code that reads as protection and provides none.
Проверки идут в обе стороны намеренно. Маршрут без правила — открытая дверь; правило про несуществующий маршрут — мёртвый код доступа, который читается как защита и не защищает ничего.
И там же — правило против молчания:
A wrapper missing from this map fails the test rather than being skipped: a new chain that quietly bypasses the policy is precisely the drift this file exists to catch.
Обёртки, которой нет в этой таблице, достаточно, чтобы проверка упала: новая цепочка, тихо обходящая правила, — ровно то расхождение, ради которого файл написан.
Разница между двумя проектами не в старании, а в направлении вопроса. Один спрашивает «работает ли то, что я проверяю». Другой — «а может ли эта проверка вообще упасть».
Что с этим делать у себя
Не «проходит ли она», а «что нужно сломать в коде, чтобы она покраснела». Нет ответа — проверки нет, есть зелёная галочка.
- Для каждой охранной проверки названо конкретное повреждение, от которого она падает
Временный файл с нарушением правила, прогон, удаление. Проверка должна назвать нарушителя поимённо — и не назвать соседей, которые в порядке.
- Подложена хотя бы одна настоящая поломка
- Проверка назвала её и не назвала здоровые случаи
pytest.raises(Exception) и expect(...).toThrow() довольствуются любой ошибкой, включая «сервер не поднялся». Требуйте текст, код ответа, тип — что-нибудь, что доказывает: сервер запрос увидел.
- У каждой проверки на отказ есть условие на сам отказ, а не только на факт исключения
Ветки переименовали, а список веток в настройках запуска — нет. Проверки при этом остаются на месте и выглядят живыми.
- Открыт последний прогон и видно, что он гонял именно эти проверки
latest означает, что чужой выпуск красит ваши проверки без единого вашего коммита — и разбираться приходится там, где ничего не менялось.
- В настройках запуска нет тега latest у зависимостей, поднимаемых для проверок
Проверка, которая ищет в исходниках строки, ошибается молча: скобка внутри типа, вторая форма записи, слово в комментарии. Разбор языка уже стоит в проекте.
- Проверки над исходниками пользуются разбором языка, а не поиском по тексту
Related lists
Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода
Люди и проекты из разборов — по поступкам, датам и ссылкам. Как определитель птиц: не оценивает, а помогает узнать, кого встретил
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении
graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег

