Every test double that stood in for `_adapter_for_source` was a delivery stand-in. Part of #88715 (phase 4).
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Caller-level fallback suppression — the branches that actually stop the leak.
|
|
|
|
Review round 3 deleted BOTH production branches these cover and every existing
|
|
test still passed (36 and 38 respectively). The suites exercised
|
|
`_approval_send_outcome` and `RelayAdapter` but never drove the real callers,
|
|
so nothing observed whether a text send followed a decline — which is the
|
|
entire security property.
|
|
|
|
Every case here pairs a DECLINE with an ordinary-FAILURE control. Without the
|
|
control a test cannot distinguish "suppressed the fallback" from "never had a
|
|
fallback": a caller that always stays silent would pass the decline case.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import pytest
|
|
|
|
from gateway.platforms.base import SendResult
|
|
from gateway.relay.egress import EGRESS_DECLINE_CODE
|
|
|
|
CODE_ONLY_DECLINE: Dict[str, Any] = {"success": False, "code": EGRESS_DECLINE_CODE}
|
|
|
|
|
|
# ── exec approval ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class _Adapter:
|
|
"""Records every plain text send the caller attempts."""
|
|
|
|
typed_command_prefix = "/"
|
|
|
|
def __init__(self, approval_result: SendResult) -> None:
|
|
self._approval_result = approval_result
|
|
self.text_sends: List[str] = []
|
|
|
|
def pause_typing_for_chat(self, chat_id: str) -> None:
|
|
return None
|
|
|
|
def resume_typing_for_chat(self, chat_id: str) -> None:
|
|
return None
|
|
|
|
async def send_exec_approval(self, *a: Any, **k: Any) -> SendResult:
|
|
return self._approval_result
|
|
|
|
async def send(self, chat_id: str, message: str, **k: Any) -> SendResult:
|
|
self.text_sends.append(message)
|
|
return SendResult(success=True, message_id="m1")
|
|
|
|
|
|
def _runner(adapter: _Adapter):
|
|
"""A TurnRunner driven through its real _approval_notify_sync."""
|
|
from gateway.run_turn_runner import TurnRunner
|
|
|
|
runner = object.__new__(TurnRunner)
|
|
ctx = SimpleNamespace(
|
|
_status_adapter=adapter,
|
|
_status_chat_id="C1",
|
|
_status_thread_metadata={},
|
|
_session_key="sk1",
|
|
session_key="sk1",
|
|
source=SimpleNamespace(chat_id="C1", platform="discord", session_key="sk1"),
|
|
)
|
|
runner._ctx = ctx
|
|
|
|
class _Fut:
|
|
def __init__(self, result): self._r = result
|
|
def result(self, timeout=None): return self._r
|
|
|
|
runner._schedule = lambda coro, _label: _Fut(asyncio.run(coro))
|
|
runner._close_native_stream_boundary = lambda _why: None
|
|
return runner
|
|
|
|
|
|
APPROVAL = {"command": "rm -rf /", "description": "danger", "pattern_key": "k"}
|
|
|
|
|
|
def test_exec_approval_decline_does_not_send_the_text_fallback():
|
|
from gateway.run_turn_runner import _ExecApprovalDeclined
|
|
|
|
adapter = _Adapter(
|
|
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
|
|
)
|
|
runner = _runner(adapter)
|
|
|
|
# Raises (not returns) so `_await_gateway_decision`'s notify-failure path
|
|
# drops the CENTRAL approval entry and unblocks the waiting tool.
|
|
with pytest.raises(_ExecApprovalDeclined):
|
|
runner._approval_notify_sync(dict(APPROVAL))
|
|
|
|
assert adapter.text_sends == []
|
|
|
|
|
|
def test_exec_approval_ORDINARY_failure_still_falls_back_to_text():
|
|
"""The control. An ordinary failure MUST still reach the user."""
|
|
adapter = _Adapter(SendResult(success=False, error="slack 500"))
|
|
runner = _runner(adapter)
|
|
|
|
runner._approval_notify_sync(dict(APPROVAL))
|
|
|
|
assert len(adapter.text_sends) == 1
|
|
assert "rm -rf /" in adapter.text_sends[0]
|
|
|
|
|
|
# ── slash confirm ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class _ConfirmAdapter:
|
|
def __init__(self, result: SendResult) -> None:
|
|
self._result = result
|
|
|
|
async def send_slash_confirm(self, **k: Any) -> SendResult:
|
|
return self._result
|
|
|
|
|
|
def _busy(adapter: _ConfirmAdapter):
|
|
from gateway.run_busy import GatewayBusySessionMixin
|
|
|
|
busy = object.__new__(GatewayBusySessionMixin)
|
|
busy._delivery_adapter_for = lambda _s: adapter
|
|
busy._thread_metadata_for_source = lambda _s, _a: {}
|
|
busy._reply_anchor_for_event = lambda _e: None
|
|
busy._session_key_for_source = lambda _s: "sk1"
|
|
return busy
|
|
|
|
|
|
def _event():
|
|
return SimpleNamespace(
|
|
source=SimpleNamespace(chat_id="C1", platform="discord", user_id="u1")
|
|
)
|
|
|
|
|
|
def _run_confirm(busy) -> Optional[str]:
|
|
return asyncio.run(
|
|
busy._request_slash_confirm(
|
|
event=_event(),
|
|
command="/wipe",
|
|
title="Confirm",
|
|
message="really wipe?",
|
|
handler=lambda choice: "done",
|
|
)
|
|
)
|
|
|
|
|
|
def test_slash_confirm_decline_returns_none_and_clears_state():
|
|
from tools import slash_confirm as mod
|
|
|
|
adapter = _ConfirmAdapter(
|
|
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
|
|
)
|
|
reply = _run_confirm(_busy(adapter))
|
|
|
|
# None = "no text ack": returning the message would post the prompt as
|
|
# plain text into the chat the connector just refused.
|
|
assert reply is None
|
|
# And the registration must not survive a card that never rendered.
|
|
assert mod.get_pending("sk1") in (None, {}, [])
|
|
|
|
|
|
def test_slash_confirm_ORDINARY_failure_returns_the_text_fallback():
|
|
"""The control. A broken card lane must still ask the user."""
|
|
adapter = _ConfirmAdapter(SendResult(success=False, error="discord 500"))
|
|
reply = _run_confirm(_busy(adapter))
|
|
|
|
assert reply == "really wipe?"
|
|
|
|
|
|
# ── task-card progress ──────────────────────────────────────────────────────
|
|
#
|
|
# Round 4 finding: the round-3 fix added the production branch AND a test, but
|
|
# the test stopped at RelayAdapter — it proved raw_response is carried and
|
|
# never called the caller that owns the security property. Deleting the real
|
|
# branch left 30 tests green. Same lesson as #7, one lane over: a component in
|
|
# the path proves nothing until a case drives THE CALLER.
|
|
|
|
|
|
class _CardAdapter:
|
|
def __init__(self, progress_result: SendResult) -> None:
|
|
self._progress_result = progress_result
|
|
self.fallbacks: List[str] = []
|
|
|
|
async def send_native_task_card_progress(self, **k: Any) -> SendResult:
|
|
return self._progress_result
|
|
|
|
|
|
def _card_runner(adapter: _CardAdapter):
|
|
from gateway.run_turn_runner import TurnRunner
|
|
|
|
runner = object.__new__(TurnRunner)
|
|
runner._ctx = SimpleNamespace(
|
|
source=SimpleNamespace(chat_id="C1", platform="slack"),
|
|
_progress_reply_to=None,
|
|
_progress_metadata={},
|
|
)
|
|
|
|
async def _fallback(st):
|
|
adapter.fallbacks.append("fallback")
|
|
|
|
runner._task_card_send_or_edit_fallback = _fallback
|
|
return runner
|
|
|
|
|
|
def _card_state(adapter: _CardAdapter):
|
|
return SimpleNamespace(
|
|
tasks=[{"text": "step"}],
|
|
native_failed=False,
|
|
publication_suppressed=False,
|
|
visible_tasks=lambda: [{"text": "step"}],
|
|
fallback_text=lambda: "step",
|
|
adapter=adapter,
|
|
)
|
|
|
|
|
|
def test_declined_task_card_progress_does_not_send_the_text_fallback():
|
|
adapter = _CardAdapter(
|
|
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
|
|
)
|
|
runner = _card_runner(adapter)
|
|
st = _card_state(adapter)
|
|
|
|
asyncio.run(runner._task_card_publish(st))
|
|
|
|
assert adapter.fallbacks == []
|
|
assert st.native_failed is True
|
|
|
|
|
|
def test_ORDINARY_task_card_failure_still_sends_the_text_fallback():
|
|
"""Control: a genuinely broken card lane must still reach the user."""
|
|
adapter = _CardAdapter(SendResult(success=False, error="slack 500"))
|
|
runner = _card_runner(adapter)
|
|
st = _card_state(adapter)
|
|
|
|
asyncio.run(runner._task_card_publish(st))
|
|
|
|
assert adapter.fallbacks == ["fallback"]
|
|
assert st.native_failed is True
|
|
|
|
|
|
def test_task_card_decline_suppression_persists_across_updates():
|
|
"""R5-4: my round-4 fix suppressed exactly ONE update.
|
|
|
|
It set `native_failed`, which the entry gate already uses for an ordinary
|
|
broken lane — so the NEXT progress event skipped the decline branch and
|
|
went straight to the text fallback. Measured: [] then ['send'].
|
|
A refusal does not expire after one tick.
|
|
"""
|
|
adapter = _CardAdapter(
|
|
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
|
|
)
|
|
runner = _card_runner(adapter)
|
|
st = _card_state(adapter)
|
|
|
|
asyncio.run(runner._task_card_publish(st))
|
|
asyncio.run(runner._task_card_publish(st))
|
|
asyncio.run(runner._task_card_publish(st))
|
|
|
|
assert adapter.fallbacks == []
|
|
|
|
|
|
def test_ORDINARY_failure_still_falls_back_on_every_later_update():
|
|
"""Control: a broken card lane must keep reaching the user each update."""
|
|
adapter = _CardAdapter(SendResult(success=False, error="slack 500"))
|
|
runner = _card_runner(adapter)
|
|
st = _card_state(adapter)
|
|
|
|
asyncio.run(runner._task_card_publish(st))
|
|
asyncio.run(runner._task_card_publish(st))
|
|
|
|
assert len(adapter.fallbacks) == 2
|
|
|
|
|
|
# ── the edit lane (round 6): one dropped raw_response, three leaks ──────────
|
|
|
|
|
|
class _EditAdapter:
|
|
"""Records every op; the edit is refused with a code-only decline."""
|
|
|
|
def __init__(self) -> None:
|
|
self.ops: List[str] = []
|
|
|
|
@staticmethod
|
|
def extract_media(text):
|
|
return [], text
|
|
|
|
async def edit_message(self, **k: Any) -> SendResult:
|
|
self.ops.append("edit")
|
|
return SendResult(
|
|
success=False, error="declined", raw_response=CODE_ONLY_DECLINE
|
|
)
|
|
|
|
async def send(self, *a: Any, **k: Any) -> SendResult:
|
|
self.ops.append("send")
|
|
return SendResult(success=True, message_id="m1")
|
|
|
|
|
|
def test_queued_reconcile_decline_does_not_fall_back_to_a_send():
|
|
"""R6-3: `_deliver_queued_first_response` re-sent the WHOLE response.
|
|
|
|
Its reconcile-by-edit falls through to `adapter.send` on any failure, so a
|
|
refused edit delivered the full text to the refused chat. Measured:
|
|
[('edit', 'SECRET'), ('send', 'SECRET')].
|
|
"""
|
|
from gateway.run_notifications import GatewayNotificationsMixin
|
|
|
|
adapter = _EditAdapter()
|
|
mixin = object.__new__(GatewayNotificationsMixin)
|
|
source = SimpleNamespace(chat_id="C1", platform="discord")
|
|
consumer = SimpleNamespace(message_id="m0", _turn_split_delivery=False)
|
|
|
|
asyncio.run(
|
|
mixin._deliver_queued_first_response(
|
|
"SECRET", source, adapter, stream_consumer=consumer, deliver_media=False
|
|
)
|
|
)
|
|
|
|
assert adapter.ops == ["edit"]
|
|
|
|
|
|
def test_queued_reconcile_ORDINARY_edit_failure_still_sends():
|
|
"""Control: a genuinely un-editable message must still be delivered."""
|
|
from gateway.run_notifications import GatewayNotificationsMixin
|
|
|
|
class _Ordinary(_EditAdapter):
|
|
async def edit_message(self, **k: Any) -> SendResult:
|
|
self.ops.append("edit")
|
|
return SendResult(success=False, error="message too old")
|
|
|
|
adapter = _Ordinary()
|
|
mixin = object.__new__(GatewayNotificationsMixin)
|
|
source = SimpleNamespace(chat_id="C1", platform="discord")
|
|
consumer = SimpleNamespace(message_id="m0", _turn_split_delivery=False)
|
|
|
|
asyncio.run(
|
|
mixin._deliver_queued_first_response(
|
|
"SECRET", source, adapter, stream_consumer=consumer, deliver_media=False
|
|
)
|
|
)
|
|
|
|
assert adapter.ops == ["edit", "send"]
|
|
|
|
|
|
def test_task_card_fallback_edit_decline_does_not_send_progress_text():
|
|
"""R6-4: R5-4 covered the native card, not the editable-text fallback."""
|
|
adapter = _EditAdapter()
|
|
runner = object.__new__(__import__("gateway.run_turn_runner", fromlist=["x"]).TurnRunner)
|
|
runner._ctx = SimpleNamespace(
|
|
source=SimpleNamespace(chat_id="C1", platform="slack"),
|
|
_progress_metadata={},
|
|
)
|
|
sent = []
|
|
runner._send_progress_text = lambda st, text: sent.append(text)
|
|
st = SimpleNamespace(
|
|
fallback_msg_id="m0",
|
|
fallback_text=lambda: "task text",
|
|
adapter=adapter,
|
|
publication_suppressed=False,
|
|
)
|
|
|
|
asyncio.run(runner._task_card_send_or_edit_fallback(st))
|
|
|
|
assert adapter.ops == ["edit"]
|
|
assert sent == []
|
|
assert st.publication_suppressed is True
|