fix(api): return 401 not 500 on failed app-password OCS validation (#824)

provision_app_password validated credentials against OCS v1
(/ocs/v1.php/cloud/user), which always returns HTTP 200 — even on auth
failure, where the real status lives in ocs.meta.statuscode (997) and
ocs.data comes back as an empty list []. The status_code != 200 guard
therefore never fired, execution fell through to [].get("id"), and the
resulting AttributeError escaped as an unhandled 500. This blocked
background vector indexing for any user whose supplied loginName didn't
resolve (e.g. display name "Admin" vs loginName "admin").

Extract a shared _validate_nextcloud_credentials helper that:
- queries OCS v2 (/ocs/v2.php), which maps the OCS status onto the HTTP
  status, so a failed credential is a real 401;
- parses the payload defensively (isinstance guards) so a non-dict
  ocs.data can never raise;
- returns a clean 502 for an unreachable Nextcloud or a non-JSON body.

delete_app_password shared the same v1.php dead-guard bug, which made its
credential check a no-op (any valid-format password passed) — an auth
bypass on deletion. Route it through the same helper and accept the
loginName from the request body (mirroring provisioning) so OIDC users
whose UID differs from their loginName are not regressed.

Adds unit regression tests for the OCS failure payload, non-dict data,
and non-JSON response, plus a login-flow integration test that provisions
with capitalized ("Admin") and spaced ("Test User") loginNames and asserts
a 401 rather than a 500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-01 12:56:44 +02:00
co-authored by Claude Opus 4.8
parent c662d57c4f
commit a11a4e709c
3 changed files with 412 additions and 62 deletions
@@ -0,0 +1,87 @@
"""Integration test for issue #824 against the live login-flow MCP server.
`POST /api/v1/users/{user_id}/app-password` validates the supplied BasicAuth
credential against Nextcloud's OCS `/cloud/user` endpoint. Nextcloud keys
app-password auth on the *loginName*, which differs from the display name —
e.g. the admin account's display name is `Admin` (capital A) while its
loginName is `admin`, and a user "Test User" (with a space) has a distinct
loginName/UID.
When the supplied loginName does not authenticate, OCS v1 (`/ocs/v1.php`)
returns **HTTP 200** with `ocs.meta.statuscode: 997` and `ocs.data: []`. The
old handler gated auth failure on the HTTP status (`!= 200`), so it never
fired, fell through to `[].get("id")`, and raised
`AttributeError: 'list' object has no attribute 'get'` — escaping as an
unhandled **500**. The fix queries OCS v2 and parses defensively, so a failed
validation is a clean **401**.
These tests exercise the live `mcp-login-flow` container (port 8004), which
performs the OCS round-trip against the real Nextcloud. The credentials are
deliberately wrong, so validation fails for every UID shape under test —
capitals (`Admin`) and spaces (`Test User`) — which is exactly the path that
used to 500.
"""
import httpx
import pytest
LOGIN_FLOW_API_BASE_URL = "http://localhost:8004"
pytestmark = [pytest.mark.integration, pytest.mark.login_flow]
# A syntactically valid app password (matches APP_PASSWORD_PATTERN) that is not
# a real credential for any account — so the OCS validation always fails.
_WRONG_APP_PASSWORD = "aaaaa-bbbbb-ccccc-ddddd-eeeee"
def _basic_auth_header(username: str, password: str) -> str:
return httpx.BasicAuth(username, password)._auth_header
@pytest.mark.parametrize(
"user_id",
[
pytest.param("Admin", id="capitalized-display-name"),
pytest.param("Test User", id="display-name-with-space"),
],
)
async def test_provision_with_unresolvable_loginname_returns_401_not_500(user_id):
"""A loginName that fails OCS validation yields 401, never 500 (#824).
Pre-fix, the capitalized/spaced loginName produced an OCS v1 ``200 +
data: []`` payload that crashed parsing with an unhandled 500.
"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{LOGIN_FLOW_API_BASE_URL}/api/v1/users/{user_id}/app-password",
headers={"Authorization": _basic_auth_header(user_id, _WRONG_APP_PASSWORD)},
)
assert response.status_code == 401, (
f"provisioning for {user_id!r} returned {response.status_code} "
f"(expected 401, not 500): {response.text}"
)
assert "Invalid app password" in response.json().get("error", "")
async def test_provision_with_mismatched_loginname_body_returns_401_not_500():
"""The body-supplied loginName is what's validated; a non-resolving
loginName (display name with a space) still yields 401, not 500 (#824).
Mirrors the production call shape where the UID in the path differs from the
Nextcloud loginName sent in the JSON body.
"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{LOGIN_FLOW_API_BASE_URL}/api/v1/users/testuser/app-password",
headers={
"Authorization": _basic_auth_header("testuser", _WRONG_APP_PASSWORD)
},
json={"username": "Test User"},
)
assert response.status_code == 401, (
f"provisioning returned {response.status_code} "
f"(expected 401, not 500): {response.text}"
)
assert "Invalid app password" in response.json().get("error", "")