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
+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()