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:
Chris Coutinho
2026-06-10 20:22:07 +02:00
co-authored by Claude Opus 4.8
parent 56ed28b421
commit d33832aba9
10 changed files with 603 additions and 33 deletions
+12
View File
@@ -0,0 +1,12 @@
"""Pact consumer/provider contract tests (ADR-029).
This package contains the Pact contract tests that keep ``nextcloud-mcp-server``
and the ``astrolabe`` Nextcloud app wire-compatible:
- **Consumer** (this server -> astrolabe): ``test_astrolabe_credentials_consumer``
generates a pact for the background-sync credentials API that the
``AstrolabeClient`` consumes.
- **Provider** (astrolabe -> this server): ``test_mcp_provider_verification``
verifies the ``/api/v1/*`` HTTP API this server exposes against pacts that
the astrolabe app publishes to the broker.
"""
+48
View File
@@ -0,0 +1,48 @@
"""Fixtures shared by the Pact contract tests (ADR-029).
The consumer tests build a fresh ``Pact`` per test and merge their interaction
into a single pact file under ``tests/contract/pacts/``. A session-scoped
autouse fixture wipes that directory once at the start of a run so merged pacts
never accumulate stale interactions across runs (``write_file(overwrite=False)``
merges into whatever is already on disk).
"""
import shutil
from pathlib import Path
import pytest
from pact import Pact
# Pact participant names. These MUST match the names used on the astrolabe side
# and in the broker, so keep them in sync with the astrolabe repo's pact tests.
CONSUMER = "nextcloud-mcp-server"
PROVIDER = "astrolabe"
PACT_DIR = Path(__file__).parent / "pacts"
@pytest.fixture(scope="session", autouse=True)
def _clean_pact_dir():
"""Start each session from an empty pacts directory."""
if PACT_DIR.exists():
shutil.rmtree(PACT_DIR)
PACT_DIR.mkdir(parents=True, exist_ok=True)
yield
@pytest.fixture
def pact_dir() -> Path:
"""Directory the generated pact files are written to."""
return PACT_DIR
@pytest.fixture
def consumer_pact():
"""A fresh Pact (consumer=nextcloud-mcp-server, provider=astrolabe).
The interaction added by each test is merged into the shared pact file on
teardown.
"""
pact = Pact(CONSUMER, PROVIDER).with_specification("V4")
yield pact
pact.write_file(PACT_DIR, overwrite=False)
@@ -0,0 +1,102 @@
"""Consumer contract: nextcloud-mcp-server -> astrolabe credentials status API.
The MCP server checks a user's background-sync provisioning status via
:meth:`AstrolabeClient.get_background_sync_status`, which calls astrolabe's
admin credentials-metadata endpoint
(``GET /apps/astrolabe/api/v1/background-sync/credentials/{user_id}``). That
endpoint returns **presence/timestamps only — never the app password itself**
(the password reaches the MCP server out-of-band, pushed by astrolabe to
``POST /api/v1/users/{user_id}/app-password``).
This pact pins the request shape and the two states the consumer branches on:
- provisioned -> ``has_background_access: true``, ``sync_type: "app_password"``
- unprovisioned -> ``has_background_access: false``, ``sync_type: null``
The OAuth token fetch (:meth:`AstrolabeClient.get_access_token`) is stubbed so
only the status call hits the Pact mock server.
See ADR-029 for the overall contract-testing architecture.
"""
import pytest
from pact import match
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
pytestmark = pytest.mark.contract
# Matches the ``Authorization: Bearer <token>`` header the client always sends.
_BEARER = match.regex("Bearer test-token", regex=r"Bearer .+")
async def test_status_reports_access_for_provisioned_user(consumer_pact, mocker):
"""A provisioned user is reported as having background access."""
(
consumer_pact.upon_receiving(
"a request for a provisioned user's background-sync status"
)
.given("user alice has provisioned background-sync credentials")
.with_request("GET", "/apps/astrolabe/api/v1/background-sync/credentials/alice")
.with_header("Authorization", _BEARER)
.will_respond_with(200)
.with_body(
{
"success": True,
"user_id": "alice",
"has_background_access": True,
"sync_type": "app_password",
# Unix seconds (astrolabe BackgroundSyncCredentialStorage::getProvisionedAt),
# not an ISO string.
"provisioned_at": match.integer(1717000000),
},
content_type="application/json",
)
)
with consumer_pact.serve() as srv:
client = AstrolabeClient(
nextcloud_host=str(srv.url), client_id="mcp", client_secret="secret"
)
mocker.patch.object(client, "get_access_token", return_value="test-token")
status = await client.get_background_sync_status("alice")
assert status["has_access"] is True
assert status["credential_type"] == "app_password"
assert status["provisioned_at"] == 1717000000
async def test_status_reports_no_access_for_unprovisioned_user(consumer_pact, mocker):
"""An unprovisioned user is reported as having no background access."""
(
consumer_pact.upon_receiving(
"a request for an unprovisioned user's background-sync status"
)
.given("user bob has no background-sync credentials")
.with_request("GET", "/apps/astrolabe/api/v1/background-sync/credentials/bob")
.with_header("Authorization", _BEARER)
.will_respond_with(200)
.with_body(
{
"success": True,
"user_id": "bob",
"has_background_access": False,
"sync_type": None,
"provisioned_at": None,
},
content_type="application/json",
)
)
with consumer_pact.serve() as srv:
client = AstrolabeClient(
nextcloud_host=str(srv.url), client_id="mcp", client_secret="secret"
)
mocker.patch.object(client, "get_access_token", return_value="test-token")
status = await client.get_background_sync_status("bob")
assert status["has_access"] is False
assert status["credential_type"] is None
assert status["provisioned_at"] is None
@@ -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()