Preskoči na sadržaj

Alati za programere9 min čitanja

Testovi za ono što se ne izvršava: CI, infrastruktura i metapodaci prodavnica

Workflow-i, docker-compose, tekstovi o privatnosti i metapodaci App Store-a ne izvršavaju se u testu, a svaki može da obori izdanje. Evo kako ih ID Caller čuva check skriptama i testovima koji namerno kvare fajlove.

Neki od najvažnijih fajlova aplikacije ID Caller nikad se ne pokreću u testu. GitHub Actions workflow, docker-compose fajl, Prometheus pravilo za alert, politika privatnosti na sajtu, opis u App Store-u: ništa od toga se ne kompajlira i ništa ne obara unit test kad je pogrešno. Lome se kasnije, na deploy-u, tokom incidenta ili na App Review-u.

ID Caller je naša aplikacija za privatne pozive bez broja telefona: klijent na Kotlin/Compose Multiplatform-u i bekend na Go-u. Konfiguraciju i tekstove oko nje tretiramo kao kod. Svaka takva površina ima skriptu check-*.sh koja proverava njene invarijante, a svaka skripta ima skup testova koji kopira fajlove u fiksturu, pokvari jednu stvar i zahteva da provera padne.

Inventar

Repozitorijum ima osam check skripti, po jednu za svaku površinu, plus check-all.sh, koji ih lokalno pokreće redom zajedno sa Go i KMP proverama. Pored njih, u scripts/tests/ nalazi se deset skupova testova: osam za check skripte, jedan za skripte za bekap, vraćanje i rotaciju tajne i jedan za generator koji pravi iOS fajl Localizable.xcstrings iz Compose resursa. Kad smo ih pokrenuli za ovaj članak, bilo je ukupno 300 prolaznih slučajeva.

Pravilo je zapisano u uputstvima projekta: nova neizvršna površina znači novu check skriptu po istom obrascu, skup testova sa fiksturom i pomoćnom funkcijom damage i korak u check-all.sh.

Anatomija check skripte

Svih osam skripti ima isti šablon. Evo check-store.sh, skraćenog na kostur, ograničenja dužine i jedno App Review pravilo:

check-store.shbash
#!/usr/bin/env bash
# Upotreba: check-store.sh [ROOT]  (podrazumevano koren repozitorijuma)
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 # nedostajući fajl prijavljuje require_file
  grep $flags "$pattern" "$ROOT/$file" || err "$file: $message"
}

IOS_META=docs/store/metadata
AND_META=docs/store/metadata/android
# ... require_file za svaki fajl metapodataka

# 2. Ograničenja dužine polja u prodavnicama (u karakterima, preko 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 karaktera (ograničenje $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
  # ... isto za ostale lokale i Google Play
fi

# 3b. App Review 2.3.10: bez pominjanja drugih platformi i njihovih prodavnica
#     u metapodacima App Store-a (odbijanje 07.08.2026). Direktorijum android/
#     su metapodaci za Google Play, zabrana se na njega ne odnosi.
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 "pominjanje druge platforme (App Review 2.3.10), obrazac '$pattern': $hits"
    fi
  done
fi

# 6. Review Notes: obrazloženje za VoIP, seed ID, video demo
rn=docs/store/review-notes.md
require_grep "$rn" 'voip'                  "nema obrazloženja za VoIP"        -qi
require_grep "$rn" 'reportNewIncomingCall' "nema reportNewIncomingCall (invarijanta 5 u CLAUDE.md)"
# ...

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

Šablon počiva na četiri odluke:

  • Koren je argument. Podrazumevano skripta proverava repozitorijum, ali može da se usmeri na bilo koji direktorijum, i upravo ta jedna linija čini proveru testabilnom.
  • err beleži grešku i nastavlja dalje, pa jedno pokretanje ispisuje sve pokvarene invarijante umesto da stane na prvoj.
  • require_grep ćuti kad fajla nema, jer je to već prijavio require_file. Jedna greška, jedna poruka.
  • Opcioni alati se isključuju bez buke. YAML validacija radi samo ako postoji python3 sa PyYAML-om, a CI poslovi koji se na nju oslanjaju eksplicitno instaliraju PyYAML.

Liste obrazaca koje koristi više provera nalaze se u scripts/lib/. Zabranjene formulacije o „apsolutnoj anonimnosti” učitavaju i provera sajta i provera prodavnica, pa obrazac ispravljen jednom važi za obe površine.

Šta čuva osam provera

  • check-ci.sh: workflow fajlovi postoje i parsiraju se; serverski pipeline i dalje pokreće golangci-lint, go test i govulncheck; pipeline aplikacije i dalje gradi iOS na macOS runner-u. Hvata „privremeno” obrisano skeniranje ranjivosti i pokvaren renovate.json.
  • check-infra.sh: Terraform, compose, TURN, alerting, bekapi. Odbija *.enc.yaml bez SOPS markera (tajna u čistom tekstu), vraćeni deploy korak preko ssh (deploy radimo samo preko Dokploy API-ja) i /metrics otvoren spolja.
  • check-e2e.sh: svih sedam Maestro tokova postoji i pokreće aplikaciju, orkestrator je izvršan i poziva svaki tok, a noćni workflow pada kad staging nije dostupan.
  • check-privacy.sh: na sajtu nema apsolutnih obećanja anonimnosti; politika privatnosti na oba jezika navodi tačno ono što server čuva (ID, javni ključ, push token, veze između ID-jeva), sa rokom čuvanja od 12 meseci i napomenom da P2P poziv otkriva vaš IP sagovorniku; iOS privacy manifest ne prijavljuje praćenje i zaista je uključen u target aplikacije.
  • check-store.sh: fajlovi metapodataka postoje i nisu prazni, polja staju u ograničenja prodavnica, Review Notes objašnjavaju korišćenje VoIP-a, a tekstovi za App Store ne pominju druge platforme.
  • check-release.sh: potpisivanje i kredencijali dolaze samo iz properties fajlova i promenljivih okruženja, a fastlane, uklonjen 19. avgusta, ne vraća se da pregazi metapodatke koje sada vodimo direktno u konzolama prodavnica.
  • check-deeplinks.sh: universal link za povezivanje usklađen je između bekenda, Android manifesta, iOS entitlement-a, zajedničke navigacije i association fajlova sajta. Rupa u bilo kom sloju znači mrtav link.
  • check-bootstrap.sh: invarijante kostura projekta, poput fiksiranih verzija Go-a i Jave.

Deo pravila čine ožiljci. App Store je 7. avgusta odbio build 1.0 (2) po smernici 2.3.10 zbog kartice na uvodnom ekranu sa natpisom „Android & iOS”. Sada check-store.sh zabranjuje imena platformi u metapodacima App Store-a, a Kotlin test radi isto za UI stringove. Alert je 20. avgusta podigao lažni critical na osam poziva, jer su se neodgovoreni pozivi računali kao kvarovi. Sada provera zahteva da pravilo oduzima neodgovorene pozive, da ima prag minimalnog obima i da koristi or vector(0): bez toga bi uvođenje pravila pre bekenda tiho utišalo alert. Dana 23. septembra našli smo Grafana panele koji su prikazivali podatke sa staginga, pa sada svaki upit na dashboard-u mora da gleda produkciju.

Testiranje provera: fikstura plus damage

Provera koja nikad ne pada gora je od nikakve: daje zelenu kvačicu ni za šta. Zato svaka skripta ima skup testova, i osim početnog kostura, testovi su uvek nastajali pre skripte: commit-ovi sa porukom „failing tests …” stižu sa potpuno crvenim skupom, jer skripta još ne postoji.

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 (očekivan exit 0)"; failures=$((failures + 1))
  fi
}
# ... assert_fail je zrcalna slika; STORE_FILES je spisak fajlova prodavnica

# Fikstura: minimalna kopija fajlova prodavnica u privremenom direktorijumu
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: briše linije sa pattern (ništa ne radi ako fajla nema)
  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 "pravi repozitorijum prolazi proveru" "$CHECK" "$REPO_ROOT"

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
assert_ok "validna fikstura prolazi proveru" "$CHECK" "$fx"

for f in "${STORE_FILES[@]}"; do
  fx="$(make_fixture)"; cleanup_dirs+=("$fx")
  rm -f "$fx/$f"
  assert_fail "nedostajući $f se hvata" "$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\" u en opisu (App Store) se hvata" "$CHECK" "$fx"

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nЗвоните между Android и iOS.\n' \
  >> "$fx/$AND_META/ru-RU/full_description.txt"
assert_ok "\"Android\" u metapodacima za Google Play je dozvoljen" "$CHECK" "$fx"

fx="$(make_fixture)"; cleanup_dirs+=("$fx")
damage "$fx/docs/store/review-notes.md" 'reportNewIncomingCall'
assert_fail "review-notes bez reportNewIncomingCall se hvata" "$CHECK" "$fx"
# ...

Svi skupovi testova imaju isti oblik:

  1. Dva osnovna slučaja: pravi repozitorijum prolazi i sveža fikstura prolazi. Drugi dokazuje da je fikstura validna polazna tačka, pa kasniji pad dolazi od oštećenja, a ne od fajla koji smo zaboravili da kopiramo.
  2. Jedno oštećenje po fiksturi. Svaki slučaj kvari tačno jednu invarijantu u svežoj kopiji i očekuje izlazni kod različit od nule: obrisane linije, dopisana zabranjena fraza, pokvaren JSON ili YAML, obrisan fajl, chmod -x na skripti.
  3. Eksplicitni dozvoljeni slučajevi. „Android” u metapodacima za Google Play mora da prođe; placeholder otisci u assetlinks.json daju samo upozorenje. Bez takvih slučajeva preterano revnosan obrazac pada na legitimnom tekstu, a ljudi nauče da ignorišu proveru.

U suštini, ovo je ručno pisano mutaciono testiranje konfiguracije: svako oštećenje je mutant, a provera mora da ga ubije.

Kad je damage test razotkrio slab obrazac

Provera bekapa je nekad tražila age -r u backup.sh da bi potvrdila da se dump-ovi šifruju. Na code review-u smo primetili da i sama poruka o grešci skripte za nedostajućeg primaoca sadrži age -r, pa bi brisanje pravog pipe-a za šifrovanje ostavilo proveru zelenom. Rešenje je bio precizniji obrazac, | age -r, i damage slučaj koji briše samo liniju sa pipe-om, a poruku ostavlja. Grep provera je dobra onoliko koliko je dobar njen obrazac, a skup testova je mesto gde se obrasci izoštravaju.

Jedan runner i CI korak koji čuva sam sebe

Runner ima jedanaest linija:

run-all.shbash
#!/usr/bin/env bash
# scripts/tests/run-all.sh: jedinstvena ulazna tačka za shell testove check skripti.
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

Pokreće se u contract workflow-u na svakom pull request-u:

contract.ymlyaml
  dto-sync:
    runs-on: ubuntu-latest
    steps:
      # ... checkout, instalacija Go-a, golden provera
      - 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

Pošto svaki skup testova počinje slučajem „pravi repozitorijum prolazi”, ovaj korak usput pokreće svih osam provera nad stvarnim stablom. Workflow-i za deploy i E2E dodatno pozivaju check-infra.sh i check-e2e.sh direktno.

Runner je stigao u junu, u ispravci nakon code review-a; pre toga su skupovi testova postojali, ali ih CI nije pokretao. Da korak ne bi ponovo nestao, zahteva ga check-ci.sh, a njegov skup testova briše tu liniju iz kopije workflow-a u fiksturi i očekuje pad:

check-ci.shbash
require_grep .github/workflows/contract.yml 'scripts/tests/run-all\.sh' \
  "nema pokretanja shell testova check skripti"

Isti review je ispravio i tiši problem. Noćni E2E i nedeljni test opterećenja ranije su se tiho preskakali kad staging nije bio dostupan, pa je pokretanje moglo da izgleda zeleno bez ijednog izvršenog toka. Sada padaju, a provere zahtevaju zaštitu sa exit 1 u oba.

Gde su granice pristupa

Ove provere dokazuju prisustvo, ne smisao: require_grep 'govulncheck' prolazi čak i ako reč preživi samo u komentaru. To prihvatamo. Skripte su zamke za regresije koje ljudi zaista prave (obrisan korak, vraćeno podešavanje, zabranjena fraza), a pravo izvršavanje ostaje na terraform validate, OpenAPI validatoru i noćnom Maestro pokretanju. Promena odluke znači promenu provere i njenog testa u istom commit-u, i to trenje je namerno.

Kako da počnete u svom projektu

  • Izaberite jednu površinu koja vas je već zabolela: workflow, compose fajl, tekstove za prodavnice.
  • Zapišite njene invarijante običnim rečenicama, pa svaku pretvorite u require_file ili require_grep.
  • Neka skripta prima koreni direktorijum kao argument i skuplja sve greške pre izlaska.
  • Prvo napišite testove: fiksturu koja kopira samo ono što provera čita, osnovne slučajeve za pravi repozitorijum i svežu fiksturu, pa jedno oštećenje po invarijanti.
  • Dodajte dozvoljene slučajeve svuda gde obrazac može da okine na legitimnom tekstu.
  • Zajedničke liste obrazaca držite u jednom fajlu koji se učitava sa source.
  • Pokrećite sve skupove testova kroz jedan runner u CI-ju i neka jedna provera zahteva da taj korak postoji.
  • Kad vas incident ili odbijanje nauče novom pravilu, dodajte invarijantu i njen damage test u istoj izmeni.
PodeliXLinkedIn

ONLYAPPS Team

Novi Sad, Srbija · onlyapps.org

Kontakt

Kolačiće za analitiku koristimo samo uz vašu saglasnost. Neophodni kolačići održavaju rad sajta. Politika privatnosti