Author SHA1 Message Date
naeel dcd978808d fix: разделение клиентов — сервисы из real API, инстансы/операции из polygon (v1.2.28) 2026-08-01 09:22:02 +04:00
naeel ca2367724e fix: create_client → get_client — POLYGON_ENDPOINT в main/api/runner (v1.2.27) 2026-08-01 09:00:45 +04:00
naeel 4a0cfcaa61 feat: POLYGON_ENDPOINT — отдельная переменная, не трогает NUBES_API_TOKEN (v1.2.26) 2026-07-31 23:42:41 +04:00
naeel 529d577b84 test: 15 интеграционных тестов polygon + bump v1.2.25 2026-07-31 23:02:34 +04:00
naeel a398892766 feat: интеграция с polygon — STANDS-check в get_client/get_stand (v1.2.24) 2026-07-31 22:45:27 +04:00
naeel 4d3ce74c03 Добавлены тесты для критических фиксов (lock_check, UniqueViolation, escName, idx_one_running) 2026-07-31 18:28:49 +04:00
naeel c1e7c4f9aa v1.2.23: lock_check three-state return (True/False/None), None→503 DB unavailable 2026-07-31 18:22:58 +04:00
naeel 23688da90a v1.2.22: 3rd audit fixes — UniqueViolation→409, lock_check no-db→False, escName add & escape 2026-07-31 18:20:33 +04:00
naeel 06eb463088 v1.2.21: fix broken advisory lock (→ partial unique index), escName add " escape, lock_check fallback False 2026-07-31 18:17:09 +04:00
naeel 5065019ffd v1.2.20: remaining Codex fixes — _op_results lock, advisory lock for scenarios, _ensure_schema logging, stale async generation token, validate-cfs explicit JSONDecodeError 2026-07-31 18:07:24 +04:00
naeel 58b823a260 v1.2.19: fixes from Codex audit — XSS in params, JS injection in onclick, scenario polling timeout, has_target validation, tracker atomic update 2026-07-31 17:58:57 +04:00
22 changed files with 817 additions and 230 deletions
+49 -10
View File
@@ -18,7 +18,7 @@ import base64
import json import json
from flask import request, current_app from flask import request, current_app
from api.http_client import HttpClient, detect_endpoint, stand_name from api.http_client import HttpClient, detect_endpoint, stand_name, STANDS
def get_token(): def get_token():
@@ -32,16 +32,48 @@ def get_token():
return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
def get_client(): def _make_client(*, use_polygon):
"""HttpClient с автоопределением стенда по активному токену. """Фабрика HttpClient — ЕДИНСТВЕННОЕ место где выбирается endpoint.
Использует detect_endpoint() — пробует dev→test стенды. Args:
Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига.""" use_polygon: если True и POLYGON_ENDPOINT задан → polygon.
Если False → всегда реальный API (для чтения сервисов/инстансов).
Returns:
HttpClient с правильным endpoint и токеном.
"""
token = get_token() token = get_token()
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"]
# Режим полигона — только если разрешено и переменная задана
if use_polygon:
polygon = current_app.config.get("POLYGON_ENDPOINT", "")
if polygon:
return HttpClient(polygon, token)
# Реальный API: автоопределение или NUBES_API_ENDPOINT
endpoint = current_app.config["NUBES_API_ENDPOINT"]
if endpoint in STANDS:
endpoint = detect_endpoint(token) or endpoint
return HttpClient(endpoint, token) return HttpClient(endpoint, token)
def get_client():
"""HttpClient для ОПЕРАЦИЙ (create/modify/delete/run/poll).
Если POLYGON_ENDPOINT задан → polygon.
Иначе → автоопределение реального стенда.
"""
return _make_client(use_polygon=True)
def get_real_client():
"""HttpClient для ЧТЕНИЯ (сервисы, список инстансов).
ВСЕГДА реальный API, даже при POLYGON_ENDPOINT.
"""
return _make_client(use_polygon=False)
def get_client_id(): def get_client_id():
"""Извлечь ClientID из payload JWT-токена (base64url, без проверки подписи). """Извлечь ClientID из payload JWT-токена (base64url, без проверки подписи).
@@ -68,12 +100,19 @@ def get_client_id():
def get_stand(): def get_stand():
"""Определить стенд (dev/test) по активному токену. """Определить стенд (dev/test/mock) по активному токену.
Если POLYGON_ENDPOINT задан → "polygon".
Иначе — автоопределение или stand_name."""
if current_app.config.get("POLYGON_ENDPOINT", ""):
return "polygon"
detect_endpoint → stand_name. Если автоопределение не сработало —
fallback на NUBES_API_ENDPOINT из конфига."""
token = get_token() token = get_token()
endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] endpoint = current_app.config["NUBES_API_ENDPOINT"]
if endpoint not in STANDS:
s = stand_name(endpoint)
return s if s != "?" else "mock"
endpoint = detect_endpoint(token) or endpoint
return stand_name(endpoint) return stand_name(endpoint)
+2 -1
View File
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
# Нужна для фильтрации истории (пользователь видит только записи своей версии). # Нужна для фильтрации истории (пользователь видит только записи своей версии).
VERSION = "1.2.18" VERSION = "1.2.28"
# Flask-приложение с Jinja2-шаблонами из папки templates/ # Flask-приложение с Jinja2-шаблонами из папки templates/
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
@@ -43,6 +43,7 @@ app.config["NUBES_API_ENDPOINT"] = os.getenv(
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc" "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
) )
app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
app.config["POLYGON_ENDPOINT"] = os.getenv("POLYGON_ENDPOINT", "") # если задан → все запросы в polygon
app.config["VERSION"] = VERSION app.config["VERSION"] = VERSION
# Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов # Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов
+6
View File
@@ -112,6 +112,12 @@ CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
CREATE INDEX IF NOT EXISTS idx_scenario_runs_status CREATE INDEX IF NOT EXISTS idx_scenario_runs_status
ON scenario_runs (client_id, stand, status); ON scenario_runs (client_id, stand, status);
-- Partial unique index — атомарный lock на уровне БД.
-- Гарантирует что только ОДИН сценарий может быть RUNNING для client_id+stand.
-- Вторая параллельная вставка получит unique violation → 409 без гонок.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running
ON scenario_runs (client_id, stand) WHERE status = 'RUNNING';
-- Миграции для scenario_runs -- Миграции для scenario_runs
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER; ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER;
ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER; ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER;
+5 -2
View File
@@ -58,8 +58,11 @@ def _ensure_schema():
from db.init_db import init_db from db.init_db import init_db
init_db() init_db()
_initialized = True _initialized = True
except Exception: except Exception as e:
pass # без БД приложение работает (без истории) print(f"[DB] Schema init FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
# Приложение продолжает работу БЕЗ БД (без истории/сценариев)
def get_pool(): def get_pool():
+8 -6
View File
@@ -209,15 +209,17 @@ def delete_definition(def_id, client_id, stand):
def lock_check(client_id, stand): def lock_check(client_id, stand):
"""Проверить что нет активного RUNNING-запуска сценария. """Проверить что нет активного RUNNING-запуска сценария.
Используется перед запуском нового сценария — предотвращает Атомарность гарантируется partial unique index idx_one_running
одновременный запуск двух сценариев одним пользователем. на уровне БД (см. init_db.py). Этот метод — быстрая предпроверка
для красивого 409 до попытки INSERT.
Returns: Returns:
True — можно запускать (нет RUNNING) True — можно запускать (нет RUNNING в БД)
False — нельзя (уже есть RUNNING) → 409 Conflict""" False — нельзя (уже есть RUNNING) → 409 Conflict
None — БД недоступна, нельзя проверить → 503 DB unavailable"""
conn = get_conn() conn = get_conn()
if not conn: if not conn:
return True # без БД — разрешаем (fallback) return None # БД недоступна — не True и не False, вызывающий решит
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""
@@ -230,6 +232,6 @@ def lock_check(client_id, stand):
return row is None # None = нет RUNNING = можно запускать return row is None # None = нет RUNNING = можно запускать
except Exception as e: except Exception as e:
print(f"[DEFS] lock_check error: {e}", flush=True) print(f"[DEFS] lock_check error: {e}", flush=True)
return True return None # ошибка БД — не можем проверить
finally: finally:
put_conn(conn) put_conn(conn)
+1
View File
@@ -306,3 +306,4 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena
# Все шаги пройдены успешно # Все шаги пройдены успешно
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1)) _save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
+6 -8
View File
@@ -245,13 +245,11 @@ def send_params_terraform(client, op_uid, params):
# Шаг 7: validate-cfs — финальная проверка # Шаг 7: validate-cfs — финальная проверка
# Если параметры невалидны — API вернёт ошибку → исключение # Если параметры невалидны — API вернёт ошибку → исключение
# Если ответ пустой или не-JSON — это ОК (значит валидация прошла) # Если ответ 200 с пустым телом — это ОК (значит валидация прошла).
# HttpClient.get() для пустого тела возвращает {} без ошибок.
# Но если тело не-JSON — будет JSONDecodeError, это тоже ОК для validate-cfs.
try: try:
client.get(f"/instanceOperations/{op_uid}/validate-cfs") client.get(f"/instanceOperations/{op_uid}/validate-cfs")
except Exception as e: except json.JSONDecodeError:
# "Expecting value" / JSONDecodeError — это норма для validate-cfs pass # пустой/не-JSON ответ — норма для validate-cfs (успех)
# (API возвращает 200 с пустым телом при успехе) # HTTPError (4xx/5xx) пробрасывается выше — это реальная ошибка валидации
if "Expecting value" in str(e) or "JSON" in str(type(e).__name__):
pass
else:
raise # реальная ошибка — пробрасываем выше
+49 -17
View File
@@ -113,36 +113,68 @@ def _locked_write(path, data):
os.close(fd) os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name): def _atomic_update(path, mutator):
"""Добавить инстанс в трекер (вызывается из executor сразу после create). """Атомарно: открыть файл → заблокировать → прочитать → мутировать → записать.
Читает текущий файл → добавляет/обновляет запись → пишет обратно. Вся операция под ОДНИМ lock, в отличие от старого подхода
Если инстанс уже есть — перезаписывает (идемпотентность). _locked_read() + _locked_write() где между ними lock снимался.
Это предотвращает lost-update при конкурентных add/remove.
Args: Args:
client_id: str — ClientID из JWT path: str — путь к файлу
stand: str — "dev"/"test" mutator: callable(data) — функция, мутирующая dict (возвращать не нужно)
instance_uid: str — UUID созданного инстанса
svc_id: int — ID сервиса Returns:
display_name: str — displayName инстанса""" dict — результат после мутации (или {} если не смогли)"""
p = _path(client_id, stand) try:
data = _locked_read(p) fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return {}
try:
if not _acquire_lock(fd):
return {}
# Читаем
data = {}
try:
raw = os.read(fd, 65536)
if raw:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# Мутируем
mutator(data)
# Пишем
os.lseek(fd, 0, 0)
os.ftruncate(fd, 0)
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
return data
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name):
"""Добавить инстанс в трекер (атомарно, под одним lock).
Читает → добавляет/обновляет запись → пишет — всё под эксклюзивной блокировкой.
Если инстанс уже есть — перезаписывает (идемпотентность)."""
def _add(data):
data[instance_uid] = { data[instance_uid] = {
"svcId": svc_id, "svcId": svc_id,
"displayName": display_name, "displayName": display_name,
"instanceUid": instance_uid, "instanceUid": instance_uid,
} }
_locked_write(p, data) _atomic_update(_path(client_id, stand), _add)
def remove(client_id, stand, instance_uid): def remove(client_id, stand, instance_uid):
"""Удалить инстанс из трекера (после успешного delete). """Удалить инстанс из трекера (атомарно, под одним lock).
pop с default=None — не падает если инстанса уже нет (уже удалён ранее).""" pop с default=None — не падает если инстанса уже нет."""
p = _path(client_id, stand) def _rem(data):
data = _locked_read(p)
data.pop(instance_uid, None) data.pop(instance_uid, None)
_locked_write(p, data) _atomic_update(_path(client_id, stand), _rem)
def list_all(client_id, stand): def list_all(client_id, stand):
+4 -4
View File
@@ -2,7 +2,7 @@ import threading
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from api.http_client import create_client from api.auth import get_client
from runner import run_tests, get_status, load_config, save_config from runner import run_tests, get_status, load_config, save_config
bp = Blueprint("api", __name__) bp = Blueprint("api", __name__)
@@ -11,10 +11,10 @@ bp = Blueprint("api", __name__)
@bp.route("/api/run", methods=["POST"]) @bp.route("/api/run", methods=["POST"])
def api_run(): def api_run():
token = current_app.config["NUBES_API_TOKEN"] token = current_app.config["NUBES_API_TOKEN"]
result = create_client(token, current_app.config["NUBES_API_ENDPOINT"]) client = get_client()
if not result: endpoint = current_app.config["NUBES_API_ENDPOINT"]
if not client:
return jsonify({"error": "Не удалось определить стенд"}), 500 return jsonify({"error": "Не удалось определить стенд"}), 500
client, endpoint = result
t = threading.Thread( t = threading.Thread(
target=run_tests, target=run_tests,
args=(endpoint, token), args=(endpoint, token),
+4 -2
View File
@@ -55,8 +55,10 @@ def _validate_steps(steps):
# Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback) # Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback)
if op != "create": if op != "create":
has_target = bool(ref or uid or (not out)) # out в не-create — ошибка выше if not ref and not uid:
# ok: ref есть, uid есть, или старый формат без новых ключей (fallback) # Старый формат (без instance_ref/instance_uid) — разрешаем,
# будет найден по service_id через instance_map в scenario.py
pass
return None return None
+8 -1
View File
@@ -41,7 +41,10 @@ def api_scenario_run():
cid = get_client_id() cid = get_client_id()
stand = get_stand() stand = get_stand()
if not lock_check(cid, stand): can_run = lock_check(cid, stand)
if can_run is None:
return jsonify({"error": "DB unavailable"}), 503
if not can_run:
return jsonify({"error": "Another scenario is already running"}), 409 return jsonify({"error": "Another scenario is already running"}), 409
defn = get_definition(def_id, cid, stand) defn = get_definition(def_id, cid, stand)
@@ -74,6 +77,10 @@ def api_scenario_run():
conn.commit() conn.commit()
cur.close() cur.close()
except Exception as e: except Exception as e:
# Unique violation (23505) → другая параллельная вставка уже создала RUNNING
if hasattr(e, 'pgcode') and e.pgcode == '23505':
conn.rollback()
return jsonify({"error": "Another scenario is already running"}), 409
print(f"[API] create scenario_run error: {e}", flush=True) print(f"[API] create scenario_run error: {e}", flush=True)
conn.rollback() conn.rollback()
return jsonify({"error": "Failed to create scenario_run"}), 500 return jsonify({"error": "Failed to create scenario_run"}), 500
+17 -10
View File
@@ -24,13 +24,14 @@
""" """
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
import threading
import uuid import uuid
import os import os
import json import json
import fcntl import fcntl
from api.http_client import HttpClient, detect_endpoint, stand_name from api.http_client import HttpClient, detect_endpoint, stand_name
from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info from api.auth import get_token, get_client, get_real_client, get_client_id, get_stand, get_token_info
from api.utils import find_uid, uid_from_location from api.utils import find_uid, uid_from_location
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
from operations.get_instances import get_instances from operations.get_instances import get_instances
@@ -102,7 +103,7 @@ def _unique_display_name(client, requested_name):
@bp.route("/api/services") @bp.route("/api/services")
def api_services(): def api_services():
try: try:
raw = get_services(get_client()) raw = get_services(get_real_client())
svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw] svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw]
svc_list.sort(key=lambda s: s["svcId"]) svc_list.sort(key=lambda s: s["svcId"])
return jsonify(svc_list) return jsonify(svc_list)
@@ -261,23 +262,27 @@ def api_test():
# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts} # Результаты фоновых операций: opUid → {status, error, stages, duration, _ts}
# _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа) # _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа)
# ЗАЩИЩЕНО _op_results_lock — несколько потоков _finish_op + main thread api_test_status
_op_results = {} _op_results = {}
_op_results_lock = threading.Lock()
_MAX_OP_RESULTS = 500 _MAX_OP_RESULTS = 500
def _cleanup_op_results(): def _cleanup_op_results():
"""Удалить старые записи: старше 1 часа или сверх лимита.""" """Удалить старые записи: старше 1 часа или сверх лимита.
Потокобезопасно — под _op_results_lock."""
import time import time
now = time.time() now = time.time()
# Удалить старше 1 часа with _op_results_lock:
# Удалить старше 1 часа (pop с default — безопасно при конкурентном доступе)
stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600] stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600]
for k in stale: for k in stale:
del _op_results[k] _op_results.pop(k, None)
# Если всё ещё много — удалить самые старые # Если всё ещё много — удалить самые старые
if len(_op_results) > _MAX_OP_RESULTS: if len(_op_results) > _MAX_OP_RESULTS:
sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0)) sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0))
for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]: for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]:
del _op_results[k] _op_results.pop(k, None)
def _get_instance_display_name(client, instance_uid): def _get_instance_display_name(client, instance_uid):
@@ -291,7 +296,7 @@ def _get_instance_display_name(client, instance_uid):
def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""): def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""):
"""Фоном ждать dtFinish и сохранить результат.""" """Фоном ждать dtFinish и сохранить результат (потокобезопасно для _op_results)."""
import time import time
_cleanup_op_results() _cleanup_op_results()
t0 = time.time() t0 = time.time()
@@ -299,7 +304,8 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_
# Поллинг через общий модуль # Поллинг через общий модуль
poll_result = poll_until_done(client, op_uid) poll_result = poll_until_done(client, op_uid)
# Обновить _op_results для UI # Обновить _op_results для UI — под локом
with _op_results_lock:
_op_results[op_uid] = { _op_results[op_uid] = {
"status": poll_result["status"], "status": poll_result["status"],
"displayName": display_name, "displayName": display_name,
@@ -346,8 +352,9 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_
@bp.route("/api/test/status/<op_uid>") @bp.route("/api/test/status/<op_uid>")
def api_test_status(op_uid): def api_test_status(op_uid):
"""Получить текущий статус операции (поллинг с UI).""" """Получить текущий статус операции (поллинг с UI) — потокобезопасно."""
# сначала проверяем фоновый трекер # сначала проверяем фоновый трекер — под локом
with _op_results_lock:
if op_uid in _op_results: if op_uid in _op_results:
return jsonify(_op_results[op_uid]) return jsonify(_op_results[op_uid])
# иначе спрашиваем API напрямую # иначе спрашиваем API напрямую
+22 -20
View File
@@ -11,7 +11,7 @@ GET /api/operations/<svc_id> — операции и autotest-инста
from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect
from api.http_client import HttpClient, detect_endpoint, create_client, stand_name from api.http_client import HttpClient, detect_endpoint, create_client, stand_name
from api.auth import get_token, get_client_id, get_token_info, get_token_masked from api.auth import get_token, get_client_id, get_token_info, get_token_masked, get_client, get_real_client, get_stand
from operations.get_instances import get_organization, get_instances from operations.get_instances import get_organization, get_instances
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
from operations.service_list import load_service_ids from operations.service_list import load_service_ids
@@ -105,24 +105,26 @@ def index():
config["service_ids"] = [] config["service_ids"] = []
stand = "?" stand = "?"
if active_token: if active_token:
# create_client — автоопределение стенда + HttpClient # Сервисы — ВСЕГДА из реального API (метаданные)
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) real_client = get_real_client()
if result: # Инстансы — из polygon если POLYGON_ENDPOINT задан
client, endpoint = result inst_client = get_client()
stand = stand_name(endpoint) endpoint = current_app.config["NUBES_API_ENDPOINT"]
stand = get_stand()
if real_client:
try: try:
# Организация — инстанс с serviceId=19 # Организация — инстанс с serviceId=19 (из реального API)
org = get_organization(client) org = get_organization(real_client)
# Все сервисы — сортировка по svcId # Все сервисы — из реального API
raw_svc = get_services(client) raw_svc = get_services(real_client)
services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", "")))
# Инфраструктурные инстансы — только определённые serviceId # Инфраструктурные инстансы — из polygon или реального API
# 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group # 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group
# 2=Template, 12=S3, 110=?, 150=K8s # 2=Template, 12=S3, 110=?, 150=K8s
infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150} infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150}
raw_inst = get_instances(client) raw_inst = get_instances(inst_client)
instances = [i for i in raw_inst instances = [i for i in raw_inst
if i.get("explainedStatus") not in ("deleted", "not created") if i.get("explainedStatus") not in ("deleted", "not created")
and i.get("serviceId") in infra_ids] and i.get("serviceId") in infra_ids]
@@ -168,22 +170,22 @@ def api_operations(svc_id):
active_token = user_token or env_token active_token = user_token or env_token
try: try:
# Автоопределение стенда + HttpClient # Сервисы — ВСЕГДА из реального API (метаданные)
result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) real_client = get_real_client()
if not result: # Инстансы — из polygon если POLYGON_ENDPOINT задан
return jsonify({"error": "Не удалось определить стенд"}), 500 inst_client = get_client()
client, endpoint = result endpoint = current_app.config["NUBES_API_ENDPOINT"]
# Детали сервиса: список операций (modify, delete, suspend, ...) # Детали сервиса: список операций (modify, delete, suspend, ...)
detail = get_service_detail(client, svc_id) detail = get_service_detail(real_client, svc_id)
ops = detail.get("operations", []) ops = detail.get("operations", [])
# Трекер: наши autotest-инстансы (изолирован по пользователю и стенду) # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду)
tracked = tracker_list(get_client_id(), stand_name(endpoint)) tracked = tracker_list(get_client_id(), stand_name(endpoint))
tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id}
# Все инстансы из облака # Все инстансы из polygon или реального API
instances = get_instances(client) instances = get_instances(inst_client)
nubes_uids = {i["instanceUid"] for i in instances} nubes_uids = {i["instanceUid"] for i in instances}
svc_instances = [] svc_instances = []
+5
View File
@@ -104,6 +104,11 @@ def run_tests(endpoint, token):
endpoint: str — URL API endpoint: str — URL API
token: str — JWT-токен""" token: str — JWT-токен"""
global _current_run global _current_run
# POLYGON_ENDPOINT проверяется в get_client(), здесь — прямой вызов для CLI
import os
polygon = os.getenv("POLYGON_ENDPOINT", "")
if polygon:
endpoint = polygon
client = HttpClient(endpoint, token) client = HttpClient(endpoint, token)
config = load_config() config = load_config()
+9 -1
View File
@@ -208,6 +208,9 @@ async function loadStepParams(idx) {
const step = st.steps[idx]; const step = st.steps[idx];
if (!step.operation) return; if (!step.operation) return;
// Запомнить поколение рендера на момент старта async-загрузки
const renderGen = st._renderGen || 0;
const svcOpId = await resolveStepSvcOpId(idx); const svcOpId = await resolveStepSvcOpId(idx);
if (!svcOpId) return; if (!svcOpId) return;
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
@@ -233,6 +236,8 @@ async function loadStepParams(idx) {
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue // Рендер: клонируем def и подставляем сохранённые значения в defaultValue
const container = document.getElementById('step-' + idx + '-params'); const container = document.getElementById('step-' + idx + '-params');
if (!container) return; if (!container) return;
// Проверка: не перезаписывать DOM если уже был новый renderEditor()
if (st._renderGen !== renderGen) return;
let html = ''; let html = '';
defs.forEach(p => { defs.forEach(p => {
const clone = Object.assign({}, p); // shallow copy const clone = Object.assign({}, p); // shallow copy
@@ -259,7 +264,10 @@ async function loadStepParams(idx) {
function renderEditor() { function renderEditor() {
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
const st = scenarioEditorState; const st = scenarioEditorState;
// Инкремент поколения — защита от stale async (loadStepParams может вернуться
// уже после следующего renderEditor и перезаписать новый DOM старыми данными)
st._renderGen = (st._renderGen || 0) + 1;
const currentGen = st._renderGen;
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">'; let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
// Заголовок // Заголовок
+28 -5
View File
@@ -116,16 +116,21 @@ async function toggleScenarioSteps(defId) {
if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`; if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`;
if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`; if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`;
const params = Object.entries(s.params || {}); const params = Object.entries(s.params || {});
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>'; if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => _esc(k)+'='+_esc(v)).join(', ') + ')</span>';
html += '</div>'; html += '</div>';
}); });
// Кнопки действий // Кнопки действий
// JS-escape для имени сценария в inline onclick:
// \ → \\ (backslash)
// ' → \' (terminate JS string literal)
// HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся.
const escName = def.name.replace(/&/g,'&amp;').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;');
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${escName}')">📋 Копировать</button>`;
// Удалить — только для НЕ-seed сценариев // Удалить — только для НЕ-seed сценариев
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`; if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${escName}')">🗑 Удалить</button>`;
html += '</div>'; html += '</div>';
el.innerHTML = html; el.innerHTML = html;
@@ -182,10 +187,21 @@ async function runScenario(defId){
} }
// Поллинг статуса // Поллинг статуса
// Поллинг статуса с защитой от бесконечного зависания
let _scenarioPollErrors = 0;
scenarioPollTimer=setInterval(async()=>{ scenarioPollTimer=setInterval(async()=>{
try{ try{
const sr=await fetch('/api/scenario/run/'+runId); const sr=await fetch('/api/scenario/run/'+runId);
if(!sr.ok){ return; } if(!sr.ok){
_scenarioPollErrors++;
if(_scenarioPollErrors>=5){
stopScenarioPoll();
busy=false;
body.innerHTML='<span style="color:var(--destructive);">Ошибка: сервер недоступен</span>';
}
return;
}
_scenarioPollErrors = 0; // сброс при успехе
const last=await sr.json(); const last=await sr.json();
if(!last||last.error) return; if(!last||last.error) return;
@@ -203,7 +219,14 @@ async function runScenario(defId){
await refreshInstances(); // обновить список инстансов await refreshInstances(); // обновить список инстансов
if(historyOpen) await loadHistory(); // обновить историю if(historyOpen) await loadHistory(); // обновить историю
} }
}catch(e){} }catch(e){
_scenarioPollErrors++;
if(_scenarioPollErrors>=5){
stopScenarioPoll();
busy=false;
body.innerHTML=`<span style="color:var(--destructive);">Ошибка поллинга: ${_esc(e.message||'')}</span>`;
}
}
},3000); },3000);
}catch(e){ }catch(e){
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`; body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`;
+10
View File
@@ -0,0 +1,10 @@
Pytest suite for critical regressions from audit rounds.
Covers:
- `/api/scenario/run` status mapping: `lock_check=None -> 503`, `False -> 409`.
- Unique violation on `scenario_runs` insert maps to `409`.
- `db.scenario_defs.lock_check` DB-unavailable semantics (`None`).
- Static guards for DB partial unique index and `escName` escape chain.
This suite is intentionally lightweight and mock-heavy.
No integration DB or external API calls are required.
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
import subprocess
import sys
import time
import pytest
import requests
# Make `site/` importable as top-level modules: app, routes, db, operations, ...
SITE_DIR = Path(__file__).resolve().parents[1] / "site"
if str(SITE_DIR) not in sys.path:
sys.path.insert(0, str(SITE_DIR))
# Путь к polygon: соседняя репа в корне autotest
POLYGON_DIR = Path(__file__).resolve().parents[2] / "polygon" / "site"
POLYGON_URL = "http://localhost:5000"
@pytest.fixture
def app_client():
"""Flask test client для app-autotest."""
from app import app
app.config.update(TESTING=True)
with app.test_client() as client:
yield client
@pytest.fixture(scope="session")
def polygon_server():
"""Запустить polygon как subprocess на весь pytest-session.
Используется для интеграционных тестов app-autotest ↔ polygon.
После всех тестов процесс убивается.
"""
if not POLYGON_DIR.is_dir():
pytest.skip("polygon repo not found")
proc = subprocess.Popen(
["python3", "app.py"],
cwd=str(POLYGON_DIR),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Ждём готовности (poll /health, timeout 10s)
deadline = time.time() + 10
while time.time() < deadline:
try:
r = requests.get(f"{POLYGON_URL}/health", timeout=2)
if r.status_code == 200:
break
except requests.ConnectionError:
time.sleep(0.5)
else:
proc.terminate()
proc.wait()
pytest.fail("polygon did not start within 10s")
yield POLYGON_URL
proc.terminate()
proc.wait()
@pytest.fixture(autouse=True)
def reset_polygon(polygon_server):
"""Перед каждым интеграционным тестом — сброс состояния polygon."""
requests.post(f"{polygon_server}/api/v1/svc/_mock/reset", timeout=5)
+88
View File
@@ -0,0 +1,88 @@
import routes.api_scenario_run as api_run
class DummyUniqueViolation(Exception):
pgcode = "23505"
class DummyCursor:
def __init__(self, fail_on_execute=None, fetchone_value=None):
self._fail_on_execute = fail_on_execute
self._fetchone_value = fetchone_value
def execute(self, *args, **kwargs):
if self._fail_on_execute is not None:
raise self._fail_on_execute
def fetchone(self):
return self._fetchone_value
def close(self):
return None
class DummyConn:
def __init__(self, fail_on_execute=None, fetchone_value=None):
self._fail_on_execute = fail_on_execute
self._fetchone_value = fetchone_value
self.committed = False
self.rolled_back = False
def cursor(self):
return DummyCursor(self._fail_on_execute, self._fetchone_value)
def commit(self):
self.committed = True
def rollback(self):
self.rolled_back = True
def _patch_common_happy(monkeypatch):
monkeypatch.setattr(api_run, "get_client_id", lambda: "cid")
monkeypatch.setattr(api_run, "get_stand", lambda: "test")
monkeypatch.setattr(api_run, "get_definition", lambda _id, _cid, _stand: {
"id": _id,
"name": "scenario-a",
"version": 7,
"steps": [{"service_id": 1, "operation": "create", "params": {}}],
})
monkeypatch.setattr(api_run, "get_token_info", lambda: {"email": "u@example.com"})
def test_run_returns_503_when_lock_check_is_none(app_client, monkeypatch):
_patch_common_happy(monkeypatch)
monkeypatch.setattr(api_run, "lock_check", lambda _cid, _stand: None)
resp = app_client.post("/api/scenario/run", json={"definition_id": 1})
assert resp.status_code == 503
assert resp.get_json()["error"] == "DB unavailable"
def test_run_returns_409_when_lock_check_is_false(app_client, monkeypatch):
_patch_common_happy(monkeypatch)
monkeypatch.setattr(api_run, "lock_check", lambda _cid, _stand: False)
resp = app_client.post("/api/scenario/run", json={"definition_id": 1})
assert resp.status_code == 409
assert "already running" in resp.get_json()["error"].lower()
def test_run_returns_409_on_unique_violation(app_client, monkeypatch):
_patch_common_happy(monkeypatch)
monkeypatch.setattr(api_run, "lock_check", lambda _cid, _stand: True)
conn = DummyConn(fail_on_execute=DummyUniqueViolation())
monkeypatch.setattr(api_run, "get_conn", lambda: conn)
monkeypatch.setattr(api_run, "put_conn", lambda _conn: None)
# Must not start a background thread in this branch.
monkeypatch.setattr(api_run, "get_client", lambda: object())
resp = app_client.post("/api/scenario/run", json={"definition_id": 1})
assert resp.status_code == 409
assert "already running" in resp.get_json()["error"].lower()
assert conn.rolled_back is True
+32
View File
@@ -0,0 +1,32 @@
import db.scenario_defs as defs
def test_lock_check_returns_none_when_db_connection_missing(monkeypatch):
monkeypatch.setattr(defs, "get_conn", lambda: None)
result = defs.lock_check("cid", "test")
assert result is None
def test_lock_check_returns_none_on_db_exception(monkeypatch):
class BrokenCursor:
def execute(self, *args, **kwargs):
raise RuntimeError("db broken")
def close(self):
return None
class BrokenConn:
def cursor(self):
return BrokenCursor()
put_calls = {"n": 0}
monkeypatch.setattr(defs, "get_conn", lambda: BrokenConn())
monkeypatch.setattr(defs, "put_conn", lambda _c: put_calls.__setitem__("n", put_calls["n"] + 1))
result = defs.lock_check("cid", "test")
assert result is None
assert put_calls["n"] == 1
+227
View File
@@ -0,0 +1,227 @@
"""
Интеграционные тесты app-autotest ↔ polygon.
Каждый тест требует запущенного polygon (фикстура polygon_server).
Перед каждым тестом состояние polygon сбрасывается (autouse reset_polygon).
Все запросы — через requests (HTTP), имитируя работу HttpClient из app-autotest.
"""
import pytest
import requests
API = "/api/v1/svc"
def _create_instance(url, service_id=1, display_name="test"):
"""POST /instances → извлечь instanceUid из Location."""
r = requests.post(
f"{url}{API}/instances",
json={"serviceId": service_id, "displayName": display_name},
allow_redirects=False,
timeout=10,
)
assert r.status_code == 201
location = r.headers.get("Location", "")
uid = location.rsplit("/", 1)[-1]
assert len(uid) >= 32 # UUID без дефисов
return uid
def _create_operation(url, instance_uid, operation, svc_op_id=None):
"""POST /instanceOperations → извлечь opUid из Location."""
body = {"instanceUid": instance_uid, "operation": operation}
if svc_op_id is not None:
body["svcOperationId"] = svc_op_id
r = requests.post(
f"{url}{API}/instanceOperations",
json=body,
allow_redirects=False,
timeout=10,
)
assert r.status_code == 201
location = r.headers.get("Location", "")
uid = location.rsplit("/", 1)[-1]
assert len(uid) >= 32
return uid
def _run(url, op_uid):
"""POST /run → дождаться dtFinish."""
r = requests.post(f"{url}{API}/instanceOperations/{op_uid}/run", timeout=10)
assert r.status_code == 200
# Проверить что операция завершена
r2 = requests.get(f"{url}{API}/instanceOperations/{op_uid}", timeout=10)
op = r2.json()["instanceOperation"]
assert op["dtFinish"] is not None
assert op["isSuccessful"] is True
class TestServices:
"""Тесты эндпоинтов /services."""
def test_list_services(self, polygon_server):
"""GET /services возвращает все сервисы."""
r = requests.get(f"{polygon_server}{API}/services", timeout=10)
assert r.status_code == 200
data = r.json()
assert len(data["results"]) >= 30 # минимум 30 сервисов
def test_service_detail(self, polygon_server):
"""GET /services/1 возвращает операции Болванки."""
r = requests.get(f"{polygon_server}{API}/services/1", timeout=10)
assert r.status_code == 200
ops = r.json()["svc"]["operations"]
assert any(op["operation"] == "create" for op in ops)
def test_service_404(self, polygon_server):
"""GET /services/99999 → 404."""
r = requests.get(f"{polygon_server}{API}/services/99999", timeout=10)
assert r.status_code == 404
class TestInstanceCreate:
"""Тесты создания инстанса."""
def test_create_dummy(self, polygon_server):
"""POST /instances → 201 + Location → GET → status=creating."""
uid = _create_instance(polygon_server)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.status_code == 200
inst = r.json()["instance"]
assert inst["status"] == "creating"
assert inst["serviceId"] == 1
def test_create_unknown_service(self, polygon_server):
"""POST /instances с несуществующим serviceId → 404."""
r = requests.post(
f"{polygon_server}{API}/instances",
json={"serviceId": 99999, "displayName": "bad"},
timeout=10,
)
assert r.status_code == 404
class TestFullCreateFlow:
"""Полный цикл create → run → проверить params."""
def test_create_and_run(self, polygon_server):
"""create → run → статус running + params из шаблона."""
uid = _create_instance(polygon_server)
op_uid = _create_operation(polygon_server, uid, "create")
_run(polygon_server, op_uid)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
inst = r.json()["instance"]
assert inst["status"] == "running"
assert "resourceRealm" in inst["state"]["params"]
assert inst["state"]["params"]["resourceRealm"] == "dummy"
def test_create_postgres_state_out(self, polygon_server):
"""create PG → state.out содержит users, databases."""
uid = _create_instance(polygon_server, service_id=90, display_name="pg")
op_uid = _create_operation(polygon_server, uid, "create")
_run(polygon_server, op_uid)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
out = r.json()["instance"]["state"]["out"]
assert "users" in out
assert "databases" in out
def test_run_twice_409(self, polygon_server):
"""Повторный POST /run → 409."""
uid = _create_instance(polygon_server)
op_uid = _create_operation(polygon_server, uid, "create")
_run(polygon_server, op_uid)
r = requests.post(
f"{polygon_server}{API}/instanceOperations/{op_uid}/run", timeout=10
)
assert r.status_code == 409
class TestOperations:
"""Тесты операций: modify, suspend, resume, delete."""
def test_modify_merges_params(self, polygon_server):
"""modify → новый paramValue в state.params."""
uid = _create_instance(polygon_server)
_run(polygon_server, _create_operation(polygon_server, uid, "create"))
# Установить новое значение durationMs (modify-версия: paramId=287, create-версия: 198)
op_uid = _create_operation(polygon_server, uid, "modify")
requests.post(
f"{polygon_server}{API}/instanceOperationCfsParams",
json={"instanceOperationUid": op_uid, "svcOperationCfsParamId": 287, "paramValue": "999"},
timeout=10,
)
_run(polygon_server, op_uid)
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.json()["instance"]["state"]["params"]["durationMs"] == "999"
def test_suspend_resume(self, polygon_server):
"""suspend → status=suspended, resume → status=running."""
uid = _create_instance(polygon_server)
_run(polygon_server, _create_operation(polygon_server, uid, "create"))
_run(polygon_server, _create_operation(polygon_server, uid, "suspend"))
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.json()["instance"]["status"] == "suspended"
_run(polygon_server, _create_operation(polygon_server, uid, "resume"))
r = requests.get(f"{polygon_server}{API}/instances/{uid}", timeout=10)
assert r.json()["instance"]["status"] == "running"
def test_delete_removes_instance(self, polygon_server):
"""delete → инстанс удалён из списка."""
uid = _create_instance(polygon_server)
_run(polygon_server, _create_operation(polygon_server, uid, "create"))
_run(polygon_server, _create_operation(polygon_server, uid, "delete"))
r = requests.get(f"{polygon_server}{API}/instances", timeout=10)
uids = [i["instanceUid"] for i in r.json()["results"]]
assert uid not in uids
class TestPagination:
"""Тесты пагинации."""
def test_pagination(self, polygon_server):
"""3 инстанса, pageSize=2 → 2 страницы."""
for i in range(3):
_create_instance(polygon_server, display_name=f"p{i}")
r1 = requests.get(
f"{polygon_server}{API}/instances", params={"pageSize": 2, "page": 1}, timeout=10
)
assert len(r1.json()["results"]) == 2
r2 = requests.get(
f"{polygon_server}{API}/instances", params={"pageSize": 2, "page": 2}, timeout=10
)
assert len(r2.json()["results"]) == 1 # последний
class TestMockEndpoints:
"""Тесты /_mock/* эндпоинтов."""
def test_mock_state(self, polygon_server):
"""GET /_mock/state — отладочный дамп."""
_create_instance(polygon_server)
r = requests.get(f"{polygon_server}{API}/_mock/state", timeout=10)
assert r.status_code == 200
assert len(r.json()["instances"]) == 1
def test_mock_services(self, polygon_server):
"""GET /_mock/services — список конфигов."""
r = requests.get(f"{polygon_server}{API}/_mock/services", timeout=10)
assert r.status_code == 200
assert r.json()["count"] >= 30
def test_mock_delay(self, polygon_server):
"""POST /_mock/delay — изменение задержки."""
r = requests.post(f"{polygon_server}{API}/_mock/delay/2", timeout=10)
assert r.json()["delay"] == 2.0
# Сбросить
requests.post(f"{polygon_server}{API}/_mock/delay/0.1", timeout=10)
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
def test_partial_unique_index_for_one_running_exists():
init_db_path = Path(__file__).resolve().parents[1] / "site" / "db" / "init_db.py"
content = init_db_path.read_text(encoding="utf-8")
assert "CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running" in content
assert "ON scenario_runs (client_id, stand) WHERE status = 'RUNNING'" in content
def test_scenario_name_escape_chain_includes_amp_backslash_quote_doublequote():
js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "scenario-list.js"
content = js_path.read_text(encoding="utf-8")
expected = (
"const escName = def.name"
".replace(/&/g,'&amp;')"
".replace(/\\\\/g,'\\\\\\\\')"
".replace(/'/g,\"\\\\'\")"
".replace(/\"/g,'&quot;');"
)
assert expected in content