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:
co-authored by
Claude Opus 4.7
parent
822a8fe2ed
commit
f3256e515e
@@ -743,7 +743,7 @@ async def setup_oauth_config():
|
|||||||
# ADR-005: Unified Token Verifier with proper audience validation
|
# ADR-005: Unified Token Verifier with proper audience validation
|
||||||
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
|
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
|
||||||
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
|
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
|
||||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer_url = settings.nextcloud_public_issuer_url
|
||||||
client_issuer = public_issuer_url if public_issuer_url else issuer
|
client_issuer = public_issuer_url if public_issuer_url else issuer
|
||||||
# Get MCP server URL for audience validation
|
# Get MCP server URL for audience validation
|
||||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
@@ -933,7 +933,7 @@ async def setup_oauth_config_for_multi_user_basic(
|
|||||||
|
|
||||||
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
|
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
|
||||||
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
|
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
|
||||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer_url = settings.nextcloud_public_issuer_url
|
||||||
client_issuer = public_issuer_url if public_issuer_url else issuer
|
client_issuer = public_issuer_url if public_issuer_url else issuer
|
||||||
|
|
||||||
# Update settings with discovered values for UnifiedTokenVerifier
|
# Update settings with discovered values for UnifiedTokenVerifier
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
|||||||
_get_userinfo_endpoint,
|
_get_userinfo_endpoint,
|
||||||
_query_idp_userinfo,
|
_query_idp_userinfo,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
from ..http import nextcloud_httpx_client
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
@@ -167,7 +168,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
scopes += " offline_access"
|
scopes += " offline_access"
|
||||||
|
|
||||||
# Replace internal Docker hostname with public URL
|
# Replace internal Docker hostname with public URL
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = get_settings().nextcloud_public_issuer_url
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ when the client supports it, or falling back to returning the URL in a message.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Path of the Astrolabe Nextcloud app's settings UI. The full URL is
|
# Path of the Astrolabe Nextcloud app's settings UI. The full URL is
|
||||||
# reconstructed at elicitation time from NEXTCLOUD_PUBLIC_ISSUER_URL /
|
# reconstructed at elicitation time from settings.nextcloud_public_issuer_url
|
||||||
# NEXTCLOUD_HOST so the user gets a browser-reachable link without needing a
|
# / settings.nextcloud_host so the user gets a browser-reachable link without
|
||||||
# separate config knob. If the Astrolabe app is not installed this path will
|
# needing a separate config knob. If the Astrolabe app is not installed this
|
||||||
# 404, and the user falls back to the nc_auth_provision_access tool path
|
# path will 404, and the user falls back to the nc_auth_provision_access tool
|
||||||
# mentioned in the same message.
|
# path mentioned in the same message.
|
||||||
ASTROLABE_SETTINGS_PATH = "/index.php/apps/astrolabe/settings"
|
ASTROLABE_SETTINGS_PATH = "/index.php/apps/astrolabe/settings"
|
||||||
|
|
||||||
|
|
||||||
@@ -40,14 +41,15 @@ class ProvisioningRequiredConfirmation(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def _astrolabe_settings_url() -> str | None:
|
def _astrolabe_settings_url() -> str | None:
|
||||||
"""Construct the Astrolabe settings page URL from environment.
|
"""Construct the Astrolabe settings page URL from settings.
|
||||||
|
|
||||||
Prefers ``NEXTCLOUD_PUBLIC_ISSUER_URL`` (the browser-reachable public URL)
|
Prefers ``nextcloud_public_issuer_url`` (the browser-reachable public URL)
|
||||||
over ``NEXTCLOUD_HOST`` (which may be an internal hostname in Docker
|
over ``nextcloud_host`` (which may be an internal hostname in Docker
|
||||||
deployments). Returns None if neither is set.
|
deployments). Returns None if neither is set.
|
||||||
"""
|
"""
|
||||||
|
settings = get_settings()
|
||||||
base = (
|
base = (
|
||||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or os.getenv("NEXTCLOUD_HOST") or ""
|
settings.nextcloud_public_issuer_url or settings.nextcloud_host or ""
|
||||||
).strip()
|
).strip()
|
||||||
if not base:
|
if not base:
|
||||||
return None
|
return None
|
||||||
@@ -132,9 +134,10 @@ async def present_provisioning_required(ctx: Context) -> str:
|
|||||||
has to translate.
|
has to translate.
|
||||||
|
|
||||||
The Astrolabe settings URL is reconstructed from
|
The Astrolabe settings URL is reconstructed from
|
||||||
``NEXTCLOUD_PUBLIC_ISSUER_URL`` / ``NEXTCLOUD_HOST``; if Astrolabe is not
|
``settings.nextcloud_public_issuer_url`` /
|
||||||
installed the link 404s and the user falls back to the tool path
|
``settings.nextcloud_host``; if Astrolabe is not installed the link
|
||||||
suggested in the same message.
|
404s and the user falls back to the tool path suggested in the same
|
||||||
|
message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Same string contract as :func:`present_login_url`:
|
Same string contract as :func:`present_login_url`:
|
||||||
@@ -148,7 +151,7 @@ async def present_provisioning_required(ctx: Context) -> str:
|
|||||||
f"Open this URL to enable it via the Astrolabe app:\n\n{settings_url}\n\n"
|
f"Open this URL to enable it via the Astrolabe app:\n\n{settings_url}\n\n"
|
||||||
"If the Astrolabe app is not installed, ask your MCP client to call "
|
"If the Astrolabe app is not installed, ask your MCP client to call "
|
||||||
"the `nc_auth_provision_access` tool instead — it will return a "
|
"the `nc_auth_provision_access` tool instead — it will return a "
|
||||||
"Login Flow v2 URL you can open directly.\n\n"
|
"Login Flow v2 URL you can open in your browser.\n\n"
|
||||||
"Then check the box below and retry the original request."
|
"Then check the box below and retry the original request."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
authorization_endpoint = discovery["authorization_endpoint"]
|
authorization_endpoint = discovery["authorization_endpoint"]
|
||||||
|
|
||||||
# Replace internal Docker hostname with public URL for browser access
|
# Replace internal Docker hostname with public URL for browser access
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = get_settings().nextcloud_public_issuer_url
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
@@ -507,7 +507,7 @@ async def oauth_authorize_nextcloud(
|
|||||||
authorization_endpoint = discovery["authorization_endpoint"]
|
authorization_endpoint = discovery["authorization_endpoint"]
|
||||||
|
|
||||||
# Fix internal hostname for browser access
|
# Fix internal hostname for browser access
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = get_settings().nextcloud_public_issuer_url
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ Flow:
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -251,7 +250,7 @@ async def provision_page(
|
|||||||
# LoginFlowV2Client) while login_url is rewritten to the public issuer
|
# LoginFlowV2Client) while login_url is rewritten to the public issuer
|
||||||
# URL here because the browser needs a publicly-reachable address.
|
# URL here because the browser needs a publicly-reachable address.
|
||||||
login_url = init_response.login_url
|
login_url = init_response.login_url
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "")
|
public_issuer = settings.nextcloud_public_issuer_url or ""
|
||||||
if public_issuer and nextcloud_host:
|
if public_issuer and nextcloud_host:
|
||||||
login_url = rewrite_url_origin(login_url, public_issuer.rstrip("/"))
|
login_url = rewrite_url_origin(login_url, public_issuer.rstrip("/"))
|
||||||
|
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
nextcloud_host_for_links = (
|
nextcloud_host_for_links = (
|
||||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
settings.nextcloud_public_issuer_url or settings.nextcloud_host
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build host info HTML (BasicAuth only)
|
# Build host info HTML (BasicAuth only)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ _DEFAULTS: dict[str, Any] = {
|
|||||||
"nextcloud_ca_bundle": None,
|
"nextcloud_ca_bundle": None,
|
||||||
"nextcloud_mcp_server_url": None,
|
"nextcloud_mcp_server_url": None,
|
||||||
"nextcloud_resource_uri": None,
|
"nextcloud_resource_uri": None,
|
||||||
|
"nextcloud_public_issuer_url": None,
|
||||||
# OAuth/OIDC
|
# OAuth/OIDC
|
||||||
"oidc_discovery_url": None,
|
"oidc_discovery_url": None,
|
||||||
"nextcloud_oidc_client_id": None,
|
"nextcloud_oidc_client_id": None,
|
||||||
@@ -406,6 +407,11 @@ class Settings:
|
|||||||
nextcloud_password: str | None = None
|
nextcloud_password: str | None = None
|
||||||
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
||||||
|
|
||||||
|
# Browser-reachable public URL for OAuth/Login-Flow-v2 redirects when
|
||||||
|
# NEXTCLOUD_HOST is an internal Docker hostname. Falls back to
|
||||||
|
# nextcloud_host when unset.
|
||||||
|
nextcloud_public_issuer_url: str | None = None
|
||||||
|
|
||||||
# Nextcloud SSL/TLS settings
|
# Nextcloud SSL/TLS settings
|
||||||
nextcloud_verify_ssl: bool = True
|
nextcloud_verify_ssl: bool = True
|
||||||
nextcloud_ca_bundle: str | None = None
|
nextcloud_ca_bundle: str | None = None
|
||||||
@@ -777,6 +783,7 @@ def get_settings() -> Settings:
|
|||||||
"nextcloud_username": "NEXTCLOUD_USERNAME",
|
"nextcloud_username": "NEXTCLOUD_USERNAME",
|
||||||
"nextcloud_password": "NEXTCLOUD_PASSWORD",
|
"nextcloud_password": "NEXTCLOUD_PASSWORD",
|
||||||
"nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD",
|
"nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD",
|
||||||
|
"nextcloud_public_issuer_url": "NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||||
# Nextcloud SSL/TLS settings
|
# Nextcloud SSL/TLS settings
|
||||||
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
|
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
|
||||||
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",
|
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for the MCP elicitation helpers."""
|
"""Unit tests for the MCP elicitation helpers."""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -14,55 +14,63 @@ from nextcloud_mcp_server.auth.elicitation import (
|
|||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def _fake_settings(
|
||||||
def clear_nc_env(monkeypatch):
|
public_issuer_url: str | None = None, host: str | None = None
|
||||||
"""Strip the NC URL env vars by default; tests opt back in."""
|
) -> SimpleNamespace:
|
||||||
monkeypatch.delenv("NEXTCLOUD_PUBLIC_ISSUER_URL", raising=False)
|
"""Build a Settings-shaped object exposing only the fields elicitation reads."""
|
||||||
monkeypatch.delenv("NEXTCLOUD_HOST", raising=False)
|
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."""
|
"""Public issuer wins over host so the link is browser-reachable in Docker."""
|
||||||
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://nc.example.com")
|
fake = _fake_settings(
|
||||||
monkeypatch.setenv("NEXTCLOUD_HOST", "http://internal:8080")
|
public_issuer_url="https://nc.example.com", host="http://internal:8080"
|
||||||
|
|
||||||
assert (
|
|
||||||
_astrolabe_settings_url() == f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
|
|
||||||
)
|
)
|
||||||
|
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):
|
def test_astrolabe_settings_url_strips_trailing_slash_from_public_issuer():
|
||||||
"""Trailing slash on NEXTCLOUD_PUBLIC_ISSUER_URL is normalized."""
|
"""Trailing slash on nextcloud_public_issuer_url is normalized."""
|
||||||
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://nc.example.com/")
|
fake = _fake_settings(public_issuer_url="https://nc.example.com/")
|
||||||
|
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
|
||||||
assert (
|
assert (
|
||||||
_astrolabe_settings_url() == f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
|
_astrolabe_settings_url()
|
||||||
)
|
== f"https://nc.example.com{ASTROLABE_SETTINGS_PATH}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_astrolabe_settings_url_falls_back_to_host(monkeypatch):
|
def test_astrolabe_settings_url_falls_back_to_host():
|
||||||
"""When only NEXTCLOUD_HOST is set, use it (and strip a trailing slash)."""
|
"""When only nextcloud_host is set, use it (and strip a trailing slash)."""
|
||||||
monkeypatch.setenv("NEXTCLOUD_HOST", "https://only-host.example.com/")
|
fake = _fake_settings(host="https://only-host.example.com/")
|
||||||
|
with patch("nextcloud_mcp_server.auth.elicitation.get_settings", return_value=fake):
|
||||||
assert (
|
assert (
|
||||||
_astrolabe_settings_url()
|
_astrolabe_settings_url()
|
||||||
== f"https://only-host.example.com{ASTROLABE_SETTINGS_PATH}"
|
== f"https://only-host.example.com{ASTROLABE_SETTINGS_PATH}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_astrolabe_settings_url_returns_none_when_unset():
|
def test_astrolabe_settings_url_returns_none_when_unset():
|
||||||
"""No NC URL configured → None (caller renders the tool-only message)."""
|
"""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."""
|
"""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 = MagicMock()
|
||||||
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="accept", data=None))
|
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"
|
assert result == "accepted"
|
||||||
ctx.elicit.assert_awaited_once()
|
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
|
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."""
|
"""When neither NC URL is set, fall back to the tool-only message."""
|
||||||
|
fake = _fake_settings()
|
||||||
ctx = MagicMock()
|
ctx = MagicMock()
|
||||||
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="accept", data=None))
|
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"
|
assert result == "accepted"
|
||||||
sent_message = ctx.elicit.await_args.kwargs["message"]
|
sent_message = ctx.elicit.await_args.kwargs["message"]
|
||||||
@@ -92,46 +102,56 @@ async def test_present_provisioning_required_no_elicit_method():
|
|||||||
|
|
||||||
ctx = _NoElicit()
|
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"
|
assert result == "message_only"
|
||||||
|
|
||||||
|
|
||||||
async def test_present_provisioning_required_handles_not_implemented():
|
async def test_present_provisioning_required_handles_not_implemented():
|
||||||
"""SDK clients that don't support elicitation raise NotImplementedError."""
|
"""SDK clients that don't support elicitation raise NotImplementedError."""
|
||||||
|
fake = _fake_settings()
|
||||||
ctx = MagicMock()
|
ctx = MagicMock()
|
||||||
ctx.elicit = AsyncMock(side_effect=NotImplementedError("client lacks elicit"))
|
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"
|
assert result == "message_only"
|
||||||
|
|
||||||
|
|
||||||
async def test_present_provisioning_required_handles_unexpected_error():
|
async def test_present_provisioning_required_handles_unexpected_error():
|
||||||
"""Any other elicit failure (e.g. transport) is fail-open to message_only."""
|
"""Any other elicit failure (e.g. transport) is fail-open to message_only."""
|
||||||
|
fake = _fake_settings()
|
||||||
ctx = MagicMock()
|
ctx = MagicMock()
|
||||||
ctx.elicit = AsyncMock(side_effect=RuntimeError("transport boom"))
|
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"
|
assert result == "message_only"
|
||||||
|
|
||||||
|
|
||||||
async def test_present_provisioning_required_decline_returns_declined():
|
async def test_present_provisioning_required_decline_returns_declined():
|
||||||
"""User chose 'decline' on the prompt → propagate that to the caller."""
|
"""User chose 'decline' on the prompt → propagate that to the caller."""
|
||||||
|
fake = _fake_settings()
|
||||||
ctx = MagicMock()
|
ctx = MagicMock()
|
||||||
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="decline", data=None))
|
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"
|
assert result == "declined"
|
||||||
|
|
||||||
|
|
||||||
async def test_present_provisioning_required_cancel_returns_cancelled():
|
async def test_present_provisioning_required_cancel_returns_cancelled():
|
||||||
"""User chose 'cancel' on the prompt → propagate that to the caller."""
|
"""User chose 'cancel' on the prompt → propagate that to the caller."""
|
||||||
|
fake = _fake_settings()
|
||||||
ctx = MagicMock()
|
ctx = MagicMock()
|
||||||
ctx.elicit = AsyncMock(return_value=SimpleNamespace(action="cancel", data=None))
|
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"
|
assert result == "cancelled"
|
||||||
|
|||||||
@@ -196,6 +196,89 @@ async def test_decorator_uses_legacy_message_when_elicitation_unsupported():
|
|||||||
assert "retry the request" not in msg
|
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():
|
async def test_decorator_does_not_elicit_when_scopes_only_partially_missing():
|
||||||
"""When the user *has* an app password but is missing some requested
|
"""When the user *has* an app password but is missing some requested
|
||||||
scopes, the decorator raises InsufficientScopeError (step-up auth),
|
scopes, the decorator raises InsufficientScopeError (step-up auth),
|
||||||
|
|||||||
Reference in New Issue
Block a user