Files
app/backend/tests/flow/test_http_hook_secret.py
T
stroblmeandClaude Opus 5 640654bd66 Rename the import package app to fluksio
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>
2026-08-21 21:48:05 +02:00

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