Files
mcp-nextcloud/tests/contract/test_astrolabe_credentials_consumer.py
T
Chris CoutinhoandClaude Opus 4.8 18baa501c9 test: address round-2 claude-review on #883
- pact.yml: guard `can-i-deploy` job on `env.PACT_BROKER != ''` so a secret
  rotation/fork can't break every master merge (the CLI errors on empty URL)
- pact.yml: pin install.sh to the v2.6.1 commit SHA (immune to tag force-push)
- astrolabe_client.py: `_token_cache` Optional[dict] -> `dict | None` and drop
  the now-unused `Optional` import (CLAUDE.md union syntax)
- add tests/unit/test_astrolabe_client.py: mocked unit coverage for
  get_background_sync_status field mapping (200 provisioned / 200 not-provisioned
  / 404) — the layer that would have caught the original silent app_password bug
- consumer pact test: note the 404 branch is internal defensive handling (covered
  by the unit test), not a contract obligation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:53:54 +02:00

107 lines
4.1 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.
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