fix: harden Polygon operation validation

This commit is contained in:
Repinoid
2026-10-25 14:07:13 +03:00
parent 3df89ec9e7
commit e3a031ce50
18 changed files with 803 additions and 346 deletions
+23 -10
View File
@@ -1,4 +1,4 @@
# polygon v0.2.2
# polygon v0.5.17
Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest.
@@ -11,17 +11,19 @@
polygon/
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
├── tests/
│ ├── test_converter.py # 10 юнит-тестов from_stands.py
│ └── test_state_machine.py # 9 юнит-тестов state_machine.py
│ ├── test_converter.py # тесты from_stands.py
│ ├── test_state_machine.py # тесты state_machine.py
│ └── test_route_regressions.py # локальные регрессионные тесты API
└── site/
├── app.py # Flask: только blueprint'ы + app.run() (57 строк)
├── routes/ # 6 blueprint-файлов по доменам
├── routes/ # blueprint-файлы по доменам
│ ├── root.py # /health, /
│ ├── services_routes.py# /api/v1/svc/services
│ ├── instances_routes.py# /api/v1/svc/instances
│ ├── operations_routes.py# /api/v1/svc/instanceOperations/*
│ ├── run.py # /api/v1/svc/instanceOperations/<uid>/run
│ └── mock_routes.py # /api/v1/svc/_mock/*
│ ├── mock_routes.py # /api/v1/svc/_mock/*
│ └── openapi.py # /api/v1/svc/openapi.json
├── mock_state.py # MockState: instances, operations, op_params
├── state_machine.py # apply_effect() — мутация состояния
├── config/
@@ -34,7 +36,7 @@ polygon/
│ └── style.css # тёмная тема
├── templates/
│ └── index.html # Jinja2-шаблон
└── services/ # YAML-конфиги (сгенерированы from_stands.py)
└── services/ # fallback YAML, также генерируются from_stands.py
```
⛔ **Ровно 1 gunicorn-воркер.** Состояние в памяти, не shared.
@@ -46,7 +48,7 @@ polygon/
cd site && python app.py # порт 5000
# Тесты
pytest tests/ -v # 19 тестов
pytest tests/ -v # локальные тесты Polygon
```
## Откуда берутся сервисы (YAML)
@@ -70,6 +72,8 @@ polygon/site/services/{dev,test,prod}/*.yaml ← скоммичены в git-
Память Flask-процесса (конфиги загружаются один раз при старте)
```
Если `POLYGON_STANDS` не задан, loader использует один стенд `default` из `site/services/`. Значения относительных путей в `POLYGON_STANDS` разрешаются относительно `site/`.
### Почему нельзя читать терраформ-формат напрямую?
Терраформ-формат хранит параметры **внутри** операций, с дубликатами.
@@ -102,6 +106,7 @@ git push
```
После успешной конвертации генератор удаляет из целевого каталога YAML, которых больше нет в источнике. Если конвертация завершилась ошибками, устаревшие файлы не удаляются.
Если `SERVICES_DIR` не передан, генератор пишет в `site/services/` независимо от CWD. Явно переданные относительные `STANDS_DIR` и `SERVICES_DIR` остаются относительно CWD.
### Как проверить что всё загрузилось?
@@ -109,18 +114,20 @@ git push
curl https://polygon.pythonk8s.dev.nubes.ru/dev/api/v1/svc/services | python3 -m json.tool | head -20
```
## API (17 эндпоинтов)
## API
| Метод | Путь | Назначение |
|-------|------|------------|
| GET | `/health` | Healthcheck |
| GET | `/` | HTML с версией |
| GET | `/swagger` | Swagger UI |
| GET | `/api/v1/svc/openapi.json` | OpenAPI JSON |
| GET | `/api/v1/svc/services` | Список сервисов |
| GET | `/api/v1/svc/services/<id>` | Операции сервиса |
| GET | `/api/v1/svc/instances` | Пагинация |
| GET | `/api/v1/svc/instances/<uid>` | Инстанс + state |
| POST | `/api/v1/svc/instances` | Создать → 201 + Location |
| GET | `/api/v1/svc/instanceOperations/default/<id>` | cfsParams |
| GET | `/api/v1/svc/instanceOperations/default/<id>` | Шаблон операции и cfsParams |
| POST | `/api/v1/svc/instanceOperations` | Создать операцию → 201 + Location |
| GET | `/api/v1/svc/instanceOperations/<uid>` | Статус + cfsParams |
| POST | `/api/v1/svc/instanceOperationCfsParams` | param → value |
@@ -130,13 +137,19 @@ curl https://polygon.pythonk8s.dev.nubes.ru/dev/api/v1/svc/services | python3 -m
| GET | `/api/v1/svc/_mock/state` | Отладка: состояние |
| GET | `/api/v1/svc/_mock/services` | Отладка: сервисы |
| POST | `/api/v1/svc/_mock/delay/<s>` | MOCK_OP_DELAY |
| POST | `/api/v1/svc/_mock/fail-next` | Следующий run завершится ошибкой |
Основные API-маршруты не требуют авторизации. Если задан `MOCK_AUTH_TOKEN`, запросы к `/_mock/*` должны передавать заголовок `X-Mock-Auth`.
## Переменные окружения
| Переменная | По умолчанию | Описание |
|------------|-------------|----------|
| `POLYGON_STANDS` | `default:services` | Список стендов `name:path,name:path`; относительные пути считаются от `site/` |
| `MOCK_OP_DELAY` | `0.1` | Задержка операции в секундах |
| `WEB_CONCURRENCY` | `1` | ⛔ Не менять — ровно 1 воркер |
| `MOCK_AUTH_TOKEN` | пусто | Опциональный токен для `/_mock/*`; при пустом значении локальные mock endpoints открыты |
| `POLYGON_ENDPOINT` | `https://polygon.pythonk8s.dev.nubes.ru` | Базовый URL для серверов в OpenAPI |
| `WEB_CONCURRENCY` | `1` | Использовать один gunicorn-воркер, поскольку состояние хранится в памяти |
## Деплой
+1 -1
View File
@@ -88,7 +88,7 @@ SERVICES = _DF["SERVICES"]
OPS_INDEX = _DF["OPS_INDEX"]
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
VERSION = "0.5.16"
VERSION = "0.5.17"
def get_services(stand_id):
+3 -1
View File
@@ -285,7 +285,9 @@ def main():
sys.exit(1)
stands_dir = sys.argv[1]
services_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(__file__) or ".", "services")
services_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
os.path.dirname(os.path.abspath(__file__)), "services"
)
if not os.path.isdir(stands_dir):
print(f"ERROR: STANDS_DIR not found: {stands_dir}")
+10 -1
View File
@@ -53,12 +53,21 @@ class MockState:
"""Получить инстанс по uid или None."""
return self.instances.get(uid)
def delete_instance(self, uid):
"""Удалить инстанс, оставив операции доступными для polling результата.
Nubes-клиент получает результат операции delete после того, как сам
ресурс уже исчез. Поэтому удаляем только запись инстанса; операции и
их параметры живут до общего reset состояния.
"""
return self.instances.pop(uid, None)
def list_instances(self, page_size=200, page=1):
"""Список инстансов с пагинацией.
page_size capped at 200. Стоп по len(batch) < page_size.
"""
page_size = min(page_size, 200)
page_size = min(max(page_size, 1), 200)
page = max(page, 1)
all_uids = list(self.instances.keys())
+3
View File
@@ -80,6 +80,9 @@ def create_instance():
service_id = body.get("serviceId")
display_name = body.get("displayName", "unnamed")
if not isinstance(display_name, str):
return jsonify({"error": "displayName must be a string"}), 400
# Валидация: serviceId обязателен
if service_id is None:
return jsonify({"error": "serviceId is required"}), 400
+6 -5
View File
@@ -9,6 +9,7 @@ Blueprint "mock" с префиксом /api/v1/svc/_mock.
"""
import os
import math
from flask import Blueprint, g, jsonify, request, abort
from werkzeug.local import LocalProxy
@@ -21,9 +22,7 @@ SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
bp = Blueprint("mock", __name__, url_prefix="/api/v1/svc/_mock")
# Токен для защиты _mock/* эндпоинтов (из env, опционально).
# Дублирует глобальную проверку в app.py, но оставлен для обратной совместимости
# и ясности — _mock/* исторически имели отдельную защиту.
# Токен для защиты _mock/* эндпоинтов (опционален для локального dev-режима).
_MOCK_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "")
@@ -35,8 +34,8 @@ def _check_mock_auth():
Если токен не задан в env — пропускаем всех (dev-режим).
Если задан — требуем совпадения с заголовком.
Примечание: app.before_request уже проверяет Authorization: Bearer;
этот хук — дополнительная защита для _mock/* эндпоинтов."""
Основные API-маршруты не используют эту проверку. Если токен не настроен,
локальные тесты могут вызывать служебные endpoints без заголовка."""
if _MOCK_TOKEN and request.headers.get("X-Mock-Auth") != _MOCK_TOKEN:
abort(403)
@@ -130,6 +129,8 @@ def mock_set_delay(seconds):
val = float(seconds)
except (ValueError, TypeError):
return jsonify({"error": f"invalid delay: {seconds}"}), 400
if not math.isfinite(val):
return jsonify({"error": f"delay must be a finite number: {seconds}"}), 400
if val < 0:
return jsonify({"error": "delay must be >= 0"}), 400
if val > _MAX_DELAY:
+23 -26
View File
@@ -49,11 +49,16 @@ def _build_spec():
"""Собрать OpenAPI 3.1.0 спецификацию.
Включает:
- Все 17 эндпоинтов (REST API + _mock)
- Bearer-авторизацию
- REST API и служебные _mock endpoints
- Условную авторизацию _mock/* через X-Mock-Auth
- Схемы: Service, Instance, Operation, CfsParam, Stage
- Примеры запросов и ответов
"""
stand_descriptions = "\n".join(
f"- **{stand_id}** — {len(stand['SERVICES'])} сервисов"
for stand_id, stand in _cfg.STANDS.items()
)
return {
"openapi": "3.1.0",
"info": {
@@ -65,10 +70,8 @@ def _build_spec():
"операции, параметры, валидацию — но работает **без реальной инфраструктуры**, "
"в памяти, с мгновенным откликом.\n\n"
"### Стенды\n"
"**3 изолированных стенда** — переключайте в выпадающем списке серверов выше:\n"
"- **dev** — 37 сервисов (разработка)\n"
"- **test** — 37 сервисов (тестирование)\n"
"- **prod** — 35 сервисов (продуктив)\n"
f"**{len(_cfg.STANDS)} изолированных стенда** — переключайте в выпадающем списке серверов выше:\n"
f"{stand_descriptions}\n"
"У каждого стенда **свои инстансы, свои операции, своё состояние**.\n\n"
"### Особенности\n"
"- Все операции синхронные (настраиваемая задержка через `/_mock/delay`)\n"
@@ -78,8 +81,8 @@ def _build_spec():
"- Симуляция ошибок через `/_mock/fail-next`\n\n"
"### Авторизация\n"
"- Основные эндпоинты — без авторизации\n"
"- `_mock/*` — заголовок `X-Mock-Auth: test-token-123` "
"(настроен через `MOCK_AUTH_TOKEN`)"
"- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере "
"задан `MOCK_AUTH_TOKEN`"
),
"contact": {
"name": "Nubes Platform",
@@ -101,7 +104,7 @@ def _build_spec():
"type": "apiKey",
"in": "header",
"name": "X-Mock-Auth",
"description": "Токен для _mock/* эндпоинтов (по умолчанию: test-token-123)",
"description": "Нужен для _mock/* только если MOCK_AUTH_TOKEN задан на сервере.",
},
},
"schemas": {
@@ -170,7 +173,7 @@ def _build_spec():
"serviceId": {"type": "integer"},
"displayName": {"type": "string"},
"descr": {"type": "string"},
"status": {"type": "string", "description": "creating | running | modifying | deleting | suspended"},
"status": {"type": "string", "description": "creating | running | suspended"},
"explainedStatus": {"type": "string"},
"svc": {"type": "string"},
"dtCreate": {"type": "string", "format": "date-time"},
@@ -209,20 +212,14 @@ def _build_spec():
"svcOperationCfsParamId": {"type": "integer", "description": "ID параметра"},
"svcOperationCfsParam": {"type": "string", "description": "Название параметра"},
"dataType": {"type": "string", "description": "string | integer | map | map-fixed | boolean"},
"dataDescriptor": {"type": "string", "description": "JSON-описание подполей (для map-типов)"},
"dataDescriptor": {"type": "object", "description": "Описание подполей (для map-типов)"},
"isRequired": {"type": "boolean"},
"isModifiable": {"type": "boolean"},
"defaultValue": {"type": "string"},
"paramValue": {"type": "string", "description": "Текущее значение (если задано)"},
"valueList": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": {"type": "string"},
"label": {"type": "string"},
},
},
"items": {"type": "string"},
},
},
},
@@ -249,7 +246,7 @@ def _build_spec():
"instanceUid": {"type": "string", "format": "uuid"},
"svcOperationId": {"type": "integer"},
"operation": {"type": "string"},
"kind": {"type": "string", "description": "instance | access"},
"kind": {"type": "string", "description": "instance | subresource"},
"action": {"type": "string", "description": "create | modify | delete | …"},
"dtStart": {"type": ["string", "null"], "format": "date-time"},
"dtFinish": {"type": ["string", "null"], "format": "date-time"},
@@ -406,8 +403,8 @@ def _build_paths():
{
"name": "pageSize",
"in": "query",
"schema": {"type": "integer", "default": 200},
"description": "Размер страницы (значения > 200 обрезаются до 200)",
"schema": {"type": "integer", "default": 200, "minimum": 1, "maximum": 200},
"description": "Размер страницы (значения ограничиваются диапазоном 1…200)",
},
{
"name": "page",
@@ -720,7 +717,7 @@ def _build_paths():
"summary": "Сброс состояния",
"description": "Удаляет **все** инстансы, операции и параметры. Используется в тестах для изоляции.",
"operationId": "mockReset",
"security": [{"mockAuth": []}],
"security": [{"mockAuth": []}, {}],
"responses": {
"200": {
"description": "Состояние сброшено",
@@ -735,7 +732,7 @@ def _build_paths():
"summary": "Дамп состояния",
"description": "Возвращает все инстансы и операции для отладки.",
"operationId": "mockState",
"security": [{"mockAuth": []}],
"security": [{"mockAuth": []}, {}],
"responses": {
"200": {
"description": "Текущее состояние",
@@ -750,7 +747,7 @@ def _build_paths():
"summary": "Список загруженных сервисов",
"description": "Возвращает все загруженные сервисы с количеством операций и параметров.",
"operationId": "mockServices",
"security": [{"mockAuth": []}],
"security": [{"mockAuth": []}, {}],
"responses": {
"200": {
"description": "Список сервисов",
@@ -768,7 +765,7 @@ def _build_paths():
"Минимум: 0, максимум: 5 (защита /health)."
),
"operationId": "mockSetDelay",
"security": [{"mockAuth": []}],
"security": [{"mockAuth": []}, {}],
"parameters": [
{
"name": "seconds",
@@ -799,7 +796,7 @@ def _build_paths():
"Позволяет тестировать негативные сценарии."
),
"operationId": "mockFailNext",
"security": [{"mockAuth": []}],
"security": [{"mockAuth": []}, {}],
"responses": {
"200": {
"description": "Флаг установлен",
+43 -22
View File
@@ -130,44 +130,48 @@ def create_operation():
Также заполняет kind и action операции из конфига сервиса — они нужны
state_machine.apply_effect() чтобы понять что делать при run.
"""
body = request.get_json(silent=True) or {}
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "request body must be a JSON object"}), 400
instance_uid = body.get("instanceUid")
operation = body.get("operation", "")
svc_op_id = body.get("svcOperationId")
# Валидация: instanceUid обязателен
if not instance_uid:
if not isinstance(instance_uid, str) or not instance_uid:
return jsonify({"error": "instanceUid is required"}), 400
if not isinstance(operation, str) or not operation:
return jsonify({"error": "operation must be a non-empty string"}), 400
# Проверка что инстанс существует
inst = state.get_instance(instance_uid)
if not inst:
return jsonify({"error": "instance not found"}), 404
# Если svcOperationId не передан — найти по имени операции
# (например app-autotest при create не знает ID, передаёт только "create")
svc_id = inst.get("serviceId")
svc_def = SERVICES.get(svc_id, {})
service_operations = svc_def.get("operations", [])
# При create app-autotest может передать только имя операции.
if svc_op_id is None:
svc_id = inst.get("serviceId")
svc_def = SERVICES.get(svc_id, {})
for op in svc_def.get("operations", []):
for op in service_operations:
if op["operation"] == operation:
svc_op_id = op["svcOperationId"]
break
elif not isinstance(svc_op_id, int) or isinstance(svc_op_id, bool):
return jsonify({"error": "svcOperationId must be an integer"}), 400
# Если и после поиска не нашли — операция не поддерживается сервисом
if svc_op_id is None:
# ID должен принадлежать именно сервису инстанса, не только глобальному индексу.
target_op = next(
(op for op in service_operations if op["svcOperationId"] == svc_op_id),
None,
)
if target_op is None:
return jsonify({"error": f"operation '{operation}' not found for service"}), 404
# Определить kind и action операции из конфига
# (один проход — поиск по svcOperationId, не по имени)
svc_def = OPS_INDEX.get(svc_op_id, {})
kind = "instance"
action = operation
for op in svc_def.get("operations", []):
if op["svcOperationId"] == svc_op_id:
kind = op.get("kind", "instance")
action = op.get("action", operation)
break
kind = target_op.get("kind", "instance")
action = target_op.get("action", operation)
# Создать операцию в MockState (dtFinish=None — ещё не выполнена)
op_uid = state.create_operation(instance_uid, svc_op_id, operation, kind, action)
@@ -228,7 +232,10 @@ def set_operation_param():
Значения сохраняются в mock_state.op_params[opUid][paramId] = value.
При apply_effect (modify) значения мержатся в state.params инстанса.
"""
body = request.get_json(silent=True) or {}
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"error": "request body must be a JSON object"}), 400
op_uid = body.get("instanceOperationUid")
param_id = body.get("svcOperationCfsParamId")
value = body.get("paramValue", "")
@@ -241,9 +248,23 @@ def set_operation_param():
if not isinstance(param_id, int) or isinstance(param_id, bool):
return jsonify({"error": "svcOperationCfsParamId must be an integer"}), 400
# Проверка что операция существует
if not state.get_operation(op_uid):
# Проверка что операция существует и ещё не завершена.
op = state.get_operation(op_uid)
if not op:
return jsonify({"error": "operation not found"}), 404
if op.get("dtFinish") is not None:
return jsonify({"error": "operation already completed"}), 409
if not isinstance(value, str):
return jsonify({"error": "paramValue must be a string"}), 400
inst = state.get_instance(op.get("instanceUid"))
if not inst:
return jsonify({"error": "instance not found"}), 404
svc_def = SERVICES.get(inst.get("serviceId"), {})
valid_param_ids = svc_def.get("cfsParamsByOp", {}).get(op.get("svcOperationId"), [])
if param_id not in valid_param_ids:
return jsonify({"error": "parameter not found for operation"}), 400
# Сохранить параметр (param_id — int, value — str)
state.set_param(op_uid, param_id, value)
+1 -5
View File
@@ -18,9 +18,6 @@ SERVICES = LocalProxy(lambda: _cfg.get_services(g.stand_id))
VERSION = _cfg.VERSION
# Токен для авторизации в Swagger UI (предзаполняется)
_MOCK_AUTH_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "test-token-123")
# URL для OpenAPI-спеке (сервер)
_POLYGON_ENDPOINT = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru")
@@ -90,7 +87,7 @@ def swagger():
"""Swagger UI — интерактивная документация API.
Спек OpenAPI встроена прямо в HTML — без отдельного HTTP-запроса.
Токен авторизации предзаполняется автоматически (MOCK_AUTH_TOKEN).
Секрет MOCK_AUTH_TOKEN никогда не передаётся в браузер.
"""
from routes.openapi import _build_spec
import json as _json
@@ -99,5 +96,4 @@ def swagger():
"swagger.html",
version=_cfg.VERSION,
spec_json=spec_json,
auth_token=_MOCK_AUTH_TOKEN,
)
+3
View File
@@ -50,6 +50,9 @@ def run_operation(uid):
if op.get("dtFinish") is not None:
return jsonify({"error": "operation already completed"}), 409
if not state.get_instance(op.get("instanceUid")):
return jsonify({"error": "instance not found"}), 404
# Фиксируем время старта
op["dtStart"] = _now()
+2 -2
View File
@@ -54,6 +54,7 @@ def apply_effect(op_uid, mock_state, services):
svc_def = services.get(svc_id, {})
template = svc_def.get("stateParams", {})
inst["state"]["params"] = dict(template)
_merge_params(inst, op_params, op.get("svcOperationId"), services)
# stateOut из шаблона
state_out_template = svc_def.get("stateOut", {})
inst["state"]["out"] = {k: dict(v) for k, v in state_out_template.items()}
@@ -64,8 +65,7 @@ def apply_effect(op_uid, mock_state, services):
_merge_params(inst, op_params, op.get("svcOperationId"), services)
elif action == "delete":
if instance_uid in mock_state.instances:
del mock_state.instances[instance_uid]
mock_state.delete_instance(instance_uid)
elif action == "suspend":
if inst:
+293 -183
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,90 +8,95 @@
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<!-- ── Header ── -->
<header class="header">
<div class="header-top">
<span class="badge badge-blue">polygon v{{ version }}</span>
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" class="logo" width="108" height="24">
{% if stands_list|length > 1 %}
{% for s in stands_list %}
<a href="/{{ s.id }}/" class="stand-chip-inline {% if s.id == current_stand %}stand-active{% endif %}">{{ s.id }}</a>
{% endfor %}
<span class="sep">·</span>
{% endif %}
<a href="/swagger" class="swagger-link">📋 Swagger</a>
<span class="sep">·</span>
<span class="dot dot-green"></span>
<span class="status-text">Работает</span>
<span class="sep">·</span>
<span class="header-stat">Сервисов: <strong>{{ svc_count }}</strong></span>
<span class="sep">·</span>
<span class="header-stat">Инстансов: <strong>{{ inst_count }}</strong></span>
<span class="sep">·</span>
<span class="header-stat">Задержка: <strong>{{ delay }}s</strong></span>
</div>
</header>
<main>
<!-- ── Что такое Polygon ── -->
<section class="card">
<div class="card-header">
<h2>Что такое Polygon?</h2>
</div>
<div class="card-body">
<p>
<strong>Polygon</strong> — это эмулятор REST API облачной платформы
<strong>Nubes</strong> для интеграционных тестов.
Он полностью повторяет контракты реального API: сервисы, инстансы,
операции, параметры, валидацию — но работает <strong>без реальной
инфраструктуры</strong>, в памяти, с мгновенным откликом.
</p>
<div class="grid-2col">
<div>
<h3 class="h4">Зачем нужен</h3>
<ul>
<li>Тестировать создание/изменение/удаление инстансов без реальных ресурсов</li>
<li>Отлаживать UI автодеплоя (<code>app-autotest</code>) на мок-данных</li>
<li>Писать интеграционные тесты с детерминированным состоянием</li>
<li>Проверять краевые случаи (ошибки валидации, сбои операций)</li>
</ul>
</div>
<div>
<h3 class="h4">Как устроен</h3>
<ul>
<li>Flask 3.0 + gunicorn, 1 воркер (состояние в памяти)</li>
<li>Конфигурация сервисов — из YAML (генерируется из STANDS)</li>
<li>Все операции — синхронные, с настраиваемой задержкой</li>
<li>Управляемый сброс состояния через <code>/_mock/reset</code></li>
</ul>
</div>
<!-- ── Header ── -->
<header class="header">
<div class="header-top">
<span class="badge badge-blue">polygon v{{ version }}</span>
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" class="logo" width="108" height="24">
{% if stands_list|length > 1 %}
{% for s in stands_list %}
<a href="/{{ s.id }}/" class="stand-chip-inline {% if s.id == current_stand %}stand-active{% endif %}">{{
s.id }}</a>
{% endfor %}
<span class="sep">·</span>
{% endif %}
<a href="/swagger" class="swagger-link">📋 Swagger</a>
<span class="sep">·</span>
<span class="dot dot-green"></span>
<span class="status-text">Работает</span>
<span class="sep">·</span>
<span class="header-stat">Сервисов: <strong>{{ svc_count }}</strong></span>
<span class="sep">·</span>
<span class="header-stat">Инстансов: <strong>{{ inst_count }}</strong></span>
<span class="sep">·</span>
<span class="header-stat">Задержка: <strong>{{ delay }}s</strong></span>
</div>
</div>
</section>
</header>
<!-- ── Как использовать ── -->
<section class="card">
<div class="card-header">
<h2>Как использовать</h2>
{% if stands_list|length > 1 %}<span class="muted">Стенд: <strong>{{ current_stand }}</strong></span>{% endif %}
</div>
<div class="card-body">
<main>
<h3 class="h4">📡 Базовые команды</h3>
<!-- ── Что такое Polygon ── -->
<section class="card">
<div class="card-header">
<h2>Что такое Polygon?</h2>
</div>
<div class="card-body">
<p>
<strong>Polygon</strong> — это эмулятор REST API облачной платформы
<strong>Nubes</strong> для интеграционных тестов.
Он полностью повторяет контракты реального API: сервисы, инстансы,
операции, параметры, валидацию — но работает <strong>без реальной
инфраструктуры</strong>, в памяти, с мгновенным откликом.
</p>
<div class="grid-2col">
<div>
<h3 class="h4">Зачем нужен</h3>
<ul>
<li>Тестировать создание/изменение/удаление инстансов без реальных ресурсов</li>
<li>Отлаживать UI автодеплоя (<code>app-autotest</code>) на мок-данных</li>
<li>Писать интеграционные тесты с детерминированным состоянием</li>
<li>Проверять краевые случаи (ошибки валидации, сбои операций)</li>
</ul>
</div>
<div>
<h3 class="h4">Как устроен</h3>
<ul>
<li>Flask 3.0 + gunicorn, 1 воркер (состояние в памяти)</li>
<li>Конфигурация сервисов — из YAML (генерируется из STANDS)</li>
<li>Все операции — синхронные, с настраиваемой задержкой</li>
<li>Управляемый сброс состояния через <code>/_mock/reset</code></li>
</ul>
</div>
</div>
</div>
</section>
<p><strong>Получить список сервисов:</strong></p>
<pre class="code-block"><code>curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/services | python3 -m json.tool</code></pre>
<!-- ── Как использовать ── -->
<section class="card">
<div class="card-header">
<h2>Как использовать</h2>
{% if stands_list|length > 1 %}<span class="muted">Стенд: <strong>{{ current_stand }}</strong></span>{%
endif %}
</div>
<div class="card-body">
<p><strong>Создать инстанс:</strong></p>
<pre class="code-block"><code>curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instances \
<h3 class="h4">📡 Базовые команды</h3>
<p><strong>Получить список сервисов:</strong></p>
<pre
class="code-block"><code>curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/services | python3 -m json.tool</code></pre>
<p><strong>Создать инстанс:</strong></p>
<pre class="code-block"><code>curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instances \
-H "Content-Type: application/json" \
-d '{"serviceId": 1, "displayName": "my-instance"}' | python3 -m json.tool</code></pre>
<p><strong>Создать операцию, установить параметры, запустить:</strong></p>
<pre class="code-block"><code># 1. Создать операцию (получить opUid из ответа)
<p><strong>Создать операцию, установить параметры, запустить:</strong></p>
<pre
class="code-block"><code># 1. Создать операцию (получить opUid из ответа)
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instanceOperations \
-H "Content-Type: application/json" \
-d '{"instanceUid": "&lt;uid&gt;", "operation": "create", "svcOperationId": 18}'
@@ -103,132 +109,236 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
# 3. Запустить операцию (будет ждать {{ delay }}s)
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instanceOperations/&lt;opUid&gt;/run</code></pre>
<hr style="margin: 20px 0; border: none; border-top: 1px solid var(--border);">
<hr style="margin: 20px 0; border: none; border-top: 1px solid var(--border);">
<h3 class="h4">🛠 Управление полигоном (служебные эндпоинты)</h3>
<h3 class="h4">🛠 Управление полигоном (служебные эндпоинты)</h3>
<p>Если на сервере задан <code>MOCK_AUTH_TOKEN</code>, для запросов к <code>/_mock/*</code> добавьте
заголовок <code>-H "X-Mock-Auth: &lt;token&gt;"</code>.</p>
<div class="feature-grid">
<div class="feature-item">
<h4>⏱ Задержка операций</h4>
<p>Каждый <code>POST /run</code> ждёт <strong>{{ delay }} секунд</strong> перед ответом — эмуляция времени выполнения в облаке.</p>
<p><strong>Текущая:</strong> <code>{{ delay }}s</code></p>
<pre class="code-block"><code># Сделать операции мгновенными
<div class="feature-grid">
<div class="feature-item">
<h4>⏱ Задержка операций</h4>
<p>Каждый <code>POST /run</code> ждёт <strong>{{ delay }} секунд</strong> перед ответом —
эмуляция времени выполнения в облаке.</p>
<p><strong>Текущая:</strong> <code>{{ delay }}s</code></p>
<pre
class="code-block"><code># Сделать операции мгновенными
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/delay/0
# Поставить задержку 3 секунды (эмуляция долгой операции)
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/delay/3</code></pre>
</div>
</div>
<div class="feature-item">
<h4>🧹 Сброс состояния</h4>
<p>Удаляет <strong>все</strong> инстансы и операции на текущем стенде. Полезно перед каждым тестом.</p>
<pre class="code-block"><code>curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/reset</code></pre>
</div>
<div class="feature-item">
<h4>🧹 Сброс состояния</h4>
<p>Удаляет <strong>все</strong> инстансы и операции на текущем стенде. Полезно перед каждым
тестом.</p>
<pre
class="code-block"><code>curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/reset</code></pre>
</div>
<div class="feature-item">
<h4>💥 Симуляция ошибок</h4>
<p>Следующий <code>POST /run</code> <strong>упадёт</strong> с ошибкой. One-shot: после использования флаг сбрасывается.</p>
<pre class="code-block"><code># Включить
<div class="feature-item">
<h4>💥 Симуляция ошибок</h4>
<p>Следующий <code>POST /run</code> <strong>упадёт</strong> с ошибкой. One-shot: после
использования флаг сбрасывается.</p>
<pre class="code-block"><code># Включить
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/fail-next
# Следующий run → {"ok": false, "error": "mock failure..."}
# После этого — снова работает нормально</code></pre>
</div>
<div class="feature-item">
<h4>📊 Отладка</h4>
<p>Посмотреть текущее состояние всех инстансов и операций:</p>
<pre
class="code-block"><code>curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/state | python3 -m json.tool</code></pre>
<p>Список загруженных сервисов:</p>
<pre
class="code-block"><code>curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/services | python3 -m json.tool</code></pre>
</div>
</div>
<hr style="margin: 20px 0; border: none; border-top: 1px solid var(--border);">
<h3 class="h4">🔁 Жизненный цикл операции</h3>
<ol style="line-height: 2;">
<li><code>GET /api/v1/svc/instanceOperations/default/{svcOperationId}</code> — узнать какие
параметры нужны</li>
<li><code>POST /api/v1/svc/instanceOperations</code> — создать операцию (получить opUid)</li>
<li><code>POST /api/v1/svc/instanceOperationCfsParams</code> × N — установить каждый параметр</li>
<li><code>GET /api/v1/svc/instanceOperations/{opUid}/validate-cfs</code> — проверить корректность
</li>
<li><code>POST /api/v1/svc/instanceOperations/{opUid}/run</code> — выполнить (ждёт <strong>{{ delay
}}s</strong>)</li>
<li><code>GET /api/v1/svc/instanceOperations/{opUid}?fields=dtFinish,isSuccessful</code> — проверить
результат</li>
</ol>
</div>
</section>
<div class="feature-item">
<h4>📊 Отладка</h4>
<p>Посмотреть текущее состояние всех инстансов и операций:</p>
<pre class="code-block"><code>curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/state | python3 -m json.tool</code></pre>
<p>Список загруженных сервисов:</p>
<pre class="code-block"><code>curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/services | python3 -m json.tool</code></pre>
<!-- ── API ── -->
<section class="card">
<div class="card-header">
<h2>API эндпоинты</h2>
</div>
</div>
<div class="card-body table-wrap">
<table class="table">
<thead>
<tr>
<th>Метод</th>
<th>Путь</th>
<th>Описание</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/health</code></td>
<td>Healthcheck</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/</code></td>
<td>Главная страница</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/services</code></td>
<td>Список сервисов</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/services/&lt;id&gt;</code></td>
<td>Детали сервиса</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/instances</code></td>
<td>Список инстансов</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/instances/&lt;uid&gt;</code></td>
<td>Детали инстанса</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/instances</code></td>
<td>Создать инстанс</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/instanceOperations/default/&lt;id&gt;</code></td>
<td>Шаблон операции и параметры</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/instanceOperations</code></td>
<td>Создать операцию</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/instanceOperations/&lt;uid&gt;</code></td>
<td>Статус операции</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/instanceOperationCfsParams</code></td>
<td>Установить параметр операции</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/instanceOperations/&lt;uid&gt;/validate-cfs</code></td>
<td>Проверить параметры</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/instanceOperations/&lt;uid&gt;/run</code></td>
<td>Запустить операцию</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/_mock/reset</code></td>
<td>Сброс состояния</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/_mock/state</code></td>
<td>Дамп состояния</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/_mock/services</code></td>
<td>Список загруженных сервисов</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/_mock/delay/&lt;s&gt;</code></td>
<td>Задать задержку</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><code>/api/v1/svc/_mock/fail-next</code></td>
<td>Следующая операция упадёт</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/swagger</code></td>
<td>Swagger UI</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><code>/api/v1/svc/openapi.json</code></td>
<td>OpenAPI JSON</td>
</tr>
</tbody>
</table>
</div>
</section>
<hr style="margin: 20px 0; border: none; border-top: 1px solid var(--border);">
<!-- ── Сервисы ── -->
<section class="card">
<div class="card-header">
<h2>Загруженные сервисы <span class="muted">({{ svc_count }})</span></h2>
</div>
<div class="card-body table-wrap">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Операций</th>
<th>CfsParams</th>
<th>StateParams</th>
<th>StateOut</th>
</tr>
</thead>
<tbody>
{% for svc in svc_list %}
<tr>
<td><code>{{ svc.id }}</code></td>
<td>{{ svc.display_name }}</td>
<td>{{ svc.op_count }}</td>
<td>{% if svc.has_cfs %}<span class="dot dot-green"></span>{% else %}<span
class="dot dot-gray"></span>{% endif %}</td>
<td>{% if svc.has_state %}<span class="dot dot-green"></span>{% else %}<span
class="dot dot-gray"></span>{% endif %}</td>
<td>{% if svc.has_out %}<span class="dot dot-green"></span>{% else %}<span
class="dot dot-gray"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<h3 class="h4">🔁 Жизненный цикл операции</h3>
<ol style="line-height: 2;">
<li><code>GET /instanceOperations/default/{svcOperationId}</code> — узнать какие параметры нужны</li>
<li><code>POST /instanceOperations</code> — создать операцию (получить opUid)</li>
<li><code>POST /instanceOperationCfsParams</code> × N — установить каждый параметр</li>
<li><code>GET /{opUid}/validate-cfs</code> — проверить корректность</li>
<li><code>POST /{opUid}/run</code> — выполнить (ждёт <strong>{{ delay }}s</strong>)</li>
<li><code>GET /{opUid}?fields=dtFinish,isSuccessful</code> — проверить результат</li>
</ol>
</div>
</section>
</main>
<!-- ── API ── -->
<section class="card">
<div class="card-header">
<h2>API эндпоинты</h2>
</div>
<div class="card-body table-wrap">
<table class="table">
<thead>
<tr>
<th>Метод</th>
<th>Путь</th>
<th>Описание</th>
</tr>
</thead>
<tbody>
<tr><td><span class="method method-get">GET</span></td><td><code>/health</code></td><td>Healthcheck</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/services</code></td><td>Список сервисов</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/services/&lt;id&gt;</code></td><td>Детали сервиса</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instances</code></td><td>Список инстансов</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instances/&lt;uid&gt;</code></td><td>Детали инстанса</td></tr>
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/instances</code></td><td>Создать инстанс</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instanceOperations</code></td><td>Список операций</td></tr>
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/instanceOperations/&lt;uid&gt;/run</code></td><td>Запустить операцию</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instanceOperations/&lt;uid&gt;/status</code></td><td>Статус операции</td></tr>
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/_mock/reset</code></td><td>Сброс состояния</td></tr>
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/_mock/state</code></td><td>Дамп состояния</td></tr>
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/_mock/delay/&lt;s&gt;</code></td><td>Задать задержку</td></tr>
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/_mock/fail-next</code></td><td>Следующая операция упадёт</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ── Сервисы ── -->
<section class="card">
<div class="card-header">
<h2>Загруженные сервисы <span class="muted">({{ svc_count }})</span></h2>
</div>
<div class="card-body table-wrap">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Операций</th>
<th>CfsParams</th>
<th>StateParams</th>
<th>StateOut</th>
</tr>
</thead>
<tbody>
{% for svc in svc_list %}
<tr>
<td><code>{{ svc.id }}</code></td>
<td>{{ svc.display_name }}</td>
<td>{{ svc.op_count }}</td>
<td>{% if svc.has_cfs %}<span class="dot dot-green"></span>{% else %}<span class="dot dot-gray"></span>{% endif %}</td>
<td>{% if svc.has_state %}<span class="dot dot-green"></span>{% else %}<span class="dot dot-gray"></span>{% endif %}</td>
<td>{% if svc.has_out %}<span class="dot dot-green"></span>{% else %}<span class="dot dot-gray"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</main>
<footer class="footer">
polygon v{{ version }} · Nubes Mock API · Flask + gunicorn
</footer>
<footer class="footer">
polygon v{{ version }} · Nubes Mock API · Flask + gunicorn
</footer>
</body>
</html>
</html>
+43 -35
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,18 +8,29 @@
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
html {
box-sizing: border-box;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
/* Nubes-брендированный topbar */
.swagger-ui .topbar {
background: #fff;
border-bottom: 4px solid #2563eb;
padding: 10px 0;
}
.swagger-ui .topbar .wrapper {
display: flex;
align-items: center;
@@ -27,15 +39,18 @@
margin: 0 auto;
padding: 0 16px;
}
.swagger-ui .topbar a {
display: flex;
align-items: center;
gap: 8px;
}
.swagger-ui .topbar img.logo {
height: 28px;
width: auto;
}
.swagger-ui .topbar .version-badge {
background: #2563eb;
color: #fff;
@@ -45,6 +60,7 @@
font-weight: 600;
font-family: system-ui, sans-serif;
}
.swagger-ui .topbar .nav-link {
color: #6b7280;
font-size: 13px;
@@ -52,58 +68,49 @@
font-family: system-ui, sans-serif;
margin-left: auto;
}
.swagger-ui .topbar .nav-link:hover {
color: #2563eb;
}
/* Скрываем лого Swagger по умолчанию (заменяем своим) */
.swagger-ui .topbar .link img[alt="Swagger UI"] {
display: none;
}
/* Инфо-блок в description */
.swagger-ui .markdown p {
font-size: 14px;
}
.swagger-ui .info .title {
font-family: system-ui, -apple-system, sans-serif;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.onload = function () {
const ui = SwaggerUIBundle({
spec: {{ spec_json | safe }},
dom_id: "#swagger-ui",
<script>
window.onload = function () {
const ui = SwaggerUIBundle({
spec: {{ spec_json | safe }},
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: "StandaloneLayout",
defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 2,
docExpansion: "list",
filter: true,
showExtensions: true,
showCommonExtensions: true,
// Предзаполняем токен для _mock/*
onComplete: function () {
const authToken = {{ auth_token | tojson }};
if (authToken) {
ui.preauthorizeApiKey("mockAuth", authToken);
}
},
requestInterceptor: function (req) {
// Добавляем X-Mock-Auth только для _mock/* эндпоинтов
if (req.url.indexOf("/_mock/") !== -1 && {{ auth_token | tojson }}) {
req.headers["X-Mock-Auth"] = {{ auth_token | tojson }};
}
return req;
},
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: "StandaloneLayout",
defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 2,
docExpansion: "list",
filter: true,
showExtensions: true,
showCommonExtensions: true,
});
// Кастомный topbar с лого Nubes
@@ -128,7 +135,8 @@
});
observer.observe(document.body, { childList: true, subtree: true });
};
</script>
</script>
</body>
</html>
</html>
+5
View File
@@ -10,6 +10,11 @@ compare_test.py — Polygon ↔ Nubes API (v2, трёхкатегорийная
exit(1) только при BUG > 0.
"""
if __name__ != "__main__":
import pytest
pytest.skip("standalone live API comparison; run explicitly with python", allow_module_level=True)
import html as _html
import os
import sys
+91 -52
View File
@@ -1,13 +1,26 @@
#!/usr/bin/env python3
"""fuzz_test.py — фаззинг Polygon: злонамеренные и экстремальные сценарии × 3 стенда."""
"""fuzz_test.py — локальные злонамеренные и экстремальные сценарии по стендам."""
if __name__ != "__main__":
import pytest
pytest.skip("standalone local fuzz scenarios; run explicitly with python", allow_module_level=True)
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "site"))
from app import app
import json
import config.loader as config
client = app.test_client()
H = {"Content-Type": "application/json"}
MH = {**H, "X-Mock-Auth": "test-token-123"}
stands = ["dev", "test", "prod"]
AUTH = os.getenv("MOCK_AUTH_TOKEN", "")
MH = {**H, **({"X-Mock-Auth": AUTH} if AUTH else {})}
stands = config.list_stands()
passed = 0
failed = 0
errors = []
@@ -89,7 +102,7 @@ for sid in stands:
json={"serviceId": 0, "displayName": "x"}, headers=H), 400))
t("displayName=число", lambda: st(client.post(f"{base}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": 12345}, headers=H), 201))
json={"serviceId": 1, "displayName": 12345}, headers=H), 400))
t("paramId строка", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams",
json={"instanceOperationUid": "00000000-0000-0000-0000-000000000000",
@@ -98,9 +111,10 @@ for sid in stands:
# ═══ 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))
def test_page_size_upper_bound():
d = js(client.get(f"{base}/api/v1/svc/instances?pageSize=999999"))
assert d["pageSize"] == 200
t("pageSize=999999", test_page_size_upper_bound)
t("pageSize=0", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize=0"), 200))
@@ -108,9 +122,10 @@ for sid in stands:
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))
def test_page_lower_bound():
d = js(client.get(f"{base}/api/v1/svc/instances?page=-100"))
assert d["page"] == 1
t("page=-100", test_page_lower_bound)
# ═══ 4. INVALID UUID ═══
print("\n── Невалидные UUID ──")
@@ -190,20 +205,28 @@ for sid in stands:
# ═══ 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))
t("GET /../services → 404", lambda: st(client.get(f"{base}/api/v1/svc/../services"), 404))
def test_double_slash_is_normalized():
response = client.get(f"{base}//api/v1/svc/services")
st(response, 200)
assert "results" in js(response)
t("GET двойной слеш → нормализованный список", test_double_slash_is_normalized)
# ═══ 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))
def test_zero_delay():
d = js(client.post(f"{base}/api/v1/svc/_mock/delay/0", headers=MH))
assert d["delay"] == 0
t("delay 0", test_zero_delay)
def test_max_delay():
d = js(client.post(f"{base}/api/v1/svc/_mock/delay/5", headers=MH))
assert d["delay"] == 5.0
t("delay 5.0", test_max_delay)
client.post(f"{base}/api/v1/svc/_mock/delay/0.1", headers=MH)
# ═══ 11. FIELDS ═══
@@ -225,13 +248,9 @@ for sid in stands:
# ═══ 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 /{sid}/ → HTML", lambda: st(client.get(f"{base}/"), 200))
t("GET /swagger → HTML", lambda: (
r := client.get("/swagger"), st(r, 200),
b"swagger" in r.data.lower() or True))
t("GET /swagger → HTML", lambda: st(client.get("/swagger"), 200))
t("GET /health → OK", lambda: (
r := client.get("/health"), st(r, 200), r.data == b"OK"))
@@ -261,37 +280,57 @@ for sid in stands:
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)))
# Cross-stand checks apply only when at least two stands are configured.
if len(stands) >= 2:
first_stand, second_stand = stands[:2]
t(
"чужая instance → 404",
lambda: st(
client.get(
f"/{first_stand}/api/v1/svc/instances/{inst_by_stand[second_stand]}"
),
404,
),
)
t("чужая instance → 404 (prod→dev)", lambda: (
st(client.get(f"/prod/api/v1/svc/instances/{inst_by_stand['dev']}"), 404)))
client.post(f"/{first_stand}/api/v1/svc/_mock/reset", headers=MH)
client.post(f"/{second_stand}/api/v1/svc/_mock/reset", headers=MH)
client.post(f"/{first_stand}/api/v1/svc/_mock/fail-next", headers=MH)
# 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():
first_instance = js(client.post(
f"/{first_stand}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "fail-isolation"}, headers=H,
))["instanceUid"]
first_operation = js(client.post(
f"/{first_stand}/api/v1/svc/instanceOperations",
json={"instanceUid": first_instance, "operation": "create", "svcOperationId": 18},
headers=H,
))["instanceOperationUid"]
first_result = js(client.post(
f"/{first_stand}/api/v1/svc/instanceOperations/{first_operation}/run",
headers=H,
))
assert first_result["ok"] is False
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 должен упасть"
second_instance = js(client.post(
f"/{second_stand}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "normal"}, headers=H,
))["instanceUid"]
second_operation = js(client.post(
f"/{second_stand}/api/v1/svc/instanceOperations",
json={"instanceUid": second_instance, "operation": "create", "svcOperationId": 18},
headers=H,
))["instanceOperationUid"]
second_result = js(client.post(
f"/{second_stand}/api/v1/svc/instanceOperations/{second_operation}/run",
headers=H,
))
assert second_result["ok"] is True
# 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)
t("fail-next изоляция между стендами", test_fail_isolation)
else:
print(" Cross-stand isolation skipped: fewer than two stands configured")
# ═══════ SUMMARY ═══════
@@ -303,5 +342,5 @@ if failed:
for e in errors:
print(f" {e}")
else:
print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} тестов × 3 стенда!")
print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} проверок, {len(stands)} стенд(а)")
print(f"{'='*55}")
+5
View File
@@ -6,6 +6,11 @@ test_api.py — smoke + edge-case тесты Polygon API.
Требует: pip install requests
"""
if __name__ != "__main__":
import pytest
pytest.skip("standalone API smoke tests; run explicitly with python", allow_module_level=True)
import os
import sys
import json
+228
View File
@@ -0,0 +1,228 @@
"""Focused tests for public page safety and mock-route input handling."""
import sys
from pathlib import Path
import pytest
SITE_DIR = Path(__file__).resolve().parents[1] / "site"
sys.path.insert(0, str(SITE_DIR))
import app as polygon_app
import config.loader as config
import mock_state
from routes import mock_routes
from routes import openapi as openapi_routes
@pytest.fixture(autouse=True)
def reset_polygon_state():
for stand_id in config.list_stands():
mock_state.get_state(stand_id).reset()
yield
for stand_id in config.list_stands():
mock_state.get_state(stand_id).reset()
def test_swagger_does_not_embed_mock_auth_token():
response = polygon_app.app.test_client().get("/swagger")
assert response.status_code == 200
assert b"test-token-123" not in response.data
assert b"auth_token" not in response.data
def test_openapi_schema_and_auth_match_runtime_contract():
spec = openapi_routes._build_spec()
schemas = spec["components"]["schemas"]
paths = spec["paths"]
assert len(spec["servers"]) == len(config.STANDS)
assert schemas["CfsParam"]["properties"]["valueList"]["items"]["type"] == "string"
assert schemas["CfsParam"]["properties"]["dataDescriptor"]["type"] == "object"
assert schemas["Operation"]["properties"]["kind"]["description"] == "instance | subresource"
assert schemas["Instance"]["properties"]["status"]["description"] == "creating | running | suspended"
page_size = paths["/api/v1/svc/instances"]["get"]["parameters"][0]["schema"]
assert page_size["minimum"] == 1
assert page_size["maximum"] == 200
for path, path_item in paths.items():
if "/_mock/" in path:
operation = path_item.get("get") or path_item.get("post")
assert operation["security"] == [{"mockAuth": []}, {}]
def test_homepage_documents_real_operation_routes():
response = polygon_app.app.test_client().get("/")
assert response.status_code == 200
assert b"/api/v1/svc/instanceOperations/default/&lt;id&gt;" in response.data
assert b"/api/v1/svc/instanceOperationCfsParams" in response.data
assert b"/api/v1/svc/openapi.json" in response.data
assert b"/api/v1/svc/instanceOperations/&lt;uid&gt;/status" not in response.data
@pytest.mark.parametrize("seconds", ["nan", "inf", "-inf"])
def test_mock_delay_rejects_non_finite_numbers(monkeypatch, seconds):
monkeypatch.setattr(mock_routes, "_MOCK_TOKEN", "")
previous_delay = config.DELAY
response = polygon_app.app.test_client().post(
f"/api/v1/svc/_mock/delay/{seconds}"
)
assert response.status_code == 400
assert config.DELAY == previous_delay
def test_operation_routes_reject_non_object_json():
client = polygon_app.app.test_client()
create_response = client.post(
"/api/v1/svc/instanceOperations", json=[1]
)
param_response = client.post(
"/api/v1/svc/instanceOperationCfsParams", json=[1]
)
assert create_response.status_code == 400
assert param_response.status_code == 400
def test_create_instance_rejects_non_string_display_name():
service_id = next(iter(config.get_services(config.DEFAULT_STAND)))
response = polygon_app.app.test_client().post(
"/api/v1/svc/instances",
json={"serviceId": service_id, "displayName": 123},
)
assert response.status_code == 400
def test_create_operation_rejects_foreign_service_operation():
stand_id = config.DEFAULT_STAND
services = config.get_services(stand_id)
service_id, service = next(iter(services.items()))
foreign_operation = next(
operation
for other_id, other_service in services.items()
if other_id != service_id
for operation in other_service.get("operations", [])
if operation["svcOperationId"] not in {
own["svcOperationId"] for own in service.get("operations", [])
}
)
instance_uid = mock_state.get_state(stand_id).create_instance(
service_id, "route-test", service
)
response = polygon_app.app.test_client().post(
"/api/v1/svc/instanceOperations",
json={
"instanceUid": instance_uid,
"operation": foreign_operation["operation"],
"svcOperationId": foreign_operation["svcOperationId"],
},
)
assert response.status_code == 404
@pytest.mark.parametrize("page_size", [0, -5])
def test_instances_page_size_is_clamped_to_one(page_size):
response = polygon_app.app.test_client().get(
f"/api/v1/svc/instances?pageSize={page_size}"
)
assert response.status_code == 200
assert response.get_json()["pageSize"] == 1
def test_run_rejects_operation_for_deleted_instance():
stand_id = config.DEFAULT_STAND
state = mock_state.get_state(stand_id)
op_uid = state.create_operation(
"missing-instance", 18, "create", "instance", "create"
)
response = polygon_app.app.test_client().post(
f"/api/v1/svc/instanceOperations/{op_uid}/run"
)
assert response.status_code == 404
def test_set_param_rejects_completed_operation():
stand_id = config.DEFAULT_STAND
services = config.get_services(stand_id)
service_id, service = next(
(service_id, definition)
for service_id, definition in services.items()
if definition.get("operations")
)
operation = service["operations"][0]
state = mock_state.get_state(stand_id)
instance_uid = state.create_instance(service_id, "route-test", service)
op_uid = state.create_operation(
instance_uid,
operation["svcOperationId"],
operation["operation"],
operation.get("kind", "instance"),
operation.get("action", operation["operation"]),
)
state.get_operation(op_uid)["dtFinish"] = "completed"
response = polygon_app.app.test_client().post(
"/api/v1/svc/instanceOperationCfsParams",
json={
"instanceOperationUid": op_uid,
"svcOperationCfsParamId": 1,
"paramValue": "value",
},
)
assert response.status_code == 409
def test_set_param_validates_value_type_and_operation_membership():
stand_id = config.DEFAULT_STAND
services = config.get_services(stand_id)
service_id, service, operation, param_id = next(
(service_id, definition, operation, param_id)
for service_id, definition in services.items()
for operation in definition.get("operations", [])
for param_id in definition.get("cfsParamsByOp", {}).get(
operation["svcOperationId"], []
)
)
state = mock_state.get_state(stand_id)
instance_uid = state.create_instance(service_id, "route-test", service)
op_uid = state.create_operation(
instance_uid,
operation["svcOperationId"],
operation["operation"],
operation.get("kind", "instance"),
operation.get("action", operation["operation"]),
)
client = polygon_app.app.test_client()
wrong_type = client.post(
"/api/v1/svc/instanceOperationCfsParams",
json={
"instanceOperationUid": op_uid,
"svcOperationCfsParamId": param_id,
"paramValue": 7,
},
)
wrong_membership = client.post(
"/api/v1/svc/instanceOperationCfsParams",
json={
"instanceOperationUid": op_uid,
"svcOperationCfsParamId": -1,
"paramValue": "value",
},
)
assert wrong_type.status_code == 400
assert wrong_membership.status_code == 400
assert state.get_params(op_uid) == {}
+20 -3
View File
@@ -84,6 +84,20 @@ class TestInstanceOperations:
assert inst["state"]["params"]["resourceRealm"] == "dummy"
assert inst["state"]["params"]["durationMs"] == "0"
def test_create_applies_supplied_operation_params(self):
"""Параметры create перекрывают дефолты из stateParams."""
st = mock_state.state
uid = st.create_instance(1, "test", SERVICES[1])
op_uid = st.create_operation(uid, 18, "create", "instance", "create")
st.set_param(op_uid, 242, "custom-realm")
st.set_param(op_uid, 198, "999")
state_machine.apply_effect(op_uid, st, SERVICES)
inst = st.get_instance(uid)
assert inst["state"]["params"]["resourceRealm"] == "custom-realm"
assert inst["state"]["params"]["durationMs"] == "999"
def test_create_sets_state_out(self):
"""create → state.out из шаблона (пустой для dummy, заполнен для pg)."""
uid, _ = _create_and_run(90, "pg", 19, "create")
@@ -92,10 +106,13 @@ class TestInstanceOperations:
assert "databases" in inst["state"]["out"]
def test_delete_removes_instance(self):
"""delete → инстанс удалён."""
uid, _ = _create_and_run(1, "test", 18, "create")
_run_op(uid, 71, "delete", action="delete")
"""delete удаляет инстанс, но сохраняет историю операций для polling."""
uid, create_op_uid = _create_and_run(1, "test", 18, "create")
delete_op_uid = _run_op(uid, 71, "delete", action="delete")
assert mock_state.state.get_instance(uid) is None
assert create_op_uid in mock_state.state.operations
assert delete_op_uid in mock_state.state.operations
assert delete_op_uid in mock_state.state.op_params
def test_suspend_sets_suspended(self):
"""suspend → статус suspended."""