docs: clarify Polygon auth and API contracts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# polygon v0.5.17
|
||||
# polygon v0.5.18
|
||||
|
||||
Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest.
|
||||
|
||||
@@ -12,12 +12,13 @@ polygon/
|
||||
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
|
||||
├── tests/
|
||||
│ ├── test_converter.py # тесты from_stands.py
|
||||
│ ├── test_loader.py # тесты CWD-independent loader
|
||||
│ ├── test_state_machine.py # тесты state_machine.py
|
||||
│ └── test_route_regressions.py # локальные регрессионные тесты API
|
||||
└── site/
|
||||
├── app.py # Flask: только blueprint'ы + app.run() (57 строк)
|
||||
├── routes/ # blueprint-файлы по доменам
|
||||
│ ├── root.py # /health, /
|
||||
│ ├── root.py # /health, /, /swagger
|
||||
│ ├── services_routes.py# /api/v1/svc/services
|
||||
│ ├── instances_routes.py# /api/v1/svc/instances
|
||||
│ ├── operations_routes.py# /api/v1/svc/instanceOperations/*
|
||||
@@ -35,7 +36,8 @@ polygon/
|
||||
├── static/
|
||||
│ └── style.css # тёмная тема
|
||||
├── templates/
|
||||
│ └── index.html # Jinja2-шаблон
|
||||
│ ├── index.html # главная и инструкции API
|
||||
│ └── swagger.html # Swagger UI и инструкция Authorize
|
||||
└── services/ # fallback YAML, также генерируются from_stands.py
|
||||
```
|
||||
|
||||
@@ -141,6 +143,12 @@ curl https://polygon.pythonk8s.dev.nubes.ru/dev/api/v1/svc/services | python3 -m
|
||||
|
||||
Основные API-маршруты не требуют авторизации. Если задан `MOCK_AUTH_TOKEN`, запросы к `/_mock/*` должны передавать заголовок `X-Mock-Auth`.
|
||||
|
||||
### Где взять и куда вводить токен
|
||||
|
||||
`MOCK_AUTH_TOKEN` задаёт администратор в переменных окружения запущенного сервиса Polygon. Приложение не генерирует и не показывает это значение. Если у вас нет доступа к настройкам сервиса, запросите токен у его администратора.
|
||||
|
||||
В Swagger нажмите **Authorize**, в поле `X-Mock-Auth` → **Value** вставьте выданный токен и нажмите **Authorize** в диалоге. После этого Swagger автоматически добавит заголовок к запросам `/_mock/*`. В curl передавайте его явно: `-H "X-Mock-Auth: <token>"`. Если `MOCK_AUTH_TOKEN` на сервере не задан, токен вводить не нужно.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|
||||
@@ -88,7 +88,7 @@ SERVICES = _DF["SERVICES"]
|
||||
OPS_INDEX = _DF["OPS_INDEX"]
|
||||
|
||||
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
|
||||
VERSION = "0.5.17"
|
||||
VERSION = "0.5.18"
|
||||
|
||||
|
||||
def get_services(stand_id):
|
||||
|
||||
@@ -71,7 +71,7 @@ def create_instance():
|
||||
Также возвращает {"instanceUid": uid} в теле — двойная защита
|
||||
на случай если заголовок не дойдёт.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
body = request.get_json(silent=True)
|
||||
|
||||
# ⛔ body должен быть dict, не массив и не примитив
|
||||
if not isinstance(body, dict):
|
||||
|
||||
+24
-4
@@ -82,7 +82,11 @@ def _build_spec():
|
||||
"### Авторизация\n"
|
||||
"- Основные эндпоинты — без авторизации\n"
|
||||
"- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере "
|
||||
"задан `MOCK_AUTH_TOKEN`"
|
||||
"задан `MOCK_AUTH_TOKEN`. Администратор задаёт его в переменных "
|
||||
"окружения сервиса; Swagger не может получить это значение с сервера.\n"
|
||||
"- В Swagger нажмите **Authorize**, вставьте токен в поле `X-Mock-Auth` "
|
||||
"и нажмите **Authorize** в диалоге. Для curl передавайте "
|
||||
"`-H \"X-Mock-Auth: <token>\"`. Если токен не настроен, заголовок не нужен."
|
||||
),
|
||||
"contact": {
|
||||
"name": "Nubes Platform",
|
||||
@@ -104,7 +108,11 @@ def _build_spec():
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-Mock-Auth",
|
||||
"description": "Нужен для _mock/* только если MOCK_AUTH_TOKEN задан на сервере.",
|
||||
"description": (
|
||||
"Если MOCK_AUTH_TOKEN задан администратором сервиса, введите его "
|
||||
"в поле Value и нажмите Authorize. Значение берётся из конфигурации "
|
||||
"окружения сервера; Swagger его не показывает."
|
||||
),
|
||||
},
|
||||
},
|
||||
"schemas": {
|
||||
@@ -553,7 +561,10 @@ def _build_paths():
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOperationResponse"}}},
|
||||
},
|
||||
"400": {
|
||||
"description": "instanceUid обязателен",
|
||||
"description": (
|
||||
"Некорректный JSON или тип поля, отсутствует обязательное поле, "
|
||||
"либо operation не соответствует svcOperationId"
|
||||
),
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
||||
},
|
||||
"404": {
|
||||
@@ -696,7 +707,12 @@ def _build_paths():
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Параметр установлен (пустое тело)",
|
||||
"description": "Параметр установлен",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"type": "object", "example": {}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
"description": "Не указан instanceOperationUid или svcOperationCfsParamId",
|
||||
@@ -706,6 +722,10 @@ def _build_paths():
|
||||
"description": "Операция не найдена",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
||||
},
|
||||
"409": {
|
||||
"description": "Операция уже завершена",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -169,12 +169,16 @@ def create_operation():
|
||||
)
|
||||
if target_op is None:
|
||||
return jsonify({"error": f"operation '{operation}' not found for service"}), 404
|
||||
if target_op.get("operation") != operation:
|
||||
return jsonify({"error": "operation does not match svcOperationId"}), 400
|
||||
|
||||
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)
|
||||
op_uid = state.create_operation(
|
||||
instance_uid, svc_op_id, target_op["operation"], kind, action
|
||||
)
|
||||
|
||||
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid
|
||||
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
|
||||
|
||||
@@ -78,6 +78,7 @@ def index():
|
||||
svc_list=svc_list,
|
||||
stands_list=stands_list,
|
||||
current_stand=current_stand,
|
||||
swagger_url=(f"/{current_stand}/swagger" if len(stands_list) > 1 else "/swagger"),
|
||||
endpoint=_POLYGON_ENDPOINT,
|
||||
)
|
||||
|
||||
@@ -92,8 +93,10 @@ def swagger():
|
||||
from routes.openapi import _build_spec
|
||||
import json as _json
|
||||
spec_json = _json.dumps(_build_spec(), ensure_ascii=False)
|
||||
home_url = f"/{g.stand_id}/" if len(_cfg.list_stands()) > 1 else "/"
|
||||
return render_template(
|
||||
"swagger.html",
|
||||
version=_cfg.VERSION,
|
||||
spec_json=spec_json,
|
||||
home_url=home_url,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endfor %}
|
||||
<span class="sep">·</span>
|
||||
{% endif %}
|
||||
<a href="/swagger" class="swagger-link">📋 Swagger</a>
|
||||
<a href="{{ swagger_url }}" class="swagger-link">📋 Swagger</a>
|
||||
<span class="sep">·</span>
|
||||
<span class="dot dot-green"></span>
|
||||
<span class="status-text">Работает</span>
|
||||
@@ -112,8 +112,12 @@ 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);">
|
||||
|
||||
<h3 class="h4">🛠 Управление полигоном (служебные эндпоинты)</h3>
|
||||
<p>Если на сервере задан <code>MOCK_AUTH_TOKEN</code>, для запросов к <code>/_mock/*</code> добавьте
|
||||
заголовок <code>-H "X-Mock-Auth: <token>"</code>.</p>
|
||||
<p><strong>Авторизация служебных запросов:</strong> администратор задаёт <code>MOCK_AUTH_TOKEN</code>
|
||||
в переменных окружения сервиса. Сам токен здесь не показывается. Если защита включена, в Swagger
|
||||
нажмите <strong>Authorize</strong>, вставьте токен в поле <code>X-Mock-Auth</code> и подтвердите
|
||||
кнопкой <strong>Authorize</strong>. Для curl добавьте к запросу
|
||||
<code>-H "X-Mock-Auth: <token>"</code>. Если переменная не задана, токен не нужен.
|
||||
</p>
|
||||
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item">
|
||||
|
||||
@@ -86,11 +86,30 @@
|
||||
.swagger-ui .info .title {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.auth-help {
|
||||
max-width: 1460px;
|
||||
margin: 16px auto;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #374151;
|
||||
font: 14px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<aside class="auth-help">
|
||||
<strong>Токен для служебных запросов</strong>
|
||||
<p>Если администратор настроил <code>MOCK_AUTH_TOKEN</code> в переменных окружения сервиса, нажмите
|
||||
<strong>Authorize</strong> в Swagger, вставьте выданное вам значение в поле <code>X-Mock-Auth</code>
|
||||
(Value) и подтвердите кнопкой <strong>Authorize</strong>. Токен выдаёт администратор; Swagger его не
|
||||
показывает. Если защита не включена, авторизация не требуется.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
||||
@@ -123,12 +142,12 @@
|
||||
if (link) {
|
||||
link.innerHTML = '<img class="logo" src="{{ url_for("static", filename="logo.svg") }}" alt="Nubes">' +
|
||||
'<span class="version-badge">polygon v{{ version }}</span>';
|
||||
link.href = "/";
|
||||
link.href = "{{ home_url }}";
|
||||
}
|
||||
// Добавляем ссылку «← На главную»
|
||||
const navLink = document.createElement("a");
|
||||
navLink.className = "nav-link";
|
||||
navLink.href = "/";
|
||||
navLink.href = "{{ home_url }}";
|
||||
navLink.textContent = "← На главную";
|
||||
topbar.appendChild(navLink);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ def test_swagger_does_not_embed_mock_auth_token():
|
||||
assert response.status_code == 200
|
||||
assert b"test-token-123" not in response.data
|
||||
assert b"auth_token" not in response.data
|
||||
assert b"MOCK_AUTH_TOKEN" in response.data
|
||||
assert b"X-Mock-Auth" in response.data
|
||||
|
||||
|
||||
def test_openapi_schema_and_auth_match_runtime_contract():
|
||||
@@ -49,6 +51,9 @@ def test_openapi_schema_and_auth_match_runtime_contract():
|
||||
if "/_mock/" in path:
|
||||
operation = path_item.get("get") or path_item.get("post")
|
||||
assert operation["security"] == [{"mockAuth": []}, {}]
|
||||
set_param_responses = paths["/api/v1/svc/instanceOperationCfsParams"]["post"]["responses"]
|
||||
assert set_param_responses["200"]["content"]["application/json"]["schema"]["type"] == "object"
|
||||
assert set_param_responses["409"]["description"] == "Операция уже завершена"
|
||||
|
||||
|
||||
def test_homepage_documents_real_operation_routes():
|
||||
@@ -59,6 +64,7 @@ def test_homepage_documents_real_operation_routes():
|
||||
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
|
||||
assert "вставьте токен".encode() in response.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seconds", ["nan", "inf", "-inf"])
|
||||
@@ -88,6 +94,16 @@ def test_operation_routes_reject_non_object_json():
|
||||
assert param_response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [[], "", 0, False, None])
|
||||
def test_create_instance_rejects_non_object_json(body):
|
||||
response = polygon_app.app.test_client().post(
|
||||
"/api/v1/svc/instances", json=body
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["error"] == "request body must be a JSON object"
|
||||
|
||||
|
||||
def test_create_instance_rejects_non_string_display_name():
|
||||
service_id = next(iter(config.get_services(config.DEFAULT_STAND)))
|
||||
|
||||
@@ -128,6 +144,43 @@ def test_create_operation_rejects_foreign_service_operation():
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_operation_rejects_mismatched_operation_name():
|
||||
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 len(definition.get("operations", [])) > 1
|
||||
)
|
||||
target_op, other_op = service["operations"][:2]
|
||||
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": other_op["operation"],
|
||||
"svcOperationId": target_op["svcOperationId"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert mock_state.get_state(stand_id).operations == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stand_id", config.list_stands())
|
||||
def test_homepage_swagger_link_keeps_current_stand(stand_id):
|
||||
if len(config.list_stands()) <= 1:
|
||||
pytest.skip("stand-specific Swagger link is needed only for multiple stands")
|
||||
|
||||
response = polygon_app.app.test_client().get(f"/{stand_id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'href="/{stand_id}/swagger"'.encode() in response.data
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
Reference in New Issue
Block a user