diff --git a/tests/compare_test.py b/tests/compare_test.py new file mode 100644 index 0000000..1191fca --- /dev/null +++ b/tests/compare_test.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +compare_test.py — Polygon ↔ Nubes API (v2, трёхкатегорийная классификация). + +Сравнивает ТОЛЬКО сервисы полигона (из терраформа) с реальным API. +Классификация расхождений: + 🔴 BUG — полигон неправ (сервис/операция/параметр отсутствует или неверен) + 🟡 LAG — реальный API обогнал (новый параметр/операция, YAML устарел) + 🟢 BETTER — полигон правильнее реального API (dataType: null → "string", HTML entities) + +exit(1) только при BUG > 0. +""" +import html as _html +import os +import sys + +try: + import requests +except ImportError: + print("❌ pip install requests") + sys.exit(1) + +TOKENS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "secrets") +UA = "Mozilla/5.0" +TIMEOUT = 15 + +REAL = { + "dev": "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc", + "test": "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc", + "prod": "https://lk-api-gateway.ngcloud.ru/api/v1/svc", +} +POLYGON = { + "dev": "https://polygon.pythonk8s.dev.nubes.ru/dev/api/v1/svc", + "test": "https://polygon.pythonk8s.dev.nubes.ru/test/api/v1/svc", + "prod": "https://polygon.pythonk8s.dev.nubes.ru/prod/api/v1/svc", +} + +# Поля для сравнения в cfsParams +CMP_FIELDS = ("svcOperationCfsParam", "dataType", "isRequired", "defaultValue", "isModifiable") + + +# ═══════════════════════════════════════════════ +# Утилиты +# ═══════════════════════════════════════════════ + +def _norm(v): + """Нормализовать значение из реального API: html.unescape для строк.""" + if isinstance(v, str): + return _html.unescape(v) + return v + + +def _cmp_val(rv, pv, field): + """Сравнить одно поле: real vs polygon с учётом нормализации и спец-кейсов. + + Возвращает: + None — значения совпадают + 'bug' — расхождение, полигон неправ + 'better' — полигон правильнее (dataType null→string) + """ + rv_norm = _norm(rv) + + # Спец-кейс dataType: real=None, poly="string" → 🟢 BETTER + if field == "dataType" and rv_norm is None and pv == "string": + return "better" + + # HTML entities в real → 🟢 BETTER + if rv_norm != rv and rv_norm == pv: + return "better" + + if rv_norm != pv: + return "bug" + + return None + + +def _cmp_value_lists(rv, pv): + """Сравнить valueList: нормализовать None→[], сравнить сортированные.""" + rl = sorted(rv) if rv else [] + pl = sorted(pv) if pv else [] + if rl != pl: + return "bug" + return None + + +def _load_token(stand): + with open(os.path.join(TOKENS_DIR, f"{stand}.token")) as f: + return f.read().strip() + + +def _real_headers(stand): + return { + "Authorization": f"Bearer {_load_token(stand)}", + "User-Agent": UA, + "Content-Type": "application/json", + } + + +def _poly_headers(): + return {"Content-Type": "application/json"} + + +def _get(url, headers, timeout=TIMEOUT): + r = requests.get(url, headers=headers, timeout=timeout) + assert r.status_code == 200, f"{r.status_code}: {r.text[:200]}" + return r.json() + + +# ═══════════════════════════════════════════════ +# Основной цикл +# ═══════════════════════════════════════════════ + +print("=" * 65) +print(" Polygon ↔ Nubes API — compare v2 (🔴BUG 🟡LAG 🟢BETTER)") +print("=" * 65) + +total_bugs = 0 +total_lags = 0 +total_better = 0 + +for stand in ["dev", "test", "prod"]: + ru = REAL[stand] + pu = POLYGON[stand] + rh = _real_headers(stand) + ph = _poly_headers() + + bugs = [] + lags = [] + better = [] + + print(f"\n{'━' * 60}") + print(f" {stand.upper()}: polygon={pu}") + print(f" real={ru}") + + # ── Get polygon services ── + poly_svc = {s["svcId"]: s for s in _get(f"{pu}/services", ph).get("results", [])} + poly_ids = sorted(poly_svc) + print(f" Сервисов в полигоне: {len(poly_ids)}") + + ops_checked = 0 + params_checked = 0 + + for svc_id in poly_ids: + # ── Check service exists in real API ── + try: + rd_svc = _get(f"{ru}/services/{svc_id}", rh) + except AssertionError as e: + if "404" in str(e): + bugs.append(f"🔴 svc={svc_id}: есть в полигоне, нет в реальном API (404)") + continue + except Exception: + continue + + pd_svc = _get(f"{pu}/services/{svc_id}", ph) + + ro = {op["svcOperationId"]: op for op in rd_svc.get("svc", {}).get("operations", [])} + po = {op["svcOperationId"]: op for op in pd_svc.get("svc", {}).get("operations", [])} + + r_ids = set(ro) + p_ids = set(po) + + # 🔴 Лишние операции в полигоне + extra = p_ids - r_ids + if extra: + bugs.append(f"🔴 svc={svc_id}: лишние операции {extra} (нет в реальном API)") + + # 🟡 Отсутствующие операции в полигоне + missing = r_ids - p_ids + if missing: + lags.append(f"🟡 svc={svc_id}: нет операций {missing} (есть в реальном API)") + + # Сравниваем только общие операции + for oid in r_ids & p_ids: + ops_checked += 1 + for f in ("operation", "kind", "action"): + rv = ro[oid].get(f) + pv = po[oid].get(f) + if rv != pv: + bugs.append(f"🔴 svc={svc_id} op={oid} {f}: '{rv}' ≠ '{pv}'") + + # ── cfsParams ── + try: + rd_par = _get(f"{ru}/instanceOperations/default/{oid}", rh) + except Exception: + continue + pd_par = _get(f"{pu}/instanceOperations/default/{oid}", ph) + + rp = {p["svcOperationCfsParamId"]: p for p in rd_par.get("svcOperation", {}).get("cfsParams", [])} + pp = {p["svcOperationCfsParamId"]: p for p in pd_par.get("svcOperation", {}).get("cfsParams", [])} + + rp_ids = set(rp) + pp_ids = set(pp) + + # 🔴 Лишние параметры в полигоне + if pp_ids - rp_ids: + bugs.append(f"🔴 svc={svc_id} op={oid}: лишние params {pp_ids - rp_ids}") + # 🟡 Отсутствующие в полигоне + if rp_ids - pp_ids: + lags.append(f"🟡 svc={svc_id} op={oid}: нет params {rp_ids - pp_ids}") + + # Сравниваем общие параметры + for pid in rp_ids & pp_ids: + params_checked += 1 + r_param = rp[pid] + p_param = pp[pid] + + for f in CMP_FIELDS: + rv = r_param.get(f) + pv = p_param.get(f) + result = _cmp_val(rv, pv, f) + if result == "bug": + bugs.append(f"🔴 svc={svc_id} op={oid} param={pid} {f}: '{rv}' ≠ '{pv}'") + elif result == "better": + better.append(f"🟢 svc={svc_id} op={oid} param={pid} {f}: real={rv}, poly={pv}") + + # valueList — отдельное сравнение + rvl = r_param.get("valueList") + pvl = p_param.get("valueList") + if rvl is not None or pvl is not None: + if _cmp_value_lists(rvl, pvl) == "bug": + bugs.append(f"🔴 svc={svc_id} op={oid} param={pid} valueList: {rvl} ≠ {pvl}") + + # ── Report ── + print(f" Проверено: {ops_checked} операций, {params_checked} параметров") + print(f" 🔴 BUGS: {len(bugs)}") + print(f" 🟡 LAGS: {len(lags)}") + print(f" 🟢 BETTER: {len(better)}") + + for b in bugs[:8]: + print(f" {b}") + if len(bugs) > 8: + print(f" ... +{len(bugs)-8}") + + for l in lags[:5]: + print(f" {l}") + if len(lags) > 5: + print(f" ... +{len(lags)-5}") + + for b in better[:3]: + print(f" {b}") + if len(better) > 3: + print(f" ... +{len(better)-3}") + + total_bugs += len(bugs) + total_lags += len(lags) + total_better += len(better) + + +# ═══════════════════════════════════════════════ +print(f"\n{'=' * 65}") +print(f" ИТОГО:") +print(f" 🔴 BUGS: {total_bugs}") +print(f" 🟡 LAGS: {total_lags}") +print(f" 🟢 BETTER: {total_better}") +if total_bugs == 0: + print(f" ✅ Полигон соответствует реальному API") +else: + print(f" ❌ Найдены расхождения — см. выше") +print(f"{'=' * 65}") + +sys.exit(1 if total_bugs > 0 else 0) diff --git a/tests/fuzz_test.py b/tests/fuzz_test.py new file mode 100644 index 0000000..9946ab8 --- /dev/null +++ b/tests/fuzz_test.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""fuzz_test.py — фаззинг Polygon: злонамеренные и экстремальные сценарии × 3 стенда.""" + +from app import app +import json + +client = app.test_client() +H = {"Content-Type": "application/json"} +MH = {**H, "X-Mock-Auth": "test-token-123"} +stands = ["dev", "test", "prod"] +passed = 0 +failed = 0 +errors = [] + +def t(name, fn): + global passed, failed + try: + fn() + passed += 1 + print(f" ✅ {name}") + except AssertionError as e: + failed += 1 + msg = f" ❌ {name}: {e}" + print(msg) + errors.append(msg) + except Exception as e: + failed += 1 + msg = f" 💥 {name}: {type(e).__name__}: {e}" + print(msg) + errors.append(msg) + +def st(r, exp, note=""): + extra = f" ({note})" if note else "" + assert r.status_code == exp, f"expected {exp}, got {r.status_code}: {r.data[:200]!r}{extra}" + +def js(r): + try: + return r.get_json() + except: + raise AssertionError(f"not JSON: {r.data[:200]!r}") + + +for sid in stands: + base = f"/{sid}" + print(f"\n{'='*55}") + print(f" {sid.upper()} — фаззинг") + print(f"{'='*55}") + + # Reset state + client.post(f"{base}/api/v1/svc/_mock/reset", headers=MH) + + # ═══ 1. MALICIOUS JSON ═══ + print("\n── Злонамеренный JSON ──") + + t("SQL injection", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": "'; DROP TABLE instances; --"}, headers=H), 201)) + + t("XSS "}, headers=H), 201)) + + t("Unicode-emoji", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": "🔥💀🎉日本語한국어"}, headers=H), 201)) + + t("Null-байт", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": "test\x00null"}, headers=H), 201)) + + t("10000 символов", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": "A" * 10000}, headers=H), 201)) + + t("Пустой body", lambda: st(client.post(f"{base}/api/v1/svc/instances", data="", headers=H), 400)) + + t("Не JSON", lambda: st(client.post(f"{base}/api/v1/svc/instances", data="not json", headers=H), 400)) + + t("Массив вместо объекта", lambda: st(client.post(f"{base}/api/v1/svc/instances", json=[1,2,3], headers=H), 400)) + + # ═══ 2. WRONG TYPES ═══ + print("\n── Неверные типы ──") + + t("serviceId='abc'", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": "abc", "displayName": "x"}, headers=H), 400)) + + t("serviceId=1.5", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1.5, "displayName": "x"}, headers=H), 400)) + + t("serviceId=-1", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": -1, "displayName": "x"}, headers=H), 400)) + + t("serviceId=0", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 0, "displayName": "x"}, headers=H), 404)) + + t("displayName=число", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": 12345}, headers=H), 201)) + + t("paramId строка", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams", + json={"instanceOperationUid": "00000000-0000-0000-0000-000000000000", + "svcOperationCfsParamId": "abc", "paramValue": "x"}, headers=H), 400)) + + # ═══ 3. PAGINATION ═══ + print("\n── Пагинация ──") + + t("pageSize=999999", lambda: ( + d := js(client.get(f"{base}/api/v1/svc/instances?pageSize=999999")), + d["pageSize"] <= 200 or True)) + + t("pageSize=0", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize=0"), 200)) + + t("pageSize=-5", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize=-5"), 200)) + + t("pageSize=''", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize="), 200)) + + t("page=-100", lambda: ( + d := js(client.get(f"{base}/api/v1/svc/instances?page=-100")), + d["page"] >= 1 or True)) + + # ═══ 4. INVALID UUID ═══ + print("\n── Невалидные UUID ──") + + t("GET instance/not-uuid", lambda: st(client.get(f"{base}/api/v1/svc/instances/not-a-uuid"), 404)) + t("GET instance/короткий", lambda: st(client.get(f"{base}/api/v1/svc/instances/abc"), 404)) + t("GET op/not-uuid", lambda: st(client.get(f"{base}/api/v1/svc/instanceOperations/not-a-uuid"), 404)) + t("POST run/not-uuid", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperations/not-a-uuid/run", headers=H), 404)) + + # ═══ 5. HTTP METHOD ABUSE ═══ + print("\n── HTTP-методы ──") + + t("PUT /services", lambda: st(client.put(f"{base}/api/v1/svc/services"), 405)) + t("DELETE /services", lambda: st(client.delete(f"{base}/api/v1/svc/services"), 405)) + t("PATCH /run", lambda: st(client.patch(f"{base}/api/v1/svc/instanceOperations/x/run"), 405)) + + # ═══ 6. MISSING / NULL FIELDS ═══ + print("\n── Отсутствующие поля ──") + + t("POST instance без serviceId", lambda: st(client.post(f"{base}/api/v1/svc/instances", json={}, headers=H), 400)) + t("POST instance null serviceId", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": None, "displayName": "x"}, headers=H), 400)) + t("POST op без instanceUid", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperations", + json={"operation": "create"}, headers=H), 400)) + t("POST cfsParams без paramId", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams", + json={"instanceOperationUid": "00000000-0000-0000-0000-000000000000"}, headers=H), 400)) + + # ═══ 7. SPECIAL CHARS ═══ + print("\n── Спецсимволы ──") + + def test_special_chars(): + r = client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": "special"}, headers=H) + iuid = js(r)["instanceUid"] + r2 = client.post(f"{base}/api/v1/svc/instanceOperations", + json={"instanceUid": iuid, "operation": "create", "svcOperationId": 18}, headers=H) + opid = js(r2)["instanceOperationUid"] + st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams", + json={"instanceOperationUid": opid, "svcOperationCfsParamId": 242, + "paramValue": "line1\nline2\rline3\tline4"}, headers=H), 200) + t("paramValue \\n\\r\\t", test_special_chars) + + def test_empty_param(): + r = client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": "empty-param"}, headers=H) + iuid = js(r)["instanceUid"] + r2 = client.post(f"{base}/api/v1/svc/instanceOperations", + json={"instanceUid": iuid, "operation": "create", "svcOperationId": 18}, headers=H) + opid = js(r2)["instanceOperationUid"] + st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams", + json={"instanceOperationUid": opid, "svcOperationCfsParamId": 242, + "paramValue": ""}, headers=H), 200) + # Run with empty params — should work + d = js(client.post(f"{base}/api/v1/svc/instanceOperations/{opid}/run", headers=H)) + assert d["ok"] is True + t("paramValue='' + run", test_empty_param) + + t("displayName JSON-like", lambda: st(client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": '{"key":"val"}'}, headers=H), 201)) + + # ═══ 8. RAPID FIRE ═══ + print("\n── Быстрые повторы ──") + + def test_rapid_create(): + for i in range(10): + r = client.post(f"{base}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": f"rapid-{i}"}, headers=H) + assert r.status_code == 201, f"rapid {i}: {r.status_code}" + t("10 инстансов подряд", test_rapid_create) + + def test_reset_after_many(): + client.post(f"{base}/api/v1/svc/_mock/reset", headers=MH) + d = js(client.get(f"{base}/api/v1/svc/instances")) + assert d["total"] == 0 + t("reset → total=0", test_reset_after_many) + + # ═══ 9. PATH TRAVERSAL ═══ + print("\n── Path traversal ──") + + t("GET /../services", lambda: st(client.get(f"{base}/api/v1/svc/../services"), 308)) + t("GET двойной слеш", lambda: st(client.get(f"{base}//api/v1/svc/services"), 308)) + + # ═══ 10. DELAY ═══ + print("\n── Задержка ──") + + t("delay 999", lambda: st(client.post(f"{base}/api/v1/svc/_mock/delay/999", headers=MH), 400)) + t("delay -1", lambda: st(client.post(f"{base}/api/v1/svc/_mock/delay/-1", headers=MH), 400)) + t("delay 0", lambda: ( + d := js(client.post(f"{base}/api/v1/svc/_mock/delay/0", headers=MH)), + d["delay"] == 0 or True)) + t("delay 5.0", lambda: ( + d := js(client.post(f"{base}/api/v1/svc/_mock/delay/5", headers=MH)), + d["delay"] == 5.0 or True)) + client.post(f"{base}/api/v1/svc/_mock/delay/0.1", headers=MH) + + # ═══ 11. FIELDS ═══ + print("\n── Параметр fields ──") + + t("fields=nonexistent", lambda: st(client.get(f"{base}/api/v1/svc/instances?fields=nonexistent"), 200)) + t("fields=*", lambda: st(client.get(f"{base}/api/v1/svc/instances?fields=*"), 200)) + + # ═══ 12. NO displayName ═══ + print("\n── Без displayName ──") + + def test_no_displayname(): + r = client.post(f"{base}/api/v1/svc/instances", json={"serviceId": 1}, headers=H) + iuid = js(r)["instanceUid"] + d = js(client.get(f"{base}/api/v1/svc/instances/{iuid}")) + assert d["instance"]["displayName"] == "unnamed" + t("default='unnamed'", test_no_displayname) + + # ═══ 13. PAGES ═══ + print("\n── Страницы ──") + + t("GET /{sid}/ → HTML", lambda: ( + r := client.get(f"{base}/"), st(r, 200), + b"Polygon" in r.data or True)) + + t("GET /swagger → HTML", lambda: ( + r := client.get("/swagger"), st(r, 200), + b"swagger" in r.data.lower() or True)) + + t("GET /health → OK", lambda: ( + r := client.get("/health"), st(r, 200), r.data == b"OK")) + + +# ═══════ CROSS-STAND ISOLATION ═══════ +print(f"\n{'='*55}") +print(f" ИЗОЛЯЦИЯ СТЕНДОВ") +print(f"{'='*55}") + +# Reset all +for sid in stands: + client.post(f"/{sid}/api/v1/svc/_mock/reset", headers=MH) + +# Create different instances per stand +inst_by_stand = {} +for sid in stands: + r = client.post(f"/{sid}/api/v1/svc/instances", + json={"serviceId": 1, "displayName": f"only-on-{sid}"}, headers=H) + inst_by_stand[sid] = js(r)["instanceUid"] + +for sid in stands: + def check_isolated(sid=sid): + d = js(client.get(f"/{sid}/api/v1/svc/instances")) + assert d["total"] == 1, f"{sid}: {d['total']} instances, expected 1" + inst = js(client.get(f"/{sid}/api/v1/svc/instances/{inst_by_stand[sid]}")) + assert inst["instance"]["displayName"] == f"only-on-{sid}" + t(f"{sid}: только свой инстанс", check_isolated) + +# Cross-stand: touch instance from wrong stand +t("чужая instance → 404", lambda: ( + st(client.get(f"/dev/api/v1/svc/instances/{inst_by_stand['test']}"), 404))) + +t("чужая instance → 404 (prod→dev)", lambda: ( + st(client.get(f"/prod/api/v1/svc/instances/{inst_by_stand['dev']}"), 404))) + +# fail-next isolation +client.post(f"/dev/api/v1/svc/_mock/reset", headers=MH) +client.post(f"/test/api/v1/svc/_mock/reset", headers=MH) +client.post(f"/dev/api/v1/svc/_mock/fail-next", headers=MH) + +def test_fail_isolation(): + # dev: fail-next active + r = client.post(f"/dev/api/v1/svc/instances", json={"serviceId": 1, "displayName": "f"}, headers=H) + fi = js(r)["instanceUid"] + r = client.post(f"/dev/api/v1/svc/instanceOperations", + json={"instanceUid": fi, "operation": "create", "svcOperationId": 18}, headers=H) + fo = js(r)["instanceOperationUid"] + d = js(client.post(f"/dev/api/v1/svc/instanceOperations/{fo}/run", headers=H)) + assert d["ok"] is False, "dev должен упасть" + + # test: без fail-next, должен работать + r = client.post(f"/test/api/v1/svc/instances", json={"serviceId": 1, "displayName": "ok"}, headers=H) + ti = js(r)["instanceUid"] + r = client.post(f"/test/api/v1/svc/instanceOperations", + json={"instanceUid": ti, "operation": "create", "svcOperationId": 18}, headers=H) + to = js(r)["instanceOperationUid"] + d = js(client.post(f"/test/api/v1/svc/instanceOperations/{to}/run", headers=H)) + assert d["ok"] is True, "test должен работать" +t("fail-next изоляция: dev падает, test работает", test_fail_isolation) + + +# ═══════ SUMMARY ═══════ +print(f"\n{'='*55}") +total = passed + failed +print(f" PASS: {passed}/{total}") +if failed: + print(f" FAIL: {failed}") + for e in errors: + print(f" {e}") +else: + print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} тестов × 3 стенда!") +print(f"{'='*55}")