refactor(config): consolidate NEXTCLOUD_PUBLIC_ISSUER_URL through Settings

Lift NEXTCLOUD_PUBLIC_ISSUER_URL out of raw os.getenv reads into
Settings.nextcloud_public_issuer_url across all 8 production call sites
(app.py x2, oauth_routes.py x2, browser_oauth_routes.py,
provision_routes.py, userinfo_routes.py, elicitation.py). cli.py
remains the env-write source so the existing config-by-flag pipeline
still works.

Also addresses remaining PR #757 review nits:
- elicitation.py: align URL-present/absent wording on "open in your
  browser" so users don't try clicking in the terminal
- test_scope_authorization_stored.py: lock in the deliberately-shared
  fall-through branch with explicit declined/cancelled decorator tests
- test_elicitation.py: switch from monkeypatch.setenv to
  patch(get_settings) since Settings is now the canonical surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 16:16:19 +02:00
co-authored by Claude Opus 4.7
parent 822a8fe2ed
commit f3256e515e
9 changed files with 174 additions and 61 deletions
+59 -39
View File
@@ -1,7 +1,7 @@
"""Unit tests for the MCP elicitation helpers."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -14,55 +14,63 @@ from nextcloud_mcp_server.auth.elicitation import (
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def clear_nc_env(monkeypatch):
"""Strip the NC URL env vars by default; tests opt back in."""
monkeypatch.delenv("NEXTCLOUD_PUBLIC_ISSUER_URL", raising=False)
monkeypatch.delenv("NEXTCLOUD_HOST", raising=False)
def _fake_settings(
public_issuer_url: str | None = None, host: str | None = None
) -> SimpleNamespace:
"""Build a Settings-shaped object exposing only the fields elicitation reads."""
return SimpleNamespace(
nextcloud_public_issuer_url=public_issuer_url,
nextcloud_host=host,
)
def test_astrolabe_settings_url_prefers_public_issuer(monkeypatch):
def test_astrolabe_settings_url_prefers_public_issuer():
"""Public issuer wins over host so the link is browser-reachable in Docker."""
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://nc.example.com")
monkeypatch.setenv("NEXTCLOUD_HOST", "http://internal:8080")
assert (
_astrolabe_settings_url() == f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
fake = _fake_settings(
public_issuer_url="https://nc.example.com", host="http://internal:8080"
)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
assert (
_astrolabe_settings_url()
== f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
)
def test_astrolabe_settings_url_strips_trailing_slash_from_public_issuer(monkeypatch):
"""Trailing slash on NEXTCLOUD_PUBLIC_ISSUER_URL is normalized."""
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://nc.example.com/")
assert (
_astrolabe_settings_url() == f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
)
def test_astrolabe_settings_url_strips_trailing_slash_from_public_issuer():
"""Trailing slash on nextcloud_public_issuer_url is normalized."""
fake = _fake_settings(public_issuer_url="https://nc.example.com/")
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
assert (
_astrolabe_settings_url()
== f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
)
def test_astrolabe_settings_url_falls_back_to_host(monkeypatch):
"""When only NEXTCLOUD_HOST is set, use it (and strip a trailing slash)."""
monkeypatch.setenv("NEXTCLOUD_HOST", "https://only-host.example.com/")
assert (
_astrolabe_settings_url()
== f"https://only-host.example.com{ASTROLABE_SETTINGS_PATH}"
)
def test_astrolabe_settings_url_falls_back_to_host():
"""When only nextcloud_host is set, use it (and strip a trailing slash)."""
fake = _fake_settings(host="https://only-host.example.com/")
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
assert (
_astrolabe_settings_url()
== f"https://only-host.example.com{ASTROLABE_SETTINGS_PATH}"
)
def test_astrolabe_settings_url_returns_none_when_unset():
"""No NC URL configured → None (caller renders the tool-only message)."""
assert _astrolabe_settings_url() is None
fake = _fake_settings()
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
assert _astrolabe_settings_url() is None
async def test_present_provisioning_required_elicits_with_url(monkeypatch):
async def test_present_provisioning_required_elicits_with_url():
"""When NC URL is set and the client supports elicitation, send the URL."""
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://nc.example.com")
fake = _fake_settings(public_issuer_url="https://nc.example.com")
ctx = MagicMock()
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="accept", data=None))
result = await present_provisioning_required(ctx)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx)
assert result == "accepted"
ctx.elicit.assert_awaited_once()
@@ -71,12 +79,14 @@ async def test_present_provisioning_required_elicits_with_url(monkeypatch):
assert "nc_auth_provision_access" in sent_message
async def test_present_provisioning_required_without_url(monkeypatch):
async def test_present_provisioning_required_without_url():
"""When neither NC URL is set, fall back to the tool-only message."""
fake = _fake_settings()
ctx = MagicMock()
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="accept", data=None))
result = await present_provisioning_required(ctx)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx)
assert result == "accepted"
sent_message = ctx.elicit.await_args.kwargs["message"]
@@ -92,46 +102,56 @@ async def test_present_provisioning_required_no_elicit_method():
ctx = _NoElicit()
result = await present_provisioning_required(ctx) # type: ignore[arg-type]
fake = _fake_settings()
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx) # type: ignore[arg-type]
assert result == "message_only"
async def test_present_provisioning_required_handles_not_implemented():
"""SDK clients that don't support elicitation raise NotImplementedError."""
fake = _fake_settings()
ctx = MagicMock()
ctx.elicit = AsyncMock(side_effect=NotImplementedError("client lacks elicit"))
result = await present_provisioning_required(ctx)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx)
assert result == "message_only"
async def test_present_provisioning_required_handles_unexpected_error():
"""Any other elicit failure (e.g. transport) is fail-open to message_only."""
fake = _fake_settings()
ctx = MagicMock()
ctx.elicit = AsyncMock(side_effect=RuntimeError("transport boom"))
result = await present_provisioning_required(ctx)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx)
assert result == "message_only"
async def test_present_provisioning_required_decline_returns_declined():
"""User chose 'decline' on the prompt → propagate that to the caller."""
fake = _fake_settings()
ctx = MagicMock()
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="decline", data=None))
result = await present_provisioning_required(ctx)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx)
assert result == "declined"
async def test_present_provisioning_required_cancel_returns_cancelled():
"""User chose 'cancel' on the prompt → propagate that to the caller."""
fake = _fake_settings()
ctx = MagicMock()
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="cancel", data=None))
result = await present_provisioning_required(ctx)
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
result = await present_provisioning_required(ctx)
assert result == "cancelled"
@@ -196,6 +196,89 @@ async def test_decorator_uses_legacy_message_when_elicitation_unsupported():
assert "retry the request" not in msg
async def test_decorator_uses_legacy_message_when_user_declines():
"""When the elicit returns "declined" the user has explicitly declined the
provisioning prompt. They still need to provision before the tool can run,
so the raised error keeps the "call nc_auth_provision_access" instruction
(same fall-through branch as message_only). Lock in this behaviour so a
future refactor that splits the else-branch can't silently change it."""
ctx = _make_login_flow_ctx()
@require_scopes("notes.read")
async def fake_tool_user_declined(ctx: Context): # noqa: ARG001
return "ok"
fake_settings = SimpleNamespace(enable_login_flow=True)
elicit_mock = AsyncMock(return_value="declined")
with (
patch(
"nextcloud_mcp_server.auth.scope_authorization.get_settings",
return_value=fake_settings,
),
patch(
"nextcloud_mcp_server.auth.scope_authorization._get_stored_scopes",
return_value=None,
),
patch(
"nextcloud_mcp_server.auth.token_utils.extract_user_id_from_token",
return_value="alice",
),
patch(
"nextcloud_mcp_server.auth.elicitation.present_provisioning_required",
elicit_mock,
),
pytest.raises(ProvisioningRequiredError) as exc_info,
):
await fake_tool_user_declined(ctx=ctx)
elicit_mock.assert_awaited_once_with(ctx)
msg = str(exc_info.value)
assert "nc_auth_provision_access" in msg
assert "retry the request" not in msg
async def test_decorator_uses_legacy_message_when_user_cancels():
"""When the elicit returns "cancelled" (user dismissed the prompt without
answering), the user is still unprovisioned and needs to call the auth
tool. Same fall-through as declined and message_only — locked in by an
explicit test so the three callers don't drift apart in a future refactor."""
ctx = _make_login_flow_ctx()
@require_scopes("notes.read")
async def fake_tool_user_cancelled(ctx: Context): # noqa: ARG001
return "ok"
fake_settings = SimpleNamespace(enable_login_flow=True)
elicit_mock = AsyncMock(return_value="cancelled")
with (
patch(
"nextcloud_mcp_server.auth.scope_authorization.get_settings",
return_value=fake_settings,
),
patch(
"nextcloud_mcp_server.auth.scope_authorization._get_stored_scopes",
return_value=None,
),
patch(
"nextcloud_mcp_server.auth.token_utils.extract_user_id_from_token",
return_value="alice",
),
patch(
"nextcloud_mcp_server.auth.elicitation.present_provisioning_required",
elicit_mock,
),
pytest.raises(ProvisioningRequiredError) as exc_info,
):
await fake_tool_user_cancelled(ctx=ctx)
elicit_mock.assert_awaited_once_with(ctx)
msg = str(exc_info.value)
assert "nc_auth_provision_access" in msg
assert "retry the request" not in msg
async def test_decorator_does_not_elicit_when_scopes_only_partially_missing():
"""When the user *has* an app password but is missing some requested
scopes, the decorator raises InsufficientScopeError (step-up auth),