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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user