fix: harden Polygon operation validation

This commit is contained in:
Repinoid
2026-10-25 14:07:13 +03:00
parent 3df89ec9e7
commit e3a031ce50
18 changed files with 803 additions and 346 deletions
+5
View File
@@ -10,6 +10,11 @@ compare_test.py — Polygon ↔ Nubes API (v2, трёхкатегорийная
exit(1) только при BUG > 0.
"""
if __name__ != "__main__":
import pytest
pytest.skip("standalone live API comparison; run explicitly with python", allow_module_level=True)
import html as _html
import os
import sys
+91 -52
View File
@@ -1,13 +1,26 @@
#!/usr/bin/env python3
"""fuzz_test.py — фаззинг Polygon: злонамеренные и экстремальные сценарии × 3 стенда."""
"""fuzz_test.py — локальные злонамеренные и экстремальные сценарии по стендам."""
if __name__ != "__main__":
import pytest
pytest.skip("standalone local fuzz scenarios; run explicitly with python", allow_module_level=True)
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "site"))
from app import app
import json
import config.loader as config
client = app.test_client()
H = {"Content-Type": "application/json"}
MH = {**H, "X-Mock-Auth": "test-token-123"}
stands = ["dev", "test", "prod"]
AUTH = os.getenv("MOCK_AUTH_TOKEN", "")
MH = {**H, **({"X-Mock-Auth": AUTH} if AUTH else {})}
stands = config.list_stands()
passed = 0
failed = 0
errors = []
@@ -89,7 +102,7 @@ for sid in stands:
json={"serviceId": 0, "displayName": "x"}, headers=H), 400))
t("displayName=число", lambda: st(client.post(f"{base}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": 12345}, headers=H), 201))
json={"serviceId": 1, "displayName": 12345}, headers=H), 400))
t("paramId строка", lambda: st(client.post(f"{base}/api/v1/svc/instanceOperationCfsParams",
json={"instanceOperationUid": "00000000-0000-0000-0000-000000000000",
@@ -98,9 +111,10 @@ for sid in stands:
# ═══ 3. PAGINATION ═══
print("\n── Пагинация ──")
t("pageSize=999999", lambda: (
d := js(client.get(f"{base}/api/v1/svc/instances?pageSize=999999")),
d["pageSize"] <= 200 or True))
def test_page_size_upper_bound():
d = js(client.get(f"{base}/api/v1/svc/instances?pageSize=999999"))
assert d["pageSize"] == 200
t("pageSize=999999", test_page_size_upper_bound)
t("pageSize=0", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize=0"), 200))
@@ -108,9 +122,10 @@ for sid in stands:
t("pageSize=''", lambda: st(client.get(f"{base}/api/v1/svc/instances?pageSize="), 200))
t("page=-100", lambda: (
d := js(client.get(f"{base}/api/v1/svc/instances?page=-100")),
d["page"] >= 1 or True))
def test_page_lower_bound():
d = js(client.get(f"{base}/api/v1/svc/instances?page=-100"))
assert d["page"] == 1
t("page=-100", test_page_lower_bound)
# ═══ 4. INVALID UUID ═══
print("\n── Невалидные UUID ──")
@@ -190,20 +205,28 @@ for sid in stands:
# ═══ 9. PATH TRAVERSAL ═══
print("\n── Path traversal ──")
t("GET /../services", lambda: st(client.get(f"{base}/api/v1/svc/../services"), 308))
t("GET двойной слеш", lambda: st(client.get(f"{base}//api/v1/svc/services"), 308))
t("GET /../services → 404", lambda: st(client.get(f"{base}/api/v1/svc/../services"), 404))
def test_double_slash_is_normalized():
response = client.get(f"{base}//api/v1/svc/services")
st(response, 200)
assert "results" in js(response)
t("GET двойной слеш → нормализованный список", test_double_slash_is_normalized)
# ═══ 10. DELAY ═══
print("\n── Задержка ──")
t("delay 999", lambda: st(client.post(f"{base}/api/v1/svc/_mock/delay/999", headers=MH), 400))
t("delay -1", lambda: st(client.post(f"{base}/api/v1/svc/_mock/delay/-1", headers=MH), 400))
t("delay 0", lambda: (
d := js(client.post(f"{base}/api/v1/svc/_mock/delay/0", headers=MH)),
d["delay"] == 0 or True))
t("delay 5.0", lambda: (
d := js(client.post(f"{base}/api/v1/svc/_mock/delay/5", headers=MH)),
d["delay"] == 5.0 or True))
def test_zero_delay():
d = js(client.post(f"{base}/api/v1/svc/_mock/delay/0", headers=MH))
assert d["delay"] == 0
t("delay 0", test_zero_delay)
def test_max_delay():
d = js(client.post(f"{base}/api/v1/svc/_mock/delay/5", headers=MH))
assert d["delay"] == 5.0
t("delay 5.0", test_max_delay)
client.post(f"{base}/api/v1/svc/_mock/delay/0.1", headers=MH)
# ═══ 11. FIELDS ═══
@@ -225,13 +248,9 @@ for sid in stands:
# ═══ 13. PAGES ═══
print("\n── Страницы ──")
t("GET /{sid}/ → HTML", lambda: (
r := client.get(f"{base}/"), st(r, 200),
b"Polygon" in r.data or True))
t("GET /{sid}/ → HTML", lambda: st(client.get(f"{base}/"), 200))
t("GET /swagger → HTML", lambda: (
r := client.get("/swagger"), st(r, 200),
b"swagger" in r.data.lower() or True))
t("GET /swagger → HTML", lambda: st(client.get("/swagger"), 200))
t("GET /health → OK", lambda: (
r := client.get("/health"), st(r, 200), r.data == b"OK"))
@@ -261,37 +280,57 @@ for sid in stands:
assert inst["instance"]["displayName"] == f"only-on-{sid}"
t(f"{sid}: только свой инстанс", check_isolated)
# Cross-stand: touch instance from wrong stand
t("чужая instance → 404", lambda: (
st(client.get(f"/dev/api/v1/svc/instances/{inst_by_stand['test']}"), 404)))
# Cross-stand checks apply only when at least two stands are configured.
if len(stands) >= 2:
first_stand, second_stand = stands[:2]
t(
"чужая instance → 404",
lambda: st(
client.get(
f"/{first_stand}/api/v1/svc/instances/{inst_by_stand[second_stand]}"
),
404,
),
)
t("чужая instance → 404 (prod→dev)", lambda: (
st(client.get(f"/prod/api/v1/svc/instances/{inst_by_stand['dev']}"), 404)))
client.post(f"/{first_stand}/api/v1/svc/_mock/reset", headers=MH)
client.post(f"/{second_stand}/api/v1/svc/_mock/reset", headers=MH)
client.post(f"/{first_stand}/api/v1/svc/_mock/fail-next", headers=MH)
# fail-next isolation
client.post(f"/dev/api/v1/svc/_mock/reset", headers=MH)
client.post(f"/test/api/v1/svc/_mock/reset", headers=MH)
client.post(f"/dev/api/v1/svc/_mock/fail-next", headers=MH)
def test_fail_isolation():
first_instance = js(client.post(
f"/{first_stand}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "fail-isolation"}, headers=H,
))["instanceUid"]
first_operation = js(client.post(
f"/{first_stand}/api/v1/svc/instanceOperations",
json={"instanceUid": first_instance, "operation": "create", "svcOperationId": 18},
headers=H,
))["instanceOperationUid"]
first_result = js(client.post(
f"/{first_stand}/api/v1/svc/instanceOperations/{first_operation}/run",
headers=H,
))
assert first_result["ok"] is False
def test_fail_isolation():
# dev: fail-next active
r = client.post(f"/dev/api/v1/svc/instances", json={"serviceId": 1, "displayName": "f"}, headers=H)
fi = js(r)["instanceUid"]
r = client.post(f"/dev/api/v1/svc/instanceOperations",
json={"instanceUid": fi, "operation": "create", "svcOperationId": 18}, headers=H)
fo = js(r)["instanceOperationUid"]
d = js(client.post(f"/dev/api/v1/svc/instanceOperations/{fo}/run", headers=H))
assert d["ok"] is False, "dev должен упасть"
second_instance = js(client.post(
f"/{second_stand}/api/v1/svc/instances",
json={"serviceId": 1, "displayName": "normal"}, headers=H,
))["instanceUid"]
second_operation = js(client.post(
f"/{second_stand}/api/v1/svc/instanceOperations",
json={"instanceUid": second_instance, "operation": "create", "svcOperationId": 18},
headers=H,
))["instanceOperationUid"]
second_result = js(client.post(
f"/{second_stand}/api/v1/svc/instanceOperations/{second_operation}/run",
headers=H,
))
assert second_result["ok"] is True
# test: без fail-next, должен работать
r = client.post(f"/test/api/v1/svc/instances", json={"serviceId": 1, "displayName": "ok"}, headers=H)
ti = js(r)["instanceUid"]
r = client.post(f"/test/api/v1/svc/instanceOperations",
json={"instanceUid": ti, "operation": "create", "svcOperationId": 18}, headers=H)
to = js(r)["instanceOperationUid"]
d = js(client.post(f"/test/api/v1/svc/instanceOperations/{to}/run", headers=H))
assert d["ok"] is True, "test должен работать"
t("fail-next изоляция: dev падает, test работает", test_fail_isolation)
t("fail-next изоляция между стендами", test_fail_isolation)
else:
print(" Cross-stand isolation skipped: fewer than two stands configured")
# ═══════ SUMMARY ═══════
@@ -303,5 +342,5 @@ if failed:
for e in errors:
print(f" {e}")
else:
print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} тестов × 3 стенда!")
print(f" 🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ — {total} проверок, {len(stands)} стенд(а)")
print(f"{'='*55}")
+5
View File
@@ -6,6 +6,11 @@ test_api.py — smoke + edge-case тесты Polygon API.
Требует: pip install requests
"""
if __name__ != "__main__":
import pytest
pytest.skip("standalone API smoke tests; run explicitly with python", allow_module_level=True)
import os
import sys
import json
+228
View File
@@ -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/&lt;id&gt;" 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/&lt;uid&gt;/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) == {}
+20 -3
View File
@@ -84,6 +84,20 @@ class TestInstanceOperations:
assert inst["state"]["params"]["resourceRealm"] == "dummy"
assert inst["state"]["params"]["durationMs"] == "0"
def test_create_applies_supplied_operation_params(self):
"""Параметры create перекрывают дефолты из stateParams."""
st = mock_state.state
uid = st.create_instance(1, "test", SERVICES[1])
op_uid = st.create_operation(uid, 18, "create", "instance", "create")
st.set_param(op_uid, 242, "custom-realm")
st.set_param(op_uid, 198, "999")
state_machine.apply_effect(op_uid, st, SERVICES)
inst = st.get_instance(uid)
assert inst["state"]["params"]["resourceRealm"] == "custom-realm"
assert inst["state"]["params"]["durationMs"] == "999"
def test_create_sets_state_out(self):
"""create → state.out из шаблона (пустой для dummy, заполнен для pg)."""
uid, _ = _create_and_run(90, "pg", 19, "create")
@@ -92,10 +106,13 @@ class TestInstanceOperations:
assert "databases" in inst["state"]["out"]
def test_delete_removes_instance(self):
"""delete → инстанс удалён."""
uid, _ = _create_and_run(1, "test", 18, "create")
_run_op(uid, 71, "delete", action="delete")
"""delete удаляет инстанс, но сохраняет историю операций для polling."""
uid, create_op_uid = _create_and_run(1, "test", 18, "create")
delete_op_uid = _run_op(uid, 71, "delete", action="delete")
assert mock_state.state.get_instance(uid) is None
assert create_op_uid in mock_state.state.operations
assert delete_op_uid in mock_state.state.operations
assert delete_op_uid in mock_state.state.op_params
def test_suspend_sets_suspended(self):
"""suspend → статус suspended."""