Files
billo-release-agent/tests/api/test_slack_interactions.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

474 lines
15 KiB
Python

"""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