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. Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest.
@@ -11,17 +11,19 @@
polygon/ polygon/
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0 ├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
├── tests/ ├── tests/
│ ├── test_converter.py # 10 юнит-тестов from_stands.py │ ├── test_converter.py # тесты from_stands.py
│ └── test_state_machine.py # 9 юнит-тестов state_machine.py │ ├── test_state_machine.py # тесты state_machine.py
│ └── test_route_regressions.py # локальные регрессионные тесты API
└── site/ └── site/
├── app.py # Flask: только blueprint'ы + app.run() (57 строк) ├── app.py # Flask: только blueprint'ы + app.run() (57 строк)
├── routes/ # 6 blueprint-файлов по доменам ├── routes/ # blueprint-файлы по доменам
│ ├── root.py # /health, / │ ├── root.py # /health, /
│ ├── services_routes.py# /api/v1/svc/services │ ├── services_routes.py# /api/v1/svc/services
│ ├── instances_routes.py# /api/v1/svc/instances │ ├── instances_routes.py# /api/v1/svc/instances
│ ├── operations_routes.py# /api/v1/svc/instanceOperations/* │ ├── operations_routes.py# /api/v1/svc/instanceOperations/*
│ ├── run.py # /api/v1/svc/instanceOperations/<uid>/run │ ├── 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 ├── mock_state.py # MockState: instances, operations, op_params
├── state_machine.py # apply_effect() — мутация состояния ├── state_machine.py # apply_effect() — мутация состояния
├── config/ ├── config/
@@ -34,7 +36,7 @@ polygon/
│ └── style.css # тёмная тема │ └── style.css # тёмная тема
├── templates/ ├── templates/
│ └── index.html # Jinja2-шаблон │ └── index.html # Jinja2-шаблон
└── services/ # YAML-конфиги (сгенерированы from_stands.py) └── services/ # fallback YAML, также генерируются from_stands.py
``` ```
⛔ **Ровно 1 gunicorn-воркер.** Состояние в памяти, не shared. ⛔ **Ровно 1 gunicorn-воркер.** Состояние в памяти, не shared.
@@ -46,7 +48,7 @@ polygon/
cd site && python app.py # порт 5000 cd site && python app.py # порт 5000
# Тесты # Тесты
pytest tests/ -v # 19 тестов pytest tests/ -v # локальные тесты Polygon
``` ```
## Откуда берутся сервисы (YAML) ## Откуда берутся сервисы (YAML)
@@ -70,6 +72,8 @@ polygon/site/services/{dev,test,prod}/*.yaml ← скоммичены в git-
Память Flask-процесса (конфиги загружаются один раз при старте) Память Flask-процесса (конфиги загружаются один раз при старте)
``` ```
Если `POLYGON_STANDS` не задан, loader использует один стенд `default` из `site/services/`. Значения относительных путей в `POLYGON_STANDS` разрешаются относительно `site/`.
### Почему нельзя читать терраформ-формат напрямую? ### Почему нельзя читать терраформ-формат напрямую?
Терраформ-формат хранит параметры **внутри** операций, с дубликатами. Терраформ-формат хранит параметры **внутри** операций, с дубликатами.
@@ -102,6 +106,7 @@ git push
``` ```
После успешной конвертации генератор удаляет из целевого каталога YAML, которых больше нет в источнике. Если конвертация завершилась ошибками, устаревшие файлы не удаляются. После успешной конвертации генератор удаляет из целевого каталога 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 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 | `/health` | Healthcheck |
| GET | `/` | HTML с версией | | GET | `/` | HTML с версией |
| GET | `/swagger` | Swagger UI |
| GET | `/api/v1/svc/openapi.json` | OpenAPI JSON |
| GET | `/api/v1/svc/services` | Список сервисов | | GET | `/api/v1/svc/services` | Список сервисов |
| GET | `/api/v1/svc/services/<id>` | Операции сервиса | | GET | `/api/v1/svc/services/<id>` | Операции сервиса |
| GET | `/api/v1/svc/instances` | Пагинация | | GET | `/api/v1/svc/instances` | Пагинация |
| GET | `/api/v1/svc/instances/<uid>` | Инстанс + state | | GET | `/api/v1/svc/instances/<uid>` | Инстанс + state |
| POST | `/api/v1/svc/instances` | Создать → 201 + Location | | 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 | | POST | `/api/v1/svc/instanceOperations` | Создать операцию → 201 + Location |
| GET | `/api/v1/svc/instanceOperations/<uid>` | Статус + cfsParams | | GET | `/api/v1/svc/instanceOperations/<uid>` | Статус + cfsParams |
| POST | `/api/v1/svc/instanceOperationCfsParams` | param → value | | 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/state` | Отладка: состояние |
| GET | `/api/v1/svc/_mock/services` | Отладка: сервисы | | GET | `/api/v1/svc/_mock/services` | Отладка: сервисы |
| POST | `/api/v1/svc/_mock/delay/<s>` | MOCK_OP_DELAY | | 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` | Задержка операции в секундах | | `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"] OPS_INDEX = _DF["OPS_INDEX"]
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1")) DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
VERSION = "0.5.16" VERSION = "0.5.17"
def get_services(stand_id): def get_services(stand_id):
+3 -1
View File
@@ -285,7 +285,9 @@ def main():
sys.exit(1) sys.exit(1)
stands_dir = sys.argv[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): if not os.path.isdir(stands_dir):
print(f"ERROR: STANDS_DIR not found: {stands_dir}") print(f"ERROR: STANDS_DIR not found: {stands_dir}")
+10 -1
View File
@@ -53,12 +53,21 @@ class MockState:
"""Получить инстанс по uid или None.""" """Получить инстанс по uid или None."""
return self.instances.get(uid) 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): def list_instances(self, page_size=200, page=1):
"""Список инстансов с пагинацией. """Список инстансов с пагинацией.
page_size capped at 200. Стоп по len(batch) < page_size. 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) page = max(page, 1)
all_uids = list(self.instances.keys()) all_uids = list(self.instances.keys())
+3
View File
@@ -80,6 +80,9 @@ def create_instance():
service_id = body.get("serviceId") service_id = body.get("serviceId")
display_name = body.get("displayName", "unnamed") display_name = body.get("displayName", "unnamed")
if not isinstance(display_name, str):
return jsonify({"error": "displayName must be a string"}), 400
# Валидация: serviceId обязателен # Валидация: serviceId обязателен
if service_id is None: if service_id is None:
return jsonify({"error": "serviceId is required"}), 400 return jsonify({"error": "serviceId is required"}), 400
+6 -5
View File
@@ -9,6 +9,7 @@ Blueprint "mock" с префиксом /api/v1/svc/_mock.
""" """
import os import os
import math
from flask import Blueprint, g, jsonify, request, abort from flask import Blueprint, g, jsonify, request, abort
from werkzeug.local import LocalProxy 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") bp = Blueprint("mock", __name__, url_prefix="/api/v1/svc/_mock")
# Токен для защиты _mock/* эндпоинтов (из env, опционально). # Токен для защиты _mock/* эндпоинтов (опционален для локального dev-режима).
# Дублирует глобальную проверку в app.py, но оставлен для обратной совместимости
# и ясности — _mock/* исторически имели отдельную защиту.
_MOCK_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "") _MOCK_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "")
@@ -35,8 +34,8 @@ def _check_mock_auth():
Если токен не задан в env — пропускаем всех (dev-режим). Если токен не задан в env — пропускаем всех (dev-режим).
Если задан — требуем совпадения с заголовком. Если задан — требуем совпадения с заголовком.
Примечание: app.before_request уже проверяет Authorization: Bearer; Основные API-маршруты не используют эту проверку. Если токен не настроен,
этот хук — дополнительная защита для _mock/* эндпоинтов.""" локальные тесты могут вызывать служебные endpoints без заголовка."""
if _MOCK_TOKEN and request.headers.get("X-Mock-Auth") != _MOCK_TOKEN: if _MOCK_TOKEN and request.headers.get("X-Mock-Auth") != _MOCK_TOKEN:
abort(403) abort(403)
@@ -130,6 +129,8 @@ def mock_set_delay(seconds):
val = float(seconds) val = float(seconds)
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({"error": f"invalid delay: {seconds}"}), 400 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: if val < 0:
return jsonify({"error": "delay must be >= 0"}), 400 return jsonify({"error": "delay must be >= 0"}), 400
if val > _MAX_DELAY: if val > _MAX_DELAY:
+23 -26
View File
@@ -49,11 +49,16 @@ def _build_spec():
"""Собрать OpenAPI 3.1.0 спецификацию. """Собрать OpenAPI 3.1.0 спецификацию.
Включает: Включает:
- Все 17 эндпоинтов (REST API + _mock) - REST API и служебные _mock endpoints
- Bearer-авторизацию - Условную авторизацию _mock/* через X-Mock-Auth
- Схемы: Service, Instance, Operation, CfsParam, Stage - Схемы: Service, Instance, Operation, CfsParam, Stage
- Примеры запросов и ответов - Примеры запросов и ответов
""" """
stand_descriptions = "\n".join(
f"- **{stand_id}** — {len(stand['SERVICES'])} сервисов"
for stand_id, stand in _cfg.STANDS.items()
)
return { return {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
@@ -65,10 +70,8 @@ def _build_spec():
"операции, параметры, валидацию — но работает **без реальной инфраструктуры**, " "операции, параметры, валидацию — но работает **без реальной инфраструктуры**, "
"в памяти, с мгновенным откликом.\n\n" "в памяти, с мгновенным откликом.\n\n"
"### Стенды\n" "### Стенды\n"
"**3 изолированных стенда** — переключайте в выпадающем списке серверов выше:\n" f"**{len(_cfg.STANDS)} изолированных стенда** — переключайте в выпадающем списке серверов выше:\n"
"- **dev** — 37 сервисов (разработка)\n" f"{stand_descriptions}\n"
"- **test** — 37 сервисов (тестирование)\n"
"- **prod** — 35 сервисов (продуктив)\n"
"У каждого стенда **свои инстансы, свои операции, своё состояние**.\n\n" "У каждого стенда **свои инстансы, свои операции, своё состояние**.\n\n"
"### Особенности\n" "### Особенности\n"
"- Все операции синхронные (настраиваемая задержка через `/_mock/delay`)\n" "- Все операции синхронные (настраиваемая задержка через `/_mock/delay`)\n"
@@ -78,8 +81,8 @@ def _build_spec():
"- Симуляция ошибок через `/_mock/fail-next`\n\n" "- Симуляция ошибок через `/_mock/fail-next`\n\n"
"### Авторизация\n" "### Авторизация\n"
"- Основные эндпоинты — без авторизации\n" "- Основные эндпоинты — без авторизации\n"
"- `_mock/*` — заголовок `X-Mock-Auth: test-token-123` " "- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере "
"(настроен через `MOCK_AUTH_TOKEN`)" "задан `MOCK_AUTH_TOKEN`"
), ),
"contact": { "contact": {
"name": "Nubes Platform", "name": "Nubes Platform",
@@ -101,7 +104,7 @@ def _build_spec():
"type": "apiKey", "type": "apiKey",
"in": "header", "in": "header",
"name": "X-Mock-Auth", "name": "X-Mock-Auth",
"description": "Токен для _mock/* эндпоинтов (по умолчанию: test-token-123)", "description": "Нужен для _mock/* только если MOCK_AUTH_TOKEN задан на сервере.",
}, },
}, },
"schemas": { "schemas": {
@@ -170,7 +173,7 @@ def _build_spec():
"serviceId": {"type": "integer"}, "serviceId": {"type": "integer"},
"displayName": {"type": "string"}, "displayName": {"type": "string"},
"descr": {"type": "string"}, "descr": {"type": "string"},
"status": {"type": "string", "description": "creating | running | modifying | deleting | suspended"}, "status": {"type": "string", "description": "creating | running | suspended"},
"explainedStatus": {"type": "string"}, "explainedStatus": {"type": "string"},
"svc": {"type": "string"}, "svc": {"type": "string"},
"dtCreate": {"type": "string", "format": "date-time"}, "dtCreate": {"type": "string", "format": "date-time"},
@@ -209,20 +212,14 @@ def _build_spec():
"svcOperationCfsParamId": {"type": "integer", "description": "ID параметра"}, "svcOperationCfsParamId": {"type": "integer", "description": "ID параметра"},
"svcOperationCfsParam": {"type": "string", "description": "Название параметра"}, "svcOperationCfsParam": {"type": "string", "description": "Название параметра"},
"dataType": {"type": "string", "description": "string | integer | map | map-fixed | boolean"}, "dataType": {"type": "string", "description": "string | integer | map | map-fixed | boolean"},
"dataDescriptor": {"type": "string", "description": "JSON-описание подполей (для map-типов)"}, "dataDescriptor": {"type": "object", "description": "Описание подполей (для map-типов)"},
"isRequired": {"type": "boolean"}, "isRequired": {"type": "boolean"},
"isModifiable": {"type": "boolean"}, "isModifiable": {"type": "boolean"},
"defaultValue": {"type": "string"}, "defaultValue": {"type": "string"},
"paramValue": {"type": "string", "description": "Текущее значение (если задано)"}, "paramValue": {"type": "string", "description": "Текущее значение (если задано)"},
"valueList": { "valueList": {
"type": "array", "type": "array",
"items": { "items": {"type": "string"},
"type": "object",
"properties": {
"value": {"type": "string"},
"label": {"type": "string"},
},
},
}, },
}, },
}, },
@@ -249,7 +246,7 @@ def _build_spec():
"instanceUid": {"type": "string", "format": "uuid"}, "instanceUid": {"type": "string", "format": "uuid"},
"svcOperationId": {"type": "integer"}, "svcOperationId": {"type": "integer"},
"operation": {"type": "string"}, "operation": {"type": "string"},
"kind": {"type": "string", "description": "instance | access"}, "kind": {"type": "string", "description": "instance | subresource"},
"action": {"type": "string", "description": "create | modify | delete | …"}, "action": {"type": "string", "description": "create | modify | delete | …"},
"dtStart": {"type": ["string", "null"], "format": "date-time"}, "dtStart": {"type": ["string", "null"], "format": "date-time"},
"dtFinish": {"type": ["string", "null"], "format": "date-time"}, "dtFinish": {"type": ["string", "null"], "format": "date-time"},
@@ -406,8 +403,8 @@ def _build_paths():
{ {
"name": "pageSize", "name": "pageSize",
"in": "query", "in": "query",
"schema": {"type": "integer", "default": 200}, "schema": {"type": "integer", "default": 200, "minimum": 1, "maximum": 200},
"description": "Размер страницы (значения > 200 обрезаются до 200)", "description": "Размер страницы (значения ограничиваются диапазоном 1…200)",
}, },
{ {
"name": "page", "name": "page",
@@ -720,7 +717,7 @@ def _build_paths():
"summary": "Сброс состояния", "summary": "Сброс состояния",
"description": "Удаляет **все** инстансы, операции и параметры. Используется в тестах для изоляции.", "description": "Удаляет **все** инстансы, операции и параметры. Используется в тестах для изоляции.",
"operationId": "mockReset", "operationId": "mockReset",
"security": [{"mockAuth": []}], "security": [{"mockAuth": []}, {}],
"responses": { "responses": {
"200": { "200": {
"description": "Состояние сброшено", "description": "Состояние сброшено",
@@ -735,7 +732,7 @@ def _build_paths():
"summary": "Дамп состояния", "summary": "Дамп состояния",
"description": "Возвращает все инстансы и операции для отладки.", "description": "Возвращает все инстансы и операции для отладки.",
"operationId": "mockState", "operationId": "mockState",
"security": [{"mockAuth": []}], "security": [{"mockAuth": []}, {}],
"responses": { "responses": {
"200": { "200": {
"description": "Текущее состояние", "description": "Текущее состояние",
@@ -750,7 +747,7 @@ def _build_paths():
"summary": "Список загруженных сервисов", "summary": "Список загруженных сервисов",
"description": "Возвращает все загруженные сервисы с количеством операций и параметров.", "description": "Возвращает все загруженные сервисы с количеством операций и параметров.",
"operationId": "mockServices", "operationId": "mockServices",
"security": [{"mockAuth": []}], "security": [{"mockAuth": []}, {}],
"responses": { "responses": {
"200": { "200": {
"description": "Список сервисов", "description": "Список сервисов",
@@ -768,7 +765,7 @@ def _build_paths():
"Минимум: 0, максимум: 5 (защита /health)." "Минимум: 0, максимум: 5 (защита /health)."
), ),
"operationId": "mockSetDelay", "operationId": "mockSetDelay",
"security": [{"mockAuth": []}], "security": [{"mockAuth": []}, {}],
"parameters": [ "parameters": [
{ {
"name": "seconds", "name": "seconds",
@@ -799,7 +796,7 @@ def _build_paths():
"Позволяет тестировать негативные сценарии." "Позволяет тестировать негативные сценарии."
), ),
"operationId": "mockFailNext", "operationId": "mockFailNext",
"security": [{"mockAuth": []}], "security": [{"mockAuth": []}, {}],
"responses": { "responses": {
"200": { "200": {
"description": "Флаг установлен", "description": "Флаг установлен",
+43 -22
View File
@@ -130,44 +130,48 @@ def create_operation():
Также заполняет kind и action операции из конфига сервиса — они нужны Также заполняет kind и action операции из конфига сервиса — они нужны
state_machine.apply_effect() чтобы понять что делать при run. 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") instance_uid = body.get("instanceUid")
operation = body.get("operation", "") operation = body.get("operation", "")
svc_op_id = body.get("svcOperationId") svc_op_id = body.get("svcOperationId")
# Валидация: instanceUid обязателен # Валидация: instanceUid обязателен
if not instance_uid: if not isinstance(instance_uid, str) or not instance_uid:
return jsonify({"error": "instanceUid is required"}), 400 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) inst = state.get_instance(instance_uid)
if not inst: if not inst:
return jsonify({"error": "instance not found"}), 404 return jsonify({"error": "instance not found"}), 404
# Если svcOperationId не передан — найти по имени операции svc_id = inst.get("serviceId")
# (например app-autotest при create не знает ID, передаёт только "create") svc_def = SERVICES.get(svc_id, {})
service_operations = svc_def.get("operations", [])
# При create app-autotest может передать только имя операции.
if svc_op_id is None: if svc_op_id is None:
svc_id = inst.get("serviceId") for op in service_operations:
svc_def = SERVICES.get(svc_id, {})
for op in svc_def.get("operations", []):
if op["operation"] == operation: if op["operation"] == operation:
svc_op_id = op["svcOperationId"] svc_op_id = op["svcOperationId"]
break break
elif not isinstance(svc_op_id, int) or isinstance(svc_op_id, bool):
return jsonify({"error": "svcOperationId must be an integer"}), 400
# Если и после поиска не нашли — операция не поддерживается сервисом # ID должен принадлежать именно сервису инстанса, не только глобальному индексу.
if svc_op_id is None: 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 return jsonify({"error": f"operation '{operation}' not found for service"}), 404
# Определить kind и action операции из конфига kind = target_op.get("kind", "instance")
# (один проход — поиск по svcOperationId, не по имени) action = target_op.get("action", operation)
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
# Создать операцию в MockState (dtFinish=None — ещё не выполнена) # Создать операцию в MockState (dtFinish=None — ещё не выполнена)
op_uid = state.create_operation(instance_uid, svc_op_id, operation, kind, action) 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. Значения сохраняются в mock_state.op_params[opUid][paramId] = value.
При apply_effect (modify) значения мержатся в state.params инстанса. При 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") op_uid = body.get("instanceOperationUid")
param_id = body.get("svcOperationCfsParamId") param_id = body.get("svcOperationCfsParamId")
value = body.get("paramValue", "") value = body.get("paramValue", "")
@@ -241,9 +248,23 @@ def set_operation_param():
if not isinstance(param_id, int) or isinstance(param_id, bool): if not isinstance(param_id, int) or isinstance(param_id, bool):
return jsonify({"error": "svcOperationCfsParamId must be an integer"}), 400 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 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) # Сохранить параметр (param_id — int, value — str)
state.set_param(op_uid, param_id, value) 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 VERSION = _cfg.VERSION
# Токен для авторизации в Swagger UI (предзаполняется)
_MOCK_AUTH_TOKEN = os.getenv("MOCK_AUTH_TOKEN", "test-token-123")
# URL для OpenAPI-спеке (сервер) # URL для OpenAPI-спеке (сервер)
_POLYGON_ENDPOINT = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru") _POLYGON_ENDPOINT = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru")
@@ -90,7 +87,7 @@ def swagger():
"""Swagger UI — интерактивная документация API. """Swagger UI — интерактивная документация API.
Спек OpenAPI встроена прямо в HTML — без отдельного HTTP-запроса. Спек OpenAPI встроена прямо в HTML — без отдельного HTTP-запроса.
Токен авторизации предзаполняется автоматически (MOCK_AUTH_TOKEN). Секрет MOCK_AUTH_TOKEN никогда не передаётся в браузер.
""" """
from routes.openapi import _build_spec from routes.openapi import _build_spec
import json as _json import json as _json
@@ -99,5 +96,4 @@ def swagger():
"swagger.html", "swagger.html",
version=_cfg.VERSION, version=_cfg.VERSION,
spec_json=spec_json, 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: if op.get("dtFinish") is not None:
return jsonify({"error": "operation already completed"}), 409 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() 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, {}) svc_def = services.get(svc_id, {})
template = svc_def.get("stateParams", {}) template = svc_def.get("stateParams", {})
inst["state"]["params"] = dict(template) inst["state"]["params"] = dict(template)
_merge_params(inst, op_params, op.get("svcOperationId"), services)
# stateOut из шаблона # stateOut из шаблона
state_out_template = svc_def.get("stateOut", {}) state_out_template = svc_def.get("stateOut", {})
inst["state"]["out"] = {k: dict(v) for k, v in state_out_template.items()} 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) _merge_params(inst, op_params, op.get("svcOperationId"), services)
elif action == "delete": elif action == "delete":
if instance_uid in mock_state.instances: mock_state.delete_instance(instance_uid)
del mock_state.instances[instance_uid]
elif action == "suspend": elif action == "suspend":
if inst: if inst:
+293 -183
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<!-- ── Header ── --> <!-- ── Header ── -->
<header class="header"> <header class="header">
<div class="header-top"> <div class="header-top">
<span class="badge badge-blue">polygon v{{ version }}</span> <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"> <img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" class="logo" width="108" height="24">
{% if stands_list|length > 1 %} {% if stands_list|length > 1 %}
{% for s in stands_list %} {% for s in stands_list %}
<a href="/{{ s.id }}/" class="stand-chip-inline {% if s.id == current_stand %}stand-active{% endif %}">{{ s.id }}</a> <a href="/{{ s.id }}/" class="stand-chip-inline {% if s.id == current_stand %}stand-active{% endif %}">{{
{% endfor %} s.id }}</a>
<span class="sep">·</span> {% endfor %}
{% endif %} <span class="sep">·</span>
<a href="/swagger" class="swagger-link">📋 Swagger</a> {% endif %}
<span class="sep">·</span> <a href="/swagger" class="swagger-link">📋 Swagger</a>
<span class="dot dot-green"></span> <span class="sep">·</span>
<span class="status-text">Работает</span> <span class="dot dot-green"></span>
<span class="sep">·</span> <span class="status-text">Работает</span>
<span class="header-stat">Сервисов: <strong>{{ svc_count }}</strong></span> <span class="sep">·</span>
<span class="sep">·</span> <span class="header-stat">Сервисов: <strong>{{ svc_count }}</strong></span>
<span class="header-stat">Инстансов: <strong>{{ inst_count }}</strong></span> <span class="sep">·</span>
<span class="sep">·</span> <span class="header-stat">Инстансов: <strong>{{ inst_count }}</strong></span>
<span class="header-stat">Задержка: <strong>{{ delay }}s</strong></span> <span class="sep">·</span>
</div> <span class="header-stat">Задержка: <strong>{{ delay }}s</strong></span>
</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>
</div> </div>
</div> </header>
</section>
<!-- ── Как использовать ── --> <main>
<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">
<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> <h3 class="h4">📡 Базовые команды</h3>
<pre class="code-block"><code>curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instances \
<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" \ -H "Content-Type: application/json" \
-d '{"serviceId": 1, "displayName": "my-instance"}' | python3 -m json.tool</code></pre> -d '{"serviceId": 1, "displayName": "my-instance"}' | python3 -m json.tool</code></pre>
<p><strong>Создать операцию, установить параметры, запустить:</strong></p> <p><strong>Создать операцию, установить параметры, запустить:</strong></p>
<pre class="code-block"><code># 1. Создать операцию (получить opUid из ответа) <pre
class="code-block"><code># 1. Создать операцию (получить opUid из ответа)
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instanceOperations \ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instanceOperations \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"instanceUid": "&lt;uid&gt;", "operation": "create", "svcOperationId": 18}' -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) # 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> 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-grid">
<div class="feature-item"> <div class="feature-item">
<h4>⏱ Задержка операций</h4> <h4>⏱ Задержка операций</h4>
<p>Каждый <code>POST /run</code> ждёт <strong>{{ delay }} секунд</strong> перед ответом — эмуляция времени выполнения в облаке.</p> <p>Каждый <code>POST /run</code> ждёт <strong>{{ delay }} секунд</strong> перед ответом —
<p><strong>Текущая:</strong> <code>{{ delay }}s</code></p> эмуляция времени выполнения в облаке.</p>
<pre class="code-block"><code># Сделать операции мгновенными <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 curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/delay/0
# Поставить задержку 3 секунды (эмуляция долгой операции) # Поставить задержку 3 секунды (эмуляция долгой операции)
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/delay/3</code></pre> 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"> <div class="feature-item">
<h4>🧹 Сброс состояния</h4> <h4>🧹 Сброс состояния</h4>
<p>Удаляет <strong>все</strong> инстансы и операции на текущем стенде. Полезно перед каждым тестом.</p> <p>Удаляет <strong>все</strong> инстансы и операции на текущем стенде. Полезно перед каждым
<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> тестом.</p>
</div> <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"> <div class="feature-item">
<h4>💥 Симуляция ошибок</h4> <h4>💥 Симуляция ошибок</h4>
<p>Следующий <code>POST /run</code> <strong>упадёт</strong> с ошибкой. One-shot: после использования флаг сбрасывается.</p> <p>Следующий <code>POST /run</code> <strong>упадёт</strong> с ошибкой. One-shot: после
<pre class="code-block"><code># Включить использования флаг сбрасывается.</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 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..."} # Следующий run → {"ok": false, "error": "mock failure..."}
# После этого — снова работает нормально</code></pre> # После этого — снова работает нормально</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> </div>
</section>
<div class="feature-item"> <!-- ── API ── -->
<h4>📊 Отладка</h4> <section class="card">
<p>Посмотреть текущее состояние всех инстансов и операций:</p> <div class="card-header">
<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> <h2>API эндпоинты</h2>
<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>
</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> </main>
<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>
<!-- ── API ── --> <footer class="footer">
<section class="card"> polygon v{{ version }} · Nubes Mock API · Flask + gunicorn
<div class="card-header"> </footer>
<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>
</body> </body>
</html>
</html>
+43 -35
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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="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"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<style> <style>
html { box-sizing: border-box; overflow-y: scroll; } html {
*, *:before, *:after { box-sizing: inherit; } box-sizing: border-box;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body { body {
margin: 0; margin: 0;
background: #fafafa; background: #fafafa;
} }
/* Nubes-брендированный topbar */ /* Nubes-брендированный topbar */
.swagger-ui .topbar { .swagger-ui .topbar {
background: #fff; background: #fff;
border-bottom: 4px solid #2563eb; border-bottom: 4px solid #2563eb;
padding: 10px 0; padding: 10px 0;
} }
.swagger-ui .topbar .wrapper { .swagger-ui .topbar .wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -27,15 +39,18 @@
margin: 0 auto; margin: 0 auto;
padding: 0 16px; padding: 0 16px;
} }
.swagger-ui .topbar a { .swagger-ui .topbar a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.swagger-ui .topbar img.logo { .swagger-ui .topbar img.logo {
height: 28px; height: 28px;
width: auto; width: auto;
} }
.swagger-ui .topbar .version-badge { .swagger-ui .topbar .version-badge {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
@@ -45,6 +60,7 @@
font-weight: 600; font-weight: 600;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
.swagger-ui .topbar .nav-link { .swagger-ui .topbar .nav-link {
color: #6b7280; color: #6b7280;
font-size: 13px; font-size: 13px;
@@ -52,58 +68,49 @@
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
margin-left: auto; margin-left: auto;
} }
.swagger-ui .topbar .nav-link:hover { .swagger-ui .topbar .nav-link:hover {
color: #2563eb; color: #2563eb;
} }
/* Скрываем лого Swagger по умолчанию (заменяем своим) */ /* Скрываем лого Swagger по умолчанию (заменяем своим) */
.swagger-ui .topbar .link img[alt="Swagger UI"] { .swagger-ui .topbar .link img[alt="Swagger UI"] {
display: none; display: none;
} }
/* Инфо-блок в description */ /* Инфо-блок в description */
.swagger-ui .markdown p { .swagger-ui .markdown p {
font-size: 14px; font-size: 14px;
} }
.swagger-ui .info .title { .swagger-ui .info .title {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
} }
</style> </style>
</head> </head>
<body> <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-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-standalone-preset.js" crossorigin></script>
<script> <script>
window.onload = function () { window.onload = function () {
const ui = SwaggerUIBundle({ const ui = SwaggerUIBundle({
spec: {{ spec_json | safe }}, spec: {{ spec_json | safe }},
dom_id: "#swagger-ui", dom_id: "#swagger-ui",
deepLinking: true, deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl], plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: "StandaloneLayout", layout: "StandaloneLayout",
defaultModelsExpandDepth: 1, defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 2, defaultModelExpandDepth: 2,
docExpansion: "list", docExpansion: "list",
filter: true, filter: true,
showExtensions: true, showExtensions: true,
showCommonExtensions: 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;
},
}); });
// Кастомный topbar с лого Nubes // Кастомный topbar с лого Nubes
@@ -128,7 +135,8 @@
}); });
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
}; };
</script> </script>
</body> </body>
</html>
</html>
+5
View File
@@ -10,6 +10,11 @@ compare_test.py — Polygon ↔ Nubes API (v2, трёхкатегорийная
exit(1) только при BUG > 0. 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 html as _html
import os import os
import sys import sys
+91 -52
View File
@@ -1,13 +1,26 @@
#!/usr/bin/env python3 #!/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 from app import app
import json import json
import config.loader as config
client = app.test_client() client = app.test_client()
H = {"Content-Type": "application/json"} H = {"Content-Type": "application/json"}
MH = {**H, "X-Mock-Auth": "test-token-123"} AUTH = os.getenv("MOCK_AUTH_TOKEN", "")
stands = ["dev", "test", "prod"] MH = {**H, **({"X-Mock-Auth": AUTH} if AUTH else {})}
stands = config.list_stands()
passed = 0 passed = 0
failed = 0 failed = 0
errors = [] errors = []
@@ -89,7 +102,7 @@ for sid in stands:
json={"serviceId": 0, "displayName": "x"}, headers=H), 400)) json={"serviceId": 0, "displayName": "x"}, headers=H), 400))
t("displayName=число", lambda: st(client.post(f"{base}/api/v1/svc/instances", 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", t("paramId строка", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams",
json={"instanceOperationUid": "00000000-0000-0000-0000-000000000000", json={"instanceOperationUid": "00000000-0000-0000-0000-000000000000",
@@ -98,9 +111,10 @@ for sid in stands:
# ═══ 3. PAGINATION ═══ # ═══ 3. PAGINATION ═══
print("\n── Пагинация ──") print("\n── Пагинация ──")
t("pageSize=999999", lambda: ( def test_page_size_upper_bound():
d := js(client.get(f"{base}/api/v1/svc/instances?pageSize=999999")), d = js(client.get(f"{base}/api/v1/svc/instances?pageSize=999999"))
d["pageSize"] <= 200 or True)) 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)) 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("pageSize=''", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize="), 200))
t("page=-100", lambda: ( def test_page_lower_bound():
d := js(client.get(f"{base}/api/v1/svc/instances?page=-100")), d = js(client.get(f"{base}/api/v1/svc/instances?page=-100"))
d["page"] >= 1 or True)) assert d["page"] == 1
t("page=-100", test_page_lower_bound)
# ═══ 4. INVALID UUID ═══ # ═══ 4. INVALID UUID ═══
print("\n── Невалидные UUID ──") print("\n── Невалидные UUID ──")
@@ -190,20 +205,28 @@ for sid in stands:
# ═══ 9. PATH TRAVERSAL ═══ # ═══ 9. PATH TRAVERSAL ═══
print("\n── Path traversal ──") print("\n── Path traversal ──")
t("GET /../services", 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))
t("GET двойной слеш", lambda: st(client.get(f"{base}//api/v1/svc/services"), 308))
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 ═══ # ═══ 10. DELAY ═══
print("\n── Задержка ──") print("\n── Задержка ──")
t("delay 999", lambda: st(client.post(f"{base}/api/v1/svc/_mock/delay/999", headers=MH), 400)) 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 -1", lambda: st(client.post(f"{base}/api/v1/svc/_mock/delay/-1", headers=MH), 400))
t("delay 0", lambda: ( def test_zero_delay():
d := js(client.post(f"{base}/api/v1/svc/_mock/delay/0", headers=MH)), d = js(client.post(f"{base}/api/v1/svc/_mock/delay/0", headers=MH))
d["delay"] == 0 or True)) assert d["delay"] == 0
t("delay 5.0", lambda: ( t("delay 0", test_zero_delay)
d := js(client.post(f"{base}/api/v1/svc/_mock/delay/5", headers=MH)),
d["delay"] == 5.0 or True)) 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) client.post(f"{base}/api/v1/svc/_mock/delay/0.1", headers=MH)
# ═══ 11. FIELDS ═══ # ═══ 11. FIELDS ═══
@@ -225,13 +248,9 @@ for sid in stands:
# ═══ 13. PAGES ═══ # ═══ 13. PAGES ═══
print("\n── Страницы ──") print("\n── Страницы ──")
t("GET /{sid}/ → HTML", lambda: ( t("GET /{sid}/ → HTML", lambda: st(client.get(f"{base}/"), 200))
r := client.get(f"{base}/"), st(r, 200),
b"Polygon" in r.data or True))
t("GET /swagger → HTML", lambda: ( t("GET /swagger → HTML", lambda: st(client.get("/swagger"), 200))
r := client.get("/swagger"), st(r, 200),
b"swagger" in r.data.lower() or True))
t("GET /health → OK", lambda: ( t("GET /health → OK", lambda: (
r := client.get("/health"), st(r, 200), r.data == b"OK")) 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}" assert inst["instance"]["displayName"] == f"only-on-{sid}"
t(f"{sid}: только свой инстанс", check_isolated) t(f"{sid}: только свой инстанс", check_isolated)
# Cross-stand: touch instance from wrong stand # Cross-stand checks apply only when at least two stands are configured.
t("чужая instance → 404", lambda: ( if len(stands) >= 2:
st(client.get(f"/dev/api/v1/svc/instances/{inst_by_stand['test']}"), 404))) 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: ( client.post(f"/{first_stand}/api/v1/svc/_mock/reset", headers=MH)
st(client.get(f"/prod/api/v1/svc/instances/{inst_by_stand['dev']}"), 404))) 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 def test_fail_isolation():
client.post(f"/dev/api/v1/svc/_mock/reset", headers=MH) first_instance = js(client.post(
client.post(f"/test/api/v1/svc/_mock/reset", headers=MH) f"/{first_stand}/api/v1/svc/instances",
client.post(f"/dev/api/v1/svc/_mock/fail-next", headers=MH) 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(): second_instance = js(client.post(
# dev: fail-next active f"/{second_stand}/api/v1/svc/instances",
r = client.post(f"/dev/api/v1/svc/instances", json={"serviceId": 1, "displayName": "f"}, headers=H) json={"serviceId": 1, "displayName": "normal"}, headers=H,
fi = js(r)["instanceUid"] ))["instanceUid"]
r = client.post(f"/dev/api/v1/svc/instanceOperations", second_operation = js(client.post(
json={"instanceUid": fi, "operation": "create", "svcOperationId": 18}, headers=H) f"/{second_stand}/api/v1/svc/instanceOperations",
fo = js(r)["instanceOperationUid"] json={"instanceUid": second_instance, "operation": "create", "svcOperationId": 18},
d = js(client.post(f"/dev/api/v1/svc/instanceOperations/{fo}/run", headers=H)) headers=H,
assert d["ok"] is False, "dev должен упасть" ))["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, должен работать t("fail-next изоляция между стендами", test_fail_isolation)
r = client.post(f"/test/api/v1/svc/instances", json={"serviceId": 1, "displayName": "ok"}, headers=H) else:
ti = js(r)["instanceUid"] print(" Cross-stand isolation skipped: fewer than two stands configured")
r = client.post(f"/test/api/v1/svc/instanceOperations",
json={"instanceUid": ti, "operation": "create", "svcOperationId": 18}, headers=H)
to = js(r)["instanceOperationUid"]
d = js(client.post(f"/test/api/v1/svc/instanceOperations/{to}/run", headers=H))
assert d["ok"] is True, "test должен работать"
t("fail-next изоляция: dev падает, test работает", test_fail_isolation)
# ═══════ SUMMARY ═══════ # ═══════ SUMMARY ═══════
@@ -303,5 +342,5 @@ if failed:
for e in errors: for e in errors:
print(f" {e}") print(f" {e}")
else: else:
print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} тестов × 3 стенда!") print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} проверок, {len(stands)} стенд(а)")
print(f"{'='*55}") print(f"{'='*55}")
+5
View File
@@ -6,6 +6,11 @@ test_api.py — smoke + edge-case тесты Polygon API.
Требует: pip install requests Требует: 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 os
import sys import sys
import json 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"]["resourceRealm"] == "dummy"
assert inst["state"]["params"]["durationMs"] == "0" 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): def test_create_sets_state_out(self):
"""create → state.out из шаблона (пустой для dummy, заполнен для pg).""" """create → state.out из шаблона (пустой для dummy, заполнен для pg)."""
uid, _ = _create_and_run(90, "pg", 19, "create") uid, _ = _create_and_run(90, "pg", 19, "create")
@@ -92,10 +106,13 @@ class TestInstanceOperations:
assert "databases" in inst["state"]["out"] assert "databases" in inst["state"]["out"]
def test_delete_removes_instance(self): def test_delete_removes_instance(self):
"""delete → инстанс удалён.""" """delete удаляет инстанс, но сохраняет историю операций для polling."""
uid, _ = _create_and_run(1, "test", 18, "create") uid, create_op_uid = _create_and_run(1, "test", 18, "create")
_run_op(uid, 71, "delete", action="delete") delete_op_uid = _run_op(uid, 71, "delete", action="delete")
assert mock_state.state.get_instance(uid) is None 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): def test_suspend_sets_suspended(self):
"""suspend → статус suspended.""" """suspend → статус suspended."""