"""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