Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"""C13 — Slack log noise & log privacy invariants.
|
||||
|
||||
Covers three bug classes consolidated in the slack/c13-lognoise cluster:
|
||||
|
||||
1. Catch-all event ack (#38847 / #64218, issue #6572): unhandled subscribed
|
||||
event types must be acked by a fallback listener registered AFTER every
|
||||
named handler — silencing slack_bolt "Unhandled request" WARNINGs and,
|
||||
more importantly, keeping Slack's Events API failure rate near 0% so
|
||||
Slack does not auto-disable the app's Event Subscriptions at scale.
|
||||
The catch-all must keep a DEBUG line (visibility into unknown event
|
||||
types) and must not log message content.
|
||||
|
||||
2. Log privacy (#58478, issue #58477, widened here): no message text /
|
||||
block content above DEBUG level anywhere in the adapter's inbound path,
|
||||
and even DEBUG lines must not embed raw message text (metadata only, or
|
||||
truncated lengths).
|
||||
|
||||
3. Clarify resolution logging: the chosen option text stays out of
|
||||
INFO-level logs (choice index only); full text is DEBUG-only and
|
||||
truncated.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_repo = str(Path(__file__).resolve().parents[2])
|
||||
if _repo not in sys.path:
|
||||
sys.path.insert(0, _repo)
|
||||
|
||||
|
||||
def _ensure_slack_mock():
|
||||
if "slack_bolt" in sys.modules:
|
||||
return
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
sys.modules["slack_bolt"] = slack_bolt
|
||||
sys.modules["slack_bolt.async_app"] = slack_bolt.async_app
|
||||
handler_mod = MagicMock()
|
||||
handler_mod.AsyncSocketModeHandler = MagicMock
|
||||
sys.modules["slack_bolt.adapter"] = MagicMock()
|
||||
sys.modules["slack_bolt.adapter.socket_mode"] = MagicMock()
|
||||
sys.modules["slack_bolt.adapter.socket_mode.async_handler"] = handler_mod
|
||||
sdk_mod = MagicMock()
|
||||
sdk_mod.web = MagicMock()
|
||||
sdk_mod.web.async_client = MagicMock()
|
||||
sdk_mod.web.async_client.AsyncWebClient = MagicMock
|
||||
sys.modules["slack_sdk"] = sdk_mod
|
||||
sys.modules["slack_sdk.web"] = sdk_mod.web
|
||||
sys.modules["slack_sdk.web.async_client"] = sdk_mod.web.async_client
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
from gateway.config import PlatformConfig # noqa: E402
|
||||
|
||||
ADAPTER_LOGGER = "plugins.platforms.slack.adapter"
|
||||
|
||||
|
||||
def _fake_create_task(coro, **kwargs):
|
||||
coro.close()
|
||||
task = MagicMock()
|
||||
task.done.return_value = True
|
||||
return task
|
||||
|
||||
|
||||
def _connect_and_capture_handlers():
|
||||
"""Run connect() with a mocked bolt app; return [(matcher, fn), ...]."""
|
||||
config = PlatformConfig(enabled=True, token="xoxb-fake")
|
||||
adapter = SlackAdapter(config)
|
||||
|
||||
registered = []
|
||||
|
||||
mock_app = MagicMock()
|
||||
|
||||
def mock_event(event_type):
|
||||
def decorator(fn):
|
||||
registered.append((event_type, fn))
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
mock_app.event = mock_event
|
||||
mock_app.client = AsyncMock()
|
||||
|
||||
mock_web_client = AsyncMock()
|
||||
mock_web_client.auth_test = AsyncMock(
|
||||
return_value={
|
||||
"user_id": "U_BOT",
|
||||
"user": "testbot",
|
||||
"team_id": "T_FAKE",
|
||||
"team": "FakeTeam",
|
||||
}
|
||||
)
|
||||
|
||||
socket_mode_handler = MagicMock()
|
||||
socket_mode_handler.start_async = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch.object(_slack_mod, "AsyncApp", return_value=mock_app),
|
||||
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client),
|
||||
patch.object(
|
||||
_slack_mod, "AsyncSocketModeHandler", return_value=socket_mode_handler
|
||||
),
|
||||
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}),
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)),
|
||||
patch("asyncio.create_task", side_effect=_fake_create_task),
|
||||
):
|
||||
asyncio.run(adapter.connect())
|
||||
|
||||
return adapter, registered
|
||||
|
||||
|
||||
class TestCatchAllEventAck:
|
||||
"""#6572 / Event Subscriptions auto-disable — catch-all fallback ack."""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catchall_acks_unsubscribed_event_quietly(self, caplog):
|
||||
"""The catch-all fires for an unhandled type, logs only at DEBUG,
|
||||
and never logs message content."""
|
||||
_, registered = await asyncio.to_thread(_connect_and_capture_handlers)
|
||||
catchall_fn = next(
|
||||
fn for m, fn in reversed(registered) if isinstance(m, re.Pattern)
|
||||
)
|
||||
|
||||
secret = "confidential launch codes in a reaction item"
|
||||
event = {
|
||||
"type": "reaction_added",
|
||||
"user": "U123",
|
||||
"item": {"type": "message", "text": secret},
|
||||
}
|
||||
body = {"event": event}
|
||||
bolt_logger = logging.getLogger("slack_bolt_listener_test")
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="slack_bolt_listener_test"):
|
||||
# Returning without raising IS the ack — bolt sends the Socket
|
||||
# Mode ack (HTTP 200) whenever a listener handles the event.
|
||||
await catchall_fn(event=event, body=body, logger=bolt_logger)
|
||||
|
||||
listener_records = [
|
||||
r for r in caplog.records if r.name == "slack_bolt_listener_test"
|
||||
]
|
||||
assert any(
|
||||
r.levelno == logging.DEBUG and "reaction_added" in r.getMessage()
|
||||
for r in listener_records
|
||||
), "catch-all must keep a DEBUG line naming the unhandled event type"
|
||||
assert all(r.levelno <= logging.DEBUG for r in listener_records), (
|
||||
"catch-all must not log above DEBUG (that's the noise it exists "
|
||||
"to remove)"
|
||||
)
|
||||
assert secret not in caplog.text
|
||||
|
||||
|
||||
class TestInboundLogPrivacy:
|
||||
"""#58477 (widened): no message text above DEBUG; DEBUG is metadata-only
|
||||
or truncated."""
|
||||
|
||||
def _make_adapter(self):
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
a = SlackAdapter(config)
|
||||
a._app = MagicMock()
|
||||
a._app.client = AsyncMock()
|
||||
a._bot_user_id = "U_BOT"
|
||||
a._running = True
|
||||
a.handle_message = AsyncMock()
|
||||
return a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_diagnostic_log_is_metadata_only(self, caplog):
|
||||
"""#30185's event-arrival DEBUG line surfaces routing metadata
|
||||
(type/subtype/user/bot_id/channel/ts) but never the message text."""
|
||||
secret = "bot payload that must stay out of logs"
|
||||
adapter = self._make_adapter()
|
||||
adapter._dedup = MagicMock(is_duplicate=MagicMock(return_value=True))
|
||||
|
||||
event = {
|
||||
"type": "message",
|
||||
"subtype": "bot_message",
|
||||
"user": "U_OTHER_BOT",
|
||||
"bot_id": "B_OTHER",
|
||||
"bot_profile": {"name": "Peer Bot"},
|
||||
"ts": "12345.6789",
|
||||
"channel": "C_SHARED",
|
||||
"text": secret,
|
||||
}
|
||||
with caplog.at_level(logging.DEBUG, logger=ADAPTER_LOGGER):
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
entry_lines = [
|
||||
r.getMessage() for r in caplog.records if "event received" in r.getMessage()
|
||||
]
|
||||
assert entry_lines, "entry diagnostic DEBUG line must fire"
|
||||
assert "bot_id=B_OTHER" in entry_lines[0]
|
||||
assert secret not in caplog.text
|
||||
|
||||
|
||||
class TestClarifyLogPrivacy:
|
||||
"""Clarify choice text is user content: index at INFO, text DEBUG-only."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_resolution_logs_index_not_text_at_info(self, caplog):
|
||||
from tools import clarify_gateway as cm
|
||||
|
||||
secret_choice = "migrate the production database tonight"
|
||||
with cm._lock:
|
||||
cm._entries.clear()
|
||||
cm.register("cid-priv", "sk-priv", "Pick", ["noop", secret_choice])
|
||||
|
||||
config = PlatformConfig(enabled=True, token="xoxb-test-token")
|
||||
adapter = SlackAdapter(config)
|
||||
adapter._app = MagicMock()
|
||||
adapter._bot_user_id = "U_BOT"
|
||||
adapter._team_clients = {"T1": AsyncMock()}
|
||||
adapter._team_bot_user_ids = {"T1": "U_BOT"}
|
||||
adapter._channel_team = {"C1": "T1"}
|
||||
adapter._clarify_resolved["9.9"] = False
|
||||
adapter._team_clients["T1"].chat_update = AsyncMock()
|
||||
|
||||
async def _handler(_event):
|
||||
return None
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
|
||||
body = {
|
||||
"message": {
|
||||
"ts": "9.9",
|
||||
"blocks": [
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": "❓ Pick"}}
|
||||
],
|
||||
},
|
||||
"channel": {"id": "C1"},
|
||||
"user": {"name": "norbert", "id": "U_NORBERT"},
|
||||
}
|
||||
action = {"action_id": "hermes_clarify_choice_1", "value": "cid-priv|1"}
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger=ADAPTER_LOGGER):
|
||||
with patch.object(
|
||||
adapter,
|
||||
"_is_interactive_user_authorized",
|
||||
return_value=True,
|
||||
):
|
||||
await adapter._handle_clarify_action(AsyncMock(), body, action)
|
||||
|
||||
info_and_above = [
|
||||
r.getMessage() for r in caplog.records if r.levelno >= logging.INFO
|
||||
]
|
||||
assert any("resolved clarify" in m for m in info_and_above)
|
||||
assert all(secret_choice not in m for m in info_and_above), (
|
||||
"clarify choice text must not appear at INFO or above"
|
||||
)
|
||||
# Full choice text remains available for debugging at DEBUG level.
|
||||
debug_msgs = [
|
||||
r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG
|
||||
]
|
||||
assert any(secret_choice in m for m in debug_msgs)
|
||||
Reference in New Issue
Block a user