A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
4.2 KiB
Python
134 lines
4.2 KiB
Python
"""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
|