docs: clarify Polygon auth and API contracts

This commit is contained in:
Repinoid
2026-10-25 14:20:35 +03:00
parent e3a031ce50
commit 474bfb2600
9 changed files with 128 additions and 17 deletions
+11 -3
View File
@@ -1,4 +1,4 @@
# polygon v0.5.17 # polygon v0.5.18
Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest. Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest.
@@ -12,12 +12,13 @@ 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 # тесты from_stands.py │ ├── test_converter.py # тесты from_stands.py
│ ├── test_loader.py # тесты CWD-independent loader
│ ├── test_state_machine.py # тесты state_machine.py │ ├── test_state_machine.py # тесты state_machine.py
│ └── test_route_regressions.py # локальные регрессионные тесты API │ └── test_route_regressions.py # локальные регрессионные тесты API
└── site/ └── site/
├── app.py # Flask: только blueprint'ы + app.run() (57 строк) ├── app.py # Flask: только blueprint'ы + app.run() (57 строк)
├── routes/ # blueprint-файлы по доменам ├── routes/ # blueprint-файлы по доменам
│ ├── root.py # /health, / │ ├── root.py # /health, /, /swagger
│ ├── 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/*
@@ -35,7 +36,8 @@ polygon/
├── static/ ├── static/
│ └── style.css # тёмная тема │ └── style.css # тёмная тема
├── templates/ ├── templates/
│ └── index.html # Jinja2-шаблон │ ├── index.html # главная и инструкции API
│ └── swagger.html # Swagger UI и инструкция Authorize
└── services/ # fallback YAML, также генерируются from_stands.py └── 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`. Основные 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` на сервере не задан, токен вводить не нужно.
## Переменные окружения ## Переменные окружения
| Переменная | По умолчанию | Описание | | Переменная | По умолчанию | Описание |
+1 -1
View File
@@ -88,7 +88,7 @@ SERVICES = _DF["SERVICES"]
OPS_INDEX = _DF["OPS_INDEX"] OPS_INDEX = _DF["OPS_INDEX"]
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1")) DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
VERSION = "0.5.17" VERSION = "0.5.18"
def get_services(stand_id): def get_services(stand_id):
+1 -1
View File
@@ -71,7 +71,7 @@ def create_instance():
Также возвращает {"instanceUid": uid} в теле — двойная защита Также возвращает {"instanceUid": uid} в теле — двойная защита
на случай если заголовок не дойдёт. на случай если заголовок не дойдёт.
""" """
body = request.get_json(silent=True) or {} body = request.get_json(silent=True)
# ⛔ body должен быть dict, не массив и не примитив # ⛔ body должен быть dict, не массив и не примитив
if not isinstance(body, dict): if not isinstance(body, dict):
+24 -4
View File
@@ -82,7 +82,11 @@ def _build_spec():
"### Авторизация\n" "### Авторизация\n"
"- Основные эндпоинты — без авторизации\n" "- Основные эндпоинты — без авторизации\n"
"- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере " "- `_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": { "contact": {
"name": "Nubes Platform", "name": "Nubes Platform",
@@ -104,7 +108,11 @@ def _build_spec():
"type": "apiKey", "type": "apiKey",
"in": "header", "in": "header",
"name": "X-Mock-Auth", "name": "X-Mock-Auth",
"description": "Нужен для _mock/* только если MOCK_AUTH_TOKEN задан на сервере.", "description": (
"Если MOCK_AUTH_TOKEN задан администратором сервиса, введите его "
"в поле Value и нажмите Authorize. Значение берётся из конфигурации "
"окружения сервера; Swagger его не показывает."
),
}, },
}, },
"schemas": { "schemas": {
@@ -553,7 +561,10 @@ def _build_paths():
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOperationResponse"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOperationResponse"}}},
}, },
"400": { "400": {
"description": "instanceUid обязателен", "description": (
"Некорректный JSON или тип поля, отсутствует обязательное поле, "
"либо operation не соответствует svcOperationId"
),
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
}, },
"404": { "404": {
@@ -696,7 +707,12 @@ def _build_paths():
}, },
"responses": { "responses": {
"200": { "200": {
"description": "Параметр установлен (пустое тело)", "description": "Параметр установлен",
"content": {
"application/json": {
"schema": {"type": "object", "example": {}},
},
},
}, },
"400": { "400": {
"description": "Не указан instanceOperationUid или svcOperationCfsParamId", "description": "Не указан instanceOperationUid или svcOperationCfsParamId",
@@ -706,6 +722,10 @@ def _build_paths():
"description": "Операция не найдена", "description": "Операция не найдена",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
}, },
"409": {
"description": "Операция уже завершена",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
},
}, },
}, },
}, },
+5 -1
View File
@@ -169,12 +169,16 @@ def create_operation():
) )
if target_op is 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
if target_op.get("operation") != operation:
return jsonify({"error": "operation does not match svcOperationId"}), 400
kind = target_op.get("kind", "instance") kind = target_op.get("kind", "instance")
action = target_op.get("action", operation) action = target_op.get("action", operation)
# Создать операцию в 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, target_op["operation"], kind, action
)
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid # ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201) resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
+3
View File
@@ -78,6 +78,7 @@ def index():
svc_list=svc_list, svc_list=svc_list,
stands_list=stands_list, stands_list=stands_list,
current_stand=current_stand, current_stand=current_stand,
swagger_url=(f"/{current_stand}/swagger" if len(stands_list) > 1 else "/swagger"),
endpoint=_POLYGON_ENDPOINT, endpoint=_POLYGON_ENDPOINT,
) )
@@ -92,8 +93,10 @@ def swagger():
from routes.openapi import _build_spec from routes.openapi import _build_spec
import json as _json import json as _json
spec_json = _json.dumps(_build_spec(), ensure_ascii=False) 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( return render_template(
"swagger.html", "swagger.html",
version=_cfg.VERSION, version=_cfg.VERSION,
spec_json=spec_json, spec_json=spec_json,
home_url=home_url,
) )
+7 -3
View File
@@ -23,7 +23,7 @@
{% endfor %} {% endfor %}
<span class="sep">·</span> <span class="sep">·</span>
{% endif %} {% endif %}
<a href="/swagger" class="swagger-link">📋 Swagger</a> <a href="{{ swagger_url }}" class="swagger-link">📋 Swagger</a>
<span class="sep">·</span> <span class="sep">·</span>
<span class="dot dot-green"></span> <span class="dot dot-green"></span>
<span class="status-text">Работает</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);"> <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> добавьте <p><strong>Авторизация служебных запросов:</strong> администратор задаёт <code>MOCK_AUTH_TOKEN</code>
заголовок <code>-H "X-Mock-Auth: &lt;token&gt;"</code>.</p> в переменных окружения сервиса. Сам токен здесь не показывается. Если защита включена, в Swagger
нажмите <strong>Authorize</strong>, вставьте токен в поле <code>X-Mock-Auth</code> и подтвердите
кнопкой <strong>Authorize</strong>. Для curl добавьте к запросу
<code>-H "X-Mock-Auth: &lt;token&gt;"</code>. Если переменная не задана, токен не нужен.
</p>
<div class="feature-grid"> <div class="feature-grid">
<div class="feature-item"> <div class="feature-item">
+21 -2
View File
@@ -86,11 +86,30 @@
.swagger-ui .info .title { .swagger-ui .info .title {
font-family: system-ui, -apple-system, sans-serif; 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> </style>
</head> </head>
<body> <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> <div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script> <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
@@ -123,12 +142,12 @@
if (link) { if (link) {
link.innerHTML = '<img class="logo" src="{{ url_for("static", filename="logo.svg") }}" alt="Nubes">' + link.innerHTML = '<img class="logo" src="{{ url_for("static", filename="logo.svg") }}" alt="Nubes">' +
'<span class="version-badge">polygon v{{ version }}</span>'; '<span class="version-badge">polygon v{{ version }}</span>';
link.href = "/"; link.href = "{{ home_url }}";
} }
// Добавляем ссылку «← На главную» // Добавляем ссылку «← На главную»
const navLink = document.createElement("a"); const navLink = document.createElement("a");
navLink.className = "nav-link"; navLink.className = "nav-link";
navLink.href = "/"; navLink.href = "{{ home_url }}";
navLink.textContent = "← На главную"; navLink.textContent = "← На главную";
topbar.appendChild(navLink); topbar.appendChild(navLink);
} }
+53
View File
@@ -30,6 +30,8 @@ def test_swagger_does_not_embed_mock_auth_token():
assert response.status_code == 200 assert response.status_code == 200
assert b"test-token-123" not in response.data assert b"test-token-123" not in response.data
assert b"auth_token" 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(): 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: if "/_mock/" in path:
operation = path_item.get("get") or path_item.get("post") operation = path_item.get("get") or path_item.get("post")
assert operation["security"] == [{"mockAuth": []}, {}] 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(): 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/instanceOperationCfsParams" in response.data
assert b"/api/v1/svc/openapi.json" in response.data assert b"/api/v1/svc/openapi.json" in response.data
assert b"/api/v1/svc/instanceOperations/&lt;uid&gt;/status" not in response.data assert b"/api/v1/svc/instanceOperations/&lt;uid&gt;/status" not in response.data
assert "вставьте токен".encode() in response.data
@pytest.mark.parametrize("seconds", ["nan", "inf", "-inf"]) @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 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(): def test_create_instance_rejects_non_string_display_name():
service_id = next(iter(config.get_services(config.DEFAULT_STAND))) 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 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]) @pytest.mark.parametrize("page_size", [0, -5])
def test_instances_page_size_is_clamped_to_one(page_size): def test_instances_page_size_is_clamped_to_one(page_size):
response = polygon_app.app.test_client().get( response = polygon_app.app.test_client().get(