fix: harden Polygon operation validation
This commit is contained in:
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]["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."""
|
||||
|
||||
Reference in New Issue
Block a user