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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4688f2f95a
commit
d8e3e9bc33
@@ -0,0 +1,179 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Unit tests for immediate-on-provision scanner spawning.
|
||||
|
||||
Covers the doorbell that lets a provisioning request wake ``user_manager_task``
|
||||
at once instead of waiting out ``VECTOR_SYNC_USER_POLL_INTERVAL``:
|
||||
|
||||
- ``ProvisionSignal`` semantics (sticky ring, wake-a-parked-wait, re-arm).
|
||||
- ``user_manager_task`` re-polls early when the signal is rung, spawning a
|
||||
newly provisioned user's scanner well before the next poll tick.
|
||||
- ``notify_user_provisioned`` is a no-op when no manager is running.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.vector.oauth_sync import ProvisionSignal, user_manager_task
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ── ProvisionSignal primitive ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_provision_signal_ring_before_wait_is_observed():
|
||||
"""A ring that lands before wait() is sticky and returns immediately."""
|
||||
signal = ProvisionSignal()
|
||||
signal.ring()
|
||||
with anyio.fail_after(1):
|
||||
await signal.wait()
|
||||
|
||||
|
||||
async def test_provision_signal_wakes_parked_waiter():
|
||||
"""ring() releases a wait() that is already parked."""
|
||||
signal = ProvisionSignal()
|
||||
woke = anyio.Event()
|
||||
|
||||
async def waiter():
|
||||
await signal.wait()
|
||||
woke.set()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(waiter)
|
||||
await anyio.sleep(0.05) # let waiter park
|
||||
assert not woke.is_set()
|
||||
signal.ring()
|
||||
with anyio.fail_after(1):
|
||||
await woke.wait()
|
||||
|
||||
|
||||
async def test_provision_signal_rearms_for_next_cycle():
|
||||
"""After a ring is consumed, the next wait() blocks until the next ring."""
|
||||
signal = ProvisionSignal()
|
||||
signal.ring()
|
||||
await signal.wait() # consumes first ring, re-arms
|
||||
|
||||
# Second wait must block (no pending ring) then release on the next ring.
|
||||
second = anyio.Event()
|
||||
|
||||
async def waiter():
|
||||
await signal.wait()
|
||||
second.set()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(waiter)
|
||||
await anyio.sleep(0.05)
|
||||
assert not second.is_set() # proves the first ring did not carry over
|
||||
signal.ring()
|
||||
with anyio.fail_after(1):
|
||||
await second.wait()
|
||||
|
||||
|
||||
# ── user_manager_task wake-on-provision ──────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeStorage:
|
||||
"""Storage stub whose provisioned-user set the test mutates between polls."""
|
||||
|
||||
def __init__(self, users: set[str]):
|
||||
self.users = users
|
||||
|
||||
async def get_all_app_password_user_ids(self) -> list[str]:
|
||||
return list(self.users)
|
||||
|
||||
|
||||
async def test_user_manager_wakes_on_provision_signal(mocker):
|
||||
"""Ringing the signal makes the manager re-poll and spawn the new user's
|
||||
scanner well before the (long) poll interval elapses."""
|
||||
# Long poll interval so any prompt spawn proves it was the signal, not poll.
|
||||
settings = MagicMock()
|
||||
settings.vector_sync_user_poll_interval = 1000
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings", return_value=settings
|
||||
)
|
||||
|
||||
spawned: set[str] = set()
|
||||
spawn_events: dict[str, anyio.Event] = {
|
||||
"alice": anyio.Event(),
|
||||
"bob": 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 in spawn_events:
|
||||
spawn_events[user_id].set()
|
||||
# Stay alive (keeps user_states populated) until shutdown.
|
||||
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,
|
||||
)
|
||||
|
||||
storage = _FakeStorage({"alice"})
|
||||
provision_signal = ProvisionSignal()
|
||||
shutdown_event = anyio.Event()
|
||||
scanner_wake_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,
|
||||
scanner_wake_event,
|
||||
storage,
|
||||
"http://nextcloud",
|
||||
user_states,
|
||||
tg,
|
||||
provision_signal,
|
||||
)
|
||||
|
||||
# First poll discovers the already-provisioned user.
|
||||
with anyio.fail_after(2):
|
||||
await spawn_events["alice"].wait()
|
||||
assert "bob" not in spawned
|
||||
|
||||
# Provision a new user, then ring — the manager must pick bob up fast.
|
||||
storage.users.add("bob")
|
||||
provision_signal.ring()
|
||||
with anyio.fail_after(2): # << 1000s poll interval
|
||||
await spawn_events["bob"].wait()
|
||||
assert spawned == {"alice", "bob"}
|
||||
|
||||
shutdown_event.set()
|
||||
|
||||
|
||||
async def test_user_manager_shutdown_still_breaks_sleep(mocker):
|
||||
"""Setting shutdown wakes the manager out of its sleep promptly even with a
|
||||
long poll interval (the doorbell race must not regress shutdown latency)."""
|
||||
settings = MagicMock()
|
||||
settings.vector_sync_user_poll_interval = 1000
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings", return_value=settings
|
||||
)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync._run_user_scanner_with_scope",
|
||||
# No users provisioned, so this is never called; keep a harmless stub.
|
||||
lambda *a, **k: anyio.sleep(0),
|
||||
)
|
||||
|
||||
storage = _FakeStorage(set())
|
||||
shutdown_event = anyio.Event()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(
|
||||
user_manager_task,
|
||||
None,
|
||||
shutdown_event,
|
||||
anyio.Event(),
|
||||
storage,
|
||||
"http://nextcloud",
|
||||
{},
|
||||
tg,
|
||||
ProvisionSignal(),
|
||||
)
|
||||
await anyio.sleep(0.05) # let it enter the sleep
|
||||
shutdown_event.set()
|
||||
# If shutdown didn't break the 1000s sleep, fail_after would trip.
|
||||
with anyio.fail_after(2):
|
||||
await anyio.sleep(0) # task group exit below is the real assertion
|
||||
|
||||
|
||||
# ── notify_user_provisioned no-op guard ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_notify_user_provisioned_noop_without_manager(mocker):
|
||||
"""When no manager is running, the helper must not raise."""
|
||||
import nextcloud_mcp_server.app as app_module
|
||||
|
||||
mocker.patch.object(app_module._vector_sync_state, "provision_signal", None)
|
||||
# Should be a silent no-op.
|
||||
app_module.notify_user_provisioned()
|
||||
|
||||
|
||||
def test_notify_user_provisioned_rings_when_present(mocker):
|
||||
"""When a manager is running, the helper rings its signal."""
|
||||
import nextcloud_mcp_server.app as app_module
|
||||
|
||||
signal = ProvisionSignal()
|
||||
mocker.patch.object(app_module._vector_sync_state, "provision_signal", signal)
|
||||
app_module.notify_user_provisioned()
|
||||
assert signal._event.is_set()
|
||||
Reference in New Issue
Block a user