test: migrate to pytest

Replace unittest-based coverage with pytest and update CI/docs to run the new suite.
This commit is contained in:
2026-02-04 19:34:44 +03:00
parent 5bb8405340
commit d89ebcaf64
7 changed files with 705 additions and 352 deletions

View File

@@ -1,332 +1,544 @@
"""CTFd client tests (ruff: ignore E402 for sys.path adjustment)."""
# ruff: noqa: E402
import asyncio
import json
import os
import sys
import types
import unittest
from pathlib import Path
import httpx
import pytest
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
for path in (SRC, ROOT):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
# Avoid optional dependency issues in the test runner (python-dotenv).
if "dotenv" not in sys.modules:
sys.modules["dotenv"] = types.SimpleNamespace(load_dotenv=lambda *_, **__: None)
from ctfd_mcp.config import Config # type: ignore
from ctfd_mcp.ctfd_client import AuthError, CTFdClient, CTFdClientError # type: ignore
CTFD_URL = os.getenv("CTFD_URL")
CTFD_USERNAME = os.getenv("CTFD_USERNAME")
CTFD_PASSWORD = os.getenv("CTFD_PASSWORD")
CTFD_TOKEN = os.getenv("CTFD_TOKEN")
CTFD_LIVE = os.getenv("CTFD_LIVE", "")
CTFD_LIVE_CHALLENGE_ID = os.getenv("CTFD_LIVE_CHALLENGE_ID")
CTFD_LIVE_FLAG = os.getenv("CTFD_LIVE_FLAG")
from ctfd_mcp.config import Config, ConfigError
from ctfd_mcp.ctfd_client import (
AuthError,
CTFdClient,
CTFdClientError,
NotFoundError,
RateLimitError,
_html_to_text,
)
from ctfd_mcp.server import (
_challenge_markdown,
_format_error,
challenge_details,
list_challenges,
start_container,
stop_container,
submit_flag,
)
def _has_creds() -> bool:
# Accept either token or username/password for real CTFd instance.
return bool(CTFD_URL and (CTFD_TOKEN or (CTFD_USERNAME and CTFD_PASSWORD)))
def _truthy(value: str | None) -> bool:
if not value:
return False
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def run(coro):
return asyncio.run(coro)
class TestCTFdClientLive(unittest.TestCase):
"""Lightweight live tests against the provided CTFd instance."""
@pytest.fixture
def base_config():
return Config(base_url="https://ctfd.example.com", token="test-token")
@unittest.skipUnless(
_truthy(CTFD_LIVE) and _has_creds(),
"Live tests disabled (set CTFD_LIVE=1 plus CTFD_URL/credentials to enable)",
@pytest.fixture
def make_client(base_config):
def _make(transport: httpx.MockTransport, config: Config | None = None):
cfg = config or base_config
client = CTFdClient(cfg, timeout=None)
run(client._client.aclose())
client._client = httpx.AsyncClient(
base_url=cfg.base_url,
headers=client._client.headers,
cookies=client._client.cookies,
transport=transport,
follow_redirects=True,
http2=False,
)
return client
return _make
def test_user_agent_header_custom(make_client):
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
assert request.headers.get("User-Agent") == "custom-agent/1.0"
if request.url.path == "/api/v1/challenges/123":
return httpx.Response(
200,
json={
"success": True,
"data": {
"id": 123,
"name": "Example",
"category": "misc",
"value": 100,
"description": "<p>hi</p>",
"type": "standard",
"tags": [],
"files": [],
},
},
)
return httpx.Response(404, json={"success": False, "message": "not found"})
transport = httpx.MockTransport(handler)
cfg = Config(
base_url="https://ctfd.example.com",
token="test-token",
user_agent="custom-agent/1.0",
)
def test_list_and_get_challenge(self):
"""Ensure we can reach CTFd and fetch challenge details.
client = make_client(transport, config=cfg)
try:
detail = run(client.get_challenge(123))
assert detail.get("id") == 123
finally:
run(client.aclose())
This is intentionally parameterized to avoid hard-coding a specific CTFd instance,
challenge id, or flag. For flag submission, provide CTFD_LIVE_CHALLENGE_ID and
CTFD_LIVE_FLAG.
"""
async def _run():
cfg = Config(
base_url=CTFD_URL,
token=CTFD_TOKEN,
username=CTFD_USERNAME,
password=CTFD_PASSWORD,
)
client = CTFdClient(cfg, timeout=None)
try:
challenges = await client.list_challenges()
self.assertIsInstance(challenges, list)
self.assertGreater(len(challenges), 0, "Expected at least one challenge")
if CTFD_LIVE_CHALLENGE_ID:
challenge_id = int(CTFD_LIVE_CHALLENGE_ID)
else:
# Default to the first challenge returned by the API.
challenge_id = int(challenges[0]["id"])
detail = await client.get_challenge(challenge_id)
self.assertEqual(detail.get("id"), challenge_id)
# Optional end-to-end submission (requires knowing a valid flag).
if CTFD_LIVE_FLAG:
result = await client.submit_flag(challenge_id, CTFD_LIVE_FLAG)
self.assertIsInstance(result, dict)
self.assertIn("status", result)
status = result.get("status")
self.assertTrue(
status is None or isinstance(status, str),
f"Unexpected status type: {type(status)}",
)
except AuthError as exc:
self.fail(f"Auth should not fail with provided creds: {exc}")
finally:
await client.aclose()
asyncio.run(_run())
assert calls == ["/api/v1/challenges/123"]
class TestCTFdClientHelpers(unittest.TestCase):
def test_k8s_type_detection(self):
cfg = Config(base_url="https://ctfd.example.com", token="placeholder")
client = CTFdClient(cfg, timeout=None)
self.assertTrue(client._is_k8s_type("k8s"))
self.assertTrue(client._is_k8s_type("dynamic_kubernetes"))
self.assertFalse(client._is_k8s_type("dynamic_docker"))
def test_token_auth_headers_and_no_csrf(make_client):
calls: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.method, request.url.path))
assert request.headers.get("Authorization") == "Token test-token"
assert request.headers.get("CSRF-Token") is None
class TestStopContainerValidation(unittest.TestCase):
def test_dynamic_docker_requires_container_id(self):
cfg = Config(base_url="https://ctfd.example.com", token="placeholder")
client = CTFdClient(cfg, timeout=None)
async def fake_get_challenge(self, challenge_id: int):
return {"type": "dynamic_docker"}
client.get_challenge = types.MethodType(fake_get_challenge, client)
async def _run():
with self.assertRaises(CTFdClientError):
await client.stop_container(challenge_id=123)
asyncio.run(_run())
class TestCsrfTokenEnsure(unittest.TestCase):
def test_submit_flag_ensures_csrf_for_session_cookie(self):
calls: list[tuple[str, str, str | None]] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(
(
request.method,
request.url.path,
request.headers.get("CSRF-Token"),
)
)
if request.url.path == "/api/v1/csrf_token":
return httpx.Response(
200, json={"success": True, "data": {"csrf_token": "api-token"}}
)
if request.url.path == "/challenges":
html = '<input type="hidden" name="nonce" value="page-nonce">'
return httpx.Response(200, text=html, headers={"Content-Type": "text/html"})
if request.url.path == "/api/v1/challenges/attempt":
if request.headers.get("CSRF-Token") != "page-nonce":
return httpx.Response(403, json={"success": False, "message": "CSRF"})
return httpx.Response(
200,
json={
"success": True,
"data": {"status": "correct", "message": "ok"},
if request.url.path == "/api/v1/challenges/123":
return httpx.Response(
200,
json={
"success": True,
"data": {
"id": 123,
"name": "Example",
"category": "misc",
"value": 100,
"description": "<p>hi</p>",
"type": "standard",
"tags": [],
"files": [],
},
)
return httpx.Response(404, json={"success": False, "message": "not found"})
},
)
transport = httpx.MockTransport(handler)
cfg = Config(
base_url="https://ctfd.example.com",
session_cookie="session",
if request.url.path == "/api/v1/challenges/attempt":
body = json.loads(request.content.decode("utf-8"))
assert body["challenge_id"] == 123
assert body["submission"] == "flag{test}"
return httpx.Response(
200,
json={
"success": True,
"data": {"status": "correct", "message": "ok"},
},
)
return httpx.Response(404, json={"success": False, "message": "not found"})
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
detail = run(client.get_challenge(123))
assert detail.get("id") == 123
result = run(client.submit_flag(123, "flag{test}"))
assert result.get("status") == "correct"
finally:
run(client.aclose())
paths = [p for _, p in calls]
assert paths == ["/api/v1/challenges/123", "/api/v1/challenges/attempt"]
def test_session_cookie_csrf_refresh_on_403(make_client):
calls: list[tuple[str, str, str | None]] = []
state = {"refreshed": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls.append(
(request.method, request.url.path, request.headers.get("CSRF-Token"))
)
client = CTFdClient(cfg, timeout=None)
async def _run():
await client._client.aclose()
client._client = httpx.AsyncClient(
base_url=cfg.base_url,
headers=client._client.headers,
cookies=client._client.cookies,
transport=transport,
follow_redirects=True,
http2=False,
if request.url.path == "/api/v1/csrf_token":
state["refreshed"] += 1
return httpx.Response(
200,
json={
"success": True,
"data": {"csrf_token": f"api-token-{state['refreshed']}"},
},
)
try:
result = await client.submit_flag(1, "flag{test}")
finally:
await client.aclose()
self.assertEqual(result.get("status"), "correct")
asyncio.run(_run())
paths = [p for _, p, _ in calls]
self.assertIn("/api/v1/csrf_token", paths)
self.assertIn("/challenges", paths)
self.assertEqual(paths[-1], "/api/v1/challenges/attempt")
self.assertEqual(calls[-1][2], "page-nonce")
def test_submit_flag_refreshes_csrf_on_403_for_session_cookie(self):
calls: list[tuple[str, str, str | None]] = []
state = {"refreshed": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls.append(
(
request.method,
request.url.path,
request.headers.get("CSRF-Token"),
)
if request.url.path == "/challenges":
html = (
'<input type="hidden" name="nonce" '
f'value="page-nonce-{state["refreshed"]}">' # noqa: E501
)
if request.url.path == "/api/v1/csrf_token":
state["refreshed"] += 1
return httpx.Response(
200,
json={
"success": True,
"data": {"csrf_token": f"api-token-{state['refreshed']}"},
},
)
if request.url.path == "/challenges":
html = (
'<input type="hidden" name="nonce" '
f'value="page-nonce-{state["refreshed"]}">'
)
return httpx.Response(200, text=html, headers={"Content-Type": "text/html"})
if request.url.path == "/api/v1/challenges/attempt":
if request.headers.get("CSRF-Token") != "page-nonce-1":
return httpx.Response(403, json={"success": False, "message": "CSRF"})
return httpx.Response(
200,
json={
"success": True,
"data": {"status": "correct", "message": "ok"},
},
)
return httpx.Response(404, json={"success": False, "message": "not found"})
transport = httpx.MockTransport(handler)
cfg = Config(
base_url="https://ctfd.example.com",
session_cookie="session",
)
client = CTFdClient(cfg, timeout=None)
client._csrf_token = "stale"
async def _run():
await client._client.aclose()
client._client = httpx.AsyncClient(
base_url=cfg.base_url,
headers=client._client.headers,
cookies=client._client.cookies,
transport=transport,
follow_redirects=True,
http2=False,
return httpx.Response(200, text=html, headers={"Content-Type": "text/html"})
if request.url.path == "/api/v1/challenges/attempt":
if request.headers.get("CSRF-Token") != "page-nonce-1":
return httpx.Response(403, json={"success": False, "message": "CSRF"})
return httpx.Response(
200,
json={
"success": True,
"data": {"status": "correct", "message": "ok"},
},
)
try:
result = await client.submit_flag(1, "flag{test}")
finally:
await client.aclose()
self.assertEqual(result.get("status"), "correct")
return httpx.Response(404, json={"success": False, "message": "not found"})
asyncio.run(_run())
transport = httpx.MockTransport(handler)
cfg = Config(base_url="https://ctfd.example.com", session_cookie="session")
client = make_client(transport, config=cfg)
client._csrf_token = "stale"
try:
result = run(client.submit_flag(1, "flag{test}"))
assert result.get("status") == "correct"
finally:
run(client.aclose())
# Two POSTs: first with stale token, second after refresh.
post_tokens = [t for m, p, t in calls if m == "POST" and p.endswith("/attempt")]
self.assertEqual(post_tokens[0], "stale")
self.assertEqual(post_tokens[-1], "page-nonce-1")
post_tokens = [t for m, p, t in calls if m == "POST" and p.endswith("/attempt")]
assert post_tokens[0] == "stale"
assert post_tokens[-1] == "page-nonce-1"
class TestCTFdClientTokenAuthOffline(unittest.TestCase):
def test_get_and_submit_do_not_require_csrf_for_token_auth(self):
calls: list[tuple[str, str]] = []
def test_session_cookie_fetches_csrf_token(make_client):
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.method, request.url.path))
self.assertEqual(request.headers.get("Authorization"), "Token test-token")
self.assertIsNone(request.headers.get("CSRF-Token"))
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
if request.url.path == "/api/v1/csrf_token":
return httpx.Response(
200,
json={"success": True, "data": {"csrf_token": "api-token"}},
)
if request.url.path == "/challenges":
html = '<input type="hidden" name="nonce" value="page-nonce">'
return httpx.Response(200, text=html, headers={"Content-Type": "text/html"})
if request.url.path == "/api/v1/challenges/attempt":
assert request.headers.get("CSRF-Token") == "page-nonce"
return httpx.Response(
200,
json={
"success": True,
"data": {"status": "correct", "message": "ok"},
},
)
return httpx.Response(404, json={"success": False, "message": "not found"})
if request.url.path == "/api/v1/challenges/123":
return httpx.Response(
200,
json={
"success": True,
"data": {
"id": 123,
"name": "Example",
"category": "misc",
"value": 100,
"description": "<p>hi</p>",
"type": "standard",
"tags": [],
"files": [],
transport = httpx.MockTransport(handler)
cfg = Config(base_url="https://ctfd.example.com", session_cookie="session")
client = make_client(transport, config=cfg)
try:
result = run(client.submit_flag(1, "flag{test}"))
assert result.get("status") == "correct"
finally:
run(client.aclose())
assert "/api/v1/csrf_token" in calls
assert "/challenges" in calls
assert calls[-1] == "/api/v1/challenges/attempt"
def test_html_to_text_and_nonce_parsing(base_config):
assert _html_to_text("<p>Hello</p><div>World</div>") == "Hello\nWorld"
client = CTFdClient(base_config, timeout=None)
try:
assert client._extract_nonce('<input type="hidden" name="nonce" value="abc">') == "abc"
assert client._extract_nonce("var config = {'csrfNonce': \"def\"};") == "def"
finally:
run(client.aclose())
def test_full_url(base_config):
client = CTFdClient(base_config, timeout=None)
try:
assert client._full_url("https://example.com/x") == "https://example.com/x"
assert client._full_url("/files/x") == "https://ctfd.example.com/files/x"
finally:
run(client.aclose())
def test_list_challenges_filters(make_client):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/v1/challenges":
return httpx.Response(
200,
json={
"success": True,
"data": [
{"id": 1, "name": "A", "category": "web", "solved": True},
{
"id": 2,
"name": "B",
"category": "pwn",
"solved_by_me": False,
},
},
)
if request.url.path == "/api/v1/challenges/attempt":
body = json.loads(request.content.decode("utf-8"))
self.assertEqual(body["challenge_id"], 123)
self.assertEqual(body["submission"], "flag{test}")
return httpx.Response(
200,
json={
"success": True,
"data": {"status": "correct", "message": "ok"},
},
)
return httpx.Response(404, json={"success": False, "message": "not found"})
transport = httpx.MockTransport(handler)
cfg = Config(base_url="https://ctfd.example.com", token="test-token")
client = CTFdClient(cfg, timeout=None)
async def _run():
await client._client.aclose()
client._client = httpx.AsyncClient(
base_url=cfg.base_url,
headers=client._client.headers,
cookies=client._client.cookies,
transport=transport,
follow_redirects=True,
http2=False,
],
},
)
try:
detail = await client.get_challenge(123)
self.assertEqual(detail.get("id"), 123)
result = await client.submit_flag(123, "flag{test}")
self.assertEqual(result.get("status"), "correct")
finally:
await client.aclose()
return httpx.Response(404, json={"success": False})
asyncio.run(_run())
paths = [p for _, p in calls]
self.assertEqual(paths, ["/api/v1/challenges/123", "/api/v1/challenges/attempt"])
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
results = run(client.list_challenges(category="pwn", only_unsolved=True))
assert len(results) == 1
assert results[0]["id"] == 2
finally:
run(client.aclose())
if __name__ == "__main__":
unittest.main()
def test_error_status_mapping(make_client):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/v1/challenges/404":
return httpx.Response(404, json={"success": False})
if request.url.path == "/api/v1/challenges/401":
return httpx.Response(401, json={"success": False})
if request.url.path == "/api/v1/challenges/429":
return httpx.Response(
429,
json={"success": False},
headers={"Retry-After": "120"},
)
return httpx.Response(500, json={"success": False, "message": "boom"})
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
with pytest.raises(NotFoundError):
run(client.get_challenge(404))
with pytest.raises(AuthError):
run(client.get_challenge(401))
with pytest.raises(RateLimitError) as exc:
run(client.get_challenge(429))
assert exc.value.retry_after == "120"
with pytest.raises(CTFdClientError, match="CTFd error 500"):
run(client.get_challenge(500))
finally:
run(client.aclose())
def test_redirect_error(make_client):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(302)
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
with pytest.raises(AuthError, match="Unexpected redirect"):
run(client.get_challenge(1))
finally:
run(client.aclose())
def test_non_json_response(make_client):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/v1/challenges/123":
return httpx.Response(200, text="<html>nope</html>", headers={"Content-Type": "text/html"})
return httpx.Response(404, json={"success": False})
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
with pytest.raises(CTFdClientError, match="non-JSON response"):
run(client.get_challenge(123))
finally:
run(client.aclose())
def test_dynamic_container_flow(make_client):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/v1/challenges/1":
return httpx.Response(
200,
json={"success": True, "data": {"id": 1, "type": "dynamic_docker"}},
)
if request.url.path == "/api/v1/containers":
return httpx.Response(
200,
json={
"success": True,
"data": {"id": 2, "challenge_id": 1, "host": "h", "port": 1337},
},
)
if request.url.path == "/api/v1/containers/2":
return httpx.Response(
200,
json={"success": True, "data": {"status": "stopped", "message": "ok"}},
)
return httpx.Response(404, json={"success": False})
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
result = run(client.start_container(1))
assert result["connection_info"] == "h:1337"
stopped = run(client.stop_container(container_id=2, challenge_id=1))
assert stopped["message"] == "ok"
finally:
run(client.aclose())
def test_owl_container_flow(make_client):
calls: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.method, request.url.path))
if request.url.path == "/api/v1/challenges/1":
return httpx.Response(
200,
json={"success": True, "data": {"id": 1, "type": "dynamic_check_docker"}},
)
if request.url.path == "/plugins/ctfd-owl/container" and request.method == "POST":
return httpx.Response(200, json={"success": True, "data": {"success": True}})
if request.url.path == "/plugins/ctfd-owl/container" and request.method == "GET":
return httpx.Response(
200,
json={
"success": True,
"data": {
"containers_data": [
{"host": "owl", "port": 9000, "container_id": "c1"}
],
"ip": "1.2.3.4",
},
},
)
if request.url.path == "/plugins/ctfd-owl/container" and request.method == "DELETE":
return httpx.Response(
200,
json={"success": True, "data": {"status": "stopped", "message": "ok"}},
)
return httpx.Response(404, json={"success": False})
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
result = run(client.start_container(1))
assert result["connection_info"] == "1.2.3.4:9000"
stopped = run(client.stop_container(challenge_id=1))
assert stopped["message"] == "ok"
finally:
run(client.aclose())
assert ("POST", "/plugins/ctfd-owl/container") in calls
assert ("GET", "/plugins/ctfd-owl/container") in calls
assert ("DELETE", "/plugins/ctfd-owl/container") in calls
def test_k8s_container_flow(make_client):
calls: list[tuple[str, str, dict[str, str]]] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/v1/challenges/1":
return httpx.Response(
200,
json={"success": True, "data": {"id": 1, "type": "k8s"}},
)
if request.url.path == "/api/v1/k8s/create":
calls.append(
(
request.method,
request.url.path,
{k.lower(): v for k, v in request.headers.items()},
)
)
return httpx.Response(302, headers={"Location": "/challenges"})
if request.url.path == "/api/v1/k8s/delete":
calls.append(
(
request.method,
request.url.path,
{k.lower(): v for k, v in request.headers.items()},
)
)
return httpx.Response(302, headers={"Location": "/challenges"})
if request.url.path == "/api/v1/k8s/get":
return httpx.Response(
200,
json={
"ConnectionHost": "k8s",
"ConnectionPort": 31337,
"InstanceRunning": True,
"ThisChallengeInstance": True,
},
)
return httpx.Response(404, json={"success": False})
transport = httpx.MockTransport(handler)
client = make_client(transport)
try:
client._csrf_token = "csrf-token"
result = run(client.start_container(1))
assert result["connection_info"] == "k8s:31337"
stopped = run(client.stop_container(challenge_id=1))
assert stopped["status"] in {"stopped", "running"}
finally:
run(client.aclose())
for _, path, headers in calls:
assert headers.get("origin") == "https://ctfd.example.com"
assert headers.get("referer") == "https://ctfd.example.com/challenges"
def test_format_error_mapping():
assert str(_format_error(ConfigError("bad"))) == "Configuration error: bad"
assert str(_format_error(AuthError("no"))) == "Auth failed: no"
assert str(_format_error(NotFoundError("no"))) == "Not found: no"
err = _format_error(RateLimitError("no", retry_after="30"))
assert str(err) == "Rate limited. Retry-After=30."
assert str(_format_error(CTFdClientError("no"))) == "CTFd API error: no"
assert str(_format_error(ValueError("nope"))) == "Unexpected error: nope"
def test_challenge_markdown_formatting():
details = {
"id": 1,
"name": "Challenge",
"category": "web",
"value": 100,
"solved": True,
"description_text": "Hello",
"connection_info": "host:1",
"files": ["https://ctfd.example.com/files/a"],
}
md = _challenge_markdown(details)
assert "# Challenge" in md
assert "ID: 1 / Category: web / Points: 100 / Solved" in md
assert "## Description" in md
assert "Hello" in md
assert "## Connection" in md
assert "host:1" in md
assert "## Files" in md
assert "- https://ctfd.example.com/files/a" in md
def test_server_tools_map_errors(monkeypatch: pytest.MonkeyPatch):
class StubClient:
async def list_challenges(self, **_):
raise AuthError("bad")
async def get_challenge(self, *_):
raise NotFoundError("missing")
async def submit_flag(self, *_):
raise CTFdClientError("boom")
async def start_container(self, *_):
raise RateLimitError("slow", retry_after="5")
async def stop_container(self, *_, **__):
raise CTFdClientError("stop")
async def fake_get_client():
return StubClient()
monkeypatch.setattr("ctfd_mcp.server._get_client", fake_get_client)
with pytest.raises(RuntimeError, match="Auth failed"):
run(list_challenges())
with pytest.raises(RuntimeError, match="Not found"):
run(challenge_details(1))
with pytest.raises(RuntimeError, match="CTFd API error: boom"):
run(submit_flag(1, "flag"))
with pytest.raises(RuntimeError, match="Rate limited"):
run(start_container(1))
with pytest.raises(RuntimeError, match="CTFd API error: stop"):
run(stop_container(container_id=1))
with pytest.raises(RuntimeError, match="CTFd API error: boom"):
run(submit_flag(1, "flag"))