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>
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""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
|