Files
mcp-nextcloud/tests/unit/vector/test_credential_self_heal.py
T
Chris CoutinhoandClaude Opus 4.8 7a9e4a8681 fix(vector): propagate cancel in cleanup task; cover 403 + sweep-failure
Address round-1 review on #913 and the SonarCloud new_reliability_rating gate:

- credential_cleanup_task no longer catches the cancellation exception
  (Sonar python:S7497). A task-group cancel must propagate for structured-
  concurrency teardown; graceful shutdown still flows through shutdown_event,
  so the sleep no longer needs a cancel/break.
- Parametrize the scanner self-heal tests over 401 AND 403 (handled
  identically at both call sites) and add a test that a failing periodic
  sweep is logged non-fatally and does not crash the task.
- Log the stored-user count before the startup sweep (operability signal),
  add a debug line when the credential row was already gone, and document
  the at-most-one extra-401 convergence in _remove_stale_credential.

Refs Deck #198.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:23:46 +02:00

169 lines
5.9 KiB
Python

"""Unit tests: self-healing removal of stale app passwords (Deck #198).
When a Nextcloud user is deleted/disabled, their stored app password keeps
returning 401/403. Previously the scanner just stopped, leaving the credential in
storage so ``user_manager_task`` re-spawned the scanner every poll interval — an
endless re-spawn/401 loop. The scanner now deletes the credential on a hard auth
failure, and a periodic ``credential_cleanup_task`` sweeps any that slip through.
"""
import logging
import anyio
import httpx
import pytest
from nextcloud_mcp_server.vector import oauth_sync
pytestmark = pytest.mark.unit
def _http_error(status: int) -> httpx.HTTPStatusError:
req = httpx.Request("GET", "https://cloud.example.org/ocs")
return httpx.HTTPStatusError(
"auth failure", request=req, response=httpx.Response(status, request=req)
)
async def test_remove_stale_credential_deletes(mocker):
"""The helper deletes the user's app password via storage."""
storage = mocker.MagicMock()
storage.delete_app_password = mocker.AsyncMock(return_value=True)
mocker.patch.object(
oauth_sync,
"_get_initialized_basic_auth_storage",
mocker.AsyncMock(return_value=storage),
)
await oauth_sync._remove_stale_credential("ghost-user", 401)
storage.delete_app_password.assert_awaited_once_with("ghost-user")
async def test_remove_stale_credential_swallows_storage_error(mocker):
"""Best-effort: a storage failure is logged, never raised (backstops cover it)."""
storage = mocker.MagicMock()
storage.delete_app_password = mocker.AsyncMock(side_effect=RuntimeError("db down"))
mocker.patch.object(
oauth_sync,
"_get_initialized_basic_auth_storage",
mocker.AsyncMock(return_value=storage),
)
# Must not raise.
await oauth_sync._remove_stale_credential("ghost-user", 401)
@pytest.mark.parametrize("status", [401, 403])
async def test_scanner_removes_credential_on_prevalidation_auth_failure(mocker, status):
"""A 401/403 validating creds deletes the credential and never enters the scan
loop — so ``user_manager_task`` won't see the user as provisioned again."""
fake_client = mocker.AsyncMock()
fake_client.capabilities = mocker.AsyncMock(side_effect=_http_error(status))
fake_client.close = mocker.AsyncMock()
mocker.patch.object(
oauth_sync,
"get_user_client_basic_auth",
mocker.AsyncMock(return_value=fake_client),
)
storage = mocker.MagicMock()
storage.delete_app_password = mocker.AsyncMock(return_value=True)
mocker.patch.object(
oauth_sync,
"_get_initialized_basic_auth_storage",
mocker.AsyncMock(return_value=storage),
)
await oauth_sync.user_scanner_task(
"ghost-user",
mocker.MagicMock(), # send_stream — unused on the pre-validation path
anyio.Event(), # shutdown_event
anyio.Event(), # wake_event
"https://cloud.example.org",
)
storage.delete_app_password.assert_awaited_once_with("ghost-user")
@pytest.mark.parametrize("status", [401, 403])
async def test_scanner_removes_credential_on_scan_loop_auth_failure(mocker, status):
"""A 401/403 raised while scanning (not pre-validation) also deletes the
credential before the scanner stops."""
fake_client = mocker.AsyncMock()
fake_client.capabilities = mocker.AsyncMock(return_value={}) # pre-validation ok
fake_client.close = mocker.AsyncMock()
mocker.patch.object(
oauth_sync,
"get_user_client_basic_auth",
mocker.AsyncMock(return_value=fake_client),
)
mocker.patch.object(
oauth_sync,
"scan_user_documents",
mocker.AsyncMock(side_effect=_http_error(status)),
)
storage = mocker.MagicMock()
storage.delete_app_password = mocker.AsyncMock(return_value=True)
mocker.patch.object(
oauth_sync,
"_get_initialized_basic_auth_storage",
mocker.AsyncMock(return_value=storage),
)
await oauth_sync.user_scanner_task(
"ghost-user",
mocker.MagicMock(),
anyio.Event(),
anyio.Event(),
"https://cloud.example.org",
)
storage.delete_app_password.assert_awaited_once_with("ghost-user")
async def test_credential_cleanup_task_sweeps_then_stops(mocker):
"""The periodic backstop validates stored passwords via
``cleanup_invalid_app_passwords`` and exits on shutdown."""
mocker.patch.object(oauth_sync, "CREDENTIAL_CLEANUP_INTERVAL", 0)
shutdown = anyio.Event()
storage = mocker.MagicMock()
async def _cleanup(host):
shutdown.set() # stop the loop after the first sweep
return ["ghost-user"]
storage.cleanup_invalid_app_passwords = mocker.AsyncMock(side_effect=_cleanup)
await oauth_sync.credential_cleanup_task(
storage, shutdown, "https://cloud.example.org"
)
storage.cleanup_invalid_app_passwords.assert_awaited_once_with(
"https://cloud.example.org"
)
async def test_credential_cleanup_task_swallows_sweep_exception(mocker, caplog):
"""A failing sweep is logged non-fatally and does not crash the task — the
loop survives to retry on the next cadence."""
mocker.patch.object(oauth_sync, "CREDENTIAL_CLEANUP_INTERVAL", 0)
shutdown = anyio.Event()
storage = mocker.MagicMock()
async def _boom(host):
shutdown.set() # exit after this (failed) iteration
raise RuntimeError("sweep failed")
storage.cleanup_invalid_app_passwords = mocker.AsyncMock(side_effect=_boom)
with caplog.at_level(
logging.WARNING, logger="nextcloud_mcp_server.vector.oauth_sync"
):
# Must return normally — the exception is swallowed, not propagated.
await oauth_sync.credential_cleanup_task(
storage, shutdown, "https://cloud.example.org"
)
storage.cleanup_invalid_app_passwords.assert_awaited_once()
assert "non-fatal" in caplog.text