Round-4 review (real bug): get_background_sync_status now returns provisioned_at as Unix seconds (the wire/pact value), but ProvisioningStatus.provisioned_at is str | None (ISO). Constructing it for a provisioned user raised a Pydantic ValidationError — a path that was unreachable before the has_access fix. Convert int -> ISO at the oauth_tools boundary (mirroring the existing refresh_token branch), keeping the model schema and the int-asserting contract pact/unit tests intact. Add a regression test that drives the full _get_provisioning_status round-trip with an integer timestamp. Also surface dropped provider-state params in the verifier's _dispatch_state no-op branch (round-4 nit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
5.0 KiB
Python
128 lines
5.0 KiB
Python
"""Unit tests for app-password-store awareness in the provisioning tools.
|
|
|
|
Login Flow v2 (nc_auth_provision_access) and the management app-password API
|
|
write the credential to this server's ``app_passwords`` store — the same store
|
|
``require_provisioning``/``get_client`` use to grant tool access. The OAuth
|
|
provisioning tools (check_provisioning_status / revoke_nextcloud_access) must
|
|
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
|
|
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.server import oauth_tools
|
|
from nextcloud_mcp_server.server.oauth_tools import (
|
|
_get_provisioning_status,
|
|
_revoke_nextcloud_access,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
@pytest.fixture
|
|
def _no_astrolabe_settings(mocker):
|
|
"""Disable the astrolabe-status branch so the app_passwords store is hit."""
|
|
mocker.patch.object(
|
|
oauth_tools,
|
|
"get_settings",
|
|
return_value=SimpleNamespace(oidc_client_id=None, oidc_client_secret=None),
|
|
)
|
|
|
|
|
|
async def test_status_reports_provisioned_for_app_password_store(
|
|
mocker, _no_astrolabe_settings
|
|
):
|
|
"""A Login Flow v2 app password in storage => is_provisioned with the
|
|
app_password credential type (was previously reported as not provisioned)."""
|
|
storage = MagicMock()
|
|
# Only truthiness + "scopes" are read by _get_provisioning_status; omit the
|
|
# app_password value entirely (avoids a false-positive hard-coded-credential
|
|
# finding and keeps the mock to what the code under test actually uses).
|
|
storage.get_app_password_with_scopes = AsyncMock(
|
|
return_value={"scopes": ["notes.read"]}
|
|
)
|
|
storage.get_refresh_token = AsyncMock(return_value=None)
|
|
mocker.patch.object(
|
|
oauth_tools, "get_shared_storage", AsyncMock(return_value=storage)
|
|
)
|
|
|
|
status = await _get_provisioning_status(MagicMock(), "tester")
|
|
|
|
assert status.is_provisioned is True
|
|
assert status.credential_type == "app_password"
|
|
assert status.flow_type == "login_flow_v2"
|
|
assert status.scopes == ["notes.read"]
|
|
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()
|
|
storage.get_app_password_with_scopes = AsyncMock(return_value={"scopes": None})
|
|
storage.get_refresh_token = AsyncMock(return_value=None)
|
|
storage.delete_app_password = AsyncMock(return_value=True)
|
|
mocker.patch.object(
|
|
oauth_tools, "get_shared_storage", AsyncMock(return_value=storage)
|
|
)
|
|
mocker.patch.object(oauth_tools, "invalidate_scope_cache")
|
|
|
|
result = await _revoke_nextcloud_access(MagicMock(), "tester")
|
|
|
|
assert result.success is True
|
|
storage.delete_app_password.assert_awaited_once_with("tester")
|
|
oauth_tools.invalidate_scope_cache.assert_called_once_with("tester")
|
|
|
|
|
|
async def test_revoke_noop_when_nothing_provisioned(mocker, _no_astrolabe_settings):
|
|
"""No credential of any kind => graceful no-op, no deletion attempted."""
|
|
storage = MagicMock()
|
|
storage.get_app_password_with_scopes = AsyncMock(return_value=None)
|
|
storage.get_refresh_token = AsyncMock(return_value=None)
|
|
storage.delete_app_password = AsyncMock()
|
|
mocker.patch.object(
|
|
oauth_tools, "get_shared_storage", AsyncMock(return_value=storage)
|
|
)
|
|
|
|
result = await _revoke_nextcloud_access(MagicMock(), "tester")
|
|
|
|
assert result.success is True
|
|
assert "No Nextcloud access to revoke" in result.message
|
|
storage.delete_app_password.assert_not_awaited()
|