Merge pull request #883 from cbcoutinho/worktree-tender-stargazing-sundae

test: Pact consumer contract for astrolabe credentials status (ADR-029)
This commit is contained in:
Chris Coutinho
2026-06-10 22:26:39 +02:00
committed by GitHub
13 changed files with 764 additions and 36 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.
"""
+42
View File
@@ -0,0 +1,42 @@
"""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 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,106 @@
"""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.
The client's 404 branch is internal defensive handling (astrolabe always returns
200 for this endpoint), not a contract obligation, so it is covered by a unit
test (``tests/unit/test_astrolabe_client.py``) rather than a pact interaction.
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,121 @@
"""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.
_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:
# 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_URL and _BROKER_USERNAME and _BROKER_PASSWORD:
verifier.broker_source(
_BROKER_URL, username=_BROKER_USERNAME, password=_BROKER_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()
+104
View File
@@ -0,0 +1,104 @@
"""Unit tests for ``AstrolabeClient.get_background_sync_status`` (ADR-029).
The Pact consumer contract (``tests/contract/``) pins the wire shape; these
mocked tests pin the *field mapping* that the original silent bug got wrong —
it read a non-existent ``app_password`` field, so ``has_access`` was always
``False``. A regression here now fails fast at the unit layer.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
pytestmark = pytest.mark.unit
def _patch_outbound_client(mocker, response: MagicMock) -> MagicMock:
"""Patch the httpx client used by AstrolabeClient; return the mock client."""
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mocker.patch(
"nextcloud_mcp_server.auth.astrolabe_client.nextcloud_httpx_client",
MagicMock(return_value=mock_client),
)
return mock_client
def _client(mocker) -> AstrolabeClient:
client = AstrolabeClient(
nextcloud_host="https://cloud.example.com",
client_id="mcp",
client_secret="secret",
)
mocker.patch.object(client, "get_access_token", AsyncMock(return_value="tok"))
return client
async def test_provisioned_user_maps_status_fields(mocker):
"""200 + has_background_access=True maps to has_access/credential_type/provisioned_at."""
response = MagicMock()
response.status_code = 200
response.json.return_value = {
"success": True,
"user_id": "alice",
"has_background_access": True,
"sync_type": "app_password",
"provisioned_at": 1717000000,
}
mock_client = _patch_outbound_client(mocker, response)
client = _client(mocker)
status = await client.get_background_sync_status("alice")
assert status == {
"has_access": True,
"credential_type": "app_password",
"provisioned_at": 1717000000,
}
response.raise_for_status.assert_called_once()
# The bearer token from get_access_token is forwarded on the request.
_, kwargs = mock_client.get.call_args
assert kwargs["headers"]["Authorization"] == "Bearer tok"
async def test_provisioned_false_reports_no_access(mocker):
"""200 + has_background_access=False reports no access (the bug's regression guard)."""
response = MagicMock()
response.status_code = 200
response.json.return_value = {
"success": True,
"user_id": "bob",
"has_background_access": False,
"sync_type": None,
"provisioned_at": None,
}
_patch_outbound_client(mocker, response)
client = _client(mocker)
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
async def test_missing_credentials_404_returns_no_access(mocker):
"""404 short-circuits to no-access without touching raise_for_status/json."""
response = MagicMock()
response.status_code = 404
_patch_outbound_client(mocker, response)
client = _client(mocker)
status = await client.get_background_sync_status("carol")
assert status == {
"has_access": False,
"credential_type": None,
"provisioned_at": None,
}
response.raise_for_status.assert_not_called()
response.json.assert_not_called()
@@ -8,6 +8,7 @@ read and clear that store too, otherwise they report "not provisioned" while
tools still work, and "nothing to revoke" while the credential persists.
"""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -58,6 +59,39 @@ async def test_status_reports_provisioned_for_app_password_store(
storage.get_refresh_token.assert_not_awaited() # app password short-circuits
async def test_status_converts_astrolabe_int_timestamp_to_iso(mocker):
"""Astrolabe returns provisioned_at as Unix seconds (per the contract pact),
but ProvisioningStatus.provisioned_at is an ISO string. The int must be
converted at the boundary, else constructing the model raises ValidationError
for every provisioned user."""
mocker.patch.object(
oauth_tools,
"get_settings",
return_value=SimpleNamespace(
oidc_client_id="mcp",
oidc_client_secret="secret",
nextcloud_host="https://cloud.example.com",
),
)
astrolabe = MagicMock()
astrolabe.get_background_sync_status = AsyncMock(
return_value={
"has_access": True,
"credential_type": "app_password",
"provisioned_at": 1717000000,
}
)
mocker.patch.object(oauth_tools, "AstrolabeClient", return_value=astrolabe)
status = await _get_provisioning_status(MagicMock(), "alice")
assert status.is_provisioned is True
assert status.credential_type == "app_password"
# Converted from Unix seconds to an ISO-8601 string that round-trips back.
assert isinstance(status.provisioned_at, str)
assert datetime.fromisoformat(status.provisioned_at).timestamp() == 1717000000
async def test_revoke_deletes_app_password(mocker, _no_astrolabe_settings):
"""Revoke must delete the app password from storage (not just refresh tokens)."""
storage = MagicMock()