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,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
|
||||
Reference in New Issue
Block a user