feat: publish Hermes Nextcloud calendar MCP under AGPL-3.0
This commit is contained in:
165
tests/test_server.py
Normal file
165
tests/test_server.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from test_caldav import SIMPLE, multistatus
|
||||
|
||||
from hermes_caldav_mcp.credentials import CredentialStore
|
||||
from hermes_caldav_mcp.server import CalendarService, execute, make_server
|
||||
from hermes_caldav_mcp.transport import event_id
|
||||
|
||||
|
||||
async def test_locked_keyring_between_calls_stops_new_requests(profile, collection):
|
||||
store = CredentialStore(lambda: collection)
|
||||
store.save("default", profile, verified_encryption=True)
|
||||
requests = []
|
||||
|
||||
def handler(r):
|
||||
requests.append(r)
|
||||
return httpx.Response(
|
||||
207, content=multistatus(profile.calendars[0].href, calendar=True)
|
||||
)
|
||||
|
||||
service = CalendarService(
|
||||
lambda: store.load("default"),
|
||||
transport_factory=lambda: httpx.MockTransport(handler),
|
||||
)
|
||||
first = await execute(service, "list_calendars", {})
|
||||
collection.locked = True
|
||||
second = await execute(service, "list_calendars", {})
|
||||
assert first["ok"]
|
||||
assert second["error"]["code"] == "CREDENTIALS_LOCKED"
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,args",
|
||||
[
|
||||
("delete_event", {}),
|
||||
("list_calendars", {"secret": "forbidden"}),
|
||||
("get_event", {"calendar_id": "other", "event_id": "bad"}),
|
||||
(
|
||||
"create_event",
|
||||
{
|
||||
"calendar_id": "calendar-1",
|
||||
"fields": {
|
||||
"title": "x",
|
||||
"start": "2025-01-01",
|
||||
"end": "2025-01-02",
|
||||
"all_day": True,
|
||||
"attendees": ["evil"],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_forbidden_inputs_cannot_send_requests(profile, name, args):
|
||||
seen = []
|
||||
service = CalendarService(
|
||||
lambda: profile,
|
||||
transport_factory=lambda: httpx.MockTransport(lambda r: seen.append(r)),
|
||||
)
|
||||
result = await execute(service, name, args)
|
||||
assert not result["ok"]
|
||||
assert not seen
|
||||
|
||||
|
||||
async def test_internal_exception_is_redacted(profile, capsys):
|
||||
def loader():
|
||||
raise RuntimeError(profile.password)
|
||||
|
||||
result = await execute(CalendarService(loader), "list_calendars", {})
|
||||
assert result["error"]["code"] == "INTERNAL_ERROR"
|
||||
assert profile.password not in str(result) + str(capsys.readouterr())
|
||||
|
||||
|
||||
async def test_service_get_put_race_has_revision_and_no_retry(profile):
|
||||
cal = profile.calendars[0]
|
||||
seen = []
|
||||
|
||||
def handler(r):
|
||||
seen.append(r)
|
||||
if r.method == "GET":
|
||||
return httpx.Response(200, content=SIMPLE, headers={"ETag": '"old"'})
|
||||
assert r.headers["If-Match"] == '"old"'
|
||||
return httpx.Response(412)
|
||||
|
||||
service = CalendarService(
|
||||
lambda: profile, transport_factory=lambda: httpx.MockTransport(handler)
|
||||
)
|
||||
result = await execute(
|
||||
service,
|
||||
"update_event",
|
||||
{
|
||||
"calendar_id": cal.id,
|
||||
"event_id": event_id(cal, cal.href + "a.ics"),
|
||||
"expected_revision": '"old"',
|
||||
"patch": {"title": "Changed"},
|
||||
},
|
||||
)
|
||||
assert result["error"]["code"] == "CONFLICT"
|
||||
assert [r.method for r in seen] == ["GET", "PUT"]
|
||||
|
||||
|
||||
async def test_read_only_calendar_never_writes(profile):
|
||||
cal = replace(profile.calendars[0], writable=False)
|
||||
profile = replace(profile, calendars=(cal,))
|
||||
seen = []
|
||||
service = CalendarService(
|
||||
lambda: profile,
|
||||
transport_factory=lambda: httpx.MockTransport(lambda r: seen.append(r)),
|
||||
)
|
||||
result = await execute(
|
||||
service,
|
||||
"create_event",
|
||||
{
|
||||
"calendar_id": cal.id,
|
||||
"fields": {
|
||||
"title": "x",
|
||||
"start": "2025-01-01",
|
||||
"end": "2025-01-02",
|
||||
"all_day": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["error"]["code"] == "PERMISSION_DENIED"
|
||||
assert not seen
|
||||
|
||||
|
||||
async def test_total_deadline_after_write_is_unknown_once(profile, monkeypatch):
|
||||
monkeypatch.setattr("hermes_caldav_mcp.server.TOOL_DEADLINE", 0.02)
|
||||
seen = []
|
||||
|
||||
async def handler(r):
|
||||
seen.append(r)
|
||||
await asyncio.sleep(1)
|
||||
return httpx.Response(201)
|
||||
|
||||
service = CalendarService(
|
||||
lambda: profile, transport_factory=lambda: httpx.MockTransport(handler)
|
||||
)
|
||||
result = await execute(
|
||||
service,
|
||||
"create_event",
|
||||
{
|
||||
"calendar_id": "calendar-1",
|
||||
"fields": {
|
||||
"title": "x",
|
||||
"start": "2025-01-01",
|
||||
"end": "2025-01-02",
|
||||
"all_day": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["error"]["code"] == "UNKNOWN_WRITE_OUTCOME"
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_mcp_advertises_only_tools(profile):
|
||||
server = make_server(CalendarService(lambda: profile))
|
||||
caps = server.get_capabilities()
|
||||
assert caps.tools is not None
|
||||
assert caps.resources is None
|
||||
assert caps.prompts is None
|
||||
assert caps.logging is None
|
||||
Reference in New Issue
Block a user