From e3a031ce50e3ce27e44acf0c5fbff01320faff18 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sun, 25 Oct 2026 14:07:13 +0300 Subject: [PATCH] fix: harden Polygon operation validation --- README.md | 33 ++- site/config/loader.py | 2 +- site/from_stands.py | 4 +- site/mock_state.py | 11 +- site/routes/instances_routes.py | 3 + site/routes/mock_routes.py | 11 +- site/routes/openapi.py | 49 ++-- site/routes/operations_routes.py | 65 +++-- site/routes/root.py | 6 +- site/routes/run.py | 3 + site/state_machine.py | 4 +- site/templates/index.html | 476 +++++++++++++++++++------------ site/templates/swagger.html | 78 ++--- tests/compare_test.py | 5 + tests/fuzz_test.py | 143 ++++++---- tests/test_api.py | 5 + tests/test_route_regressions.py | 228 +++++++++++++++ tests/test_state_machine.py | 23 +- 18 files changed, 803 insertions(+), 346 deletions(-) create mode 100644 tests/test_route_regressions.py diff --git a/README.md b/README.md index 5d55898..20a0d02 100644 --- a/README.md +++ b/README.md @@ -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//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/` | Операции сервиса | | GET | `/api/v1/svc/instances` | Пагинация | | GET | `/api/v1/svc/instances/` | Инстанс + state | | POST | `/api/v1/svc/instances` | Создать → 201 + Location | -| GET | `/api/v1/svc/instanceOperations/default/` | cfsParams | +| GET | `/api/v1/svc/instanceOperations/default/` | Шаблон операции и cfsParams | | POST | `/api/v1/svc/instanceOperations` | Создать операцию → 201 + Location | | GET | `/api/v1/svc/instanceOperations/` | Статус + 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/` | 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-воркер, поскольку состояние хранится в памяти | ## Деплой diff --git a/site/config/loader.py b/site/config/loader.py index 80335e5..a5d7025 100644 --- a/site/config/loader.py +++ b/site/config/loader.py @@ -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): diff --git a/site/from_stands.py b/site/from_stands.py index 4527d4a..207891d 100644 --- a/site/from_stands.py +++ b/site/from_stands.py @@ -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}") diff --git a/site/mock_state.py b/site/mock_state.py index 11a01db..4e0dcb6 100644 --- a/site/mock_state.py +++ b/site/mock_state.py @@ -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()) diff --git a/site/routes/instances_routes.py b/site/routes/instances_routes.py index 183a405..e089dc5 100644 --- a/site/routes/instances_routes.py +++ b/site/routes/instances_routes.py @@ -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 diff --git a/site/routes/mock_routes.py b/site/routes/mock_routes.py index e7c6630..09d21a2 100644 --- a/site/routes/mock_routes.py +++ b/site/routes/mock_routes.py @@ -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: diff --git a/site/routes/openapi.py b/site/routes/openapi.py index a2a90c0..f009b42 100644 --- a/site/routes/openapi.py +++ b/site/routes/openapi.py @@ -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": "Флаг установлен", diff --git a/site/routes/operations_routes.py b/site/routes/operations_routes.py index 2fc7495..b7d3c3f 100644 --- a/site/routes/operations_routes.py +++ b/site/routes/operations_routes.py @@ -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) diff --git a/site/routes/root.py b/site/routes/root.py index 40a13bf..73ce87b 100644 --- a/site/routes/root.py +++ b/site/routes/root.py @@ -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, ) diff --git a/site/routes/run.py b/site/routes/run.py index abeb2f3..78fdf74 100644 --- a/site/routes/run.py +++ b/site/routes/run.py @@ -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() diff --git a/site/state_machine.py b/site/state_machine.py index 171f438..7573b69 100644 --- a/site/state_machine.py +++ b/site/state_machine.py @@ -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: diff --git a/site/templates/index.html b/site/templates/index.html index 63d7931..1409913 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -1,5 +1,6 @@ + @@ -7,90 +8,95 @@ + - -
-
- polygon v{{ version }} - - {% if stands_list|length > 1 %} - {% for s in stands_list %} - {{ s.id }} - {% endfor %} - · - {% endif %} - 📋 Swagger - · - - Работает - · - Сервисов: {{ svc_count }} - · - Инстансов: {{ inst_count }} - · - Задержка: {{ delay }}s -
-
- -
- - -
-
-

Что такое Polygon?

-
-
-

- Polygon — это эмулятор REST API облачной платформы - Nubes для интеграционных тестов. - Он полностью повторяет контракты реального API: сервисы, инстансы, - операции, параметры, валидацию — но работает без реальной - инфраструктуры, в памяти, с мгновенным откликом. -

-
-
-

Зачем нужен

-
    -
  • Тестировать создание/изменение/удаление инстансов без реальных ресурсов
  • -
  • Отлаживать UI автодеплоя (app-autotest) на мок-данных
  • -
  • Писать интеграционные тесты с детерминированным состоянием
  • -
  • Проверять краевые случаи (ошибки валидации, сбои операций)
  • -
-
-
-

Как устроен

-
    -
  • Flask 3.0 + gunicorn, 1 воркер (состояние в памяти)
  • -
  • Конфигурация сервисов — из YAML (генерируется из STANDS)
  • -
  • Все операции — синхронные, с настраиваемой задержкой
  • -
  • Управляемый сброс состояния через /_mock/reset
  • -
-
+ +
+
+ polygon v{{ version }} + + {% if stands_list|length > 1 %} + {% for s in stands_list %} + {{ + s.id }} + {% endfor %} + · + {% endif %} + 📋 Swagger + · + + Работает + · + Сервисов: {{ svc_count }} + · + Инстансов: {{ inst_count }} + · + Задержка: {{ delay }}s
-
-
+ - -
-
-

Как использовать

- {% if stands_list|length > 1 %}Стенд: {{ current_stand }}{% endif %} -
-
+
-

📡 Базовые команды

+ +
+
+

Что такое Polygon?

+
+
+

+ Polygon — это эмулятор REST API облачной платформы + Nubes для интеграционных тестов. + Он полностью повторяет контракты реального API: сервисы, инстансы, + операции, параметры, валидацию — но работает без реальной + инфраструктуры, в памяти, с мгновенным откликом. +

+
+
+

Зачем нужен

+
    +
  • Тестировать создание/изменение/удаление инстансов без реальных ресурсов
  • +
  • Отлаживать UI автодеплоя (app-autotest) на мок-данных
  • +
  • Писать интеграционные тесты с детерминированным состоянием
  • +
  • Проверять краевые случаи (ошибки валидации, сбои операций)
  • +
+
+
+

Как устроен

+
    +
  • Flask 3.0 + gunicorn, 1 воркер (состояние в памяти)
  • +
  • Конфигурация сервисов — из YAML (генерируется из STANDS)
  • +
  • Все операции — синхронные, с настраиваемой задержкой
  • +
  • Управляемый сброс состояния через /_mock/reset
  • +
+
+
+
+
-

Получить список сервисов:

-
curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/services | python3 -m json.tool
+ +
+
+

Как использовать

+ {% if stands_list|length > 1 %}Стенд: {{ current_stand }}{% + endif %} +
+
-

Создать инстанс:

-
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instances \
+                

📡 Базовые команды

+ +

Получить список сервисов:

+
curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/services | python3 -m json.tool
+ +

Создать инстанс:

+
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
-

Создать операцию, установить параметры, запустить:

-
# 1. Создать операцию (получить opUid из ответа)
+                

Создать операцию, установить параметры, запустить:

+
# 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": "<uid>", "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/<opUid>/run
-
+
-

🛠 Управление полигоном (служебные эндпоинты)

+

🛠 Управление полигоном (служебные эндпоинты)

+

Если на сервере задан MOCK_AUTH_TOKEN, для запросов к /_mock/* добавьте + заголовок -H "X-Mock-Auth: <token>".

-
-
-

⏱ Задержка операций

-

Каждый POST /run ждёт {{ delay }} секунд перед ответом — эмуляция времени выполнения в облаке.

-

Текущая: {{ delay }}s

-
# Сделать операции мгновенными
+                
+
+

⏱ Задержка операций

+

Каждый POST /run ждёт {{ delay }} секунд перед ответом — + эмуляция времени выполнения в облаке.

+

Текущая: {{ delay }}s

+
# Сделать операции мгновенными
 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
-
+
-
-

🧹 Сброс состояния

-

Удаляет все инстансы и операции на текущем стенде. Полезно перед каждым тестом.

-
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/reset
-
+
+

🧹 Сброс состояния

+

Удаляет все инстансы и операции на текущем стенде. Полезно перед каждым + тестом.

+
curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/reset
+
-
-

💥 Симуляция ошибок

-

Следующий POST /run упадёт с ошибкой. One-shot: после использования флаг сбрасывается.

-
# Включить
+                    
+

💥 Симуляция ошибок

+

Следующий POST /run упадёт с ошибкой. One-shot: после + использования флаг сбрасывается.

+
# Включить
 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..."}
 # После этого — снова работает нормально
+
+ +
+

📊 Отладка

+

Посмотреть текущее состояние всех инстансов и операций:

+
curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/state | python3 -m json.tool
+

Список загруженных сервисов:

+
curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/services | python3 -m json.tool
+
+
+ +
+ +

🔁 Жизненный цикл операции

+
    +
  1. GET /api/v1/svc/instanceOperations/default/{svcOperationId} — узнать какие + параметры нужны
  2. +
  3. POST /api/v1/svc/instanceOperations — создать операцию (получить opUid)
  4. +
  5. POST /api/v1/svc/instanceOperationCfsParams × N — установить каждый параметр
  6. +
  7. GET /api/v1/svc/instanceOperations/{opUid}/validate-cfs — проверить корректность +
  8. +
  9. POST /api/v1/svc/instanceOperations/{opUid}/run — выполнить (ждёт {{ delay + }}s)
  10. +
  11. GET /api/v1/svc/instanceOperations/{opUid}?fields=dtFinish,isSuccessful — проверить + результат
  12. +
+
-
-

📊 Отладка

-

Посмотреть текущее состояние всех инстансов и операций:

-
curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/state | python3 -m json.tool
-

Список загруженных сервисов:

-
curl -s {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/_mock/services | python3 -m json.tool
+ +
+
+

API эндпоинты

-
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
МетодПутьОписание
GET/healthHealthcheck
GET/Главная страница
GET/api/v1/svc/servicesСписок сервисов
GET/api/v1/svc/services/<id>Детали сервиса
GET/api/v1/svc/instancesСписок инстансов
GET/api/v1/svc/instances/<uid>Детали инстанса
POST/api/v1/svc/instancesСоздать инстанс
GET/api/v1/svc/instanceOperations/default/<id>Шаблон операции и параметры
POST/api/v1/svc/instanceOperationsСоздать операцию
GET/api/v1/svc/instanceOperations/<uid>Статус операции
POST/api/v1/svc/instanceOperationCfsParamsУстановить параметр операции
GET/api/v1/svc/instanceOperations/<uid>/validate-cfsПроверить параметры
POST/api/v1/svc/instanceOperations/<uid>/runЗапустить операцию
POST/api/v1/svc/_mock/resetСброс состояния
GET/api/v1/svc/_mock/stateДамп состояния
GET/api/v1/svc/_mock/servicesСписок загруженных сервисов
POST/api/v1/svc/_mock/delay/<s>Задать задержку
POST/api/v1/svc/_mock/fail-nextСледующая операция упадёт
GET/swaggerSwagger UI
GET/api/v1/svc/openapi.jsonOpenAPI JSON
+
+
-
+ +
+
+

Загруженные сервисы ({{ svc_count }})

+
+
+ + + + + + + + + + + + + {% for svc in svc_list %} + + + + + + + + + {% endfor %} + +
IDНазваниеОперацийCfsParamsStateParamsStateOut
{{ svc.id }}{{ svc.display_name }}{{ svc.op_count }}{% if svc.has_cfs %}{% else %}{% endif %}{% if svc.has_state %}{% else %}{% endif %}{% if svc.has_out %}{% else %}{% endif %}
+
+
-

🔁 Жизненный цикл операции

-
    -
  1. GET /instanceOperations/default/{svcOperationId} — узнать какие параметры нужны
  2. -
  3. POST /instanceOperations — создать операцию (получить opUid)
  4. -
  5. POST /instanceOperationCfsParams × N — установить каждый параметр
  6. -
  7. GET /{opUid}/validate-cfs — проверить корректность
  8. -
  9. POST /{opUid}/run — выполнить (ждёт {{ delay }}s)
  10. -
  11. GET /{opUid}?fields=dtFinish,isSuccessful — проверить результат
  12. -
- - +
- -
-
-

API эндпоинты

-
-
- - - - - - - - - - - - - - - - - - - - - - - -
МетодПутьОписание
GET/healthHealthcheck
GET/api/v1/svc/servicesСписок сервисов
GET/api/v1/svc/services/<id>Детали сервиса
GET/api/v1/svc/instancesСписок инстансов
GET/api/v1/svc/instances/<uid>Детали инстанса
POST/api/v1/svc/instancesСоздать инстанс
GET/api/v1/svc/instanceOperationsСписок операций
POST/api/v1/svc/instanceOperations/<uid>/runЗапустить операцию
GET/api/v1/svc/instanceOperations/<uid>/statusСтатус операции
POST/api/v1/svc/_mock/resetСброс состояния
GET/api/v1/svc/_mock/stateДамп состояния
POST/api/v1/svc/_mock/delay/<s>Задать задержку
POST/api/v1/svc/_mock/fail-nextСледующая операция упадёт
-
-
- - -
-
-

Загруженные сервисы ({{ svc_count }})

-
-
- - - - - - - - - - - - - {% for svc in svc_list %} - - - - - - - - - {% endfor %} - -
IDНазваниеОперацийCfsParamsStateParamsStateOut
{{ svc.id }}{{ svc.display_name }}{{ svc.op_count }}{% if svc.has_cfs %}{% else %}{% endif %}{% if svc.has_state %}{% else %}{% endif %}{% if svc.has_out %}{% else %}{% endif %}
-
-
- - - -
- polygon v{{ version }} · Nubes Mock API · Flask + gunicorn -
+
+ polygon v{{ version }} · Nubes Mock API · Flask + gunicorn +
- + + \ No newline at end of file diff --git a/site/templates/swagger.html b/site/templates/swagger.html index e4cf98e..96fff0a 100644 --- a/site/templates/swagger.html +++ b/site/templates/swagger.html @@ -1,5 +1,6 @@ + @@ -7,18 +8,29 @@ + -
+
- - + + - + - + + \ No newline at end of file diff --git a/tests/compare_test.py b/tests/compare_test.py index 23c1c96..77e7b1d 100644 --- a/tests/compare_test.py +++ b/tests/compare_test.py @@ -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 diff --git a/tests/fuzz_test.py b/tests/fuzz_test.py index 4582309..5fc7d08 100644 --- a/tests/fuzz_test.py +++ b/tests/fuzz_test.py @@ -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}") diff --git a/tests/test_api.py b/tests/test_api.py index 43fab09..7eaf9bf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_route_regressions.py b/tests/test_route_regressions.py new file mode 100644 index 0000000..9bba04e --- /dev/null +++ b/tests/test_route_regressions.py @@ -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/<id>" 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/<uid>/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) == {} diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index ac8973b..1c0f266 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -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."""