packmate/mcp/tests/integration.py
2026-10-23 11:42:44 +03:00

195 lines
12 KiB
Python

"""Run only in tests/compose.yml's isolated network and disposable PostgreSQL database."""
import asyncio
import base64
import json
import math
import struct
import time
from pathlib import Path
import httpx
import psycopg
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from websockets.asyncio.client import connect
API = "http://172.31.250.10:65000"
TOKEN = "mcp-integration-test-token-000000000000"
async def main():
report = {"baseline_streams": 200, "live_streams": 3000, "checks": []}
db = await psycopg.AsyncConnection.connect("host=db dbname=packmate user=packmate password=network-stress", autocommit=True)
async def scalar(sql, params=()):
cursor = await db.execute(sql, params)
return (await cursor.fetchone())[0]
async with httpx.AsyncClient(auth=("stress", "stress"), timeout=10) as rest:
for _ in range(60):
try:
if (await rest.get(API + "/api/analysis/overview")).status_code == 200:
break
except httpx.HTTPError:
pass
await asyncio.sleep(1)
else:
raise AssertionError("backend failed to become ready")
headers = {"Authorization": "Bearer " + TOKEN}
async with httpx.AsyncClient(headers=headers, timeout=45) as transport:
async with streamable_http_client("http://mcp:65002/mcp", http_client=transport) as (read, write, _):
async with ClientSession(read, write) as client:
await client.initialize()
tools = {tool.name for tool in (await client.list_tools()).tools}
assert {"create_service", "create_pattern", "delete_service", "search_traffic", "start_lookback"} <= tools
report["tool_count"] = len(tools)
async def call(name, arguments=None):
response = await client.call_tool(name, arguments or {})
assert not response.isError, (name, response.content)
return response.structuredContent
async def finish(job):
for _ in range(150):
status = await call("get_job", {"job_id": job["job_id"]})
if status["status"] != "running":
assert status["status"] == "completed", status
return status
await asyncio.sleep(0.2)
raise AssertionError("job never finished")
async def send(i):
reader, writer = await asyncio.open_connection("172.31.250.10", 18080)
payload = f"PMMCP-{i:08d} FLAG{{mcp_test}}".encode()
writer.write(struct.pack("!I", len(payload)) + payload)
await writer.drain()
length = struct.unpack("!I", await reader.readexactly(4))[0]
assert await reader.readexactly(length) == payload
writer.close()
await writer.wait_closed()
authorization = "Basic " + base64.b64encode(b"stress:stress").decode()
async with connect("ws://172.31.250.10:65000/api/ws/websocket", additional_headers={"Authorization": authorization}) as ws:
service = await call("create_service", {"port": 18080, "settings": {"name": "MCP echo", "http": False,
"urldecode_http_requests": False, "merge_adjacent_packets": False}})
assert service["service"]["port"] == 18080
pattern = await call("create_pattern", {"settings": {"name": "MCP flag", "match": {"type": "regex", "value": r"FLAG\{[^}]+\}"}, "service_port": 18080}})
pid = pattern["pattern"]["id"]
assert pattern["pattern"]["regexEngine"] == "RE2J"
events = []
async with asyncio.timeout(10):
while not {"SAVE_SERVICE", "SAVE_PATTERN"} <= set(events):
event = json.loads(await ws.recv())
events.append(event["type"])
report["checks"].append("MCP-created service and pattern broadcast to browser websocket")
for first in range(0, 200, 20):
await asyncio.gather(*(send(i) for i in range(first, first + 20)))
for _ in range(30):
if await scalar("SELECT count(*) FROM stream") == 200:
break
await asyncio.sleep(0.5)
assert await scalar("SELECT count(*) FROM stream") == 200
baseline_ids = [row[0] for row in await (await db.execute("SELECT id FROM stream ORDER BY id")).fetchall()]
assert await scalar("SELECT count(*) FROM stream_found_patterns WHERE pattern_id = %s", (pid,)) == 200
report["checks"].append("all baseline TCP streams have live pattern highlights")
search = await finish(await call("search_traffic", {"query": {"service_ports": [18080], "direction": "incoming",
"match": {"type": "regex", "value": "PMMCP-"}}, "limit": 500}))
assert search["coverage_complete"] and search["matched_packets"] == 200, search
rows = (await call("get_job_results", {"job_id": search["job_id"], "limit": 5}))["items"]
selected = rows[0]
packet = await call("read_packet", {"packet_id": selected["packet_id"], "format": "hex"})
assert b"PMMCP-" in bytes.fromhex(packet["content"])
assert (await rest.post(API + f"/api/packet/{selected['stream_id']}", json={"pageSize": 20})).status_code == 200
ip = await finish(await call("search_traffic", {"query": {"direction": "incoming", "match": {
"target": "source_ip", "type": "regex", "value": r"^172\.31\.250\."}}, "limit": 500}))
assert ip["matched_packets"] == 200, ip
preserved = [row["stream_id"] for row in rows]
favored = await call("set_favorites", {"stream_ids": preserved})
assert len(favored["updated"]) == 5
report["checks"].append("content and IP regex, exact payload reads, favorites confirmed")
historical = await call("create_pattern", {"settings": {"name": "Historical MCP", "match": {"type": "substring", "value": "PMMCP-"}, "direction": "INPUT"}})
hpid = historical["pattern"]["id"]
history = await finish(await call("start_lookback", {"pattern_id": hpid, "query": {}, "limit": 500}))
assert history["applied_packets"] == 200, history
count = await scalar("SELECT count(*) FROM found_pattern WHERE pattern_id = %s", (hpid,))
assert count == 200, count
await finish(await call("start_lookback", {"pattern_id": hpid, "query": {}, "limit": 500}))
assert await scalar("SELECT count(*) FROM found_pattern WHERE pattern_id = %s", (hpid,)) == count
visible = (await rest.post(API + f"/api/packet/{selected['stream_id']}", json={"pageSize": 20})).json()
assert any(any(m["patternId"] == hpid for m in packet["matches"]) for packet in visible), visible
report["checks"].append("lookback stores UI-visible highlights and repeat creates no duplicates")
# Reject requests before work, without trusting an agent to observe the limit.
bad = await client.call_tool("search_traffic", {"query": {"match": {"value": r"(x)\1"}}})
assert bad.isError
forbidden = await transport.post("http://mcp:65002/mcp", headers={"Authorization": "Bearer wrong"}, json={})
assert forbidden.status_code == 401
bound = await rest.get(API + f"/api/analysis/packets/{selected['packet_id']}", params={"length": 9999999})
assert bound.status_code == 400
report["checks"].append("invalid regex, missing token and oversized reads rejected")
# Keep later searches alive while traffic is written and the old baseline is cleaned.
await db.execute("UPDATE stream SET start_timestamp = start_timestamp - 120000, end_timestamp = end_timestamp - 120000, persisted_at = persisted_at - 120000 WHERE favorite = false")
load_start = time.monotonic()
latencies = []
sent = 0
overlap = False
async def load():
nonlocal sent
for start in range(0, 3000, 20):
await asyncio.gather(*(send(10000 + i) for i in range(start, start + 20)))
sent += 20
await asyncio.sleep(max(0, load_start + (start + 20) * 15 / 3000 - time.monotonic()))
async def monitor():
nonlocal overlap
while sent < 3000:
before = time.monotonic()
response = await rest.get(API + "/api/pcap/metrics")
response.raise_for_status()
latencies.append((time.monotonic() - before) * 1000)
remaining = await scalar("SELECT count(*) FROM stream WHERE id = ANY(%s) AND favorite = false", (baseline_ids,))
if 0 < remaining < 195 and 0 < sent < 3000:
overlap = True
await asyncio.sleep(0.1)
async def investigations():
while sent < 3000:
job = await call("search_traffic", {"query": {"match": {"value": "(a+)+$"}}, "limit": 1000})
await finish(job)
await call("delete_job", {"job_id": job["job_id"]})
await asyncio.gather(load(), monitor(), investigations())
assert overlap, "cleanup did not overlap live capture and MCP"
for _ in range(60):
if await scalar("SELECT count(*) FROM stream WHERE id = ANY(%s) AND favorite = false", (baseline_ids,)) == 0:
break
await asyncio.sleep(0.5)
assert await scalar("SELECT count(*) FROM stream WHERE id = ANY(%s)", (baseline_ids,)) == 5
for _ in range(30):
if await scalar("SELECT count(*) FROM stream") == 3005:
break
await asyncio.sleep(0.5)
assert await scalar("SELECT count(*) FROM stream") == 3005
metrics = (await rest.get(API + "/api/pcap/metrics")).json()
assert all(metrics[key] == 0 for key in ("droppedPackets", "interfaceDroppedPackets", "applicationDroppedPackets")), metrics
p99 = sorted(latencies)[math.ceil(len(latencies) * 0.99) - 1]
assert p99 < 500, p99
assert await scalar("SELECT count(*) FROM packet p LEFT JOIN stream s ON s.id = p.stream_id WHERE s.id IS NULL") == 0
report.update(api_p99_ms=round(p99, 3), api_samples=len(latencies), metrics=metrics, cleanup_overlap=overlap)
report["checks"].append("3000 live TCP streams + concurrent regex jobs + cleanup: no stream loss, no drops, favorites preserved")
await db.close()
Path("/reports").mkdir(exist_ok=True)
Path("/reports/integration.json").write_text(json.dumps(report, indent=2))
print(json.dumps(report, indent=2), flush=True)
if __name__ == "__main__":
asyncio.run(main())