feat(s52): webhook notifier with fail-closed opt-in gate + injectable transport

This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 19:48:02 +02:00
commit 61c4cb2d26
3 changed files with 119 additions and 1 deletions

View file

@ -6,13 +6,31 @@ import MAF-tainted modules freely (``verdicts``) — only ``notify.py`` itself m
from __future__ import annotations
import inspect
import io
import json
from portfolio_optimiser.notify import ConsoleNotifier, FileNotifier, Notifier, _verdict_payload
from portfolio_optimiser.notify import (
ConsoleNotifier,
FileNotifier,
Notifier,
WebhookNotifier,
_urllib_post,
_verdict_payload,
)
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict, verdict_to_dict
def _make_post(recorder: list[tuple[str, str]]):
"""A canned webhook transport: records ``(url, body)``, opens no socket. Local to this file
(mirrors ``test_ingest_http._make_get`` locality; never conftest)."""
def post(url: str, body: str) -> None:
recorder.append((url, body))
return post
def _verdict() -> Verdict:
return capture_verdict(
ProposalFeatures(
@ -69,3 +87,21 @@ def test_file_notifier_byte_deterministic(tmp_path) -> None:
assert payload["id"] == verdict.id
assert payload["proposal_features"]["affected_codes"] == ["03.1", "05.2"]
assert not any(k in payload for k in ("date", "timestamp", "created", "ts"))
def test_webhook_posts_correct_payload() -> None:
"""The injected transport receives the exact ``(url, nested verdict_to_dict payload)``."""
calls: list[tuple[str, str]] = []
verdict = _verdict()
url = "https://hooks.example.test/T000/B000/token"
WebhookNotifier(url, allow_egress=True, post=_make_post(calls))(verdict)
assert len(calls) == 1
posted_url, body = calls[0]
assert (posted_url, json.loads(body)) == (url, verdict_to_dict(verdict))
def test_webhook_default_post_is_urllib_post() -> None:
"""inspect.signature proves the stdlib default WITHOUT opening a socket — ``_urllib_post`` is
the single socket path and is never called by the offline suite."""
sig = inspect.signature(WebhookNotifier.__init__)
assert sig.parameters["post"].default is _urllib_post

View file

@ -14,10 +14,49 @@ from __future__ import annotations
import ast
from pathlib import Path
import pytest
from portfolio_optimiser.notify import NotifyRefused, WebhookNotifier
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict
_SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser"
_MAF_ROOTS = {"agent_framework", "mcp"}
def _verdict() -> Verdict:
return capture_verdict(
ProposalFeatures(
affected_codes=frozenset({"05.2"}),
measure_type="scope_reduction",
claimed_saving_nok=200_000.0,
),
"approved",
"expert reviewed (test)",
)
# --- LOAD-BEARING: egress gate (both branches, same url + same recording post) --------------------
def test_webhook_gate_refuses_without_optin_and_posts_with_optin() -> None:
"""LOAD-BEARING (no-silent-egress): without explicit per-run opt-in the webhook notifier
refuses at CONSTRUCTION (``NotifyRefused``) and the transport is NEVER touched; with
``allow_egress=True`` the same url + same transport delivers exactly one post. Detach the
``if not allow_egress: raise`` gate the False branch stops raising (silent egress) RED."""
url = "https://hooks.example.test/T000/B000/secret-token"
posts: list[tuple[str, str]] = []
def recording_post(post_url: str, body: str) -> None:
posts.append((post_url, body))
with pytest.raises(NotifyRefused):
WebhookNotifier(url, allow_egress=False, post=recording_post)
assert posts == [] # gate refused BEFORE any transport access
WebhookNotifier(url, allow_egress=True, post=recording_post)(_verdict())
assert len(posts) == 1
def test_notify_registered_maf_free() -> None:
"""Meta: notify.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free``
actually scans it otherwise the MAF-free claim would be green-but-dead (never checked)."""