fix(api): use stored app password for chunk-context and pdf-preview

The /api/v1/chunk-context and /api/v1/pdf-preview handlers in
api/visualization.py forwarded the incoming OAuth bearer directly to
Nextcloud via NextcloudClient.from_token. In multi-user BasicAuth mode
Nextcloud has no validator for those bearers on Notes/WebDAV, so it
treats the request as anonymous and returns 401 — surfaced to the user
as a 500 from /apps/astrolabe/api/chunk-context. Search worked because
it only hits Qdrant.

Architecturally, OAuth is only for Astrolabe→MCP server; MCP server→
Nextcloud always uses the per-user app password stored during provision
(background sync already does this via vector.oauth_sync).

- Resolve the Nextcloud client through get_user_client_basic_auth in
  both get_chunk_context and get_pdf_preview, surfacing
  NotProvisionedError as a clean 401 instead of opaque 500.
- Apply the same fix to the session-cookie variant in
  auth/viz_routes.chunk_context_endpoint for the internal viz UI.

Tests:
- New unit file test_management_chunk_context_endpoint.py, including a
  regression guard that asserts get_user_client_basic_auth is awaited
  (so reverting to from_token fails without needing a live Nextcloud).
- Updated test_management_pdf_preview_endpoint.py to mock the new auth
  path (drops extract_bearer_token / NextcloudClient.from_token patches).
- New integration test test_astrolabe_chunk_context.py drives the full
  chain (browser → Astrolabe → MCP → Nextcloud) in multi-user BasicAuth
  mode, plus bare-bones 401 checks on the MCP endpoint.

Full unit suite: 546 passed.

Companion PR on astrolabe (cbcoutinho/astrolabe#66) sends the Nextcloud
UID as loginName in the app-password POST body so the stored record is
complete. Submodule bump to that branch will follow once CI reproduces
the failure on the old submodule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-22 19:50:13 +02:00
co-authored by Claude Opus 4.7
parent d766f3c014
commit 8a0d06107e
5 changed files with 597 additions and 98 deletions
@@ -72,10 +72,6 @@ class TestPdfPreviewParameterValidation:
new_callable=AsyncMock,
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
):
app = create_test_app()
client = TestClient(app)
@@ -97,10 +93,6 @@ class TestPdfPreviewParameterValidation:
new_callable=AsyncMock,
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
):
app = create_test_app()
client = TestClient(app)
@@ -130,10 +122,6 @@ class TestPdfPreviewParameterValidation:
new_callable=AsyncMock,
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
):
app = create_test_app()
client = TestClient(app)
@@ -163,10 +151,6 @@ class TestPdfPreviewParameterValidation:
new_callable=AsyncMock,
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
):
app = create_test_app()
client = TestClient(app)
@@ -240,11 +224,8 @@ class TestPdfPreviewRendering:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -289,11 +270,8 @@ class TestPdfPreviewRendering:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -329,11 +307,8 @@ class TestPdfPreviewRendering:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -368,11 +343,8 @@ class TestPdfPreviewRendering:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -412,11 +384,8 @@ class TestPdfPreviewEdgeCases:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -442,10 +411,6 @@ class TestPdfPreviewEdgeCases:
new_callable=AsyncMock,
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
):
app = create_test_app()
# Override with empty config
@@ -481,11 +446,8 @@ class TestPdfPreviewEdgeCases:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -523,11 +485,8 @@ class TestPdfPreviewEdgeCases:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -560,10 +519,6 @@ class TestPdfPreviewSecurityValidation:
new_callable=AsyncMock,
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
):
app = create_test_app()
client = TestClient(app)
@@ -610,11 +565,8 @@ class TestPdfPreviewSecurityValidation:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -652,11 +604,8 @@ class TestPdfPreviewSecurityValidation:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):
@@ -696,11 +645,8 @@ class TestPdfPreviewSecurityValidation:
return_value=("testuser", True),
),
patch(
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
return_value="test-token",
),
patch(
"nextcloud_mcp_server.client.NextcloudClient.from_token",
"nextcloud_mcp_server.api.visualization.get_user_client_basic_auth",
new_callable=AsyncMock,
return_value=mock_nc_client,
),
):