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

Инструменты разработчика8 мин чтения

Тесты для того, что не исполняется: CI, инфраструктура и метаданные сторов

Workflow, docker-compose, тексты о приватности и метаданные App Store не исполняются в тестах, но каждый может сорвать релиз. Как ID Caller стережёт их check-скриптами и сюитами, которые ломают файлы нарочно.

Некоторые из самых важных файлов ID Caller никогда не запускаются в тестах. Workflow GitHub Actions, docker-compose, правило алерта Prometheus, политика конфиденциальности на сайте, описание в App Store — ничто из этого не компилируется и не роняет юнит-тест, когда в нём ошибка. Ломается оно позже: на деплое, во время инцидента или на ревью App Store.

ID Caller — наше приложение для приватных звонков без номера телефона: клиент на Kotlin/Compose Multiplatform и бэкенд на Go. Конфигурацию и тексты вокруг него мы считаем кодом. У каждой такой поверхности есть скрипт check-*.sh, который проверяет её инварианты, а у каждого скрипта — сюита, которая копирует файлы в фикстуру, ломает одну вещь и требует, чтобы проверка упала.

Инвентарь

В репозитории восемь check-скриптов, по одному на поверхность, плюс check-all.sh, который локально запускает их подряд вместе с гейтами Go и KMP. Рядом, в scripts/tests/, лежат десять сюит: восемь для check-скриптов, одна для скриптов бэкапа, восстановления и ротации секрета и одна для генератора, который собирает iOS-файл Localizable.xcstrings из Compose-ресурсов. Когда мы прогнали их для этой статьи, набралось 300 зелёных кейсов.

Правило записано в инструкции проекта: новая неисполняемая поверхность — это новый check-скрипт по образцу, сюита с фикстурой и damage-хелпером и шаг в check-all.sh.

Анатомия check-скрипта

Все восемь построены по одному шаблону. Вот check-store.sh, урезанный до каркаса, лимитов длины и одного правила App Review:

check-store.shbash
#!/usr/bin/env bash
# Использование: check-store.sh [ROOT]  (по умолчанию — корень репозитория)
set -euo pipefail

ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
fail=0

err() {
  echo "FAIL: $*" >&2
  fail=1
}

require_grep() { # file pattern message [grep-flags]
  local file="$1" pattern="$2" message="$3" flags="${4:--q}"
  [[ -f "$ROOT/$file" ]] || return 0 # отсутствие файла ловит require_file
  grep $flags "$pattern" "$ROOT/$file" || err "$file: $message"
}

IOS_META=docs/store/metadata
AND_META=docs/store/metadata/android
# ... require_file для каждого файла метаданных

# 2. Лимиты длины полей сторов (в символах — python3, как в остальных чеках)
if command -v python3 >/dev/null 2>&1; then
  check_len() { # file max
    local file="$1" max="$2" n
    [[ -f "$ROOT/$file" ]] || return 0
    n="$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$ROOT/$file")"
    [[ "$n" -le "$max" ]] || err "$file: $n символов (лимит $max)"
  }
  for locale in ru en-US; do
    check_len "$IOS_META/$locale/name.txt"          30
    check_len "$IOS_META/$locale/keywords.txt"      100
    # ...
  done
  # ... то же для остальных локалей и Google Play
fi

# 3b. App Review 2.3.10: в метаданных App Store нет упоминаний сторонних
#     платформ и их магазинов (режект 07.08.2026). Каталог android/ —
#     метаданные Google Play, запрет на него не распространяется.
ios_only_texts=()
while IFS= read -r f; do
  ios_only_texts+=("$f")
done < <(find "$ROOT/$IOS_META" -type f -name '*.txt' -not -path "$ROOT/$AND_META/*" 2>/dev/null)
if [[ "${#ios_only_texts[@]}" -gt 0 ]]; then
  source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/forbidden-platforms.sh"
  for pattern in "${FORBIDDEN_PLATFORMS[@]}"; do
    hits="$(grep -lE "$pattern" "${ios_only_texts[@]}" 2>/dev/null || true)"
    if [[ -n "$hits" ]]; then
      err "упоминание сторонней платформы (App Review 2.3.10), паттерн '$pattern': $hits"
    fi
  done
fi

# 6. Review Notes: VoIP-обоснование, seed-ID, видео-демо
rn=docs/store/review-notes.md
require_grep "$rn" 'voip'                  "нет VoIP-обоснования"            -qi
require_grep "$rn" 'reportNewIncomingCall' "нет reportNewIncomingCall (инвариант 5 CLAUDE.md)"
# ...

if [[ "$fail" -ne 0 ]]; then
  # ...
  exit 1
fi

Шаблон держится на четырёх решениях:

  • Корень — аргумент. По умолчанию скрипт проверяет репозиторий, но его можно направить на любой каталог, и именно эта строка делает проверку тестируемой.
  • err фиксирует провал и идёт дальше, поэтому один прогон выводит все сломанные инварианты, а не останавливается на первом.
  • require_grep молчит, если файла нет: об этом уже сообщил require_file. Одна ошибка — одно сообщение.
  • Необязательные инструменты отключаются мягко. YAML-валидация работает, только если есть python3 с PyYAML, а CI-джобы, которые на неё опираются, ставят PyYAML явно.

Списки паттернов, нужные нескольким проверкам, живут в scripts/lib/. Запрещённые формулировки «абсолютной анонимности» подключают и проверка сайта, и проверка сторов, так что паттерн, исправленный один раз, действует на обе поверхности.

Что стерегут восемь проверок

  • check-ci.sh: workflow-файлы на месте и парсятся; серверный пайплайн по-прежнему запускает golangci-lint, go test и govulncheck; пайплайн приложения по-прежнему собирает iOS на macOS-раннере. Ловит «временно» удалённый скан уязвимостей и битый renovate.json.
  • check-infra.sh: Terraform, compose, TURN, алертинг, бэкапы. Отвергает *.enc.yaml без маркеров SOPS (секрет открытым текстом), вернувшийся шаг деплоя через ssh (деплоим только через API Dokploy) и /metrics, открытый наружу.
  • check-e2e.sh: все семь Maestro-флоу на месте и запускают приложение, оркестратор исполняемый и вызывает каждый флоу, а nightly-workflow падает, если staging недоступен.
  • check-privacy.sh: на сайте нет абсолютных обещаний анонимности; политика конфиденциальности на обоих языках перечисляет ровно то, что хранит сервер (ID, публичный ключ, push-токен, связи между ID), со сроком хранения 12 месяцев и оговоркой, что при P2P-звонке ваш IP виден собеседнику; iOS privacy manifest не декларирует трекинг и действительно подключён к таргету приложения.
  • check-store.sh: файлы метаданных на месте и непустые, поля укладываются в лимиты сторов, Review Notes объясняют использование VoIP, а тексты App Store не называют другие платформы.
  • check-release.sh: подпись и учётные данные приходят только из properties и переменных окружения, а fastlane, снесённый 19 августа, не возвращается, чтобы перезатереть метаданные, которые мы теперь ведём прямо в консолях сторов.
  • check-deeplinks.sh: universal link для линковки согласован между бэкендом, Android-манифестом, iOS-entitlement, общей навигацией и association-файлами сайта. Разрыв в любом слое — мёртвая ссылка.
  • check-bootstrap.sh: инварианты каркаса вроде зафиксированных версий Go и Java.

Часть правил — это шрамы. 7 августа App Store отклонил билд 1.0 (2) по Guideline 2.3.10 за карточку онбординга «Android & iOS». Теперь check-store.sh запрещает названия платформ в метаданных App Store, а Kotlin-тест делает то же для UI-строк. 20 августа алерт поднял ложный critical на восьми звонках, потому что неотвеченные звонки считались сбоями. Теперь проверка требует, чтобы правило вычитало неотвеченные звонки, не срабатывало на слишком малом объёме и использовало or vector(0): без него выкатка правила раньше бэкенда молча заглушила бы алерт. 23 сентября мы обнаружили панели Grafana, которые показывали данные staging, и теперь каждый запрос дашборда обязан смотреть на прод.

Тестируем проверки: фикстура плюс damage

Проверка, которая никогда не падает, хуже, чем никакой: она ставит зелёную галочку ни за что. Поэтому у каждого скрипта есть сюита, и, кроме самого первого каркаса, сюита всегда появлялась раньше скрипта: коммиты вида «failing tests for privacy-текстов» приходят с полностью красной сюитой, потому что скрипта ещё нет.

check-store.test.shbash
assert_ok() { # name cmd...
  local name="$1"; shift
  if "$@" >/dev/null 2>&1; then
    echo "ok   - $name"; pass=$((pass + 1))
  else
    echo "FAIL - $name (ожидался exit 0)"; failures=$((failures + 1))
  fi
}
# ... assert_fail — зеркальный; STORE_FILES — список файлов store-поверхности

# Фикстура: минимальная копия store-поверхности во временной директории
make_fixture() {
  local dir f
  dir="$(mktemp -d)"
  for f in "${STORE_FILES[@]}"; do
    [[ -f "$REPO_ROOT/$f" ]] && { mkdir -p "$dir/$(dirname "$f")"; cp "$REPO_ROOT/$f" "$dir/$f"; }
  done
  echo "$dir"
}

damage() { # file pattern — удалить строки с pattern (no-op, если файла нет)
  local file="$1" pattern="$2"
  [[ -f "$file" ]] || return 0
  grep -v -- "$pattern" "$file" > "$file.tmp" && mv "$file.tmp" "$file"
}

cleanup_dirs=()
trap 'rm -rf "${cleanup_dirs[@]:-}"' EXIT

assert_ok "реальный репозиторий проходит проверку" "$CHECK" "$REPO_ROOT"

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
assert_ok "валидная фикстура проходит проверку" "$CHECK" "$fx"

for f in "${STORE_FILES[@]}"; do
  fx="$(make_fixture)"; cleanup_dirs+=("$fx")
  rm -f "$fx/$f"
  assert_fail "отсутствие $f ловится" "$CHECK" "$fx"
done

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nWorks on Android too.\n' >> "$fx/$IOS_META/en-US/description.txt"
assert_fail "\"Android\" в en description (App Store) ловится" "$CHECK" "$fx"

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nЗвоните между Android и iOS.\n' \
  >> "$fx/$AND_META/ru-RU/full_description.txt"
assert_ok "«Android» в метаданных Google Play разрешён" "$CHECK" "$fx"

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
damage "$fx/docs/store/review-notes.md" 'reportNewIncomingCall'
assert_fail "review-notes без reportNewIncomingCall ловится" "$CHECK" "$fx"
# ...

Форма у всех сюит одинаковая:

  1. Два базовых кейса: реальный репозиторий проходит, и свежая фикстура проходит. Второй доказывает, что фикстура — валидная отправная точка, поэтому дальнейший провал вызван повреждением, а не файлом, который мы забыли скопировать.
  2. Одно повреждение на фикстуру. Каждый кейс ломает ровно один инвариант в свежей копии и ждёт ненулевой код выхода: удалённые строки, дописанная запрещённая фраза, битый JSON или YAML, удалённый файл, chmod -x на скрипте.
  3. Явные разрешённые кейсы. «Android» в метаданных Google Play обязан проходить; placeholder-отпечатки в assetlinks.json дают только предупреждение. Без таких кейсов слишком жадный паттерн падает на легитимном тексте, и люди привыкают игнорировать проверку.

По сути это написанное руками мутационное тестирование конфигурации: каждое повреждение — мутант, и проверка обязана его убить.

Когда damage-тест вскрыл слабый паттерн

Проверка бэкапа когда-то искала в backup.sh строку age -r, чтобы убедиться, что дампы шифруются. На код-ревью мы заметили, что собственное сообщение скрипта об отсутствующем получателе тоже содержит age -r, так что удаление настоящего шифрующего пайпа оставило бы проверку зелёной. Исправление — более точный паттерн | age -r и damage-кейс, который удаляет только строку с пайпом и оставляет сообщение. Grep-проверка хороша ровно настолько, насколько хорош её паттерн, и сюита — то место, где паттерны затачиваются.

Один runner и шаг в CI, который сторожит сам себя

Runner занимает одиннадцать строк:

run-all.shbash
#!/usr/bin/env bash
# scripts/tests/run-all.sh — единая точка запуска shell-тестов check-скриптов.
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"

for test_script in scripts/tests/*.test.sh; do
  echo "==> $test_script"
  "$test_script"
done

Он запускается в contract-workflow на каждом pull request:

contract.ymlyaml
  dto-sync:
    runs-on: ubuntu-latest
    steps:
      # ... checkout, установка Go, golden-сверка
      - name: CI config invariants
        run: ./scripts/check-ci.sh
      - name: Privacy texts invariants (T-041, R-08)
        run: ./scripts/check-privacy.sh
      - name: Shell validator test suites
        run: ./scripts/tests/run-all.sh

Поскольку каждая сюита начинается с кейса «реальный репозиторий проходит», этот шаг заодно прогоняет все восемь проверок на настоящем дереве. Workflow деплоя и E2E дополнительно вызывают check-infra.sh и check-e2e.sh напрямую.

Runner появился в июне в правке по итогам код-ревью; до этого сюиты существовали, но CI их не запускал. Чтобы шаг не исчез снова, его требует check-ci.sh, а сюита этой проверки удаляет строку из фикстурной копии workflow и ждёт провала:

check-ci.shbash
require_grep .github/workflows/contract.yml 'scripts/tests/run-all\.sh' \
  "нет запуска shell-тестов check-скриптов"

То же ревью исправило проблему потише. Nightly E2E и еженедельный нагрузочный тест раньше мягко пропускались, если staging был недоступен, и прогон мог выглядеть зелёным, не выполнив ни одного флоу. Теперь они падают, а проверки требуют guard с exit 1 в обоих.

Границы подхода

Эти проверки доказывают наличие, а не смысл: require_grep 'govulncheck' пройдёт, даже если слово осталось только в комментарии. Мы с этим согласны. Скрипты — растяжки для регрессий, которые люди совершают на практике (удалённый шаг, откаченная настройка, запрещённая фраза), а настоящее исполнение остаётся за terraform validate, валидатором OpenAPI и ночным прогоном Maestro. Изменить решение — значит поменять проверку и её тест в одном коммите, и это трение задумано.

Как начать у себя

  • Выберите одну поверхность, которая уже делала вам больно: workflow, compose-файл, тексты для сторов.
  • Запишите её инварианты обычными предложениями и превратите каждое в require_file или require_grep.
  • Пусть скрипт принимает корневой каталог аргументом и собирает все провалы перед выходом.
  • Сначала напишите сюиту: фикстуру, которая копирует только то, что читает проверка, базовые кейсы для реального репозитория и свежей фикстуры, затем по одному повреждению на инвариант.
  • Добавьте разрешённые кейсы везде, где паттерн может сработать на легитимном тексте.
  • Держите общие списки паттернов в одном подключаемом файле.
  • Гоняйте все сюиты через один runner в CI и заставьте одну из проверок требовать наличие этого шага.
  • Когда инцидент или режект учит вас новому правилу, добавляйте инвариант и его damage-тест в том же изменении.
ПоделитьсяXLinkedIn

ONLYAPPS Team

Нови-Сад, Сербия · onlyapps.org

Контакты

Аналитические cookie мы используем только с вашего согласия. Необходимые cookie обеспечивают работу сайта. Политика конфиденциальности