strat-chatbot/tests/adapters/test_gitea_issues.py
Cal Corum 2fe7163c89 fix: resolve MEDIUM-severity issues from code review
Prompt injection mitigation:
- Wrap user question in <user_question> XML tags in LLM prompt
- Add system prompt instruction to treat tagged content as untrusted

Docker security:
- Bind ChromaDB and API ports to localhost only (127.0.0.1)
- Remove redundant DB init command from api service (lifespan handles it)
- Remove deprecated version field and unused volume definitions
- Add API_SECRET env var to api and discord-bot services

Gitea labels fix:
- Remove string labels from API payload (Gitea expects integer IDs)
- Include label names as text in issue body instead

Conversation cleanup:
- Add periodic background task in lifespan (every 5 minutes)
- Cleans up conversations older than CONVERSATION_TTL (default 30 min)
- Graceful cancellation on shutdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:04:25 -05:00

415 lines
16 KiB
Python

"""Tests for GiteaIssueTracker — the outbound adapter for the IssueTracker port.
Strategy: use httpx.MockTransport to intercept HTTP calls without a live Gitea
server. This exercises the real adapter code (headers, URL construction, JSON
serialisation, error handling) without any external network dependency.
We import GiteaIssueTracker from adapters.outbound.gitea_issues and verify it
against the IssueTracker ABC from domain.ports — confirming the adapter truly
satisfies the port contract.
"""
import json
import pytest
import httpx
from domain.ports import IssueTracker
from adapters.outbound.gitea_issues import GiteaIssueTracker
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_issue_response(
issue_number: int = 1,
title: str = "Test issue",
html_url: str = "https://gitea.example.com/owner/repo/issues/1",
) -> dict:
"""Return a minimal Gitea issue API response payload."""
return {
"id": issue_number,
"number": issue_number,
"title": title,
"html_url": html_url,
"state": "open",
}
class _MockTransport(httpx.AsyncBaseTransport):
"""Configurable httpx transport that returns a pre-built response.
Captures the outgoing request so tests can assert on it after the fact.
"""
def __init__(self, status_code: int = 201, body: dict | None = None):
self.status_code = status_code
self.body = body or _make_issue_response()
self.last_request: httpx.Request | None = None
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
self.last_request = request
content = json.dumps(self.body).encode()
return httpx.Response(
status_code=self.status_code,
headers={"Content-Type": "application/json"},
content=content,
)
def _make_tracker(transport: httpx.AsyncBaseTransport) -> GiteaIssueTracker:
"""Construct a GiteaIssueTracker wired to the given mock transport."""
tracker = GiteaIssueTracker(
token="test-token-abc",
owner="testowner",
repo="testrepo",
base_url="https://gitea.example.com",
)
# Replace the internal client's transport with our mock.
# We recreate the client so we don't have to expose the transport in __init__.
tracker._client = httpx.AsyncClient(
transport=transport,
headers=tracker._headers,
timeout=30.0,
)
return tracker
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def good_transport():
"""Mock transport that returns a successful 201 issue response."""
return _MockTransport(status_code=201)
@pytest.fixture
def error_transport():
"""Mock transport that simulates a Gitea API 422 error."""
return _MockTransport(
status_code=422,
body={"message": "label does not exist"},
)
@pytest.fixture
def good_tracker(good_transport):
return _make_tracker(good_transport)
@pytest.fixture
def error_tracker(error_transport):
return _make_tracker(error_transport)
# ---------------------------------------------------------------------------
# Port contract test
# ---------------------------------------------------------------------------
class TestPortContract:
"""GiteaIssueTracker must be a concrete subclass of IssueTracker."""
def test_is_subclass_of_issue_tracker_port(self):
"""The adapter satisfies the IssueTracker ABC — no missing abstract methods."""
assert issubclass(GiteaIssueTracker, IssueTracker)
def test_instance_passes_isinstance_check(self, good_tracker):
"""An instantiated adapter is accepted anywhere IssueTracker is expected."""
assert isinstance(good_tracker, IssueTracker)
# ---------------------------------------------------------------------------
# Successful issue creation
# ---------------------------------------------------------------------------
class TestSuccessfulIssueCreation:
"""Happy-path behaviour when Gitea responds with 201."""
async def test_returns_html_url(self, good_tracker):
"""create_unanswered_issue should return the html_url from the API response."""
url = await good_tracker.create_unanswered_issue(
question="Can I steal home?",
user_id="user-42",
channel_id="chan-99",
attempted_rules=["5.2.1(b)", "5.2.2"],
conversation_id="conv-abc",
)
assert url == "https://gitea.example.com/owner/repo/issues/1"
async def test_posts_to_correct_endpoint(self, good_tracker, good_transport):
"""The adapter must POST to /repos/{owner}/{repo}/issues."""
await good_tracker.create_unanswered_issue(
question="Can I steal home?",
user_id="user-42",
channel_id="chan-99",
attempted_rules=[],
conversation_id="conv-abc",
)
req = good_transport.last_request
assert req is not None
assert req.method == "POST"
assert "/repos/testowner/testrepo/issues" in str(req.url)
async def test_sends_bearer_token(self, good_tracker, good_transport):
"""Authorization header must carry the configured token."""
await good_tracker.create_unanswered_issue(
question="test question",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="conv-1",
)
req = good_transport.last_request
assert req.headers["Authorization"] == "token test-token-abc"
async def test_content_type_is_json(self, good_tracker, good_transport):
"""The request must declare application/json content type."""
await good_tracker.create_unanswered_issue(
question="test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
req = good_transport.last_request
assert req.headers["Content-Type"] == "application/json"
async def test_also_accepts_200_status(self, good_tracker):
"""Some Gitea instances return 200 on issue creation; both are valid."""
transport_200 = _MockTransport(status_code=200)
tracker = _make_tracker(transport_200)
url = await tracker.create_unanswered_issue(
question="Is 200 ok?",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
assert url == "https://gitea.example.com/owner/repo/issues/1"
# ---------------------------------------------------------------------------
# Issue body content
# ---------------------------------------------------------------------------
class TestIssueBodyContent:
"""The issue body must contain context needed for human triage."""
async def _get_body(self, transport, **kwargs) -> str:
tracker = _make_tracker(transport)
defaults = dict(
question="Can I intentionally walk a batter?",
user_id="user-99",
channel_id="channel-7",
attempted_rules=["4.1.1", "4.1.2"],
conversation_id="conv-xyz",
)
defaults.update(kwargs)
await tracker.create_unanswered_issue(**defaults)
req = transport.last_request
return json.loads(req.content)["body"]
async def test_body_contains_question_in_code_block(self, good_transport):
"""The question must be wrapped in a fenced code block to prevent markdown
injection — a user could craft a question containing headers, links, or
other markdown that would corrupt the issue layout."""
body = await self._get_body(
good_transport, question="Can I intentionally walk a batter?"
)
assert "```" in body
assert "Can I intentionally walk a batter?" in body
# Must be inside a fenced block (preceded by ```)
fence_idx = body.index("```")
question_idx = body.index("Can I intentionally walk a batter?")
assert fence_idx < question_idx
async def test_body_contains_user_id(self, good_transport):
"""User ID must appear in the body so reviewers know who asked."""
body = await self._get_body(good_transport, user_id="user-99")
assert "user-99" in body
async def test_body_contains_channel_id(self, good_transport):
"""Channel ID must appear so reviewers can locate the conversation."""
body = await self._get_body(good_transport, channel_id="channel-7")
assert "channel-7" in body
async def test_body_contains_conversation_id(self, good_transport):
"""Conversation ID must be present for traceability to the chat log."""
body = await self._get_body(good_transport, conversation_id="conv-xyz")
assert "conv-xyz" in body
async def test_body_contains_attempted_rules(self, good_transport):
"""Searched rule IDs must be listed so reviewers know what was tried."""
body = await self._get_body(good_transport, attempted_rules=["4.1.1", "4.1.2"])
assert "4.1.1" in body
assert "4.1.2" in body
async def test_body_handles_empty_attempted_rules(self, good_transport):
"""An empty rules list should not crash; body should gracefully note none."""
body = await self._get_body(good_transport, attempted_rules=[])
# Should not raise and body should still be a non-empty string
assert isinstance(body, str)
assert len(body) > 0
async def test_title_contains_truncated_question(self, good_transport):
"""Issue title should contain the question (truncated to ~80 chars)."""
transport = good_transport
tracker = _make_tracker(transport)
long_question = "A" * 200
await tracker.create_unanswered_issue(
question=long_question,
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
req = transport.last_request
payload = json.loads(req.content)
# Title should not be absurdly long — it should be truncated
assert len(payload["title"]) < 150
# ---------------------------------------------------------------------------
# Labels
# ---------------------------------------------------------------------------
class TestLabels:
"""Label tags must appear in the issue body text.
The Gitea create-issue API expects label IDs (integers), not label names
(strings). To avoid a 422 error, we omit the 'labels' field from the API
payload and instead embed the label names as plain text in the issue body
so reviewers can apply them manually or via a Gitea webhook/action.
"""
async def test_labels_not_in_request_payload(self, good_tracker, good_transport):
"""The 'labels' key must be absent from the POST payload to avoid a
422 Unprocessable Entity — Gitea expects integer IDs, not name strings."""
await good_tracker.create_unanswered_issue(
question="test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
payload = json.loads(good_transport.last_request.content)
assert "labels" not in payload
async def test_label_tags_present_in_body(self, good_tracker, good_transport):
"""Label names should appear in the issue body text so reviewers can
identify the issue origin and apply labels manually or via automation.
We require 'rules-gap', 'ai-generated', and 'needs-review' to be
present so that Gitea project boards can be populated correctly.
"""
await good_tracker.create_unanswered_issue(
question="test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
body = json.loads(good_transport.last_request.content)["body"]
assert "rules-gap" in body
assert "needs-review" in body
assert "ai-generated" in body
# ---------------------------------------------------------------------------
# API error handling
# ---------------------------------------------------------------------------
class TestAPIErrorHandling:
"""Non-2xx responses from Gitea should raise a descriptive RuntimeError."""
async def test_raises_on_422(self, error_tracker):
"""A 422 Unprocessable Entity should raise RuntimeError with status info."""
with pytest.raises(RuntimeError) as exc_info:
await error_tracker.create_unanswered_issue(
question="bad label question",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
msg = str(exc_info.value)
assert "422" in msg
async def test_raises_on_401(self):
"""A 401 Unauthorized (bad token) should raise RuntimeError."""
transport = _MockTransport(status_code=401, body={"message": "Unauthorized"})
tracker = _make_tracker(transport)
with pytest.raises(RuntimeError) as exc_info:
await tracker.create_unanswered_issue(
question="test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
assert "401" in str(exc_info.value)
async def test_raises_on_500(self):
"""A 500 server error should raise RuntimeError, not silently return empty."""
transport = _MockTransport(
status_code=500, body={"message": "Internal Server Error"}
)
tracker = _make_tracker(transport)
with pytest.raises(RuntimeError) as exc_info:
await tracker.create_unanswered_issue(
question="test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
assert "500" in str(exc_info.value)
async def test_error_message_includes_response_body(self, error_tracker):
"""The RuntimeError message should embed the raw API error body to aid
debugging — operators need to know whether the failure was a bad label,
an auth issue, a quota error, etc."""
with pytest.raises(RuntimeError) as exc_info:
await error_tracker.create_unanswered_issue(
question="test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
# The error transport returns {"message": "label does not exist"}
assert "label does not exist" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Lifecycle — persistent client
# ---------------------------------------------------------------------------
class TestClientLifecycle:
"""The adapter must expose a close() coroutine for clean resource teardown."""
async def test_close_is_callable(self, good_tracker):
"""close() should exist and be awaitable (used in dependency teardown)."""
# Should not raise
await good_tracker.close()
async def test_close_after_request_does_not_raise(self, good_tracker):
"""Closing after making a real request should be clean."""
await good_tracker.create_unanswered_issue(
question="cleanup test",
user_id="u",
channel_id="c",
attempted_rules=[],
conversation_id="c1",
)
await good_tracker.close() # should not raise