The connector now serves /mcp, its OAuth documents and /hooks/ besides /api/v1/, refuses any dot segment (httpx resolves them, which carried /api/v1/../../mcp onto the MCP mount), addresses calls to the hub's host, and bridges served sockets by calling the app with a websocket scope. A hook without a secret answers the tunnel with the 404 (or 1008) of a wrong secret. Requests through the hub get the hub's issuer, resource and consent page in the OAuth metadata and the 401, and the MCP host check admits the hub while enrolled.
145 lines
5.1 KiB
Python
145 lines
5.1 KiB
Python
"""What the portal tunnel reaches on this instance, and what it refuses.
|
|
|
|
Requests are handed to the connector as the hub frames them. Besides the API,
|
|
which the hub opens only to a portal session, the tunnel serves the MCP
|
|
endpoint and the flows' hooks, and a hook only with a secret.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from fluksio.cloud.connector import CloudConnector
|
|
from fluksio.flow.controller import HOOK_PREFIX
|
|
from fluksio.flow.messages import DType, MessageSpec
|
|
from fluksio.flow.nodes import HttpNode, WebSocketNode
|
|
from fluksio.flow.pipeline import Pipeline
|
|
|
|
SECRET = "tunnel-key"
|
|
PORT = [MessageSpec(name="temp", dtype=DType.FLOAT)]
|
|
|
|
|
|
class _Socket:
|
|
"""The connector's side of the tunnel, recording what it sends the hub."""
|
|
|
|
def __init__(self) -> None:
|
|
self.frames: list[dict[str, Any]] = []
|
|
|
|
async def send(self, text: str) -> None:
|
|
self.frames.append(json.loads(text))
|
|
|
|
|
|
def _through_the_tunnel(app: FastAPI, path: str, method: str = "POST") -> int | None:
|
|
"""The status a call through the hub gets, or None when it is refused."""
|
|
socket = _Socket()
|
|
body = base64.b64encode(json.dumps({"temp": 21.5}).encode()).decode()
|
|
frame = {
|
|
"id": "c1",
|
|
"method": method,
|
|
"path": path,
|
|
"query": "",
|
|
"headers": {"content-type": "application/json"},
|
|
"body": body,
|
|
}
|
|
asyncio.run(CloudConnector(app)._serve(socket, frame))
|
|
head = socket.frames[0]
|
|
return None if head["op"] == "err" else int(head["status"])
|
|
|
|
|
|
def _hooks(*nodes: HttpNode | WebSocketNode) -> tuple[FastAPI, Pipeline]:
|
|
"""Mount nodes the way the controller does, behind an app at the root."""
|
|
pipeline = Pipeline(nodes=list(nodes))
|
|
app = FastAPI()
|
|
app.mount("/", FastAPI()) # the MCP catch-all
|
|
for node in nodes:
|
|
node.url = f"{HOOK_PREFIX}/house/{node.url.lstrip('/')}"
|
|
node.register_route(app)
|
|
return app, pipeline
|
|
|
|
|
|
def _webhook(name: str, **params: object) -> HttpNode:
|
|
node = HttpNode(provides=PORT, params={"url": f"/{name}", **params}, name=name)
|
|
node.assign_flow("house", name)
|
|
return node
|
|
|
|
|
|
def _socket(name: str, **params: object) -> WebSocketNode:
|
|
node = WebSocketNode(provides=PORT, params={"url": f"/{name}", **params}, name=name)
|
|
node.assign_flow("house", name)
|
|
return node
|
|
|
|
|
|
def test_a_webhook_answers_the_tunnel_only_with_its_secret() -> None:
|
|
app, pipeline = _hooks(_webhook("open"), _webhook("guarded", secret=SECRET))
|
|
|
|
# On this network a hook without a secret stays open, as it always was.
|
|
assert TestClient(app).post("/hooks/house/open", json={}).status_code == 200
|
|
# Through the hub it looks like a wrong secret does.
|
|
assert _through_the_tunnel(app, "/hooks/house/open") == 404
|
|
assert _through_the_tunnel(app, "/hooks/house/guarded/wrong") == 404
|
|
assert "house.temp" not in pipeline.values()
|
|
|
|
assert _through_the_tunnel(app, f"/hooks/house/guarded/{SECRET}") == 200
|
|
assert pipeline.values()["house.temp"]["value"] == 21.5
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
# httpx resolves a literal dot segment, which would carry a path the hub
|
|
# forwarded without a session onto the API.
|
|
"/hooks/../api/v1/login/access-token",
|
|
"/api/v1/../../mcp",
|
|
"/hooks/%2e%2e/api/v1/utils/health-check/",
|
|
"/docs",
|
|
"/.well-known/openid-configuration",
|
|
"/mcpx",
|
|
],
|
|
)
|
|
def test_the_tunnel_refuses_what_it_does_not_serve(path: str) -> None:
|
|
assert _through_the_tunnel(FastAPI(), path, method="GET") is None
|
|
|
|
|
|
def test_a_served_socket_answers_the_tunnel_only_with_its_secret() -> None:
|
|
app, pipeline = _hooks(_socket("open"), _socket("guarded", secret=SECRET))
|
|
|
|
async def scenario() -> list[list[dict[str, Any]]]:
|
|
connector = CloudConnector(app)
|
|
refused = []
|
|
for stream, path in [
|
|
("w1", "/hooks/house/open"),
|
|
("w2", "/hooks/house/guarded/wrong"),
|
|
("w3", "/hooks/house/nothing"),
|
|
]:
|
|
socket = _Socket()
|
|
await connector._stream(socket, {"id": stream, "path": path, "query": ""})
|
|
refused.append(socket.frames)
|
|
|
|
socket = _Socket()
|
|
frame = {"id": "w4", "path": f"/hooks/house/guarded/{SECRET}", "query": ""}
|
|
task = asyncio.create_task(connector._stream(socket, frame))
|
|
while not socket.frames:
|
|
await asyncio.sleep(0.01)
|
|
assert socket.frames == [{"op": "ws_open_ok", "id": "w4"}]
|
|
# What the session loop does with the hub's ws_msg.
|
|
connector._inboxes["w4"].put_nowait(
|
|
{"type": "websocket.receive", "text": '{"temp": 21.5}'}
|
|
)
|
|
while "house.temp" not in pipeline.values():
|
|
await asyncio.sleep(0.01)
|
|
task.cancel()
|
|
return refused
|
|
|
|
refused = asyncio.run(scenario())
|
|
# Refused before opening, every way alike.
|
|
assert refused == [
|
|
[{"op": "ws_close", "id": stream, "code": 1008}]
|
|
for stream in ("w1", "w2", "w3")
|
|
]
|
|
assert pipeline.values()["house.temp"]["value"] == 21.5
|