Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a474996df4 | |||
| 5d6dd5ad38 | |||
| 21e4d3effd | |||
| 817df43af1 | |||
| 906b9d892c | |||
| 534723c9f6 | |||
| 1d5832ed3a | |||
| 844bd589e0 | |||
| 127af15623 | |||
| ff5fc5d5b2 | |||
| 158865d99f | |||
| 94674eca27 | |||
| a8b5d6e701 | |||
| e0675b2127 | |||
| 86582bdb8f | |||
| dc8009a785 | |||
| b5e658e1ff | |||
| 6a19c2d136 | |||
| 99e359ffbf | |||
| f16f4e8cb5 | |||
| 8597f2a272 | |||
| 11f67e2bc4 | |||
| 2e49a16e49 | |||
| 713fddeaa5 | |||
| 0dfefb0516 | |||
| 63d2aeaa43 | |||
| 07f0a7c0dc | |||
| 84bde6d5ed | |||
| 9695f8a6d7 | |||
| a2c410e8d2 | |||
| 271b5f6155 | |||
| ba4f7c1429 | |||
| c763e96596 | |||
| 23e9cbaec5 | |||
| ddd5defa40 | |||
| 723dcc524d | |||
| 46eba0a693 | |||
| b61980a623 | |||
| 65cc894e21 | |||
| 700996e100 | |||
| 546f0c0674 | |||
| e625eab689 | |||
| a26a470af6 | |||
| 71ace47197 | |||
| 30d3d9f0cf | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| 48a4182ef9 | |||
| 13dd709fc2 | |||
| a987643f8e |
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -66,14 +66,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,20 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.60.4 (2026-01-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
|
||||
## v0.60.3 (2025-12-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
## v0.60.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:a75662dfec8d90bd7161c91050be2e0a9b21d284f3b7a7253d5db25f7d583fb3
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.24@sha256:816fdce3387ed2142e37d2e56e1b1b97ccc1ea87731ba199dc8a25c04e4997c5 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:a75662dfec8d90bd7161c91050be2e0a9b21d284f3b7a7253d5db25f7d583fb3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.24@sha256:816fdce3387ed2142e37d2e56e1b1b97ccc1ea87731ba199dc8a25c04e4997c5 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.56.1"
|
||||
version = "0.56.2"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||
|
||||
## nextcloud-mcp-server-0.56.1 (2025-12-26)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.36.0
|
||||
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
||||
generated: "2025-12-22T11:09:39.166328543Z"
|
||||
version: 1.37.0
|
||||
digest: sha256:0ce3bb4b5e95a3b8fde3f5f374d7b62aeafcb0dcf8a60b9d95978530b6c05b68
|
||||
generated: "2026-01-08T11:11:12.857375888Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.56.1
|
||||
appVersion: "0.60.2"
|
||||
version: 0.56.2
|
||||
appVersion: "0.60.4"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -31,6 +31,6 @@ dependencies:
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.36.0"
|
||||
version: "1.37.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
+8
-6
@@ -8,6 +8,8 @@ services:
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
- db:/var/lib/mysql
|
||||
ports:
|
||||
- 127.0.0.1:3306:3306
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=password
|
||||
- MYSQL_PASSWORD=password
|
||||
@@ -21,10 +23,10 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:271a9c9fe81f38e92df5bdfbb518977322de8cbf3d749ff41de213f26bd7cba5
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
- 127.0.0.1:8080:80
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
@@ -52,14 +54,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||
image: docker.io/library/nginx:alpine@sha256:c083c3799197cfff91fe5c3c558db3d2eea65ccbbfd419fa42a64d2c39a24027
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -209,7 +211,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
image: quay.io/keycloak/keycloak:26.5.0@sha256:5fdd7cda82e58775ed124294c7e16fabc33166d38dfc4aabebda7d64e7a964bf
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -297,7 +299,7 @@ services:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient):
|
||||
archived: Optional[bool] = None,
|
||||
done: Optional[str] = None,
|
||||
) -> None:
|
||||
# First, get the current card to use existing values for required fields
|
||||
# Deck PUT API is a full replacement - all required fields must be sent.
|
||||
# Fetch current card to preserve values for fields not being updated.
|
||||
current_card = await self.get_card(board_id, stack_id, card_id)
|
||||
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if description is not None:
|
||||
json_data["description"] = description
|
||||
# Type is required by the API, use provided or keep current
|
||||
json_data["type"] = type if type is not None else current_card.type
|
||||
# Owner is required by the API, use provided or keep current
|
||||
json_data["owner"] = (
|
||||
owner
|
||||
if owner is not None
|
||||
else (
|
||||
current_card.owner
|
||||
if isinstance(current_card.owner, str)
|
||||
else current_card.owner.uid
|
||||
if hasattr(current_card.owner, "uid")
|
||||
else current_card.owner.primaryKey
|
||||
)
|
||||
)
|
||||
# Build payload with required fields always included
|
||||
json_data = {
|
||||
# Title is required by the API
|
||||
"title": title if title is not None else current_card.title,
|
||||
# Type is required by the API
|
||||
"type": type if type is not None else current_card.type,
|
||||
# Owner is required by the API (model validator ensures it's a string)
|
||||
"owner": owner if owner is not None else current_card.owner,
|
||||
# Description must be sent to preserve it (PUT clears omitted fields)
|
||||
"description": description
|
||||
if description is not None
|
||||
else (current_card.description or ""),
|
||||
}
|
||||
if order is not None:
|
||||
json_data["order"] = order
|
||||
if duedate is not None:
|
||||
@@ -391,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
# Use the non-API route /cards/{cardId}/reorder which correctly reads
|
||||
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
|
||||
# has a parameter conflict where URL stackId overrides body stackId.
|
||||
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
f"/apps/deck/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Labels
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.60.2"
|
||||
version = "0.60.4"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Integration tests for DeckClient.update_card API behavior.
|
||||
|
||||
These tests define the EXPECTED behavior for partial card updates:
|
||||
- Only fields explicitly passed should be modified
|
||||
- All other fields should be preserved unchanged
|
||||
|
||||
Related issues:
|
||||
- nextcloud-mcp-server #452: DeckClient.update_card partial update bugs
|
||||
- deck #3127: REST API Docs: missing parameter in "update cards"
|
||||
- deck #4106: Provide a working example of API usage to update a cards details
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def deck_test_card(nc_client):
|
||||
"""Create a board, stack, and card for testing, cleanup after."""
|
||||
board = await nc_client.deck.create_board("Test Update Card API", "FF0000")
|
||||
stack = await nc_client.deck.create_stack(board.id, "Test Stack", 1)
|
||||
card = await nc_client.deck.create_card(
|
||||
board.id,
|
||||
stack.id,
|
||||
"Original Title",
|
||||
type="plain",
|
||||
description="Original description",
|
||||
)
|
||||
|
||||
yield {
|
||||
"board_id": board.id,
|
||||
"stack_id": stack.id,
|
||||
"card_id": card.id,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
|
||||
|
||||
class TestDeckClientUpdateCard:
|
||||
"""
|
||||
Test DeckClient.update_card() partial update behavior.
|
||||
|
||||
Expected: Only explicitly provided fields are updated, all others preserved.
|
||||
"""
|
||||
|
||||
async def test_update_title_only_preserves_description(
|
||||
self, nc_client, deck_test_card
|
||||
):
|
||||
"""Updating only the title should preserve the description."""
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
title="New Title",
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.title == "New Title"
|
||||
assert updated.description == "Original description"
|
||||
|
||||
async def test_update_description_only(self, nc_client, deck_test_card):
|
||||
"""Updating only the description should work and preserve other fields."""
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
description="New description only",
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.title == "Original Title"
|
||||
assert updated.description == "New description only"
|
||||
|
||||
async def test_update_title_and_description(self, nc_client, deck_test_card):
|
||||
"""Updating title and description together should work."""
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
title="New Title",
|
||||
description="New description",
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.title == "New Title"
|
||||
assert updated.description == "New description"
|
||||
|
||||
async def test_update_duedate_only(self, nc_client, deck_test_card):
|
||||
"""Updating only the duedate should work and preserve other fields."""
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
duedate="2025-12-31T23:59:59+00:00",
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.title == "Original Title"
|
||||
assert updated.description == "Original description"
|
||||
assert updated.duedate is not None
|
||||
|
||||
async def test_update_archived_only(self, nc_client, deck_test_card):
|
||||
"""Updating only the archived status should work and preserve other fields."""
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
archived=True,
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.title == "Original Title"
|
||||
assert updated.description == "Original description"
|
||||
assert updated.archived is True
|
||||
|
||||
async def test_update_order_only(self, nc_client, deck_test_card):
|
||||
"""Updating only the order should work and preserve other fields."""
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
order=99,
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.title == "Original Title"
|
||||
assert updated.description == "Original description"
|
||||
assert updated.order == 99
|
||||
|
||||
async def test_update_preserves_type(self, nc_client, deck_test_card):
|
||||
"""Type should be preserved when not explicitly changed."""
|
||||
original = deck_test_card["card"]
|
||||
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
title="Changed Title",
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.type == original.type
|
||||
assert updated.description == "Original description"
|
||||
|
||||
async def test_update_preserves_owner(self, nc_client, deck_test_card):
|
||||
"""Owner should be preserved when not explicitly changed."""
|
||||
original = deck_test_card["card"]
|
||||
|
||||
await nc_client.deck.update_card(
|
||||
board_id=deck_test_card["board_id"],
|
||||
stack_id=deck_test_card["stack_id"],
|
||||
card_id=deck_test_card["card_id"],
|
||||
title="Changed Title",
|
||||
)
|
||||
|
||||
updated = await nc_client.deck.get_card(
|
||||
deck_test_card["board_id"],
|
||||
deck_test_card["stack_id"],
|
||||
deck_test_card["card_id"],
|
||||
)
|
||||
assert updated.owner == original.owner
|
||||
assert updated.description == "Original description"
|
||||
@@ -559,3 +559,259 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
logger.info(
|
||||
f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!"
|
||||
)
|
||||
|
||||
|
||||
async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
"""Revoke background sync access by clicking the Revoke Access button.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
username: Username (for logging)
|
||||
|
||||
Returns:
|
||||
True if revocation was successful
|
||||
"""
|
||||
logger.info(f"Revoking background sync access for {username}...")
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
# Set up network request and console listeners
|
||||
network_requests = []
|
||||
network_responses = []
|
||||
console_messages = []
|
||||
|
||||
def log_request(req):
|
||||
network_requests.append(f"{req.method} {req.url}")
|
||||
|
||||
def log_response(resp):
|
||||
response_info = f"{resp.status} {resp.url}"
|
||||
network_responses.append(response_info)
|
||||
logger.info(f"Response: {response_info}")
|
||||
|
||||
def log_console(msg):
|
||||
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||
|
||||
page.on("request", log_request)
|
||||
page.on("response", log_response)
|
||||
page.on("console", log_console)
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
|
||||
# Wait for page to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if "Active" badge is visible (indicating background sync is enabled)
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if not await active_text.is_visible(timeout=2000):
|
||||
logger.warning(
|
||||
f"Background sync not active for {username}, nothing to revoke"
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning(f"Could not find Active badge for {username}")
|
||||
return False
|
||||
|
||||
# Find the "Revoke Access" button
|
||||
revoke_button = page.get_by_role("button", name="Revoke Access")
|
||||
|
||||
try:
|
||||
await revoke_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info("Found Revoke Access button")
|
||||
except Exception:
|
||||
screenshot_path = f"/tmp/astrolabe_no_revoke_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find Revoke Access button for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Set up dialog handler for confirmation dialog
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
|
||||
# Click the Revoke Access button
|
||||
await revoke_button.click()
|
||||
logger.info("Clicked Revoke Access button")
|
||||
|
||||
# Wait for the request to complete and page to reload
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Log network requests after clicking
|
||||
logger.info(f"Network requests after Revoke for {username}:")
|
||||
for req in network_requests[-10:]:
|
||||
logger.info(f" {req}")
|
||||
|
||||
# Log network responses
|
||||
logger.info(f"Network responses after Revoke for {username}:")
|
||||
for resp in network_responses[-10:]:
|
||||
logger.info(f" {resp}")
|
||||
|
||||
# Check specifically for the revoke POST response
|
||||
revoke_responses = [r for r in network_responses if "credentials/revoke" in r]
|
||||
if revoke_responses:
|
||||
logger.info(f"Revoke endpoint response: {revoke_responses[-1]}")
|
||||
if "200" not in revoke_responses[-1]:
|
||||
logger.error(f"Revoke POST did not return 200 OK: {revoke_responses[-1]}")
|
||||
return False
|
||||
else:
|
||||
logger.warning("No response found for credentials/revoke endpoint!")
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_revoke_no_response_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
|
||||
# Log any console messages
|
||||
if console_messages:
|
||||
logger.info(f"Console messages for {username}:")
|
||||
for msg in console_messages:
|
||||
logger.info(f" {msg}")
|
||||
|
||||
# Check for error notifications (toast messages)
|
||||
try:
|
||||
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||
if await error_toast.count() > 0:
|
||||
error_text = await error_toast.first.text_content()
|
||||
logger.error(f"Error notification for {username}: {error_text}")
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify "Active" badge is no longer visible
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.error(f"Active badge still visible for {username} after revoke!")
|
||||
screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"✓ Background sync access revoked for {username}")
|
||||
return True
|
||||
|
||||
|
||||
async def verify_app_password_deleted(username: str) -> bool:
|
||||
"""Verify that background sync app password was deleted for the user.
|
||||
|
||||
Args:
|
||||
username: Nextcloud username
|
||||
|
||||
Returns:
|
||||
True if background sync credentials no longer exist
|
||||
"""
|
||||
logger.info(f"Verifying background sync credentials deleted for {username}...")
|
||||
|
||||
query = f"""
|
||||
SELECT userid, configkey, configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
|
||||
ORDER BY configkey;
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
logger.debug(f"Background sync credentials query result:\n{output}")
|
||||
|
||||
# After deletion, we should NOT see background_sync_password
|
||||
if "background_sync_password" not in output:
|
||||
logger.info(f"✓ Background sync credentials deleted for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Background sync credentials still exist for {username}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking background sync credentials for {username}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_revoke_background_sync_access(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that users can revoke background sync access via the Revoke Access button.
|
||||
|
||||
This test verifies:
|
||||
1. User enables background sync via app password
|
||||
2. User clicks "Revoke Access" button
|
||||
3. Confirmation dialog is handled
|
||||
4. POST request is sent to /api/v1/background-sync/credentials/revoke
|
||||
5. "Active" badge disappears from settings page
|
||||
6. Background sync credentials are deleted from database
|
||||
|
||||
This tests the fix for the issue where POST requests to the revoke endpoint
|
||||
were returning errors due to HTTP method mismatch (was DELETE, now POST).
|
||||
"""
|
||||
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
# Test with a single user for this specific test
|
||||
username = "alice"
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
# Create new browser context
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Step 1: Login to Nextcloud
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# Step 2: Generate app password and enable background sync
|
||||
app_password = await generate_app_password(page, username)
|
||||
await enable_background_sync_via_app_password(page, username, app_password)
|
||||
|
||||
# Step 3: Verify background sync is enabled
|
||||
assert await verify_app_password_created(username), (
|
||||
f"Background sync not enabled for {username}"
|
||||
)
|
||||
|
||||
# Step 4: Revoke background sync access
|
||||
revoke_success = await revoke_background_sync_access(page, username)
|
||||
assert revoke_success, f"Failed to revoke background sync access for {username}"
|
||||
|
||||
# Step 5: Verify credentials are deleted from database
|
||||
credentials_deleted = await verify_app_password_deleted(username)
|
||||
assert credentials_deleted, (
|
||||
f"Background sync credentials not deleted for {username}"
|
||||
)
|
||||
|
||||
logger.info(f"\n✓ Successfully revoked background sync access for {username}!")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Integration tests for Deck card reorder functionality.
|
||||
|
||||
Tests issue #469: Moving Deck card from one column (stack) to another not working.
|
||||
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"""Create a temporary board with two stacks for testing card movement.
|
||||
|
||||
Yields:
|
||||
tuple: (board_data, source_stack_data, target_stack_data)
|
||||
"""
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
board_title = f"Reorder Test Board {unique_suffix}"
|
||||
board = None
|
||||
|
||||
logger.info(f"Creating board with two stacks: {board_title}")
|
||||
try:
|
||||
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||
board_id = board.id
|
||||
|
||||
# Create source stack (stack 1)
|
||||
source_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Source Stack {unique_suffix}", order=1
|
||||
)
|
||||
source_stack_data = {
|
||||
"id": source_stack.id,
|
||||
"title": source_stack.title,
|
||||
"order": source_stack.order,
|
||||
}
|
||||
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||
|
||||
# Create target stack (stack 2)
|
||||
target_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Target Stack {unique_suffix}", order=2
|
||||
)
|
||||
target_stack_data = {
|
||||
"id": target_stack.id,
|
||||
"title": target_stack.title,
|
||||
"order": target_stack.order,
|
||||
}
|
||||
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": board.title,
|
||||
"color": board.color,
|
||||
}
|
||||
|
||||
yield (board_data, source_stack_data, target_stack_data)
|
||||
|
||||
finally:
|
||||
if board:
|
||||
logger.info(f"Cleaning up board ID: {board.id}")
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up board: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_move_to_different_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test moving a card from one stack to another (issue #469).
|
||||
|
||||
This test reproduces the bug where the reorder_card API reports success
|
||||
but the card doesn't actually move to the target stack.
|
||||
"""
|
||||
board_data, source_stack_data, target_stack_data = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
target_stack_id = target_stack_data["id"]
|
||||
|
||||
# Create a card in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card_title = f"Test Card {unique_suffix}"
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||
)
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||
|
||||
try:
|
||||
# Verify card is in source stack
|
||||
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
|
||||
assert card_before.stackId == source_stack_id, (
|
||||
f"Card should start in source stack {source_stack_id}, "
|
||||
f"but is in {card_before.stackId}"
|
||||
)
|
||||
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||
|
||||
# Move card to target stack
|
||||
logger.info(
|
||||
f"Moving card {card_id} from stack {source_stack_id} "
|
||||
f"to stack {target_stack_id}"
|
||||
)
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card_id,
|
||||
order=0,
|
||||
target_stack_id=target_stack_id,
|
||||
)
|
||||
logger.info("reorder_card API call completed")
|
||||
|
||||
# Verify card moved to target stack
|
||||
# Note: After moving, the card should be accessible from the target stack
|
||||
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
|
||||
assert card_after.stackId == target_stack_id, (
|
||||
f"Card should have moved to target stack {target_stack_id}, "
|
||||
f"but is in {card_after.stackId}"
|
||||
)
|
||||
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - try to delete from target stack first, then source
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
|
||||
except Exception:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up card: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_within_same_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test reordering a card within the same stack (should work)."""
|
||||
board_data, source_stack_data, _ = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
|
||||
# Create two cards in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card1 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
|
||||
)
|
||||
card2 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||
)
|
||||
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||
|
||||
try:
|
||||
# Reorder card1 to position after card2
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card1.id,
|
||||
order=2, # Move to position 2
|
||||
target_stack_id=source_stack_id, # Same stack
|
||||
)
|
||||
logger.info(f"Reordered card {card1.id} to order 2")
|
||||
|
||||
# Verify card is still in the same stack
|
||||
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||
assert card_after.stackId == source_stack_id
|
||||
logger.info("Card reorder within same stack succeeded")
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up cards: {e}")
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.7.0"
|
||||
version = "0.7.2"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
- name: Get version matrix
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
php-lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
Vendored
+14
@@ -25,6 +25,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Requires external MCP server deployment
|
||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
|
||||
## astrolabe-v0.7.2 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
|
||||
## astrolabe-v0.7.1 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||
- **mcp**: Move all imports to the top of modules
|
||||
|
||||
## astrolabe-v0.7.0 (2025-12-26)
|
||||
|
||||
### Feat
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
||||
|
||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||
]]></description>
|
||||
<version>0.7.0</version>
|
||||
<version>0.7.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@ return [
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#deleteCredentials',
|
||||
'url' => '/api/v1/background-sync/credentials',
|
||||
'verb' => 'DELETE',
|
||||
'url' => '/api/v1/background-sync/credentials/revoke',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#getStatus',
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
|
||||
Vendored
+5
-3
@@ -7,9 +7,10 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: '.',
|
||||
emptyOutDir: false,
|
||||
cssCodeSplit: false, // Bundle all CSS into entry points (Nextcloud doesn't load CSS chunks)
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'src/main.js'),
|
||||
'astrolabe-main': resolve(__dirname, 'src/main.js'),
|
||||
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
|
||||
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
||||
},
|
||||
@@ -17,9 +18,10 @@ export default defineConfig({
|
||||
entryFileNames: 'js/[name].mjs',
|
||||
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
||||
assetFileNames: (assetInfo) => {
|
||||
// Output CSS to css/ directory, JS/other assets to js/
|
||||
// With cssCodeSplit:false, all CSS goes to a single file
|
||||
// Name it astrolabe-main.css to match Nextcloud's Util::addStyle expectation
|
||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
||||
return 'css/[name][extname]';
|
||||
return 'css/astrolabe-main.css';
|
||||
}
|
||||
return 'js/[name][extname]';
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user