Add test client and automated integration test suite
- 'test' OAuth client maps to stefan's data via USER_ALIASES - 38 tests covering OAuth (metadata, client_credentials, PKCE, invalid secret, no auth), Mail (accounts, folders, search), Calendar (calendars, tasks, events, search), Contacts (search, empty), Files (list, info), Notes (notebooks) - Daily systemd timer (05:00) with NTFY notification on failure - Shared token store (.active_tokens.json) for cross-process auth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
BASE_DIR = os.path.dirname(__file__)
|
BASE_DIR = os.path.dirname(__file__)
|
||||||
TOKENS_FILE = os.path.join(BASE_DIR, "tokens.json")
|
TOKENS_FILE = os.path.join(BASE_DIR, "tokens.json")
|
||||||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
|
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
|
||||||
VALID_USERS = ["stefan", "kati"]
|
VALID_USERS = ["stefan", "kati", "test"]
|
||||||
|
|
||||||
_config_cache = None
|
_config_cache = None
|
||||||
|
|
||||||
@@ -61,8 +61,11 @@ def _load_tokens():
|
|||||||
return _tokens_cache
|
return _tokens_cache
|
||||||
|
|
||||||
|
|
||||||
|
USER_ALIASES = {"test": "stefan"}
|
||||||
|
|
||||||
def get_current_user() -> str | None:
|
def get_current_user() -> str | None:
|
||||||
return _current_user.get()
|
user = _current_user.get()
|
||||||
|
return USER_ALIASES.get(user, user)
|
||||||
|
|
||||||
|
|
||||||
def get_user_key(username: str) -> str:
|
def get_user_key(username: str) -> str:
|
||||||
|
|||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# MCP Server Integration Tests — laeuft taeglich via systemd timer
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
LOG="/var/log/mcp-tests.log"
|
||||||
|
NTFY_TOPIC="admin"
|
||||||
|
VENV="/opt/mcp-servers/venv/bin"
|
||||||
|
|
||||||
|
echo "[$(date)] MCP Tests gestartet" | tee -a "$LOG"
|
||||||
|
|
||||||
|
OUTPUT=$($VENV/python -m pytest /opt/mcp-servers/tests/test_all.py -v --tb=short 2>&1)
|
||||||
|
EXIT=$?
|
||||||
|
|
||||||
|
echo "$OUTPUT" | tee -a "$LOG"
|
||||||
|
|
||||||
|
if [ $EXIT -eq 0 ]; then
|
||||||
|
PASSED=$(echo "$OUTPUT" | grep -oP '\d+ passed' | head -1)
|
||||||
|
echo "[$(date)] MCP Tests OK: $PASSED" | tee -a "$LOG"
|
||||||
|
else
|
||||||
|
FAILED=$(echo "$OUTPUT" | grep "FAILED" | head -5)
|
||||||
|
echo "[$(date)] MCP Tests FEHLGESCHLAGEN" | tee -a "$LOG"
|
||||||
|
/usr/local/bin/notify-ntfy "$NTFY_TOPIC" "MCP Tests fehlgeschlagen" \
|
||||||
|
"$(echo "$FAILED" | head -3)\n\nLog: tail -50 $LOG" "urgent" "x,test_tube"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MCP Server Integration Tests.
|
||||||
|
|
||||||
|
Tests all tools on all servers via HTTP, using the 'test' OAuth client.
|
||||||
|
Run: /opt/mcp-servers/venv/bin/python -m pytest /opt/mcp-servers/tests/test_all.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SERVERS = {
|
||||||
|
"mail": 5100,
|
||||||
|
"calendar": 5101,
|
||||||
|
"contacts": 5102,
|
||||||
|
"files": 5103,
|
||||||
|
"notes": 5104,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open("/opt/mcp-servers/tokens.json") as f:
|
||||||
|
TEST_SECRET = json.load(f)["test"]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(port):
|
||||||
|
"""Get access token via OAuth client_credentials."""
|
||||||
|
r = httpx.post(f"http://127.0.0.1:{port}/token", data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": "test",
|
||||||
|
"client_secret": TEST_SECRET,
|
||||||
|
}, timeout=10)
|
||||||
|
assert r.status_code == 200, f"Token request failed: {r.text}"
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_pkce(port):
|
||||||
|
"""Get access token via OAuth authorization_code + PKCE."""
|
||||||
|
verifier = secrets.token_urlsafe(32)
|
||||||
|
challenge = base64.urlsafe_b64encode(
|
||||||
|
hashlib.sha256(verifier.encode()).digest()
|
||||||
|
).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
r = httpx.get(f"http://127.0.0.1:{port}/authorize", params={
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": "test",
|
||||||
|
"redirect_uri": "http://localhost/callback",
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}, follow_redirects=False, timeout=10)
|
||||||
|
assert r.status_code == 302
|
||||||
|
code = r.headers["location"].split("code=")[1].split("&")[0]
|
||||||
|
|
||||||
|
r2 = httpx.post(f"http://127.0.0.1:{port}/token", data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": "test",
|
||||||
|
"code_verifier": verifier,
|
||||||
|
}, timeout=10)
|
||||||
|
assert r2.status_code == 200, f"Token exchange failed: {r2.text}"
|
||||||
|
return r2.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_call(port, token, method, params=None):
|
||||||
|
"""Call an MCP method and return the result."""
|
||||||
|
r = httpx.post(f"http://127.0.0.1:{port}/mcp",
|
||||||
|
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
}, timeout=30)
|
||||||
|
assert r.status_code == 200, f"MCP request failed: {r.status_code} {r.text[:200]}"
|
||||||
|
for line in r.text.split("\n"):
|
||||||
|
if line.startswith("data: "):
|
||||||
|
return json.loads(line[6:])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tool_call(port, token, tool_name, arguments=None):
|
||||||
|
"""Call an MCP tool and return the text content."""
|
||||||
|
result = mcp_call(port, token, "tools/call", {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments or {},
|
||||||
|
})
|
||||||
|
assert result is not None, "No SSE response"
|
||||||
|
assert "error" not in result, f"RPC error: {result.get('error')}"
|
||||||
|
content = result["result"]["content"]
|
||||||
|
assert len(content) > 0, "Empty content"
|
||||||
|
return content[0].get("text", "")
|
||||||
|
|
||||||
|
|
||||||
|
# --- OAuth Tests ---
|
||||||
|
|
||||||
|
class TestOAuth:
|
||||||
|
@pytest.mark.parametrize("service,port", SERVERS.items())
|
||||||
|
def test_metadata(self, service, port):
|
||||||
|
r = httpx.get(f"http://127.0.0.1:{port}/.well-known/oauth-authorization-server", timeout=10)
|
||||||
|
assert r.status_code == 200
|
||||||
|
d = r.json()
|
||||||
|
assert "authorization_endpoint" in d
|
||||||
|
assert "token_endpoint" in d
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("service,port", SERVERS.items())
|
||||||
|
def test_client_credentials(self, service, port):
|
||||||
|
token = get_token(port)
|
||||||
|
assert len(token) > 20
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("service,port", SERVERS.items())
|
||||||
|
def test_pkce_flow(self, service, port):
|
||||||
|
token = get_token_pkce(port)
|
||||||
|
assert len(token) > 20
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("service,port", SERVERS.items())
|
||||||
|
def test_invalid_secret(self, service, port):
|
||||||
|
r = httpx.post(f"http://127.0.0.1:{port}/token", data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": "test",
|
||||||
|
"client_secret": "WRONG",
|
||||||
|
}, timeout=10)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("service,port", SERVERS.items())
|
||||||
|
def test_no_auth(self, service, port):
|
||||||
|
r = httpx.post(f"http://127.0.0.1:{port}/mcp",
|
||||||
|
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
||||||
|
headers={"Accept": "application/json, text/event-stream"},
|
||||||
|
timeout=10)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mail Tests ---
|
||||||
|
|
||||||
|
class TestMail:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self):
|
||||||
|
self.token = get_token(SERVERS["mail"])
|
||||||
|
|
||||||
|
def test_list_accounts(self):
|
||||||
|
text = tool_call(SERVERS["mail"], self.token, "list_accounts")
|
||||||
|
assert "d370128_0-slohmaier" in text
|
||||||
|
assert "folders" in text
|
||||||
|
|
||||||
|
def test_list_folders(self):
|
||||||
|
text = tool_call(SERVERS["mail"], self.token, "list_folders", {"account": "d370128_0-slohmaier"})
|
||||||
|
assert "INBOX" in text
|
||||||
|
|
||||||
|
def test_search_mail(self):
|
||||||
|
text = tool_call(SERVERS["mail"], self.token, "search_mail", {"query": "stefan", "limit": 2})
|
||||||
|
assert "Subject:" in text or "No results" in text
|
||||||
|
|
||||||
|
def test_search_nonexistent(self):
|
||||||
|
text = tool_call(SERVERS["mail"], self.token, "search_mail", {"query": "xyzzy_nonexistent_12345", "limit": 1, "account": "d370128_0-gitea"})
|
||||||
|
assert "No results" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Calendar Tests ---
|
||||||
|
|
||||||
|
class TestCalendar:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self):
|
||||||
|
self.token = get_token(SERVERS["calendar"])
|
||||||
|
|
||||||
|
def test_list_calendars(self):
|
||||||
|
text = tool_call(SERVERS["calendar"], self.token, "list_calendars")
|
||||||
|
assert "calendar-stefan" in text or "Stefan" in text
|
||||||
|
|
||||||
|
def test_list_task_lists(self):
|
||||||
|
text = tool_call(SERVERS["calendar"], self.token, "list_task_lists")
|
||||||
|
assert "reminders" in text.lower() or "erinnerungen" in text.lower() or "calendar" in text.lower()
|
||||||
|
|
||||||
|
def test_get_events(self):
|
||||||
|
text = tool_call(SERVERS["calendar"], self.token, "get_events", {
|
||||||
|
"calendar": "stefan", "date_from": "2026-06-01", "date_to": "2026-06-30"
|
||||||
|
})
|
||||||
|
assert "Wann:" in text or "Keine Termine" in text
|
||||||
|
|
||||||
|
def test_search_events(self):
|
||||||
|
text = tool_call(SERVERS["calendar"], self.token, "search_events", {"query": "Training"})
|
||||||
|
assert "UID:" in text or "Keine Treffer" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Contacts Tests ---
|
||||||
|
|
||||||
|
class TestContacts:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self):
|
||||||
|
self.token = get_token(SERVERS["contacts"])
|
||||||
|
|
||||||
|
def test_search_contacts(self):
|
||||||
|
text = tool_call(SERVERS["contacts"], self.token, "search_contacts", {"query": "Lohmaier", "limit": 3})
|
||||||
|
assert "Lohmaier" in text or "Keine Kontakte" in text
|
||||||
|
|
||||||
|
def test_search_empty(self):
|
||||||
|
text = tool_call(SERVERS["contacts"], self.token, "search_contacts", {"query": "xyzzy_nonexistent_99999"})
|
||||||
|
assert "Keine Kontakte" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Files Tests ---
|
||||||
|
|
||||||
|
class TestFiles:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self):
|
||||||
|
self.token = get_token(SERVERS["files"])
|
||||||
|
|
||||||
|
def test_list_root(self):
|
||||||
|
text = tool_call(SERVERS["files"], self.token, "list_files", {"path": "/"})
|
||||||
|
assert "DIR" in text or "Empty" in text or "Leeres" in text
|
||||||
|
|
||||||
|
def test_file_info(self):
|
||||||
|
text = tool_call(SERVERS["files"], self.token, "file_info", {"path": "/"})
|
||||||
|
assert "Verzeichnis" in text or "Directory" in text or "Name:" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Notes Tests ---
|
||||||
|
|
||||||
|
class TestNotes:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self):
|
||||||
|
self.token = get_token(SERVERS["notes"])
|
||||||
|
|
||||||
|
def test_list_notebooks(self):
|
||||||
|
text = tool_call(SERVERS["notes"], self.token, "list_notebooks")
|
||||||
|
# Notes likely not configured, so accept error message too
|
||||||
|
assert "Kein" in text or "Fehler" in text or "id:" in text.lower()
|
||||||
Reference in New Issue
Block a user