Round-2 style note: replace `return None` with a comment-only intentionally empty body for the _state_admin_can_purge stub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
"""Provider verification: astrolabe -> nextcloud-mcp-server /api/v1 API.
|
|
|
|
The astrolabe Nextcloud app consumes this server's ``/api/v1/*`` HTTP API
|
|
(``lib/Service/McpServerClient.php``: ``search``, ``webhooks`` CRUD, ``apps``,
|
|
``status``, ``vector-sync/status``, ``chunk-context``, ``pdf-preview``,
|
|
``vector-viz/search``). This test plays the **provider** role: it pulls the
|
|
pacts astrolabe published to the broker and replays each interaction against a
|
|
running MCP server, failing if a response no longer matches the contract.
|
|
|
|
It is **environment-gated** and skips unless a running provider and a pact
|
|
source are configured, so it is a no-op in the consumer-only job and in local
|
|
unit runs. Wire it into CI against the integration docker stack (see
|
|
``.github/workflows/pact.yml``).
|
|
|
|
Required environment:
|
|
- ``PACT_PROVIDER_URL`` — base URL of the running MCP server to verify against
|
|
(e.g. ``http://localhost:8000``).
|
|
- One pact source, either:
|
|
- ``PACT_BROKER`` (+ ``PACT_USERNAME`` / ``PACT_PASSWORD``) — verify against
|
|
pacts in the broker, or
|
|
- ``PACT_PROVIDER_PACT_DIR`` — verify against a local directory of pacts.
|
|
|
|
Optional:
|
|
- ``PACT_PROVIDER_VERSION`` — provider version (git SHA) to publish results under.
|
|
- ``PACT_PROVIDER_BRANCH`` — provider branch for the published results.
|
|
- ``PACT_PUBLISH_RESULTS=true`` — publish verification results to the broker.
|
|
|
|
See ADR-029.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from collections.abc import Callable
|
|
|
|
import pytest
|
|
from pact import Verifier
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROVIDER_NAME = "nextcloud-mcp-server"
|
|
|
|
_PROVIDER_URL = os.environ.get("PACT_PROVIDER_URL")
|
|
_BROKER_URL = os.environ.get("PACT_BROKER")
|
|
_BROKER_USERNAME = os.environ.get("PACT_USERNAME")
|
|
_BROKER_PASSWORD = os.environ.get("PACT_PASSWORD")
|
|
_LOCAL_PACT_DIR = os.environ.get("PACT_PROVIDER_PACT_DIR")
|
|
|
|
# A usable broker source needs the URL *and* its basic-auth credentials; gating
|
|
# on all three keeps a misconfigured CI (broker set, creds missing) a clean skip
|
|
# rather than a confusing KeyError at verify time.
|
|
_BROKER_READY = bool(_BROKER_URL and _BROKER_USERNAME and _BROKER_PASSWORD)
|
|
|
|
# Skip the whole module unless we have a provider to hit AND a pact source.
|
|
pytestmark = [
|
|
pytest.mark.contract,
|
|
pytest.mark.skipif(
|
|
not _PROVIDER_URL or not (_BROKER_READY or _LOCAL_PACT_DIR),
|
|
reason=(
|
|
"Provider verification needs PACT_PROVIDER_URL and a pact source: "
|
|
"PACT_BROKER (+ PACT_USERNAME/PACT_PASSWORD) or "
|
|
"PACT_PROVIDER_PACT_DIR. Skipped outside CI."
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
# Map astrolabe-side provider-state strings -> setup callables. astrolabe's
|
|
# consumer pacts declare the ``given(...)`` provider states; add one handler per
|
|
# state name here as those pacts are written (seeding webhooks DB, qdrant
|
|
# fixtures, etc.). Keep the keys identical to the astrolabe ``given(...)``
|
|
# strings. Unhandled states fall through to ``_dispatch_state`` which logs and
|
|
# no-ops, so state-less interactions still verify.
|
|
def _state_admin_can_purge() -> None:
|
|
"""Provider state for astrolabe's consent-purge pact
|
|
(``POST /api/v1/vector-sync/purge``).
|
|
|
|
Full verification of this authenticated endpoint (admin OAuth token +
|
|
Nextcloud admin-group check + Qdrant delete) needs the live-stack auth
|
|
test-hook that is the ADR-029 phase-4 follow-up. Until then the interaction
|
|
rides the broker's pending flow (see ``include_pending`` below); this handler
|
|
is registered so the dispatcher recognises the state by name rather than
|
|
logging an "unhandled state" warning.
|
|
"""
|
|
# Intentionally empty: no live-stack state to set up yet (phase 4).
|
|
|
|
|
|
_PROVIDER_STATES: dict[str, Callable[[], None]] = {
|
|
"an admin can purge indexed documents": _state_admin_can_purge,
|
|
# "a webhook is registered for user alice": _state_webhook_registered,
|
|
# "vector sync has indexed documents": _state_vector_sync_ran,
|
|
# "the search index returns a hit for 'budget'": _state_search_has_hit,
|
|
}
|
|
|
|
|
|
def _dispatch_state(state: str, **kwargs) -> None:
|
|
"""Provider-state dispatcher passed to the verifier.
|
|
|
|
Looks up a registered handler by state name; logs and no-ops for unknown
|
|
states so contracts that don't require seeded state (``/api/v1/status``,
|
|
``/api/v1/vector-sync/status``) verify without a handler.
|
|
"""
|
|
handler = _PROVIDER_STATES.get(state)
|
|
if handler is None:
|
|
# Log any params astrolabe passed so they're visible once real handlers
|
|
# need them (e.g. given("user X exists", params={"user_id": ...})).
|
|
logger.warning(
|
|
"No provider-state handler registered for %r (params=%s); no-op",
|
|
state,
|
|
kwargs,
|
|
)
|
|
return
|
|
handler()
|
|
|
|
|
|
def test_verify_astrolabe_consumer_pacts() -> None:
|
|
"""Verify the MCP server honours every interaction astrolabe published."""
|
|
verifier = Verifier(PROVIDER_NAME).add_transport(url=_PROVIDER_URL)
|
|
verifier.state_handler(_dispatch_state, teardown=True)
|
|
|
|
if _BROKER_READY:
|
|
# selector=True to opt into pending pacts: a new/authenticated contract
|
|
# (e.g. the consent-purge endpoint) reports as *pending* instead of
|
|
# failing this build until provider verification of the authenticated
|
|
# surface is stood up (ADR-029 phase 4). Already-verified interactions
|
|
# (GET /api/v1/status) stay blocking. Empty consumer selectors keep the
|
|
# default "latest pacts for this provider" fetch.
|
|
broker = verifier.broker_source(
|
|
_BROKER_URL,
|
|
username=_BROKER_USERNAME,
|
|
password=_BROKER_PASSWORD,
|
|
selector=True,
|
|
)
|
|
broker.include_pending()
|
|
provider_branch = os.environ.get("PACT_PROVIDER_BRANCH")
|
|
if provider_branch:
|
|
broker.provider_branch(provider_branch)
|
|
broker.build()
|
|
else:
|
|
assert _LOCAL_PACT_DIR is not None # guaranteed by module skipif
|
|
verifier.add_source(_LOCAL_PACT_DIR)
|
|
|
|
if os.environ.get("PACT_PUBLISH_RESULTS", "").lower() == "true":
|
|
version = os.environ.get("PACT_PROVIDER_VERSION", "dev")
|
|
verifier.set_publish_options(
|
|
version=version,
|
|
branch=os.environ.get("PACT_PROVIDER_BRANCH"),
|
|
)
|
|
|
|
# Raises (failing the test) if any interaction does not match.
|
|
verifier.verify()
|