diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0b9782f..0c33f82 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: run: uv sync --frozen --no-editable - name: Run tests - run: uv run python -m unittest discover -s tests + run: uv run pytest - name: Build distributions run: uv build --no-sources @@ -63,4 +63,4 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - packages_dir: dist + packages-dir: dist diff --git a/.gitignore b/.gitignore index 6b9fee8..c3d372d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ htmlcov/ # OS / editor .DS_Store + +# Claude +.claude/ diff --git a/README.md b/README.md index a2cfd11..38e2115 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # CTFd MCP server (user scope) + [](https://github.com/umbra2728/ctfd-mcp/releases) [](LICENSE) [](https://www.python.org/downloads/) @@ -112,14 +113,14 @@ If something breaks or you have questions, reach out: ## Testing -- Run `uv run python -m tests.test_ctfd_client` (requires a real `CTFD_URL` plus token or username/password) to exercise challenge fetching/submission flows. +- Run `uv run pytest`. - Timeouts are configurable via env: `CTFD_TIMEOUT` (total), `CTFD_CONNECT_TIMEOUT`, `CTFD_READ_TIMEOUT` (seconds). Defaults are 20s total / 10s connect / 15s read. ## Development - Dev dependencies: `uv sync --group dev` - Lint/format: `uv run ruff check .` and `uv run ruff format .` -- Tests: `uv run python -m unittest discover -s tests` +- Tests: `uv run pytest` - Pre-commit: `uv run pre-commit install` (see `CONTRIBUTING.md`) ## License diff --git a/pyproject.toml b/pyproject.toml index 4522713..12a3968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,9 +27,14 @@ ctfd-mcp = "ctfd_mcp.main:main" [dependency-groups] dev = [ "pre-commit>=3.6.0", + "pytest>=8.0.0", "ruff>=0.5.0", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src", "."] + [tool.ruff] line-length = 88 target-version = "py313" diff --git a/tests/test_config.py b/tests/test_config.py index adeecba..4362425 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,69 +1,147 @@ -"""Config loading tests (ruff: ignore E402 for sys.path adjustment).""" +import pytest -# ruff: noqa: E402 - -import os -import sys -import unittest -from pathlib import Path -from unittest.mock import patch - -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)) - -from ctfd_mcp.config import load_config +from ctfd_mcp.config import DEFAULT_USER_AGENT, ConfigError, _parse_timeout, load_config -def _load_with_env(env: dict[str, str]): - """Load config with isolated env and no .env side effects.""" - with patch("ctfd_mcp.config.load_dotenv", return_value=None): - with patch.dict(os.environ, env, clear=True): - return load_config() +def _load_with_env(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]): + monkeypatch.setattr("ctfd_mcp.config.load_dotenv", lambda: None) + for key in ( + "CTFD_URL", + "CTFD_TOKEN", + "CTFD_SESSION", + "CTFD_CSRF_TOKEN", + "CTFD_USERNAME", + "CTFD_PASSWORD", + "CTFD_TIMEOUT", + "CTFD_CONNECT_TIMEOUT", + "CTFD_READ_TIMEOUT", + "CTFD_USER_AGENT", + ): + monkeypatch.delenv(key, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + return load_config() -class ConfigPrecedenceTests(unittest.TestCase): - def test_username_password_take_priority(self): - env = { +def test_username_password_take_priority(monkeypatch: pytest.MonkeyPatch): + cfg = _load_with_env( + monkeypatch, + { "CTFD_URL": "https://ctfd.example.com", "CTFD_USERNAME": "user1", "CTFD_PASSWORD": "pw1", "CTFD_TOKEN": "token-should-be-ignored", "CTFD_SESSION": "session-should-be-ignored", "CTFD_CSRF_TOKEN": "csrf-should-be-ignored", - } - cfg = _load_with_env(env) - self.assertEqual(cfg.username, "user1") - self.assertEqual(cfg.password, "pw1") - self.assertIsNone(cfg.token) - self.assertIsNone(cfg.session_cookie) - self.assertIsNone(cfg.csrf_token) + }, + ) + assert cfg.username == "user1" + assert cfg.password == "pw1" + assert cfg.token is None + assert cfg.session_cookie is None + assert cfg.csrf_token is None - def test_token_beats_session_cookie(self): - env = { + +def test_token_beats_session_cookie(monkeypatch: pytest.MonkeyPatch): + cfg = _load_with_env( + monkeypatch, + { "CTFD_URL": "https://ctfd.example.com", "CTFD_TOKEN": "use-this-token", "CTFD_SESSION": "drop-this-session", "CTFD_CSRF_TOKEN": "csrf-should-be-ignored", - } - cfg = _load_with_env(env) - self.assertEqual(cfg.token, "use-this-token") - self.assertIsNone(cfg.session_cookie) - self.assertIsNone(cfg.csrf_token) + }, + ) + assert cfg.token == "use-this-token" + assert cfg.session_cookie is None + assert cfg.csrf_token is None - def test_session_cookie_when_no_other_creds(self): - env = { + +def test_session_cookie_when_no_other_creds(monkeypatch: pytest.MonkeyPatch): + cfg = _load_with_env( + monkeypatch, + { "CTFD_URL": "https://ctfd.example.com", "CTFD_SESSION": "session-only", - } - cfg = _load_with_env(env) - self.assertEqual(cfg.session_cookie, "session-only") - self.assertIsNone(cfg.token) - self.assertIsNone(cfg.username) - self.assertIsNone(cfg.password) + }, + ) + assert cfg.session_cookie == "session-only" + assert cfg.token is None + assert cfg.username is None + assert cfg.password is None -if __name__ == "__main__": - unittest.main() +def test_missing_base_url(monkeypatch: pytest.MonkeyPatch): + with pytest.raises(ConfigError, match="CTFD_URL is not set"): + _load_with_env( + monkeypatch, + { + "CTFD_TOKEN": "token", + }, + ) + + +def test_missing_creds(monkeypatch: pytest.MonkeyPatch): + with pytest.raises(ConfigError, match="Set CTFD_TOKEN"): + _load_with_env( + monkeypatch, + { + "CTFD_URL": "https://ctfd.example.com", + }, + ) + + +def test_invalid_url(monkeypatch: pytest.MonkeyPatch): + with pytest.raises(ConfigError, match="CTFD_URL must be a full URL"): + _load_with_env( + monkeypatch, + { + "CTFD_URL": "ctfd.example.com", + "CTFD_TOKEN": "token", + }, + ) + + +def test_parse_timeout_valid(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CTFD_TIMEOUT", "12.5") + assert _parse_timeout("CTFD_TIMEOUT") == 12.5 + + +def test_parse_timeout_invalid(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CTFD_TIMEOUT", "nope") + with pytest.raises(ConfigError, match="CTFD_TIMEOUT must be a number"): + _parse_timeout("CTFD_TIMEOUT") + + +def test_auth_header(monkeypatch: pytest.MonkeyPatch): + cfg = _load_with_env( + monkeypatch, + { + "CTFD_URL": "https://ctfd.example.com", + "CTFD_TOKEN": "token", + }, + ) + assert cfg.auth_header == {"Authorization": "Token token"} + + +def test_user_agent_default(monkeypatch: pytest.MonkeyPatch): + cfg = _load_with_env( + monkeypatch, + { + "CTFD_URL": "https://ctfd.example.com", + "CTFD_TOKEN": "token", + }, + ) + assert cfg.user_agent == DEFAULT_USER_AGENT + + +def test_user_agent_override(monkeypatch: pytest.MonkeyPatch): + cfg = _load_with_env( + monkeypatch, + { + "CTFD_URL": "https://ctfd.example.com", + "CTFD_TOKEN": "token", + "CTFD_USER_AGENT": " custom-agent/1.0 ", + }, + ) + assert cfg.user_agent == "custom-agent/1.0" diff --git a/tests/test_ctfd_client.py b/tests/test_ctfd_client.py index 54854f8..dbd31d5 100644 --- a/tests/test_ctfd_client.py +++ b/tests/test_ctfd_client.py @@ -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": "
hi
", + "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 = '' - 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": "hi
", + "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 = ( + '' # 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 = ( - '' - ) - 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 = '' + 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": "hi
", - "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("Hello