Skip to content

Четыре проверки у незапертой двери

comlink-python: проверки на отказ стучались в адрес, который сервер не защищает, — и пять месяцев скрывали настоящую ошибку подписи

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1

Как это должно было работать

comlink-python, интеграционные тесты подписи запросов, март — август 2026.

comlink-python — обёртка на Python к серверу Comlink, через который получают данные мобильной игры. У сервера есть защищённый режим: запрос подписывается секретным ключом, и без верной подписи сервер отвечает отказом.

Проверить такое положено с двух сторон: с верным ключом — пускает, без ключа и с чужим ключом — не пускает. Ровно так тесты и написаны.

Кадр первый: 7 марта. Четыре проверки на отказ

Приезжает коммит 5e005f5feat(tests): add integration tests for HMAC and async workflows, enhance CI. В нём появляются четыре проверки с говорящими именами:

test_hmac_no_key_rejected
test_hmac_wrong_key_rejected
test_hmac_no_key_async_rejected
test_hmac_wrong_key_async_rejected

Каждая устроена одинаково: создать клиента без ключа (или с неверным) и потребовать, чтобы вызов провалился.

with SwgohComlink(url=COMLINK_HMAC_URL) as client, pytest.raises(SwgohComlinkException):
client.get_enums()

По-человечески: «постучись без ключа и убедись, что тебя не пустили».

Кадр второй: 9 мая. Оказывается, никто не стучался

Через два месяца тот же автор открывает PR #85 — про настройки запуска проверок, скучнее не придумаешь. Но в описании стоит фраза, после которой первый кадр читается иначе:

CI workflows still triggered on the previous 2.0-development and 1.0-maintenance names and never ran on develop itself — so PRs targeting develop got no checks and merges to develop had 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 with Failed: DID NOT RAISE.

Все четыре зовут get_enums() — это GET-запрос к /enums. Служба не защищает GET, только POST. На GET сервер отвечает 200 независимо от подписи, и тесты падают с «НЕ ВЫБРОСИЛО».

Проверки стучались в дверь, которая не заперта. Их не пускали ровно с той же охотой, с какой пускали всех остальных.

К записи приложен протокол опроса живого сервера — не рассуждение, а замер:

GET /enums no auth -> HTTP 200
POST /metadata no auth -> HTTP 403 (HMACValidationError, "Authorization header missing")
POST /player no auth -> HTTP 403
POST /metadata wrong key -> HTTP 403 (HMACValidationError, "HMAC validation failed")

Починка приезжает в тот же день, 75feb67, и меняет одну строку в каждой из четырёх проверок:

- client.get_enums()
+ client.get_player_arena(allycode=TEST_ALLYCODE, player_details_only=True)

Плюс объяснение, вписанное прямо в описание каждой проверки, — чтобы следующий не вернул как было:

"""Sync client without HMAC keys is rejected by the protected endpoint.
Uses a POST endpoint (`playerArena`) because the Comlink HMAC service
only enforces HMAC on POST; GET endpoints like `/enums` are
unauthenticated.
"""

Казалось бы, история закончена: дверь нашли, стучаться стали в неё.

Кадр четвёртый: 3 августа. Что было за той дверью

Проходит два с половиной месяца. Integration Tests горит красным на всех ветках, включая main, — и это первая строка PR #123:

The Integration Tests workflow has been red on every branch since 2026-07-26, including on main. This fixes a real HMAC bug found along the way

Проверки красные на всех ветках с 26 июля, включая main. Здесь чинится настоящая ошибка подписи, найденная по дороге.

Вот она:

_construct_request_headers tested payload truthiness, so an empty dict hashed "" while httpx transmitted {}:

payload={} wire=b'{}' digest_matches=False

Every HMAC-authenticated POST with an empty payload was rejected with HTTP 403 HMACValidationError — including get_game_metadata() with no client_specs, the common case.

Заголовки проверяли содержимое на «непустоту», поэтому пустой словарь подписывался как пустая строка, а по сети уходило {}. Каждый подписанный POST с пустым телом сервер отвергал — включая самый обычный вызов.

Библиотека была сломана. Не в углу, не в редком режиме: обычный вызов без параметров, с правильным ключом, получал отказ.

Правка — одно условие (_base.py:198-206):

- if payload:
+ # The digest has to cover exactly the bytes that go on the wire. A POST sends
+ # `json=payload`, so an empty dict is transmitted as `{}` — testing truthiness
+ # here hashed `""` instead and every empty-payload signed POST (get_game_metadata
+ # with no client_specs) was rejected with HTTP 403 HMACValidationError. Only a
+ # bodiless request (GET, payload=None) hashes the empty string.
+ if payload is not None:
payload_string = dumps(payload, separators=(",", ":"))
else:
payload_string = dumps("")

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 — и подтверждали, что подпись работает, ни разу её не применив.

Кадр пятый: «сделать проверки способными падать»

В том же заходе появляется коммит с названием, которое хочется повесить на стену: 2e66314test(tests): make the integration assertions able to fail.

Он вводит четыре строки, объясняющие третий слой той же беды (test_hmac.py:27-31):

# SwgohComlinkException wraps transport failures as well as HTTP status errors, so a
# bare `pytest.raises(SwgohComlinkException)` is satisfied by "connection refused" —
# the rejection tests would pass if the HMAC service never came up. Matching the HTTP
# status text asserts the server actually saw the request and turned it away.
REJECTED = r"HTTP 4\d{2}"

По-человечески: исключение, которого ждали, выбрасывается и тогда, когда сервер вообще не поднялся. То есть четыре проверки на отказ отчитались бы зелёным, если бы защищённой службы не существовало. Теперь они требуют, чтобы в тексте ошибки стоял код ответа 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.

Обёртки, которой нет в этой таблице, достаточно, чтобы проверка упала: новая цепочка, тихо обходящая правила, — ровно то расхождение, ради которого файл написан.

Разница между двумя проектами не в старании, а в направлении вопроса. Один спрашивает «работает ли то, что я проверяю». Другой — «а может ли эта проверка вообще упасть».

Что с этим делать у себя

1
Спросить у каждой охранной проверки: при какой поломке она упадёт

Не «проходит ли она», а «что нужно сломать в коде, чтобы она покраснела». Нет ответа — проверки нет, есть зелёная галочка.

Why: Отсутствующую проверку видно в отчёте о покрытии. Проверка, которая не может упасть, сообщает «здесь проверено» — и на это опираются.
Check
  • Для каждой охранной проверки названо конкретное повреждение, от которого она падает
2
Подложить поломку и убедиться, что названа именно она

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

Why: Это единственный способ отличить «проверка работает» от «проверка смотрит не туда». Рассуждение здесь не годится: в comlink-python рассуждение было верным пять месяцев подряд.
Check
  • Подложена хотя бы одна настоящая поломка
  • Проверка назвала её и не назвала здоровые случаи
3
Проверить, что ожидаемая ошибка — та самая

pytest.raises(Exception) и expect(...).toThrow() довольствуются любой ошибкой, включая «сервер не поднялся». Требуйте текст, код ответа, тип — что-нибудь, что доказывает: сервер запрос увидел.

Why: Четыре проверки на отказ проходили против недоступного сервера: «соединение отклонено» — тоже исключение.
Check
  • У каждой проверки на отказ есть условие на сам отказ, а не только на факт исключения
4
Сверить, что проверки вообще запускаются на вашей ветке

Ветки переименовали, а список веток в настройках запуска — нет. Проверки при этом остаются на месте и выглядят живыми.

Why: Здесь между «написали проверки» и «первым запуском» прошло два месяца. Всё это время они значились в репозитории.
Check
  • Открыт последний прогон и видно, что он гонял именно эти проверки
5
Прибить версии чужих образов и службRecommended

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

Why: Красное, в котором вы не виноваты, обесценивает красное вообще: на него перестают смотреть.
Check
  • В настройках запуска нет тега latest у зависимостей, поднимаемых для проверок
6
Читать дерево разбора, а не текст регуляркамиRecommended

Проверка, которая ищет в исходниках строки, ошибается молча: скобка внутри типа, вторая форма записи, слово в комментарии. Разбор языка уже стоит в проекте.

Why: У нас на регулярках получилось три молчаливых дыры за один день. Дерево отличает код от разговоров о коде.
Check
  • Проверки над исходниками пользуются разбором языка, а не поиском по тексту

Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода

v10Public 0 0
updated Sep 1, 2026

Люди и проекты из разборов — по поступкам, датам и ссылкам. Как определитель птиц: не оценивает, а помогает узнать, кого встретил

v5Public 0 0
updated Sep 1, 2026

Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда

v3Public 0 0
updated Sep 1, 2026

Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении

v2Public 0 0
updated Sep 1, 2026

graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет

v2Public 0 0
updated Sep 1, 2026

Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег

v1Public 0 0
updated Sep 1, 2026