Files
mcp-nextcloud/tests/unit/client/test_calendar.py
T
Chris CoutinhoandClaude Opus 4.8 52da297ded fix(auth): authenticate stored app passwords with loginName, not UID
Nextcloud authenticates app passwords against the *loginName*, which differs
from the UID for OIDC-provisioned users (e.g. user_oidc makes the UID the
display name: UID "Ada Lovelace", loginName "ada@example.com"). The runtime
consumers of stored app passwords bound the UID as the BasicAuth username, so
every Notes/Files/Shares/CalDAV call returned HTTP 401.

PR #818 fixed only the provisioning endpoint; the consuming paths were missed.
Observed on a login_flow tenant (NC's own OIDC app as IdP): the background-sync
scan loop never started ("Credential validation failed ... HTTP 401") and
semantic search returned 0 results because the ACL shared_with_me lookup 401'd
and degraded to a self-only owner filter.

Root cause: NextcloudClient / CalendarClient conflated two identities — the
DAV/URL path identity (the user_id the whole system keys on = NC UID) and the
auth-credential username (the loginName). Decouple them:

- Thread a keyword-only auth_username through NextcloudClient -> CalendarClient
  (defaults to username, so single-user / OAuth where UID == loginName is
  unchanged).
- get_user_client_basic_auth (background sync + the /api/v1/vector-viz/search
  endpoint) authenticates as the stored loginName, UID for paths.
- _get_client_from_login_flow (the get_client(ctx) MCP-tool path) does the same.
- cleanup_invalid_app_passwords validates with the loginName, so it no longer
  401s and wrongly deletes a valid OIDC user's password.

The loginName is already persisted in app_passwords.username and returned by
get_app_password_with_scopes. Adds unit tests covering the UID != loginName
split for both client builders, the calendar credential/path split, and the
cleanup validation. Also genericises the example user in the #818 comment/test
(real name/email -> Ada Lovelace / ada@example.com).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:34:05 +02:00

145 lines
5.0 KiB
Python

"""Unit tests for the CalendarClient construction path.
These pin the wiring into ``caldav.aio.AsyncDAVClient``. caldav v3.x prefers
``niquests`` over ``httpx`` and rejects ``httpx.Auth`` objects when ``niquests``
is the active backend (issue #731), so we no longer build an httpx auth object
ourselves — we pass the raw credential plus an explicit ``auth_type`` and let
caldav build whichever auth its backend needs.
"""
import pytest
pytestmark = pytest.mark.unit
def test_basic_auth_passes_password_and_auth_type_basic(mocker):
"""Password path: pass ``password=`` + ``auth_type='basic'``, no ``auth=`` arg.
The previous wiring passed ``auth=httpx.BasicAuth(...)`` which caldav-on-niquests
rejects with "Unexpected non-callable authentication" — the regression #731 came
in via caldav 3.x's mandatory niquests dependency.
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
CalendarClient("https://cloud.example.org", "alice", password="app-pw-1234")
mock_dav_client.assert_called_once()
call_kwargs = mock_dav_client.call_args.kwargs
assert call_kwargs["url"] == "https://cloud.example.org/remote.php/dav/"
assert call_kwargs["username"] == "alice"
assert call_kwargs["password"] == "app-pw-1234"
assert call_kwargs["auth_type"] == "basic"
# Critical: no httpx.Auth object — that's what broke under niquests.
assert "auth" not in call_kwargs
def test_token_passes_token_and_auth_type_bearer(mocker):
"""Token path: pass ``password=<token>`` + ``auth_type='bearer'``.
caldav v3 reuses the ``password`` slot for bearer tokens — see
``async_davclient.build_auth_object``.
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
CalendarClient("https://cloud.example.org", "alice", token="oauth-bearer-xyz")
call_kwargs = mock_dav_client.call_args.kwargs
assert call_kwargs["password"] == "oauth-bearer-xyz"
assert call_kwargs["auth_type"] == "bearer"
assert "auth" not in call_kwargs
def test_no_credentials_leaves_dav_client_unauthenticated(mocker):
"""Defensive: if neither credential is provided, don't pass any auth kwargs.
AsyncDAVClient handles its own discovery when no auth is configured; we
don't want to silently inject an empty password.
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
CalendarClient("https://cloud.example.org", "alice")
call_kwargs = mock_dav_client.call_args.kwargs
assert "password" not in call_kwargs
assert "auth_type" not in call_kwargs
assert "auth" not in call_kwargs
def test_password_takes_precedence_over_token(mocker):
"""If a caller supplies both, password wins. Documents the precedence so a
future caller passing both isn't surprised by which one selects auth_type.
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
CalendarClient(
"https://cloud.example.org",
"alice",
password="app-pw",
token="bearer-tok",
)
call_kwargs = mock_dav_client.call_args.kwargs
assert call_kwargs["password"] == "app-pw"
assert call_kwargs["auth_type"] == "basic"
def test_auth_username_used_for_credential_uid_for_path(mocker):
"""OIDC users: the loginName authenticates, the UID builds the DAV path.
Nextcloud keys app-password auth on the loginName (which can differ from
the UID), but ``/remote.php/dav/calendars/<uid>/`` must use the UID. The
two must not be conflated.
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
client = CalendarClient(
"https://cloud.example.org",
"Ada Lovelace", # UID
auth_username="ada@example.com", # loginName
password="app-pw-1234",
)
# Credential identity → loginName
assert mock_dav_client.call_args.kwargs["username"] == "ada@example.com"
# Path identity → UID
assert client.username == "Ada Lovelace"
assert (
client._calendar_home_url
== "https://cloud.example.org/remote.php/dav/calendars/Ada Lovelace/"
)
def test_auth_username_defaults_to_username(mocker):
"""Backwards compat: without ``auth_username`` the UID is used for both,
so single-user / OAuth callers (UID == loginName) are unchanged.
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
CalendarClient("https://cloud.example.org", "alice", password="app-pw")
assert mock_dav_client.call_args.kwargs["username"] == "alice"