Deleted/disabled Nextcloud users left their app_passwords row in storage, so user_manager_task re-spawned their scanner every poll interval only to 401 again — an endless re-spawn/auth-failure loop (observed on tenant-blackbox-demo: ~534 respawns/3h, matching the 60s poll interval). - Delete the stored app password on a hard 401/403 in user_scanner_task (both the pre-validation and in-scan-loop paths), breaking the re-spawn loop at the source so the user-manager stops recreating the scanner. - Add a periodic credential_cleanup_task backstop (hourly) that sweeps cleanup_invalid_app_passwords for anything the per-scanner path misses. - Run the startup cleanup for all deployment modes: drop the stale `not oauth_enabled` guard so login_flow tenants (the cloud default) are covered. NOTE: login_flow startup now makes one concurrent OCS validation call per stored user before readiness. Refs Deck #198. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.7 KiB
Python
140 lines
4.7 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. 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 anyio
|
|
import httpx
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.vector import oauth_sync
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _http_401() -> httpx.HTTPStatusError:
|
|
req = httpx.Request("GET", "https://cloud.example.org/ocs")
|
|
return httpx.HTTPStatusError(
|
|
"unauth", request=req, response=httpx.Response(401, 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)
|
|
|
|
|
|
async def test_scanner_removes_credential_on_prevalidation_401(mocker):
|
|
"""A 401 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_401())
|
|
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")
|
|
|
|
|
|
async def test_scanner_removes_credential_on_scan_loop_401(mocker):
|
|
"""A 401 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_401()),
|
|
)
|
|
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"
|
|
)
|