fix: harden Polygon operation validation
This commit is contained in:
@@ -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-воркер, поскольку состояние хранится в памяти |
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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": "Флаг установлен",
|
||||||
|
|||||||
@@ -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 не передан — найти по имени операции
|
|
||||||
# (например app-autotest при create не знает ID, передаёт только "create")
|
|
||||||
if svc_op_id is None:
|
|
||||||
svc_id = inst.get("serviceId")
|
svc_id = inst.get("serviceId")
|
||||||
svc_def = SERVICES.get(svc_id, {})
|
svc_def = SERVICES.get(svc_id, {})
|
||||||
for op in svc_def.get("operations", []):
|
service_operations = svc_def.get("operations", [])
|
||||||
|
|
||||||
|
# При create app-autotest может передать только имя операции.
|
||||||
|
if svc_op_id is None:
|
||||||
|
for op in service_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
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+143
-33
@@ -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,6 +8,7 @@
|
|||||||
<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 ── -->
|
||||||
@@ -16,7 +18,8 @@
|
|||||||
<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 %}">{{
|
||||||
|
s.id }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -75,14 +78,16 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Как использовать</h2>
|
<h2>Как использовать</h2>
|
||||||
{% if stands_list|length > 1 %}<span class="muted">Стенд: <strong>{{ current_stand }}</strong></span>{% endif %}
|
{% if stands_list|length > 1 %}<span class="muted">Стенд: <strong>{{ current_stand }}</strong></span>{%
|
||||||
|
endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<h3 class="h4">📡 Базовые команды</h3>
|
<h3 class="h4">📡 Базовые команды</h3>
|
||||||
|
|
||||||
<p><strong>Получить список сервисов:</strong></p>
|
<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>
|
<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>
|
<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 \
|
<pre class="code-block"><code>curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }}/{% endif %}api/v1/svc/instances \
|
||||||
@@ -90,7 +95,8 @@
|
|||||||
-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": "<uid>", "operation": "create", "svcOperationId": 18}'
|
-d '{"instanceUid": "<uid>", "operation": "create", "svcOperationId": 18}'
|
||||||
@@ -106,13 +112,17 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
<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: <token>"</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>
|
||||||
<p><strong>Текущая:</strong> <code>{{ delay }}s</code></p>
|
<p><strong>Текущая:</strong> <code>{{ delay }}s</code></p>
|
||||||
<pre class="code-block"><code># Сделать операции мгновенными
|
<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 секунды (эмуляция долгой операции)
|
||||||
@@ -121,13 +131,16 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
|
|
||||||
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<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: после
|
||||||
|
использования флаг сбрасывается.</p>
|
||||||
<pre class="code-block"><code># Включить
|
<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
|
||||||
|
|
||||||
@@ -138,9 +151,11 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
<div class="feature-item">
|
<div class="feature-item">
|
||||||
<h4>📊 Отладка</h4>
|
<h4>📊 Отладка</h4>
|
||||||
<p>Посмотреть текущее состояние всех инстансов и операций:</p>
|
<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>
|
<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>
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -148,12 +163,16 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
|
|
||||||
<h3 class="h4">🔁 Жизненный цикл операции</h3>
|
<h3 class="h4">🔁 Жизненный цикл операции</h3>
|
||||||
<ol style="line-height: 2;">
|
<ol style="line-height: 2;">
|
||||||
<li><code>GET /instanceOperations/default/{svcOperationId}</code> — узнать какие параметры нужны</li>
|
<li><code>GET /api/v1/svc/instanceOperations/default/{svcOperationId}</code> — узнать какие
|
||||||
<li><code>POST /instanceOperations</code> — создать операцию (получить opUid)</li>
|
параметры нужны</li>
|
||||||
<li><code>POST /instanceOperationCfsParams</code> × N — установить каждый параметр</li>
|
<li><code>POST /api/v1/svc/instanceOperations</code> — создать операцию (получить opUid)</li>
|
||||||
<li><code>GET /{opUid}/validate-cfs</code> — проверить корректность</li>
|
<li><code>POST /api/v1/svc/instanceOperationCfsParams</code> × N — установить каждый параметр</li>
|
||||||
<li><code>POST /{opUid}/run</code> — выполнить (ждёт <strong>{{ delay }}s</strong>)</li>
|
<li><code>GET /api/v1/svc/instanceOperations/{opUid}/validate-cfs</code> — проверить корректность
|
||||||
<li><code>GET /{opUid}?fields=dtFinish,isSuccessful</code> — проверить результат</li>
|
</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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -173,19 +192,106 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/health</code></td><td>Healthcheck</td></tr>
|
<tr>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/services</code></td><td>Список сервисов</td></tr>
|
<td><span class="method method-get">GET</span></td>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/services/<id></code></td><td>Детали сервиса</td></tr>
|
<td><code>/health</code></td>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instances</code></td><td>Список инстансов</td></tr>
|
<td>Healthcheck</td>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instances/<uid></code></td><td>Детали инстанса</td></tr>
|
</tr>
|
||||||
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/instances</code></td><td>Создать инстанс</td></tr>
|
<tr>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instanceOperations</code></td><td>Список операций</td></tr>
|
<td><span class="method method-get">GET</span></td>
|
||||||
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/instanceOperations/<uid>/run</code></td><td>Запустить операцию</td></tr>
|
<td><code>/</code></td>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/instanceOperations/<uid>/status</code></td><td>Статус операции</td></tr>
|
<td>Главная страница</td>
|
||||||
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/_mock/reset</code></td><td>Сброс состояния</td></tr>
|
</tr>
|
||||||
<tr><td><span class="method method-get">GET</span></td><td><code>/api/v1/svc/_mock/state</code></td><td>Дамп состояния</td></tr>
|
<tr>
|
||||||
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/_mock/delay/<s></code></td><td>Задать задержку</td></tr>
|
<td><span class="method method-get">GET</span></td>
|
||||||
<tr><td><span class="method method-post">POST</span></td><td><code>/api/v1/svc/_mock/fail-next</code></td><td>Следующая операция упадёт</td></tr>
|
<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/<id></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/<uid></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/<id></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/<uid></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/<uid>/validate-cfs</code></td>
|
||||||
|
<td>Проверить параметры</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="method method-post">POST</span></td>
|
||||||
|
<td><code>/api/v1/svc/instanceOperations/<uid>/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/<s></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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,9 +320,12 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
<td><code>{{ svc.id }}</code></td>
|
<td><code>{{ svc.id }}</code></td>
|
||||||
<td>{{ svc.display_name }}</td>
|
<td>{{ svc.display_name }}</td>
|
||||||
<td>{{ svc.op_count }}</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_cfs %}<span class="dot dot-green"></span>{% else %}<span
|
||||||
<td>{% if svc.has_state %}<span class="dot dot-green"></span>{% else %}<span class="dot dot-gray"></span>{% endif %}</td>
|
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>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -231,4 +340,5 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
+24
-16
@@ -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,22 +68,27 @@
|
|||||||
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>
|
||||||
@@ -90,20 +111,6 @@
|
|||||||
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
|
||||||
@@ -131,4 +138,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -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
@@ -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
|
|
||||||
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():
|
def test_fail_isolation():
|
||||||
# dev: fail-next active
|
first_instance = js(client.post(
|
||||||
r = client.post(f"/dev/api/v1/svc/instances", json={"serviceId": 1, "displayName": "f"}, headers=H)
|
f"/{first_stand}/api/v1/svc/instances",
|
||||||
fi = js(r)["instanceUid"]
|
json={"serviceId": 1, "displayName": "fail-isolation"}, headers=H,
|
||||||
r = client.post(f"/dev/api/v1/svc/instanceOperations",
|
))["instanceUid"]
|
||||||
json={"instanceUid": fi, "operation": "create", "svcOperationId": 18}, headers=H)
|
first_operation = js(client.post(
|
||||||
fo = js(r)["instanceOperationUid"]
|
f"/{first_stand}/api/v1/svc/instanceOperations",
|
||||||
d = js(client.post(f"/dev/api/v1/svc/instanceOperations/{fo}/run", headers=H))
|
json={"instanceUid": first_instance, "operation": "create", "svcOperationId": 18},
|
||||||
assert d["ok"] is False, "dev должен упасть"
|
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
|
||||||
|
|
||||||
# test: без fail-next, должен работать
|
second_instance = js(client.post(
|
||||||
r = client.post(f"/test/api/v1/svc/instances", json={"serviceId": 1, "displayName": "ok"}, headers=H)
|
f"/{second_stand}/api/v1/svc/instances",
|
||||||
ti = js(r)["instanceUid"]
|
json={"serviceId": 1, "displayName": "normal"}, headers=H,
|
||||||
r = client.post(f"/test/api/v1/svc/instanceOperations",
|
))["instanceUid"]
|
||||||
json={"instanceUid": ti, "operation": "create", "svcOperationId": 18}, headers=H)
|
second_operation = js(client.post(
|
||||||
to = js(r)["instanceOperationUid"]
|
f"/{second_stand}/api/v1/svc/instanceOperations",
|
||||||
d = js(client.post(f"/test/api/v1/svc/instanceOperations/{to}/run", headers=H))
|
json={"instanceUid": second_instance, "operation": "create", "svcOperationId": 18},
|
||||||
assert d["ok"] is True, "test должен работать"
|
headers=H,
|
||||||
t("fail-next изоляция: dev падает, test работает", test_fail_isolation)
|
))["instanceOperationUid"]
|
||||||
|
second_result = js(client.post(
|
||||||
|
f"/{second_stand}/api/v1/svc/instanceOperations/{second_operation}/run",
|
||||||
|
headers=H,
|
||||||
|
))
|
||||||
|
assert second_result["ok"] is True
|
||||||
|
|
||||||
|
t("fail-next изоляция между стендами", test_fail_isolation)
|
||||||
|
else:
|
||||||
|
print(" Cross-stand isolation skipped: fewer than two stands configured")
|
||||||
|
|
||||||
|
|
||||||
# ═══════ 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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) == {}
|
||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user