test: add Pact consumer contract for astrolabe credentials status (ADR-029)
Introduce consumer-driven contract testing between nextcloud-mcp-server and the astrolabe Nextcloud app, published to the homelab Pact Broker and verified in CI. - pact-python dev dep + `contract` pytest marker - tests/contract/test_astrolabe_credentials_consumer.py: consumer pact for the background-sync *status* call (provisioned -> has_background_access:true, sync_type:"app_password", integer provisioned_at; unprovisioned -> false/null) - tests/contract/test_mcp_provider_verification.py: env-gated Verifier harness for this server's /api/v1/* provider role (provider-state handlers stubbed pending astrolabe's published pacts) - .github/workflows/pact.yml: join tailnet -> publish pacts -> provider verify -> can-i-deploy; broker steps skip when PACT_BROKER is unset (forks) - docs/ADR-029-pact-contract-testing.md Fix astrolabe_client.get_background_sync_status: it previously read a non-existent `app_password` field (always reporting no-access). Rewrite it to read the real status contract (has_background_access / sync_type / provisioned_at) and drop the unsatisfiable get_user_app_password. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
56ed28b421
commit
d33832aba9
@@ -0,0 +1,110 @@
|
||||
"""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
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_NAME = "nextcloud-mcp-server"
|
||||
|
||||
_PROVIDER_URL = os.environ.get("PACT_PROVIDER_URL")
|
||||
_BROKER_URL = os.environ.get("PACT_BROKER")
|
||||
_LOCAL_PACT_DIR = os.environ.get("PACT_PROVIDER_PACT_DIR")
|
||||
|
||||
# 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_URL or _LOCAL_PACT_DIR),
|
||||
reason=(
|
||||
"Provider verification needs PACT_PROVIDER_URL and a pact source "
|
||||
"(PACT_BROKER 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.
|
||||
_PROVIDER_STATES: dict[str, Callable[[], None]] = {
|
||||
# "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:
|
||||
logger.warning("No provider-state handler registered for %r; no-op", state)
|
||||
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_URL:
|
||||
# Basic-auth creds accompany the broker URL (see module skipif).
|
||||
username = os.environ["PACT_USERNAME"]
|
||||
password = os.environ["PACT_PASSWORD"]
|
||||
verifier.broker_source(_BROKER_URL, username=username, password=password)
|
||||
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()
|
||||
Reference in New Issue
Block a user