"""Webhooks are mounted without authentication, so their secret is a boundary. The secret travels as the last path segment; anything but an exact match must be indistinguishable from a hook that does not exist. """ import asyncio from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from fluksio.flow.controller import HOOK_PREFIX, FlowController from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes import HttpNode from fluksio.flow.pipeline import Pipeline from fluksio.flow.schemas import FlowDef, NodeDef from fluksio.flow.secrets import init_secrets, resolve_params from fluksio.flow.store import FlowStore SECRET = "d3vice-key" HOOK = f"{HOOK_PREFIX}/house/sensor" def hook_node(**params: object) -> HttpNode: """A trigger node mounted the way the controller mounts it.""" node = HttpNode( provides=[MessageSpec(name="temp", dtype=DType.FLOAT)], params={"url": "/sensor", "method": "POST", **params}, name="sensor", ) node.assign_flow("house", "sensor") node.url = HOOK return node def mount(node: HttpNode) -> tuple[TestClient, Pipeline]: pipeline = Pipeline(nodes=[node]) app = FastAPI() node.register_route(app) return TestClient(app), pipeline def test_the_right_secret_carries_the_payload_into_the_pipeline(): node = hook_node(secret=SECRET) client, pipeline = mount(node) response = client.post(f"{HOOK}/{SECRET}", json={"temp": 21.5}) assert response.status_code == 200 assert pipeline.values()["house.temp"]["value"] == 21.5 def test_a_hook_without_a_secret_still_answers_on_its_plain_url(): # Flows written before the parameter existed keep working unchanged. node = hook_node() client, pipeline = mount(node) assert client.post(HOOK, json={"temp": 21.5}).status_code == 200 assert pipeline.values()["house.temp"]["value"] == 21.5 def test_a_hook_is_reachable_past_an_app_mounted_at_the_root(): """The MCP app is mounted at "/" and answers for every path, so a hook registered after it would never be reached.""" node = hook_node() pipeline = Pipeline(nodes=[node]) app = FastAPI() app.mount("/", FastAPI()) node.register_route(app) response = TestClient(app).post(HOOK, json={"temp": 21.5}) assert response.status_code == 200 assert pipeline.values()["house.temp"]["value"] == 21.5 @pytest.mark.parametrize( "path", [ f"{HOOK}/wrong", # A prefix of the secret must not pass either. f"{HOOK}/{SECRET[:-1]}", # No secret at all: the route only exists with the segment. HOOK, f"{HOOK}/", ], ) def test_anything_but_the_secret_looks_like_no_such_hook(path: str): node = hook_node(secret=SECRET) client, pipeline = mount(node) assert client.post(path, json={"temp": 21.5}).status_code == 404 assert pipeline.values() == {} def test_the_secret_can_be_read_from_the_secrets_store(tmp_path: Path): init_secrets(tmp_path / "secrets.enc", "app-key").set("hook_key", SECRET) # What the controller does before building the node. params = resolve_params({"url": "/sensor", "secret": {"$secret": "hook_key"}}) node = hook_node(**params) assert node.secret == SECRET client, _ = mount(node) assert client.post(f"{HOOK}/{SECRET}", json={"temp": 21.5}).status_code == 200 @pytest.mark.parametrize("secret, issues", [("", 1), (SECRET, 0)]) def test_a_hook_without_a_secret_is_reported(tmp_path: Path, secret: str, issues: int): store = FlowStore(tmp_path / "flows") store.write_flow( FlowDef( name="house", nodes=[ NodeDef( id="sensor", type="http", params={"url": "/sensor", "secret": secret}, provides=[MessageSpec(name="temp")], ) ], ) ) controller = FlowController(store=store, fastapi_app=FastAPI()) asyncio.run(controller.reload()) open_hooks = [i for i in controller.issues if i.code == "unauthenticated_hook"] assert len(open_hooks) == issues # Either way the flow loads — an existing hook keeps working. assert controller.get_node("house.sensor") is not None