From 474bfb2600e8fee0b3b87cfcde01907c643eebc3 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sun, 25 Oct 2026 14:20:35 +0300 Subject: [PATCH] docs: clarify Polygon auth and API contracts --- README.md | 18 ++++++++--- site/config/loader.py | 2 +- site/routes/instances_routes.py | 2 +- site/routes/openapi.py | 28 ++++++++++++++--- site/routes/operations_routes.py | 6 +++- site/routes/root.py | 3 ++ site/templates/index.html | 10 ++++-- site/templates/swagger.html | 23 ++++++++++++-- tests/test_route_regressions.py | 53 ++++++++++++++++++++++++++++++++ 9 files changed, 128 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 20a0d02..aceafd7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# polygon v0.5.17 +# polygon v0.5.18 Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest. @@ -11,13 +11,14 @@ polygon/ ├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0 ├── tests/ -│ ├── test_converter.py # тесты from_stands.py -│ ├── test_state_machine.py # тесты state_machine.py +│ ├── 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: "`. Если `MOCK_AUTH_TOKEN` на сервере не задан, токен вводить не нужно. + ## Переменные окружения | Переменная | По умолчанию | Описание | diff --git a/site/config/loader.py b/site/config/loader.py index a5d7025..15acad9 100644 --- a/site/config/loader.py +++ b/site/config/loader.py @@ -88,7 +88,7 @@ SERVICES = _DF["SERVICES"] OPS_INDEX = _DF["OPS_INDEX"] DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1")) -VERSION = "0.5.17" +VERSION = "0.5.18" def get_services(stand_id): diff --git a/site/routes/instances_routes.py b/site/routes/instances_routes.py index e089dc5..2ef91ed 100644 --- a/site/routes/instances_routes.py +++ b/site/routes/instances_routes.py @@ -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): diff --git a/site/routes/openapi.py b/site/routes/openapi.py index f009b42..2afb4f4 100644 --- a/site/routes/openapi.py +++ b/site/routes/openapi.py @@ -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: \"`. Если токен не настроен, заголовок не нужен." ), "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"}}}, + }, }, }, }, diff --git a/site/routes/operations_routes.py b/site/routes/operations_routes.py index b7d3c3f..632cd91 100644 --- a/site/routes/operations_routes.py +++ b/site/routes/operations_routes.py @@ -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) diff --git a/site/routes/root.py b/site/routes/root.py index 73ce87b..7e226a5 100644 --- a/site/routes/root.py +++ b/site/routes/root.py @@ -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, ) diff --git a/site/templates/index.html b/site/templates/index.html index 1409913..e8ac3b7 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -23,7 +23,7 @@ {% endfor %} · {% endif %} - 📋 Swagger + 📋 Swagger · Работает @@ -112,8 +112,12 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }

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

-

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

+

Авторизация служебных запросов: администратор задаёт MOCK_AUTH_TOKEN + в переменных окружения сервиса. Сам токен здесь не показывается. Если защита включена, в Swagger + нажмите Authorize, вставьте токен в поле X-Mock-Auth и подтвердите + кнопкой Authorize. Для curl добавьте к запросу + -H "X-Mock-Auth: <token>". Если переменная не задана, токен не нужен. +

diff --git a/site/templates/swagger.html b/site/templates/swagger.html index 96fff0a..e4ec25d 100644 --- a/site/templates/swagger.html +++ b/site/templates/swagger.html @@ -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; + } + +
@@ -123,12 +142,12 @@ if (link) { link.innerHTML = '' + 'polygon v{{ version }}'; - 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); } diff --git a/tests/test_route_regressions.py b/tests/test_route_regressions.py index 9bba04e..9b8175a 100644 --- a/tests/test_route_regressions.py +++ b/tests/test_route_regressions.py @@ -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(