Files
mcp-nextcloud/tests/integration/test_login_flow_provision_wake.py
T
Chris CoutinhoandClaude Opus 4.8 d8e3e9bc33 feat(vector-sync): scan provisioned users immediately
Background vector sync discovered newly provisioned users only on the
periodic user-manager poll (VECTOR_SYNC_USER_POLL_INTERVAL, default 60s),
delaying first indexing by up to a minute. Add a ProvisionSignal doorbell
that provisioning paths ring after storing a user's app password, waking
user_manager_task to re-poll and spawn the user's scanner at once. The
periodic poll remains the backstop (covers cross-replica provisioning).

- ProvisionSignal (stable reference, wait-and-re-arm) held on
  VectorSyncState; closes the lost-wakeup window (no await between observing
  the ring and re-arming; anyio.Event stickiness covers a mid-poll ring)
- user_manager_task races its poll timeout against the doorbell + shutdown
- notify_user_provisioned() rung from the three app-password provisioning
  sites: Login Flow v2 web, MCP provisioning tool, management/BasicAuth API

Note: the pre-existing scanner_wake_event was never .set() and only wakes
existing scanners; a brand-new user has none, so the manager is what must
be nudged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:48:56 +02:00

180 lines
6.3 KiB
Python

"""Integration test: Login Flow v2 provisioning wakes the background sync
user manager immediately.
Wires the real login-flow web-provision path end to end at the component level
(no browser / container):
_poll_and_store (provision_routes)
-> RefreshTokenStorage.store_app_password_with_scopes (real, temp DB)
-> notify_user_provisioned -> ProvisionSignal.ring
-> user_manager_task wakes, re-polls the same storage, spawns the scanner
Only the Nextcloud-facing Login Flow v2 poll is mocked (returning "completed");
everything in between is the real code. With a deliberately long poll interval,
the new user's scanner must still be spawned promptly — proving the wake came
from the provisioning signal, not the periodic poll.
This is the Login Flow v2 deployment-mode counterpart to the multi-user
BasicAuth coverage in ``test_app_password_provisioning.py``.
"""
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.login_flow import LoginFlowPollResult
from nextcloud_mcp_server.auth.provision_routes import (
_poll_and_store,
_provision_sessions,
)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.vector.oauth_sync import ProvisionSignal, user_manager_task
pytestmark = pytest.mark.integration
@pytest.fixture
def encryption_key():
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Real RefreshTokenStorage backed by a temporary SQLite DB."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_login_flow_wake.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
async def test_login_flow_provision_wakes_user_manager(temp_storage, mocker):
"""A completed Login Flow v2 web provision spawns the user's scanner at
once via the provision signal, well inside a long poll interval."""
# ── user_manager: long poll interval + stubbed per-user scanner ──────────
manager_settings = MagicMock()
manager_settings.vector_sync_user_poll_interval = 1000 # never fires here
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
return_value=manager_settings,
)
spawned: set[str] = set()
alice_spawned = anyio.Event()
async def fake_scanner(
user_id,
cancel_scope,
send_stream,
shutdown_event,
wake_event,
nextcloud_host,
user_states,
):
spawned.add(user_id)
if user_id == "alice":
alice_spawned.set()
with cancel_scope:
await shutdown_event.wait()
user_states.pop(user_id, None)
mocker.patch(
"nextcloud_mcp_server.vector.oauth_sync._run_user_scanner_with_scope",
fake_scanner,
)
# ── wire the doorbell exactly as the lifespan does ───────────────────────
import nextcloud_mcp_server.app as app_module
provision_signal = ProvisionSignal()
mocker.patch.object(
app_module._vector_sync_state, "provision_signal", provision_signal
)
# ── mock only the Nextcloud Login Flow v2 poll ───────────────────────────
completed = LoginFlowPollResult(
status="completed",
server="https://cloud.example.com",
login_name="alice",
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
)
flow_client = AsyncMock()
flow_client.poll.return_value = completed
provision_settings = MagicMock()
provision_settings.nextcloud_host = "https://cloud.example.com"
provision_settings.nextcloud_public_issuer_url = None
provision_id = "login-flow-wake"
_provision_sessions[provision_id] = {
"status": "pending",
"poll_endpoint": "https://cloud.example.com/login/v2/poll",
"poll_token": "secret-token",
"user_id": "alice",
"created_at": time.time(),
"expires_at": time.time() + 1200,
}
try:
shutdown_event = anyio.Event()
user_states: dict = {}
async with anyio.create_task_group() as tg:
await tg.start(
user_manager_task,
None, # send_stream — unused by the stubbed scanner
shutdown_event,
anyio.Event(), # scanner wake_event
temp_storage,
"https://cloud.example.com",
user_states,
tg,
provision_signal,
)
# First poll: no users provisioned yet → no scanner.
await anyio.sleep(0.1)
assert not spawned
# Run the real login-flow web-provision background task. It stores
# the app password into temp_storage and rings the doorbell.
with (
patch(
"nextcloud_mcp_server.auth.provision_routes.get_settings",
return_value=provision_settings,
),
patch(
"nextcloud_mcp_server.auth.provision_routes.get_nextcloud_ssl_verify",
return_value=False,
),
patch(
"nextcloud_mcp_server.auth.provision_routes.LoginFlowV2Client",
return_value=flow_client,
),
patch(
"nextcloud_mcp_server.auth.provision_routes.get_shared_storage",
new_callable=AsyncMock,
return_value=temp_storage,
),
):
await _poll_and_store(provision_id)
# The app password was really stored …
assert "alice" in await temp_storage.get_all_app_password_user_ids()
# … and the manager woke and spawned alice's scanner far inside the
# 1000s poll interval (i.e. because of the signal, not the poll).
with anyio.fail_after(3):
await alice_spawned.wait()
assert spawned == {"alice"}
shutdown_event.set()
finally:
_provision_sessions.pop(provision_id, None)