"""Tests for api/slack_interactions.py endpoint. Written FIRST (TDD RED phase). Tests cover: - Signature verification (HMAC-SHA256) - Payload parsing - Button routing - _resume_graph invocation - Error handling """ import hashlib import hmac import json import time import urllib.parse from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from release_agent.api.slack_interactions import router as slack_interactions_router from release_agent.api.slack_interactions import _verify_slack_signature # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _TEST_SIGNING_SECRET = "test-signing-secret-abc" def _make_slack_signature(*, signing_secret: str, timestamp: str, body: str) -> str: """Compute a valid Slack signing signature.""" base_string = f"v0:{timestamp}:{body}" sig = hmac.new( signing_secret.encode(), base_string.encode(), hashlib.sha256, ).hexdigest() return f"v0={sig}" def _make_test_app( *, signing_secret: str = _TEST_SIGNING_SECRET, thread_graph_name: str | None = "release", graph_result: dict | None = None, ) -> FastAPI: """Return a FastAPI test app with mocked state for slack interactions.""" app = FastAPI() app.include_router(slack_interactions_router) mock_settings = MagicMock() mock_settings.slack_signing_secret.get_secret_value.return_value = signing_secret mock_settings.operator_token.get_secret_value.return_value = "" mock_graph = MagicMock() mock_graph.ainvoke = AsyncMock(return_value=graph_result or {"messages": ["done"]}) mock_graphs = { "release": mock_graph, "pr_completed": MagicMock(), } mock_clients = MagicMock() mock_pool = MagicMock() mock_conn = AsyncMock() mock_cursor = AsyncMock() mock_cursor.fetchone = AsyncMock( return_value=(thread_graph_name,) if thread_graph_name else None ) mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) mock_cursor.__aexit__ = AsyncMock(return_value=False) mock_conn.cursor = MagicMock(return_value=mock_cursor) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) mock_pool.connection = MagicMock(return_value=mock_conn) app.state.settings = mock_settings app.state.graphs = mock_graphs app.state.tool_clients = mock_clients app.state.db_pool = mock_pool app.state.background_tasks = set() return app def _make_button_payload( *, thread_id: str = "test-thread-123", value: str = "approve", user_id: str = "U12345", user_name: str = "alice", ) -> str: """Build a URL-encoded Slack button action payload.""" payload = { "type": "block_actions", "user": {"id": user_id, "name": user_name}, "actions": [ { "type": "button", "value": f"{thread_id}:{value}", "action_id": f"approval_{value}_{thread_id}", } ], } return urllib.parse.urlencode({"payload": json.dumps(payload)}) # --------------------------------------------------------------------------- # _verify_slack_signature pure function tests # --------------------------------------------------------------------------- class TestVerifySlackSignature: """Tests for the _verify_slack_signature pure function.""" def test_returns_true_for_valid_signature(self) -> None: timestamp = str(int(time.time())) body = "test=body&data=here" sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, signature=sig, ) is True def test_returns_false_for_wrong_secret(self) -> None: timestamp = str(int(time.time())) body = "test=body" sig = _make_slack_signature( signing_secret="wrong-secret", timestamp=timestamp, body=body, ) assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, signature=sig, ) is False def test_returns_false_for_tampered_body(self) -> None: timestamp = str(int(time.time())) original_body = "original=body" sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=original_body, ) assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body="tampered=body", signature=sig, ) is False def test_returns_false_for_wrong_timestamp(self) -> None: body = "test=body" sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp="1000000", body=body, ) assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp="9999999", body=body, signature=sig, ) is False def test_returns_false_for_malformed_signature(self) -> None: timestamp = str(int(time.time())) assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body="body", signature="not-a-valid-sig", ) is False def test_returns_false_for_empty_signature(self) -> None: timestamp = str(int(time.time())) assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body="body", signature="", ) is False def test_uses_hmac_sha256(self) -> None: timestamp = "1234567890" body = "payload=data" base = f"v0:{timestamp}:{body}" expected_hash = hmac.new( _TEST_SIGNING_SECRET.encode(), base.encode(), hashlib.sha256, ).hexdigest() sig = f"v0={expected_hash}" # Inject current_time matching timestamp to bypass replay prevention assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, signature=sig, current_time=1234567890.0, ) is True def test_rejects_stale_timestamp(self) -> None: old_timestamp = "1000000000" # year 2001 body = "payload=data" base = f"v0:{old_timestamp}:{body}" expected_hash = hmac.new( _TEST_SIGNING_SECRET.encode(), base.encode(), hashlib.sha256, ).hexdigest() sig = f"v0={expected_hash}" # Valid signature but timestamp too old assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=old_timestamp, body=body, signature=sig, ) is False def test_rejects_non_integer_timestamp(self) -> None: assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp="not-a-number", body="body", signature="v0=abc", ) is False def test_signature_prefix_must_be_v0(self) -> None: timestamp = "1234567890" body = "payload=data" base = f"v0:{timestamp}:{body}" hash_val = hmac.new( _TEST_SIGNING_SECRET.encode(), base.encode(), hashlib.sha256, ).hexdigest() wrong_prefix_sig = f"v1={hash_val}" assert _verify_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, signature=wrong_prefix_sig, ) is False # --------------------------------------------------------------------------- # POST /slack/interactions endpoint tests # --------------------------------------------------------------------------- class TestSlackInteractionsEndpoint: """Tests for POST /slack/interactions.""" def test_returns_200_for_valid_request(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="t-abc", value="approve") sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig, }, ) assert response.status_code == 200 def test_returns_403_for_invalid_signature(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload() response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": "v0=invalid_signature", }, ) assert response.status_code == 403 def test_returns_400_when_missing_timestamp_header(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) body = _make_button_payload() response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Signature": "v0=something", }, ) assert response.status_code in (400, 403, 422) def test_rejects_when_signing_secret_not_configured(self) -> None: app = _make_test_app(signing_secret="") client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="t-abc", value="approve") response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": "v0=any_sig", }, ) assert response.status_code == 503 def test_returns_200_with_approve_action(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="thread-1", value="approve") sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig, }, ) assert response.status_code == 200 def test_returns_200_with_reject_action(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="thread-2", value="reject") sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig, }, ) assert response.status_code == 200 def test_schedules_graph_resume_in_background(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="t-bg", value="approve") sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig, }, ) assert response.status_code == 200 def test_returns_404_for_unknown_thread(self) -> None: app = _make_test_app(thread_graph_name=None) client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="unknown-thread", value="approve") sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig, }, ) # Should return 200 immediately (Slack requires immediate 200) # but the background task may log an error assert response.status_code == 200 def test_response_body_is_empty_or_ok(self) -> None: app = _make_test_app() client = TestClient(app, raise_server_exceptions=False) timestamp = str(int(time.time())) body = _make_button_payload(thread_id="t-ok", value="approve") sig = _make_slack_signature( signing_secret=_TEST_SIGNING_SECRET, timestamp=timestamp, body=body, ) response = client.post( "/slack/interactions", content=body, headers={ "Content-Type": "application/x-www-form-urlencoded", "X-Slack-Request-Timestamp": timestamp, "X-Slack-Signature": sig, }, ) assert response.status_code == 200 # Body may be empty or a simple JSON with ok=True if response.content: data = response.json() assert data.get("ok") is True or "ok" not in data