feat: troppa roba davvero
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import importlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from core.security import AuthContext, get_auth_context
|
||||
from models.auth import AccessTokenPayload, AuthUserDocument
|
||||
from app.core.security import AuthContext, get_auth_context
|
||||
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
||||
|
||||
auth_router_module = importlib.import_module("api.auth.router")
|
||||
auth_router_module = importlib.import_module("app.api.auth.router")
|
||||
|
||||
|
||||
def test_login_success_returns_bearer_token(client, monkeypatch: object):
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import pytest
|
||||
from services.editor import python_lsp
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.services.editor import python_lsp
|
||||
|
||||
|
||||
def test_python_editor_session(client, monkeypatch):
|
||||
session = python_lsp.PythonEditorSession(
|
||||
root_path=python_lsp.WORKSPACE_ROOT,
|
||||
file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE,
|
||||
)
|
||||
monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp")
|
||||
monkeypatch.setattr("api.editor.router.python_lsp.get_session", lambda: session)
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.python_lsp.get_session", lambda: session
|
||||
)
|
||||
|
||||
response = client.get("/proxy/editor/python/session")
|
||||
|
||||
@@ -40,11 +45,13 @@ def test_python_editor_socket_accepts_token(client, monkeypatch):
|
||||
await websocket.close()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.editor.router.authenticate_editor_socket",
|
||||
"app.api.editor.router.authenticate_editor_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp")
|
||||
monkeypatch.setattr("api.editor.router.python_lsp.bridge", fake_bridge)
|
||||
monkeypatch.setattr(
|
||||
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
||||
)
|
||||
monkeypatch.setattr("app.api.editor.router.python_lsp.bridge", fake_bridge)
|
||||
|
||||
with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket:
|
||||
assert socket.receive_text().endswith("/proxy_addon.py")
|
||||
|
||||
+385
-21
@@ -1,14 +1,18 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from core.security import get_auth_context, require_authenticated_user
|
||||
from models.proxy import ProxyCreateInput, ProxyDocument
|
||||
from models.service import ServiceEndpointDocument
|
||||
from services.mitmproxy.examples import BUILTIN_EXAMPLES
|
||||
from services.mitmproxy.validator import ProxyCodeValidationError
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
proxy_router_module = importlib.import_module("api.proxy.router")
|
||||
from app.core.security import get_auth_context, require_authenticated_user
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyCreateInput, ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
|
||||
from app.services.mitmproxy.validator import ProxyCodeValidationError
|
||||
|
||||
proxy_router_module = importlib.import_module("app.api.proxy.router")
|
||||
|
||||
|
||||
def make_proxy_document(
|
||||
@@ -28,6 +32,21 @@ def make_proxy_document(
|
||||
)
|
||||
|
||||
|
||||
def make_health_snapshot(
|
||||
proxy_port: int,
|
||||
*,
|
||||
overall: str = "healthy",
|
||||
timestamp: datetime | None = None,
|
||||
) -> HealthSnapshot:
|
||||
return HealthSnapshot(
|
||||
proxy_port=proxy_port,
|
||||
timestamp=timestamp or datetime.now(UTC),
|
||||
enabled=True,
|
||||
overall=overall,
|
||||
checks=[],
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_requires_auth_without_dependency_overrides(client):
|
||||
client.app.dependency_overrides.pop(require_authenticated_user, None)
|
||||
client.app.dependency_overrides.pop(get_auth_context, None)
|
||||
@@ -80,6 +99,7 @@ def test_list_services_uses_query_scan_root(client, monkeypatch):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"backend_port": 3671,
|
||||
"scan_root": "/root",
|
||||
"services": [
|
||||
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
|
||||
@@ -105,10 +125,10 @@ def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
|
||||
|
||||
|
||||
def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
|
||||
existing = make_proxy_document(proxy_port=3671)
|
||||
existing = make_proxy_document(proxy_port=4088)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 3671
|
||||
assert proxy_port == 4088
|
||||
return existing
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -120,7 +140,7 @@ def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
|
||||
response = client.post(
|
||||
"/proxy",
|
||||
json={
|
||||
"proxy_port": 3671,
|
||||
"proxy_port": 4088,
|
||||
"name": "new",
|
||||
"code": "abc",
|
||||
"service": {"port": 8080},
|
||||
@@ -128,7 +148,44 @@ def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Proxy already exists on port 3671"
|
||||
assert response.json()["detail"] == "Proxy already exists on port 4088"
|
||||
|
||||
|
||||
def test_create_proxy_rejects_backend_api_port(client, monkeypatch: object):
|
||||
create_called = False
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 3671
|
||||
return None
|
||||
|
||||
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||
nonlocal create_called
|
||||
create_called = True
|
||||
return make_proxy_document(proxy_port=payload.proxy_port)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||
|
||||
response = client.post(
|
||||
"/proxy",
|
||||
json={
|
||||
"proxy_port": 3671,
|
||||
"name": "backend-clash",
|
||||
"code": "abc",
|
||||
"service": {"port": 8080},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == (
|
||||
"Proxy port 3671 is reserved for the backend API "
|
||||
"and cannot be used by mitmdump."
|
||||
)
|
||||
assert create_called is False
|
||||
|
||||
|
||||
def test_create_proxy_returns_created_document(client, monkeypatch: object):
|
||||
@@ -470,6 +527,297 @@ def test_proxy_logs_socket_streams_snapshot_and_append(client, monkeypatch, tmp_
|
||||
assert appended == {"type": "append", "log": "line two\n"}
|
||||
|
||||
|
||||
def test_proxy_logs_socket_resets_snapshot_after_log_rotation(
|
||||
client,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
log_path = tmp_path / "proxy.log"
|
||||
log_path.write_text("line one\n", encoding="utf-8")
|
||||
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
||||
|
||||
async def fake_authenticate(_: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"authenticate_proxy_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
|
||||
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
||||
snapshot = socket.receive_json()
|
||||
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
||||
|
||||
log_path.write_text("fresh start\n", encoding="utf-8")
|
||||
|
||||
rotated = socket.receive_json()
|
||||
assert rotated == {"type": "snapshot", "log": "fresh start\n"}
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write("line two\n")
|
||||
handle.flush()
|
||||
|
||||
appended = socket.receive_json()
|
||||
assert appended == {"type": "append", "log": "line two\n"}
|
||||
|
||||
|
||||
def test_remove_proxy_stops_runtime_and_cleans_artifacts(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(
|
||||
proxy_port=4040,
|
||||
log_path="/tmp/theplug/logs/4040.log",
|
||||
).model_copy(
|
||||
update={
|
||||
"script_path": "/tmp/theplug/addons/4040.py",
|
||||
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
||||
}
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
class FakeManager:
|
||||
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
||||
captured["stopped"] = document.proxy_port
|
||||
return {
|
||||
"status": "stopped",
|
||||
"pid": None,
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
def cleanup_runtime_artifacts(self, document: ProxyDocument) -> None:
|
||||
captured["cleaned"] = {
|
||||
"proxy_port": document.proxy_port,
|
||||
"script_path": document.script_path,
|
||||
"config_path": document.config_path,
|
||||
"log_path": document.log_path,
|
||||
}
|
||||
|
||||
async def fake_delete_proxy(proxy_port: int) -> bool:
|
||||
captured["deleted"] = proxy_port
|
||||
return True
|
||||
|
||||
async def fake_delete_health_history(proxy_port: int) -> None:
|
||||
captured["history_deleted"] = proxy_port
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"delete_health_history",
|
||||
fake_delete_health_history,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "delete_proxy", fake_delete_proxy)
|
||||
|
||||
response = client.delete("/proxy/4040")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert captured == {
|
||||
"stopped": 4040,
|
||||
"cleaned": {
|
||||
"proxy_port": 4040,
|
||||
"script_path": "/tmp/theplug/addons/4040.py",
|
||||
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
||||
"log_path": "/tmp/theplug/logs/4040.log",
|
||||
},
|
||||
"deleted": 4040,
|
||||
"history_deleted": 4040,
|
||||
}
|
||||
|
||||
|
||||
def test_proxy_health_runs_immediate_check_and_publishes(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
captured: dict[str, object] = {}
|
||||
snapshot = make_health_snapshot(4040)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_check_proxy(document: ProxyDocument):
|
||||
captured["checked"] = document.proxy_port
|
||||
return snapshot
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
captured["recorded"] = document
|
||||
return document
|
||||
|
||||
class FakeHealthMonitor:
|
||||
async def publish(self, proxy_port: int, document: HealthSnapshot) -> None:
|
||||
captured["published"] = (proxy_port, document)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "check_proxy", fake_check_proxy)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
||||
|
||||
response = client.get("/proxy/4040/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["overall"] == "healthy"
|
||||
assert captured["checked"] == 4040
|
||||
assert captured["recorded"] == snapshot
|
||||
assert captured["published"] == (4040, snapshot)
|
||||
|
||||
|
||||
def test_proxy_health_history_returns_latest_and_history(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
timestamp = datetime.now(UTC)
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_get_latest_health_sample(proxy_port: int):
|
||||
assert proxy_port == 4040
|
||||
return make_health_snapshot(4040, timestamp=timestamp)
|
||||
|
||||
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
||||
assert proxy_port == 4040
|
||||
assert limit == 40
|
||||
return [make_health_snapshot(4040, timestamp=timestamp)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_latest_health_sample",
|
||||
fake_get_latest_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_health_history",
|
||||
fake_list_health_history,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/4040/health/history")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["proxy_port"] == 4040
|
||||
assert body["latest"]["overall"] == "healthy"
|
||||
assert len(body["history"]) == 1
|
||||
|
||||
|
||||
def test_proxy_health_history_clamps_requested_limit(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
captured: dict[str, int] = {}
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_get_latest_health_sample(_proxy_port: int) -> HealthSnapshot | None:
|
||||
return None
|
||||
|
||||
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
||||
assert proxy_port == 4040
|
||||
captured["limit"] = limit
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_latest_health_sample",
|
||||
fake_get_latest_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_health_history",
|
||||
fake_list_health_history,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/4040/health/history", params={"limit": 9999})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["limit"] == 200
|
||||
|
||||
|
||||
def test_proxy_health_socket_streams_latest_and_updates(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040)
|
||||
timestamp = datetime.now(UTC)
|
||||
follow_up = make_health_snapshot(4040, overall="unhealthy")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_authenticate(_: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
assert proxy_port == 4040
|
||||
return proxy
|
||||
|
||||
async def fake_get_latest_health_sample(proxy_port: int):
|
||||
assert proxy_port == 4040
|
||||
return make_health_snapshot(4040, timestamp=timestamp)
|
||||
|
||||
class FakeHealthMonitor:
|
||||
def subscribe(self, proxy_port: int):
|
||||
assert proxy_port == 4040
|
||||
queue = asyncio.Queue()
|
||||
queue.put_nowait(follow_up)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, proxy_port: int, _queue) -> None:
|
||||
assert proxy_port == 4040
|
||||
captured["unsubscribed"] = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"authenticate_proxy_socket",
|
||||
fake_authenticate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_proxy_by_port",
|
||||
fake_get_proxy_by_port,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"get_latest_health_sample",
|
||||
fake_get_latest_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
||||
|
||||
with client.websocket_connect("/proxy/4040/health/ws?token=test-token") as socket:
|
||||
first = socket.receive_json()
|
||||
assert first["overall"] == "healthy"
|
||||
second = socket.receive_json()
|
||||
assert second["overall"] == "unhealthy"
|
||||
|
||||
assert captured["unsubscribed"] is True
|
||||
|
||||
|
||||
def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||
proxy = make_proxy_document(proxy_port=4040).model_copy(
|
||||
update={"status": "running", "pid": 1234}
|
||||
@@ -515,37 +863,46 @@ def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||
assert response.json()["status"] == "stopped"
|
||||
|
||||
|
||||
def test_list_mitmproxy_examples_returns_builtins_and_custom(client, monkeypatch):
|
||||
async def fake_list_custom_examples():
|
||||
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
|
||||
async def fake_list_custom_templates():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"list_custom_examples",
|
||||
fake_list_custom_examples,
|
||||
"list_custom_templates",
|
||||
fake_list_custom_templates,
|
||||
)
|
||||
|
||||
response = client.get("/proxy/mitmproxy/examples")
|
||||
response = client.get("/proxy/mitmproxy/templates")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == len(BUILTIN_EXAMPLES)
|
||||
assert len(body) == len(BUILTIN_TEMPLATES)
|
||||
assert body[0]["built_in"] is True
|
||||
assert body[0]["protocol"] in {"http", "tcp"}
|
||||
assert isinstance(body[0]["tags"], list)
|
||||
assert body[0]["action"] in {"block", "rewrite"}
|
||||
|
||||
|
||||
def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch):
|
||||
def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_validate_proxy_code(code: str) -> None:
|
||||
captured["validated"] = code
|
||||
|
||||
async def fake_create_custom_example(payload):
|
||||
async def fake_create_custom_template(payload):
|
||||
captured["payload"] = payload
|
||||
return {
|
||||
"id": payload.id,
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"category": payload.category,
|
||||
"protocol": payload.protocol,
|
||||
"family": payload.family,
|
||||
"tags": payload.tags,
|
||||
"action": payload.action,
|
||||
"summary": payload.summary,
|
||||
"behavior": payload.behavior,
|
||||
"code": payload.code,
|
||||
"source_url": payload.source_url,
|
||||
"built_in": False,
|
||||
@@ -558,21 +915,28 @@ def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_router_module,
|
||||
"create_custom_example",
|
||||
fake_create_custom_example,
|
||||
"create_custom_template",
|
||||
fake_create_custom_template,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/proxy/mitmproxy/examples",
|
||||
"/proxy/mitmproxy/templates",
|
||||
json={
|
||||
"id": "custom-header",
|
||||
"name": "Custom header",
|
||||
"description": "Adds a custom response header",
|
||||
"category": "http",
|
||||
"protocol": "http",
|
||||
"family": "custom",
|
||||
"tags": ["observe"],
|
||||
"action": "custom",
|
||||
"summary": "Saved custom template.",
|
||||
"behavior": "Applies the current script as-is.",
|
||||
"code": "def response(flow):\n pass\n",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["id"] == "custom-header"
|
||||
assert response.json()["protocol"] == "http"
|
||||
assert captured["validated"] == "def response(flow):\n pass\n"
|
||||
|
||||
+110
-8
@@ -1,9 +1,14 @@
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
os.environ.setdefault(
|
||||
"MONGODB_URI",
|
||||
@@ -19,7 +24,7 @@ os.environ.setdefault("AUTH_PASSWORD", "admin")
|
||||
|
||||
@pytest.fixture
|
||||
def fake_user():
|
||||
from models.auth import AuthUserDocument
|
||||
from app.models.auth import AuthUserDocument
|
||||
|
||||
return AuthUserDocument(
|
||||
username="admin",
|
||||
@@ -29,8 +34,8 @@ def fake_user():
|
||||
|
||||
@pytest.fixture
|
||||
def auth_context(fake_user):
|
||||
from core.security import AuthContext
|
||||
from models.auth import AccessTokenPayload
|
||||
from app.core.security import AuthContext
|
||||
from app.models.auth import AccessTokenPayload
|
||||
|
||||
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
|
||||
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
|
||||
@@ -39,11 +44,15 @@ def auth_context(fake_user):
|
||||
|
||||
@pytest.fixture
|
||||
def app(fake_user, auth_context) -> FastAPI:
|
||||
from api.auth.router import router as auth_router
|
||||
from api.editor.router import router as editor_router
|
||||
from api.proxy.router import router as proxy_router
|
||||
from core.security import AuthContext, get_auth_context, require_authenticated_user
|
||||
from models.auth import AuthUserDocument
|
||||
from app.api.auth.router import router as auth_router
|
||||
from app.api.editor.router import router as editor_router
|
||||
from app.api.proxy.router import router as proxy_router
|
||||
from app.core.security import (
|
||||
AuthContext,
|
||||
get_auth_context,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from app.models.auth import AuthUserDocument
|
||||
|
||||
app_instance = FastAPI()
|
||||
app_instance.include_router(auth_router)
|
||||
@@ -67,3 +76,96 @@ def app(fake_user, auth_context) -> FastAPI:
|
||||
def client(app: FastAPI):
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
async def _mongo_ping(uri: str) -> str | None:
|
||||
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
||||
try:
|
||||
await client.admin.command("ping")
|
||||
except Exception as error: # pragma: no cover - exercised via skip path
|
||||
return str(error)
|
||||
finally:
|
||||
client.close()
|
||||
return None
|
||||
|
||||
|
||||
async def _drop_database(uri: str, database_name: str) -> None:
|
||||
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
||||
try:
|
||||
await client.drop_database(database_name)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integration_runtime(tmp_path: Path) -> Iterator[dict[str, str]]:
|
||||
from app.core.config import settings
|
||||
from app.services.mitmproxy.manager import manager
|
||||
|
||||
mongodb_uri = settings.MONGODB_URI
|
||||
ping_error = asyncio.run(_mongo_ping(mongodb_uri))
|
||||
if ping_error is not None:
|
||||
pytest.skip(f"MongoDB is not available for integration tests: {ping_error}")
|
||||
|
||||
db_name = f"theplug_test_{uuid4().hex}"
|
||||
scan_root = tmp_path / "scan-root"
|
||||
runtime_root = tmp_path / "runtime"
|
||||
scan_root.mkdir(parents=True, exist_ok=True)
|
||||
runtime_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_db_name = settings.MONGODB_DB_NAME
|
||||
original_scan_root = settings.SERVICE_SCAN_ROOT
|
||||
original_auth_username = settings.AUTH_USERNAME
|
||||
original_auth_password = settings.AUTH_PASSWORD
|
||||
original_manager_work_dir = manager.work_dir
|
||||
|
||||
settings.MONGODB_DB_NAME = db_name
|
||||
settings.SERVICE_SCAN_ROOT = str(scan_root)
|
||||
settings.AUTH_USERNAME = "integration-admin"
|
||||
settings.AUTH_PASSWORD = "integration-admin"
|
||||
manager.work_dir = runtime_root
|
||||
|
||||
try:
|
||||
yield {
|
||||
"db_name": db_name,
|
||||
"scan_root": str(scan_root),
|
||||
"runtime_root": str(runtime_root),
|
||||
}
|
||||
finally:
|
||||
manager.work_dir = original_manager_work_dir
|
||||
manager.processes.clear()
|
||||
manager.attached_pids.clear()
|
||||
settings.MONGODB_DB_NAME = original_db_name
|
||||
settings.SERVICE_SCAN_ROOT = original_scan_root
|
||||
settings.AUTH_USERNAME = original_auth_username
|
||||
settings.AUTH_PASSWORD = original_auth_password
|
||||
asyncio.run(_drop_database(mongodb_uri, db_name))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_app(integration_runtime) -> FastAPI:
|
||||
from app.main import app as real_app_instance
|
||||
|
||||
real_app_instance.dependency_overrides.clear()
|
||||
return real_app_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_client(real_app: FastAPI):
|
||||
with TestClient(real_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token(real_client: TestClient) -> str:
|
||||
response = real_client.post(
|
||||
"/auth/login",
|
||||
json={"username": "integration-admin", "password": "integration-admin"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(auth_token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
||||
|
||||
|
||||
def test_login_logout_revokes_real_token(real_client):
|
||||
login_response = real_client.post(
|
||||
"/auth/login",
|
||||
json={"username": "integration-admin", "password": "integration-admin"},
|
||||
)
|
||||
|
||||
assert login_response.status_code == 200
|
||||
token = login_response.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
list_response = real_client.get("/proxy", headers=headers)
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json() == []
|
||||
|
||||
logout_response = real_client.post("/auth/logout", headers=headers)
|
||||
assert logout_response.status_code == 204
|
||||
|
||||
revoked_response = real_client.get("/proxy", headers=headers)
|
||||
assert revoked_response.status_code == 401
|
||||
assert revoked_response.json()["detail"] == "Could not validate credentials"
|
||||
@@ -0,0 +1,80 @@
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
||||
|
||||
VALID_PROXY_CODE = """def request(flow):
|
||||
return None
|
||||
"""
|
||||
|
||||
|
||||
def test_proxy_crud_round_trip_uses_real_app_and_mongo(real_client, auth_headers):
|
||||
create_response = real_client.post(
|
||||
"/proxy",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"proxy_port": 49081,
|
||||
"name": "integration-echo",
|
||||
"code": VALID_PROXY_CODE,
|
||||
"protocol": "http",
|
||||
"service": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 49082,
|
||||
},
|
||||
"healthcheck": {
|
||||
"enabled": True,
|
||||
"path": "/healthz",
|
||||
"benchmark_enabled": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()
|
||||
assert created["proxy_port"] == 49081
|
||||
assert created["name"] == "integration-echo"
|
||||
assert created["healthcheck"]["benchmark_enabled"] is True
|
||||
|
||||
list_response = real_client.get("/proxy", headers=auth_headers)
|
||||
assert list_response.status_code == 200
|
||||
assert [item["proxy_port"] for item in list_response.json()] == [49081]
|
||||
|
||||
get_response = real_client.get("/proxy/49081", headers=auth_headers)
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()["service"]["port"] == 49082
|
||||
|
||||
update_response = real_client.put(
|
||||
"/proxy/49081",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "integration-echo-updated",
|
||||
"code": VALID_PROXY_CODE.replace("None", '"updated"'),
|
||||
"healthcheck": {
|
||||
"enabled": True,
|
||||
"path": "/ready",
|
||||
"benchmark_enabled": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["name"] == "integration-echo-updated"
|
||||
assert updated["code"].endswith('return "updated"\n')
|
||||
assert updated["healthcheck"]["path"] == "/ready"
|
||||
assert updated["healthcheck"]["benchmark_enabled"] is False
|
||||
|
||||
history_response = real_client.get(
|
||||
"/proxy/49081/health/history",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert history_response.status_code == 200
|
||||
history_payload = history_response.json()
|
||||
assert history_payload["proxy_port"] == 49081
|
||||
assert history_payload["latest"] is None
|
||||
assert history_payload["history"] == []
|
||||
|
||||
delete_response = real_client.delete("/proxy/49081", headers=auth_headers)
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
missing_response = real_client.get("/proxy/49081", headers=auth_headers)
|
||||
assert missing_response.status_code == 404
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.docker.compose import get_all_services_in_dir
|
||||
from app.integrations.docker.compose import get_all_services_in_dir
|
||||
from app.integrations.docker.editor import change_port
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path):
|
||||
@@ -48,3 +50,137 @@ services:
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == []
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_returns_empty_when_root_is_inaccessible(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
root = tmp_path / "workspace"
|
||||
root.mkdir()
|
||||
original_iterdir = Path.iterdir
|
||||
|
||||
def fake_iterdir(self: Path):
|
||||
if self == root:
|
||||
raise PermissionError("denied")
|
||||
return original_iterdir(self)
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", fake_iterdir)
|
||||
|
||||
assert get_all_services_in_dir(root) == []
|
||||
|
||||
|
||||
def test_get_all_services_in_dir_skips_inaccessible_child_folder(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
root = tmp_path / "workspace"
|
||||
allowed = root / "allowed"
|
||||
blocked = root / "blocked"
|
||||
allowed.mkdir(parents=True)
|
||||
blocked.mkdir(parents=True)
|
||||
(allowed / "docker-compose.yml").write_text(
|
||||
"""
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_is_dir = Path.is_dir
|
||||
|
||||
def fake_is_dir(self: Path):
|
||||
if self == blocked:
|
||||
raise PermissionError("denied")
|
||||
return original_is_dir(self)
|
||||
|
||||
monkeypatch.setattr(Path, "is_dir", fake_is_dir)
|
||||
|
||||
services = get_all_services_in_dir(root)
|
||||
|
||||
assert services == [
|
||||
{
|
||||
"name": "echo",
|
||||
"ports": [9081],
|
||||
"bindings": [{"published": 9081, "host": None}],
|
||||
"folder": allowed,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_change_port_restores_original_compose_when_reload_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
folder = tmp_path / "workspace"
|
||||
folder.mkdir()
|
||||
compose_path = folder / "docker-compose.yml"
|
||||
original = """
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "9081:5678"
|
||||
""".strip()
|
||||
compose_path.write_text(original, encoding="utf-8")
|
||||
|
||||
async def fake_run_docker_command(
|
||||
_file: Path,
|
||||
_command: list[str],
|
||||
) -> dict | None:
|
||||
return {"status": "error", "reason": "boom"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.docker.editor.run_docker_command",
|
||||
fake_run_docker_command,
|
||||
)
|
||||
|
||||
changed = asyncio.run(change_port("echo", folder, 9081, 54001))
|
||||
|
||||
assert changed is False
|
||||
assert compose_path.read_text(encoding="utf-8") == original
|
||||
assert (folder / "docker-compose.bak.yml").exists() is False
|
||||
|
||||
|
||||
def test_change_port_restores_original_compose_when_reload_raises(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
folder = tmp_path / "workspace"
|
||||
folder.mkdir()
|
||||
compose_path = folder / "docker-compose.yml"
|
||||
original = """
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
ports:
|
||||
- "127.0.0.1:54001:5678"
|
||||
""".strip()
|
||||
compose_path.write_text(original, encoding="utf-8")
|
||||
|
||||
async def fake_run_docker_command(
|
||||
_file: Path,
|
||||
_command: list[str],
|
||||
) -> dict | None:
|
||||
raise RuntimeError("docker unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.docker.editor.run_docker_command",
|
||||
fake_run_docker_command,
|
||||
)
|
||||
|
||||
changed = asyncio.run(
|
||||
change_port(
|
||||
"echo",
|
||||
folder,
|
||||
54001,
|
||||
9081,
|
||||
host_to=None,
|
||||
rewrite_host=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
assert compose_path.read_text(encoding="utf-8") == original
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
|
||||
from app.integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
|
||||
|
||||
|
||||
def test_get_service_binding_includes_loopback_hosts():
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import asyncio
|
||||
|
||||
from models.proxy import ProxyDocument
|
||||
from models.service import ServiceEndpointDocument
|
||||
from services.mitmproxy.health import check_proxy
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.health import check_proxy
|
||||
|
||||
|
||||
def test_check_proxy_reports_upstream_and_proxy_healthy():
|
||||
@@ -26,11 +27,44 @@ def test_check_proxy_reports_upstream_and_proxy_healthy():
|
||||
await upstream.wait_closed()
|
||||
await proxy.wait_closed()
|
||||
|
||||
assert health["overall"] == "healthy"
|
||||
assert [check["target"] for check in health["checks"]] == [
|
||||
assert isinstance(health, HealthSnapshot)
|
||||
assert health.overall == "healthy"
|
||||
assert [check.target for check in health.checks] == [
|
||||
"upstream",
|
||||
"proxy",
|
||||
]
|
||||
assert all(check.duration_ms is None for check in health.checks)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_check_proxy_reports_benchmark_latency_when_enabled():
|
||||
async def run():
|
||||
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||
try:
|
||||
upstream_port = upstream.sockets[0].getsockname()[1]
|
||||
proxy_port = proxy.sockets[0].getsockname()[1]
|
||||
document = ProxyDocument(
|
||||
proxy_port=proxy_port,
|
||||
name="health",
|
||||
code="",
|
||||
healthcheck={"benchmark_enabled": True},
|
||||
service=ServiceEndpointDocument(port=upstream_port),
|
||||
)
|
||||
|
||||
health = await check_proxy(document)
|
||||
finally:
|
||||
upstream.close()
|
||||
proxy.close()
|
||||
await upstream.wait_closed()
|
||||
await proxy.wait_closed()
|
||||
|
||||
assert isinstance(health, HealthSnapshot)
|
||||
assert all(
|
||||
check.duration_ms is not None and check.duration_ms >= 0
|
||||
for check in health.checks
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.health import HealthSnapshot
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.health_monitor import HealthMonitor
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
data = {
|
||||
"proxy_port": 8080,
|
||||
"name": "svc",
|
||||
"code": "",
|
||||
"status": "running",
|
||||
"service": ServiceEndpointDocument(port=9000),
|
||||
}
|
||||
data.update(updates)
|
||||
return ProxyDocument(**data)
|
||||
|
||||
|
||||
def make_snapshot(proxy_port: int, *, overall: str = "healthy") -> HealthSnapshot:
|
||||
return HealthSnapshot(
|
||||
proxy_port=proxy_port,
|
||||
timestamp=datetime.now(UTC),
|
||||
enabled=True,
|
||||
overall=overall,
|
||||
checks=[],
|
||||
)
|
||||
|
||||
|
||||
def test_run_cycle_records_and_publishes_running_healthchecks(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
proxy = make_proxy()
|
||||
captured: dict[str, object] = {}
|
||||
snapshot = make_snapshot(8080)
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [proxy]
|
||||
|
||||
async def fake_check_proxy(document):
|
||||
captured["checked"] = document.proxy_port
|
||||
return snapshot
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
captured["recorded"] = document
|
||||
return document
|
||||
|
||||
async def fake_publish(proxy_port, document):
|
||||
captured["published"] = (proxy_port, document)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||
|
||||
asyncio.run(monitor.run_cycle())
|
||||
|
||||
assert captured["checked"] == 8080
|
||||
assert captured["recorded"] == snapshot
|
||||
assert captured["published"] == (8080, snapshot)
|
||||
|
||||
|
||||
def test_run_cycle_skips_stopped_or_disabled_proxies(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
running_disabled = make_proxy(healthcheck={"enabled": False})
|
||||
stopped = make_proxy(proxy_port=8081, status="stopped")
|
||||
called = False
|
||||
|
||||
async def fake_list_proxies():
|
||||
return [running_disabled, stopped]
|
||||
|
||||
async def fake_check_proxy(_document):
|
||||
nonlocal called
|
||||
called = True
|
||||
return make_snapshot(8080)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
|
||||
asyncio.run(monitor.run_cycle())
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_run_cycle_checks_proxies_in_parallel(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
proxies = [
|
||||
make_proxy(proxy_port=8080),
|
||||
make_proxy(proxy_port=8081),
|
||||
make_proxy(proxy_port=8082),
|
||||
]
|
||||
started: list[int] = []
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_list_proxies():
|
||||
return proxies
|
||||
|
||||
async def fake_check_proxy(document):
|
||||
started.append(document.proxy_port)
|
||||
if len(started) == len(proxies):
|
||||
release.set()
|
||||
await release.wait()
|
||||
return make_snapshot(document.proxy_port)
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
return document
|
||||
|
||||
async def fake_publish(_proxy_port, _document):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||
|
||||
asyncio.run(asyncio.wait_for(monitor.run_cycle(), timeout=1))
|
||||
|
||||
assert started == [8080, 8081, 8082]
|
||||
|
||||
|
||||
def test_run_cycle_continues_when_one_proxy_check_fails(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
proxies = [
|
||||
make_proxy(proxy_port=8080),
|
||||
make_proxy(proxy_port=8081),
|
||||
]
|
||||
captured: list[int] = []
|
||||
|
||||
async def fake_list_proxies():
|
||||
return proxies
|
||||
|
||||
async def fake_check_proxy(document):
|
||||
if document.proxy_port == 8080:
|
||||
raise RuntimeError("boom")
|
||||
return make_snapshot(document.proxy_port)
|
||||
|
||||
async def fake_record_health_sample(document):
|
||||
captured.append(document.proxy_port)
|
||||
return document
|
||||
|
||||
async def fake_publish(proxy_port, _document):
|
||||
captured.append(proxy_port + 10_000)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||
fake_list_proxies,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||
fake_check_proxy,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||
fake_record_health_sample,
|
||||
)
|
||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||
|
||||
asyncio.run(monitor.run_cycle())
|
||||
|
||||
assert captured == [8081, 18081]
|
||||
|
||||
|
||||
def test_run_retries_after_cycle_failure(monkeypatch):
|
||||
monitor = HealthMonitor()
|
||||
calls = {"run_cycle": 0}
|
||||
sleep_calls: list[float] = []
|
||||
|
||||
async def fake_run_cycle():
|
||||
calls["run_cycle"] += 1
|
||||
if calls["run_cycle"] == 1:
|
||||
raise RuntimeError("temporary failure")
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
sleep_calls.append(seconds)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(monitor, "run_cycle", fake_run_cycle)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.health_monitor.asyncio.sleep",
|
||||
fake_sleep,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(monitor.run())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert calls["run_cycle"] == 2
|
||||
assert len(sleep_calls) == 1
|
||||
assert sleep_calls[0] == pytest.approx(1.0, abs=0.01)
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from models.proxy import ProxyDocument
|
||||
from models.service import ServiceEndpointDocument
|
||||
from services.mitmproxy.manager import MitmdumpManager
|
||||
import pytest
|
||||
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy.manager import MitmdumpError, MitmdumpManager
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
@@ -65,6 +67,20 @@ def test_regular_mode_uses_proxy_port_as_listen_port():
|
||||
assert command[-2:] == ["--listen-port", "8080"]
|
||||
|
||||
|
||||
def test_start_rejects_reserved_backend_port():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(proxy_port=3671)
|
||||
|
||||
async def run():
|
||||
await manager.start(proxy)
|
||||
|
||||
with pytest.raises(
|
||||
MitmdumpError,
|
||||
match="Proxy port 3671 is reserved for the backend API",
|
||||
):
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_running_document_without_pid_is_treated_as_stopped():
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(status="running", pid=None)
|
||||
@@ -167,3 +183,99 @@ def test_reconcile_restores_upstream_when_process_is_gone():
|
||||
"service.upstream_port": None,
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
|
||||
def test_cleanup_runtime_artifacts_removes_generated_files(tmp_path: Path):
|
||||
manager = MitmdumpManager(work_dir=str(tmp_path / "theplug"))
|
||||
proxy = make_proxy(
|
||||
script_path=str(manager.script_path_for(make_proxy())),
|
||||
config_path=str(manager.config_path_for(make_proxy())),
|
||||
log_path=str(manager.log_path_for(make_proxy())),
|
||||
)
|
||||
|
||||
script_path = Path(proxy.script_path)
|
||||
config_path = Path(proxy.config_path)
|
||||
log_path = Path(proxy.log_path)
|
||||
script_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
script_path.write_text("print('hello')", encoding="utf-8")
|
||||
config_path.write_text("block_global: false\n", encoding="utf-8")
|
||||
log_path.write_text("old log\n", encoding="utf-8")
|
||||
|
||||
manager.cleanup_runtime_artifacts(proxy)
|
||||
|
||||
assert script_path.exists() is False
|
||||
assert log_path.exists() is False
|
||||
assert config_path.exists() is False
|
||||
assert manager.conf_dir_for(proxy).exists() is False
|
||||
|
||||
|
||||
def test_prepare_upstream_retries_until_port_remap_succeeds(monkeypatch):
|
||||
manager = MitmdumpManager()
|
||||
proxy = make_proxy(
|
||||
mode="reverse",
|
||||
service=ServiceEndpointDocument(
|
||||
port=9000,
|
||||
name="echo",
|
||||
folder="/tmp/playground",
|
||||
),
|
||||
)
|
||||
attempted_ports: list[int] = []
|
||||
candidate_ports = iter([54001, 54002, 54003])
|
||||
|
||||
async def fake_change_port(
|
||||
_service_name,
|
||||
_folder,
|
||||
_port_from,
|
||||
port_to,
|
||||
**_kwargs,
|
||||
):
|
||||
attempted_ports.append(port_to)
|
||||
return port_to == 54003
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.get_random_port",
|
||||
lambda: next(candidate_ports),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.change_port",
|
||||
fake_change_port,
|
||||
)
|
||||
|
||||
upstream_port = asyncio.run(manager.prepare_upstream(proxy))
|
||||
|
||||
assert upstream_port == 54003
|
||||
assert attempted_ports == [54001, 54002, 54003]
|
||||
|
||||
|
||||
def test_prepare_upstream_fails_after_retry_budget(monkeypatch):
|
||||
manager = MitmdumpManager()
|
||||
manager.upstream_port_attempts = 3
|
||||
proxy = make_proxy(
|
||||
mode="reverse",
|
||||
service=ServiceEndpointDocument(
|
||||
port=9000,
|
||||
name="echo",
|
||||
folder="/tmp/playground",
|
||||
),
|
||||
)
|
||||
candidate_ports = iter([54001, 54002, 54003])
|
||||
|
||||
async def fake_change_port(*_args, **_kwargs):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.get_random_port",
|
||||
lambda: next(candidate_ports),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.mitmproxy.manager.change_port",
|
||||
fake_change_port,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
MitmdumpError,
|
||||
match="Unable to move Docker service port after retries",
|
||||
):
|
||||
asyncio.run(manager.prepare_upstream(proxy))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from services.mitmproxy.options import (
|
||||
|
||||
from app.services.mitmproxy.options import (
|
||||
MitmOption,
|
||||
parse_options,
|
||||
public_options,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from services.editor import python_lsp
|
||||
from app.services.editor import python_lsp
|
||||
|
||||
|
||||
def test_encode_message():
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
|
||||
from models.proxy import ProxyDocument
|
||||
from models.service import ServiceEndpointDocument
|
||||
from services.mitmproxy import recovery as recovery_module
|
||||
from app.models.proxy import ProxyDocument
|
||||
from app.models.service import ServiceEndpointDocument
|
||||
from app.services.mitmproxy import recovery as recovery_module
|
||||
|
||||
|
||||
def make_proxy(**updates):
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
|
||||
|
||||
|
||||
def test_builtin_templates_use_four_space_indentation():
|
||||
for template in BUILTIN_TEMPLATES:
|
||||
for line in template.code.splitlines():
|
||||
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
assert indent % 4 == 0, (
|
||||
f"{template.id} has non-4-space indentation: {line!r}"
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.security import create_access_token, decode_access_token
|
||||
from app.core.security import create_access_token, decode_access_token
|
||||
|
||||
|
||||
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
|
||||
monkeypatch.setattr("core.security.settings.JWT_EXPIRE_MINUTES", 720)
|
||||
monkeypatch.setattr("app.core.security.settings.JWT_EXPIRE_MINUTES", 720)
|
||||
|
||||
issued_at = datetime.now(UTC)
|
||||
token = create_access_token(subject="admin")
|
||||
|
||||
Reference in New Issue
Block a user