fix: address PR #589 review feedback (round 2)

Consolidate three independent RefreshTokenStorage lazy singletons into a
single lock-protected get_shared_storage() function, eliminating race
conditions on concurrent first-access. Remove blanket try/except in
_get_stored_scopes so storage errors propagate as proper MCP errors
instead of silently triggering "please provision" messages. Handle
declined/cancelled elicitation results in Login Flow tools by cleaning up
sessions and returning clear status. Add update_app_password_scopes() to
avoid unnecessary decrypt/re-encrypt when only scopes change. Add
unprovisioned-user early exit and no-op detection to nc_auth_update_scopes.
Remove four dead config fields and misleading NEXTCLOUD_PASSWORD deprecation
warning. Add periodic login flow session cleanup task. Generate separate
Fernet keys per service. Add board cleanup in deck integration test. Gate
CI unit tests on linting and skip Astrolabe build for single-user profile.
Fix test markers from oauth to multi_user_basic for astrolabe integration
tests. Update login_flow.py docstrings to document outbound HTTP calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-01 16:35:31 +01:00
parent 33cf0fee9b
commit db1e0606ad
15 changed files with 248 additions and 131 deletions
@@ -27,7 +27,7 @@ from playwright.async_api import Page
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
async def login_to_nextcloud(page: Page, username: str, password: str):
@@ -899,7 +899,7 @@ def clear_stale_test_state(clear_preferences: bool = False) -> None:
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.multi_user_basic
async def test_multi_user_astrolabe_background_sync_enablement(
browser,
nc_client,
@@ -1246,7 +1246,7 @@ async def verify_app_password_deleted(username: str) -> bool:
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.multi_user_basic
async def test_revoke_background_sync_access(
browser,
nc_client,
@@ -35,7 +35,7 @@ from tests.integration.test_astrolabe_multi_user_background_sync import (
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
async def wait_for_vector_sync(
@@ -101,7 +101,7 @@ async def navigate_to_astrolabe_main(page: Page):
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.multi_user_basic
@pytest.mark.timeout(
300
) # 5 minutes - this test involves OAuth, app password, and vector sync
@@ -30,7 +30,7 @@ import anyio
import pytest
from playwright.async_api import Page
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
pytestmark = [pytest.mark.integration, pytest.mark.multi_user_basic]
logger = logging.getLogger(__name__)
@@ -334,7 +334,7 @@ def delete_user_credentials(username: str) -> bool:
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.multi_user_basic
async def test_app_password_storage_and_cleanup(
browser,
nc_client,
@@ -440,7 +440,7 @@ async def test_app_password_storage_and_cleanup(
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.multi_user_basic
async def test_credential_isolation_between_users(
browser,
nc_client,
@@ -549,7 +549,7 @@ async def test_credential_isolation_between_users(
@pytest.mark.integration
@pytest.mark.oauth
@pytest.mark.multi_user_basic
async def test_credential_revoke_and_reprovision(
browser,
nc_client,
@@ -443,35 +443,59 @@ class TestLoginFlowDeck:
async def test_deck_board_workflow(self, nc_mcp_login_flow_client: ClientSession):
"""Create board → list boards → get board details."""
import os
import httpx
suffix = uuid.uuid4().hex[:8]
board_title = f"LoginFlow Board {suffix}"
board_id = None
# Create board (requires title and color)
create_result = await nc_mcp_login_flow_client.call_tool(
"deck_create_board", {"title": board_title, "color": "0076D1"}
)
assert create_result.isError is False, (
f"Create board failed: {create_result.content[0].text}"
)
board_data = json.loads(create_result.content[0].text)
board_id = board_data.get("id") or board_data.get("board_id")
logger.info(f"Created board: {board_id}")
try:
# Create board (requires title and color)
create_result = await nc_mcp_login_flow_client.call_tool(
"deck_create_board", {"title": board_title, "color": "0076D1"}
)
assert create_result.isError is False, (
f"Create board failed: {create_result.content[0].text}"
)
board_data = json.loads(create_result.content[0].text)
board_id = board_data.get("id") or board_data.get("board_id")
logger.info(f"Created board: {board_id}")
# List boards (tool name is deck_get_boards)
list_result = await nc_mcp_login_flow_client.call_tool("deck_get_boards", {})
assert list_result.isError is False
boards_data = json.loads(list_result.content[0].text)
boards = boards_data.get("boards", [])
board_ids = [b.get("id") for b in boards]
assert board_id in board_ids
# List boards (tool name is deck_get_boards)
list_result = await nc_mcp_login_flow_client.call_tool(
"deck_get_boards", {}
)
assert list_result.isError is False
boards_data = json.loads(list_result.content[0].text)
boards = boards_data.get("boards", [])
board_ids = [b.get("id") for b in boards]
assert board_id in board_ids
# Get board details
detail_result = await nc_mcp_login_flow_client.call_tool(
"deck_get_board", {"board_id": board_id}
)
assert detail_result.isError is False
# Note: no deck_delete_board tool exists, board cleanup is manual
# Get board details
detail_result = await nc_mcp_login_flow_client.call_tool(
"deck_get_board", {"board_id": board_id}
)
assert detail_result.isError is False
finally:
# Clean up board via Deck REST API (no MCP delete_board tool exists)
if board_id is not None:
nc_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
nc_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
nc_pass = os.getenv("NEXTCLOUD_PASSWORD", "admin")
try:
async with httpx.AsyncClient(
base_url=nc_host,
auth=httpx.BasicAuth(nc_user, nc_pass),
headers={"OCS-APIREQUEST": "true"},
) as client:
resp = await client.delete(
f"/apps/deck/api/v1.0/boards/{board_id}"
)
logger.info(f"Board cleanup: {board_id}{resp.status_code}")
except Exception as e:
logger.warning(f"Board cleanup failed: {e}")
# ---------------------------------------------------------------------------
+11 -10
View File
@@ -27,7 +27,7 @@ async def test_get_stored_scopes_with_scopes():
}
with patch(
"nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
return_value=mock_storage,
):
result = await _get_stored_scopes("alice")
@@ -47,7 +47,7 @@ async def test_get_stored_scopes_null_scopes():
}
with patch(
"nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
return_value=mock_storage,
):
result = await _get_stored_scopes("bob")
@@ -61,7 +61,7 @@ async def test_get_stored_scopes_no_password():
mock_storage.get_app_password_with_scopes.return_value = None
with patch(
"nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
return_value=mock_storage,
):
result = await _get_stored_scopes("nobody")
@@ -70,14 +70,15 @@ async def test_get_stored_scopes_no_password():
async def test_get_stored_scopes_storage_error():
"""Test that storage errors return None (fail-closed)."""
"""Test that storage errors propagate to the caller."""
mock_storage = AsyncMock()
mock_storage.get_app_password_with_scopes.side_effect = RuntimeError("DB error")
with patch(
"nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
return_value=mock_storage,
with (
patch(
"nextcloud_mcp_server.auth.scope_authorization.get_shared_storage",
return_value=mock_storage,
),
pytest.raises(RuntimeError, match="DB error"),
):
result = await _get_stored_scopes("alice")
assert result is None
await _get_stored_scopes("alice")