diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 7e0ee70f..81a7f513 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -118,7 +118,13 @@ async def _get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSt " get_provisioning_status: app password FOUND for user_id=%s", user_id, ) - provisioned_at_str = status.get("provisioned_at") + # Astrolabe returns provisioned_at as Unix seconds (see the + # contract pact); convert to the ISO string the model expects. + provisioned_at_str = None + provisioned_at_raw = status.get("provisioned_at") + if provisioned_at_raw: + dt = datetime.fromtimestamp(provisioned_at_raw, tz=timezone.utc) + provisioned_at_str = dt.isoformat() return ProvisioningStatus( is_provisioned=True, provisioned_at=provisioned_at_str, diff --git a/tests/contract/test_mcp_provider_verification.py b/tests/contract/test_mcp_provider_verification.py index a2bcc6ed..db5754ca 100644 --- a/tests/contract/test_mcp_provider_verification.py +++ b/tests/contract/test_mcp_provider_verification.py @@ -86,7 +86,13 @@ def _dispatch_state(state: str, **kwargs) -> None: """ handler = _PROVIDER_STATES.get(state) if handler is None: - logger.warning("No provider-state handler registered for %r; no-op", state) + # Log any params astrolabe passed so they're visible once real handlers + # need them (e.g. given("user X exists", params={"user_id": ...})). + logger.warning( + "No provider-state handler registered for %r (params=%s); no-op", + state, + kwargs, + ) return handler() diff --git a/tests/unit/test_oauth_tools_app_password_provisioning.py b/tests/unit/test_oauth_tools_app_password_provisioning.py index 81e96cdc..f710e43c 100644 --- a/tests/unit/test_oauth_tools_app_password_provisioning.py +++ b/tests/unit/test_oauth_tools_app_password_provisioning.py @@ -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()