diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index b6879c6..f2cb17e 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -18,6 +18,7 @@ from .deck import DeckClient from .notes import NotesClient from .tables import TablesClient from .webdav import WebDAVClient +from .users import UsersClient logger = logging.getLogger(__name__) @@ -71,6 +72,7 @@ class NextcloudClient: self.calendar = CalendarClient(self._client, username) self.contacts = ContactsClient(self._client, username) self.deck = DeckClient(self._client, username) + self.users = UsersClient(self._client, username) # Initialize controllers self._notes_search = NotesSearchController() diff --git a/nextcloud_mcp_server/client/users.py b/nextcloud_mcp_server/client/users.py new file mode 100644 index 0000000..41ca5c9 --- /dev/null +++ b/nextcloud_mcp_server/client/users.py @@ -0,0 +1,244 @@ +from typing import List, Optional, Dict +from nextcloud_mcp_server.client.base import BaseNextcloudClient +from nextcloud_mcp_server.models.users import UserDetails + + +class UsersClient(BaseNextcloudClient): + """Client for Nextcloud User API operations.""" + + def _get_user_headers( + self, additional_headers: Optional[Dict[str, str]] = None + ) -> Dict[str, str]: + """Get standard headers required for User API calls.""" + headers = {"OCS-APIRequest": "true"} + if additional_headers: + headers.update(additional_headers) + return headers + + async def create_user( + self, + userid: str, + password: Optional[str] = None, + display_name: Optional[str] = None, + email: Optional[str] = None, + groups: Optional[List[str]] = None, + subadmin_groups: Optional[List[str]] = None, + quota: Optional[str] = None, + language: Optional[str] = None, + ) -> None: + """ + Create a new user on the Nextcloud server. + """ + data = {"userid": userid} + if password is not None: + data["password"] = password + if display_name is not None: + data["displayName"] = display_name + if email is not None: + data["email"] = email + if groups is not None: + for i, group in enumerate(groups): + data[f"groups[{i}]"] = group + if subadmin_groups is not None: + for i, group in enumerate(subadmin_groups): + data[f"subadmin[{i}]"] = group + if quota is not None: + data["quota"] = quota + if language is not None: + data["language"] = language + + headers = self._get_user_headers() + await self._make_request( + "POST", "/ocs/v1.php/cloud/users", data=data, headers=headers + ) + + async def search_users( + self, + search: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> List[str]: + """ + Retrieves a list of users from the Nextcloud server. + """ + params = {} + if search is not None: + params["search"] = search + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + + headers = self._get_user_headers() + response = await self._make_request( + "GET", "/ocs/v1.php/cloud/users", params=params, headers=headers + ) + # The API returns XML, which is parsed into a dict. + # The user IDs are under ocs.data.users.element (can be a list or a single string) + data = response.json()["ocs"]["data"] + if "users" in data and "element" in data["users"]: + elements = data["users"]["element"] + if isinstance(elements, list): + return elements + elif isinstance(elements, str): + return [elements] + return [] + + async def get_user_details(self, userid: str) -> UserDetails: + """ + Retrieves information about a single user. + """ + headers = self._get_user_headers() + response = await self._make_request( + "GET", f"/ocs/v1.php/cloud/users/{userid}", headers=headers + ) + return UserDetails(**response.json()["ocs"]["data"]) + + async def update_user_field(self, userid: str, key: str, value: str) -> None: + """ + Edits attributes related to a user. + """ + data = {"key": key, "value": value} + headers = self._get_user_headers() + await self._make_request( + "PUT", f"/ocs/v1.php/cloud/users/{userid}", data=data, headers=headers + ) + + async def get_editable_user_fields(self) -> List[str]: + """ + Gets the list of editable data fields for a user. + """ + headers = self._get_user_headers() + response = await self._make_request( + "GET", "/ocs/v1.php/cloud/user/fields", headers=headers + ) + data = response.json()["ocs"]["data"] + if "element" in data: + elements = data["element"] + if isinstance(elements, list): + return elements + elif isinstance(elements, str): + return [elements] + return [] + + async def disable_user(self, userid: str) -> None: + """ + Disables a user on the Nextcloud server. + """ + headers = self._get_user_headers() + await self._make_request( + "PUT", f"/ocs/v1.php/cloud/users/{userid}/disable", headers=headers + ) + + async def enable_user(self, userid: str) -> None: + """ + Enables a user on the Nextcloud server. + """ + headers = self._get_user_headers() + await self._make_request( + "PUT", f"/ocs/v1.php/cloud/users/{userid}/enable", headers=headers + ) + + async def delete_user(self, userid: str) -> None: + """ + Deletes a user from the Nextcloud server. + """ + headers = self._get_user_headers() + await self._make_request( + "DELETE", f"/ocs/v1.php/cloud/users/{userid}", headers=headers + ) + + async def get_user_groups(self, userid: str) -> List[str]: + """ + Retrieves a list of groups the specified user is a member of. + """ + headers = self._get_user_headers() + response = await self._make_request( + "GET", f"/ocs/v1.php/cloud/users/{userid}/groups", headers=headers + ) + data = response.json()["ocs"]["data"] + if "groups" in data and "element" in data["groups"]: + elements = data["groups"]["element"] + if isinstance(elements, list): + return elements + elif isinstance(elements, str): + return [elements] + return [] + + async def add_user_to_group(self, userid: str, groupid: str) -> None: + """ + Adds the specified user to the specified group. + """ + data = {"groupid": groupid} + headers = self._get_user_headers() + await self._make_request( + "POST", + f"/ocs/v1.php/cloud/users/{userid}/groups", + data=data, + headers=headers, + ) + + async def remove_user_from_group(self, userid: str, groupid: str) -> None: + """ + Removes the specified user from the specified group. + """ + data = {"groupid": groupid} + headers = self._get_user_headers() + await self._make_request( + "DELETE", + f"/ocs/v1.php/cloud/users/{userid}/groups", + data=data, + headers=headers, + ) + + async def promote_user_to_subadmin(self, userid: str, groupid: str) -> None: + """ + Makes a user the subadmin of a group. + """ + data = {"groupid": groupid} + headers = self._get_user_headers() + await self._make_request( + "POST", + f"/ocs/v1.php/cloud/users/{userid}/subadmins", + data=data, + headers=headers, + ) + + async def demote_user_from_subadmin(self, userid: str, groupid: str) -> None: + """ + Removes the subadmin rights for the user specified from the group specified. + """ + data = {"groupid": groupid} + headers = self._get_user_headers() + await self._make_request( + "DELETE", + f"/ocs/v1.php/cloud/users/{userid}/subadmins", + data=data, + headers=headers, + ) + + async def get_user_subadmin_groups(self, userid: str) -> List[str]: + """ + Returns the groups in which the user is a subadmin. + """ + headers = self._get_user_headers() + response = await self._make_request( + "GET", f"/ocs/v1.php/cloud/users/{userid}/subadmins", headers=headers + ) + data = response.json()["ocs"]["data"] + if "element" in data: + elements = data["element"] + if isinstance(elements, list): + return elements + elif isinstance(elements, str): + return [elements] + return [] + + async def resend_welcome_email(self, userid: str) -> None: + """ + Triggers the welcome email for this user again. + """ + headers = self._get_user_headers() + await self._make_request( + "POST", f"/ocs/v1.php/cloud/users/{userid}/welcome", headers=headers + ) diff --git a/nextcloud_mcp_server/models/users.py b/nextcloud_mcp_server/models/users.py new file mode 100644 index 0000000..de3070b --- /dev/null +++ b/nextcloud_mcp_server/models/users.py @@ -0,0 +1,38 @@ +from typing import List, Optional +from pydantic import BaseModel, Field + + +class User(BaseModel): + """Model for creating a new user.""" + + userid: str + password: Optional[str] = None + displayName: Optional[str] = None + email: Optional[str] = None + groups: Optional[List[str]] = Field(default_factory=list) + subadmin: Optional[List[str]] = Field(default_factory=list) + quota: Optional[str] = None + language: Optional[str] = None + + +class UserDetails(BaseModel): + """Model for retrieving detailed user information.""" + + enabled: bool + id: str + quota: str + email: str + displayname: str = Field( + alias="display-name" + ) # Handle both displayname and display-name + phone: Optional[str] = None + address: Optional[str] = None + website: Optional[str] = None + twitter: Optional[str] = None + groups: Optional[List[str]] = Field(default_factory=list) + + +class Group(BaseModel): + """Model for a user group.""" + + id: str diff --git a/tests/integration/test_deck_advanced_features.py b/tests/integration/test_deck_advanced_features.py new file mode 100644 index 0000000..3fa682d --- /dev/null +++ b/tests/integration/test_deck_advanced_features.py @@ -0,0 +1,569 @@ +import json +import logging +import uuid + +import pytest +from mcp import ClientSession + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + + +# Stack MCP Tools Tests +async def test_deck_stack_mcp_tools( + nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict +): + """Test complete deck stack operations via MCP tools.""" + board_id = temporary_board["id"] + stack_title = f"MCP Test Stack {uuid.uuid4().hex[:8]}" + stack_order = 1 + + # 1. Create stack via MCP tool + logger.info(f"Creating stack via MCP: {stack_title}") + create_result = await nc_mcp_client.call_tool( + "deck_create_stack", + {"board_id": board_id, "title": stack_title, "order": stack_order}, + ) + + assert create_result.isError is False, ( + f"MCP stack creation failed: {create_result.content}" + ) + created_stack_response = json.loads(create_result.content[0].text) + stack_id = created_stack_response["id"] + assert created_stack_response["title"] == stack_title + assert created_stack_response["order"] == stack_order + logger.info(f"Stack created via MCP with ID: {stack_id}") + + try: + # 2. Get stack via MCP resource + logger.info(f"Getting stack via MCP resource: {stack_id}") + get_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks/{stack_id}" + ) + + assert len(get_result.contents) == 1, "Expected exactly one content item" + get_stack_response = json.loads(get_result.contents[0].text) + assert get_stack_response["title"] == stack_title + logger.info("Stack retrieved via MCP resource successfully") + + # 3. Update stack via MCP tool + updated_title = f"Updated {stack_title}" + updated_order = 2 + logger.info(f"Updating stack via MCP tool: {stack_id}") + update_result = await nc_mcp_client.call_tool( + "deck_update_stack", + { + "board_id": board_id, + "stack_id": stack_id, + "title": updated_title, + "order": updated_order, + }, + ) + + assert update_result.isError is False, ( + f"MCP stack update failed: {update_result.content}" + ) + logger.info("Stack updated via MCP tool successfully") + + # 4. Verify update via direct client + updated_stack = await nc_client.deck.get_stack(board_id, stack_id) + assert updated_stack.title == updated_title + assert updated_stack.order == updated_order + logger.info("Stack update verified via direct client") + + # 5. List stacks via MCP resource + logger.info("Listing stacks via MCP resource") + list_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks" + ) + + assert len(list_result.contents) == 1, "Expected exactly one content item" + stacks_data = json.loads(list_result.contents[0].text) + assert isinstance(stacks_data, list) + + # Verify our stack is in the list + stack_ids = [stack["id"] for stack in stacks_data] + assert stack_id in stack_ids, "Updated stack not found in list" + logger.info(f"Stack {stack_id} found in stacks list") + + # 6. Read stack via MCP resource + logger.info(f"Reading stack via MCP resource: {stack_id}") + read_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks/{stack_id}" + ) + read_stack_data = json.loads(read_result.contents[0].text) + assert read_stack_data["title"] == updated_title + logger.info("Stack read via MCP resource successfully") + + finally: + # Clean up + await nc_client.deck.delete_stack(board_id, stack_id) + logger.info(f"Cleaned up stack ID: {stack_id}") + + +# Card MCP Tools Tests +async def test_deck_card_mcp_tools( + nc_mcp_client: ClientSession, + nc_client: NextcloudClient, + temporary_board_with_stack: tuple, +): + """Test complete deck card operations via MCP tools.""" + board_data, stack_data = temporary_board_with_stack + board_id = board_data["id"] + stack_id = stack_data["id"] + card_title = f"MCP Test Card {uuid.uuid4().hex[:8]}" + card_description = f"Test description for {card_title}" + + # 1. Create card via MCP tool + logger.info(f"Creating card via MCP: {card_title}") + create_result = await nc_mcp_client.call_tool( + "deck_create_card", + { + "board_id": board_id, + "stack_id": stack_id, + "title": card_title, + "description": card_description, + "type": "plain", + "order": 1, + }, + ) + + assert create_result.isError is False, ( + f"MCP card creation failed: {create_result.content}" + ) + created_card_response = json.loads(create_result.content[0].text) + card_id = created_card_response["id"] + assert created_card_response["title"] == card_title + assert created_card_response["description"] == card_description + logger.info(f"Card created via MCP with ID: {card_id}") + + try: + # 2. Get card via MCP resource + logger.info(f"Getting card via MCP resource: {card_id}") + get_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}" + ) + + assert len(get_result.contents) == 1, "Expected exactly one content item" + get_card_response = json.loads(get_result.contents[0].text) + assert get_card_response["title"] == card_title + logger.info("Card retrieved via MCP resource successfully") + + # 3. Update card via MCP tool + updated_title = f"Updated {card_title}" + updated_description = f"Updated description for {card_title}" + logger.info(f"Updating card via MCP tool: {card_id}") + update_result = await nc_mcp_client.call_tool( + "deck_update_card", + { + "board_id": board_id, + "stack_id": stack_id, + "card_id": card_id, + "title": updated_title, + "description": updated_description, + }, + ) + + assert update_result.isError is False, ( + f"MCP card update failed: {update_result.content}" + ) + logger.info("Card updated via MCP tool successfully") + + # 4. Verify update via direct client + updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id) + assert updated_card.title == updated_title + assert updated_card.description == updated_description + logger.info("Card update verified via direct client") + + # 5. Archive/unarchive card via MCP tools + logger.info(f"Archiving card via MCP tool: {card_id}") + archive_result = await nc_mcp_client.call_tool( + "deck_archive_card", + {"board_id": board_id, "stack_id": stack_id, "card_id": card_id}, + ) + + assert archive_result.isError is False, ( + f"MCP card archive failed: {archive_result.content}" + ) + logger.info("Card archived via MCP tool successfully") + + logger.info(f"Unarchiving card via MCP tool: {card_id}") + unarchive_result = await nc_mcp_client.call_tool( + "deck_unarchive_card", + {"board_id": board_id, "stack_id": stack_id, "card_id": card_id}, + ) + + assert unarchive_result.isError is False, ( + f"MCP card unarchive failed: {unarchive_result.content}" + ) + logger.info("Card unarchived via MCP tool successfully") + + # 6. Move card to different position via MCP tool + logger.info(f"Reordering card via MCP tool: {card_id}") + reorder_result = await nc_mcp_client.call_tool( + "deck_reorder_card", + { + "board_id": board_id, + "stack_id": stack_id, + "card_id": card_id, + "order": 10, + "target_stack_id": stack_id, + }, + ) + + assert reorder_result.isError is False, ( + f"MCP card reorder failed: {reorder_result.content}" + ) + logger.info("Card reordered via MCP tool successfully") + + # 7. Read card via MCP resource + logger.info(f"Reading card via MCP resource: {card_id}") + read_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}" + ) + read_card_data = json.loads(read_result.contents[0].text) + assert read_card_data["title"] == updated_title + logger.info("Card read via MCP resource successfully") + + finally: + # Clean up + await nc_client.deck.delete_card(board_id, stack_id, card_id) + logger.info(f"Cleaned up card ID: {card_id}") + + +# Label MCP Tools Tests +async def test_deck_label_mcp_tools( + nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict +): + """Test complete deck label operations via MCP tools.""" + board_id = temporary_board["id"] + label_title = f"MCP Test Label {uuid.uuid4().hex[:8]}" + label_color = "FF0000" # Red + + # 1. Create label via MCP tool + logger.info(f"Creating label via MCP: {label_title}") + create_result = await nc_mcp_client.call_tool( + "deck_create_label", + {"board_id": board_id, "title": label_title, "color": label_color}, + ) + + assert create_result.isError is False, ( + f"MCP label creation failed: {create_result.content}" + ) + created_label_response = json.loads(create_result.content[0].text) + label_id = created_label_response["id"] + assert created_label_response["title"] == label_title + assert created_label_response["color"] == label_color + logger.info(f"Label created via MCP with ID: {label_id}") + + try: + # 2. Get label via MCP resource + logger.info(f"Getting label via MCP resource: {label_id}") + get_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/labels/{label_id}" + ) + + assert len(get_result.contents) == 1, "Expected exactly one content item" + get_label_response = json.loads(get_result.contents[0].text) + assert get_label_response["title"] == label_title + logger.info("Label retrieved via MCP resource successfully") + + # 3. Update label via MCP tool + updated_title = f"Updated {label_title}" + updated_color = "00FF00" # Green + logger.info(f"Updating label via MCP tool: {label_id}") + update_result = await nc_mcp_client.call_tool( + "deck_update_label", + { + "board_id": board_id, + "label_id": label_id, + "title": updated_title, + "color": updated_color, + }, + ) + + assert update_result.isError is False, ( + f"MCP label update failed: {update_result.content}" + ) + logger.info("Label updated via MCP tool successfully") + + # 4. Verify update via direct client + updated_label = await nc_client.deck.get_label(board_id, label_id) + assert updated_label.title == updated_title + assert updated_label.color == updated_color + logger.info("Label update verified via direct client") + + # 5. Read label via MCP resource + logger.info(f"Reading label via MCP resource: {label_id}") + read_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/labels/{label_id}" + ) + read_label_data = json.loads(read_result.contents[0].text) + assert read_label_data["title"] == updated_title + logger.info("Label read via MCP resource successfully") + + finally: + # Clean up + await nc_client.deck.delete_label(board_id, label_id) + logger.info(f"Cleaned up label ID: {label_id}") + + +# Label-Card Assignment Tests +async def test_deck_card_label_assignment_mcp_tools( + nc_mcp_client: ClientSession, + nc_client: NextcloudClient, + temporary_board_with_card: tuple, +): + """Test card-label assignment operations via MCP tools.""" + board_data, stack_data, card_data = temporary_board_with_card + board_id = board_data["id"] + stack_id = stack_data["id"] + card_id = card_data["id"] + + # Create a label for assignment + label = await nc_client.deck.create_label( + board_id, "Assignment Test Label", "0000FF" + ) + label_id = label.id + + try: + # 1. Assign label to card via MCP tool + logger.info(f"Assigning label {label_id} to card {card_id} via MCP") + assign_result = await nc_mcp_client.call_tool( + "deck_assign_label_to_card", + { + "board_id": board_id, + "stack_id": stack_id, + "card_id": card_id, + "label_id": label_id, + }, + ) + + assert assign_result.isError is False, ( + f"MCP label assignment failed: {assign_result.content}" + ) + logger.info("Label assigned to card via MCP tool successfully") + + # 2. Verify assignment via direct client + card = await nc_client.deck.get_card(board_id, stack_id, card_id) + if card.labels: + label_ids = [label.id for label in card.labels] + assert label_id in label_ids, "Label not found in card labels" + logger.info("Label assignment verified via direct client") + + # 3. Remove label from card via MCP tool + logger.info(f"Removing label {label_id} from card {card_id} via MCP") + remove_result = await nc_mcp_client.call_tool( + "deck_remove_label_from_card", + { + "board_id": board_id, + "stack_id": stack_id, + "card_id": card_id, + "label_id": label_id, + }, + ) + + assert remove_result.isError is False, ( + f"MCP label removal failed: {remove_result.content}" + ) + logger.info("Label removed from card via MCP tool successfully") + + # 4. Verify removal via direct client + card = await nc_client.deck.get_card(board_id, stack_id, card_id) + if card.labels: + label_ids = [label.id for label in card.labels] + assert label_id not in label_ids, ( + "Label still found in card labels after removal" + ) + logger.info("Label removal verified via direct client") + + finally: + # Clean up + await nc_client.deck.delete_label(board_id, label_id) + logger.info(f"Cleaned up label ID: {label_id}") + + +# User Assignment Tests +async def test_deck_card_user_assignment_mcp_tools( + nc_mcp_client: ClientSession, + nc_client: NextcloudClient, + temporary_board_with_card: tuple, +): + """Test card-user assignment operations via MCP tools.""" + board_data, stack_data, card_data = temporary_board_with_card + board_id = board_data["id"] + stack_id = stack_data["id"] + card_id = card_data["id"] + + # Use the current user ID (admin in most test environments) + user_id = "admin" + + # 1. Assign user to card via MCP tool + logger.info(f"Assigning user {user_id} to card {card_id} via MCP") + assign_result = await nc_mcp_client.call_tool( + "deck_assign_user_to_card", + { + "board_id": board_id, + "stack_id": stack_id, + "card_id": card_id, + "user_id": user_id, + }, + ) + + assert assign_result.isError is False, ( + f"MCP user assignment failed: {assign_result.content}" + ) + logger.info("User assigned to card via MCP tool successfully") + + # 2. Verify assignment via direct client + card = await nc_client.deck.get_card(board_id, stack_id, card_id) + if card.assignedUsers: + user_ids = [] + for user in card.assignedUsers: + if hasattr(user, "participant"): + # It's a DeckAssignedUser with participant + user_ids.append(user.participant.uid) + elif hasattr(user, "uid"): + # It's a direct DeckUser + user_ids.append(user.uid) + assert user_id in user_ids, "User not found in card assigned users" + logger.info("User assignment verified via direct client") + + # 3. Unassign user from card via MCP tool + logger.info(f"Unassigning user {user_id} from card {card_id} via MCP") + unassign_result = await nc_mcp_client.call_tool( + "deck_unassign_user_from_card", + { + "board_id": board_id, + "stack_id": stack_id, + "card_id": card_id, + "user_id": user_id, + }, + ) + + assert unassign_result.isError is False, ( + f"MCP user unassignment failed: {unassign_result.content}" + ) + logger.info("User unassigned from card via MCP tool successfully") + + # 4. Verify unassignment via direct client + card = await nc_client.deck.get_card(board_id, stack_id, card_id) + if card.assignedUsers: + user_ids = [] + for user in card.assignedUsers: + if hasattr(user, "participant"): + # It's a DeckAssignedUser with participant + user_ids.append(user.participant.uid) + elif hasattr(user, "uid"): + # It's a direct DeckUser + user_ids.append(user.uid) + assert user_id not in user_ids, ( + "User still found in card assigned users after removal" + ) + logger.info("User unassignment verified via direct client") + + +# Error handling tests +async def test_deck_mcp_tools_error_handling(nc_mcp_client: ClientSession): + """Test error handling for deck MCP tools with invalid parameters.""" + non_existent_id = 999999999 + + # Test stack operations with non-existent board + stack_result = await nc_mcp_client.call_tool( + "deck_create_stack", + {"board_id": non_existent_id, "title": "Should Fail", "order": 1}, + ) + assert stack_result.isError is True, ( + "Expected error for stack creation on non-existent board" + ) + + # Test card operations with non-existent IDs + card_result = await nc_mcp_client.call_tool( + "deck_create_card", + { + "board_id": non_existent_id, + "stack_id": non_existent_id, + "title": "Should Fail", + "type": "plain", + }, + ) + assert card_result.isError is True, ( + "Expected error for card creation with non-existent IDs" + ) + + # Test label operations with non-existent board + label_result = await nc_mcp_client.call_tool( + "deck_create_label", + {"board_id": non_existent_id, "title": "Should Fail", "color": "FF0000"}, + ) + assert label_result.isError is True, ( + "Expected error for label creation on non-existent board" + ) + + logger.info("Error handling tests passed for deck MCP tools") + + +# Resource template tests +async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession): + """Test deck MCP resource templates are properly registered.""" + templates = await nc_mcp_client.list_resource_templates() + template_uris = [template.uriTemplate for template in templates.resourceTemplates] + + expected_templates = [ + "nc://Deck/boards/{board_id}/stacks/{stack_id}", + "nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}", + "nc://Deck/boards/{board_id}/labels/{label_id}", + ] + + for expected_template in expected_templates: + assert expected_template in template_uris, ( + f"Expected template '{expected_template}' not found" + ) + logger.info(f"Found expected deck resource template: {expected_template}") + + +# Listing resource tests +async def test_deck_mcp_listing_resources( + nc_mcp_client: ClientSession, temporary_board_with_card: tuple +): + """Test deck MCP listing resources for stacks and cards.""" + board_data, stack_data, card_data = temporary_board_with_card + board_id = board_data["id"] + stack_id = stack_data["id"] + + # 1. Test listing stacks resource + logger.info(f"Reading stacks list via MCP resource for board {board_id}") + stacks_resource_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks" + ) + stacks_resource_data = json.loads(stacks_resource_result.contents[0].text) + assert isinstance(stacks_resource_data, list) + + # Verify our stack is in the resource list + stack_ids = [stack["id"] for stack in stacks_resource_data] + assert stack_id in stack_ids, "Stack not found in stacks resource list" + logger.info("Stack found in stacks resource list") + + # 2. Test listing cards resource + logger.info(f"Reading cards list via MCP resource for stack {stack_id}") + cards_resource_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards" + ) + cards_resource_data = json.loads(cards_resource_result.contents[0].text) + assert isinstance(cards_resource_data, list) + + # Verify our card is in the resource list + card_ids = [card["id"] for card in cards_resource_data] + assert card_data["id"] in card_ids, "Card not found in cards resource list" + logger.info("Card found in cards resource list") + + # 3. Test listing labels resource + logger.info(f"Reading labels list via MCP resource for board {board_id}") + labels_resource_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{board_id}/labels" + ) + labels_resource_data = json.loads(labels_resource_result.contents[0].text) + assert isinstance(labels_resource_data, list) + logger.info("Labels resource read successfully") diff --git a/tests/integration/test_users_api.py b/tests/integration/test_users_api.py new file mode 100644 index 0000000..3be5ee8 --- /dev/null +++ b/tests/integration/test_users_api.py @@ -0,0 +1,126 @@ +import pytest +from nextcloud_mcp_server.client import NextcloudClient + + +@pytest.mark.asyncio +async def test_create_and_delete_user(nc_client: NextcloudClient): + userid = "testuser1" + password = "testpassword1" + display_name = "Test User One" + email = "test1@example.com" + + # Create user + await nc_client.users.create_user( + userid=userid, + password=password, + display_name=display_name, + email=email, + ) + + # Verify user exists + users = await nc_client.users.search_users(search=userid) + assert userid in users + + user_details = await nc_client.users.get_user_details(userid) + assert user_details.id == userid + assert user_details.displayname == display_name + assert user_details.email == email + + # Delete user + await nc_client.users.delete_user(userid) + + # Verify user is deleted + users = await nc_client.users.search_users(search=userid) + assert userid not in users + + +@pytest.mark.asyncio +async def test_update_user_field(nc_client: NextcloudClient): + userid = "testuser2" + password = "testpassword2" + display_name = "Test User Two" + email = "test2@example.com" + + await nc_client.users.create_user( + userid=userid, + password=password, + display_name=display_name, + email=email, + ) + + new_email = "new.test2@example.com" + await nc_client.users.update_user_field(userid, "email", new_email) + + user_details = await nc_client.users.get_user_details(userid) + assert user_details.email == new_email + + await nc_client.users.delete_user(userid) + + +@pytest.mark.asyncio +async def test_user_groups(nc_client: NextcloudClient): + userid = "testuser3" + password = "testpassword3" + groupid = "testgroup" + + await nc_client.users.create_user(userid=userid, password=password) + + # Add user to group + await nc_client.users.add_user_to_group(userid, groupid) + groups = await nc_client.users.get_user_groups(userid) + assert groupid in groups + + # Remove user from group + await nc_client.users.remove_user_from_group(userid, groupid) + groups = await nc_client.users.get_user_groups(userid) + assert groupid not in groups + + await nc_client.users.delete_user(userid) + + +@pytest.mark.asyncio +async def test_user_subadmins(nc_client: NextcloudClient): + userid = "testuser4" + password = "testpassword4" + groupid = "subadmingroup" + + await nc_client.users.create_user(userid=userid, password=password) + + # Promote to subadmin + await nc_client.users.promote_user_to_subadmin(userid, groupid) + subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid) + assert groupid in subadmin_groups + + # Demote from subadmin + await nc_client.users.demote_user_from_subadmin(userid, groupid) + subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid) + assert groupid not in subadmin_groups + + await nc_client.users.delete_user(userid) + + +@pytest.mark.asyncio +async def test_disable_enable_user(nc_client: NextcloudClient): + userid = "testuser5" + password = "testpassword5" + + await nc_client.users.create_user(userid=userid, password=password) + + # Disable user + await nc_client.users.disable_user(userid) + user_details = await nc_client.users.get_user_details(userid) + assert not user_details.enabled + + # Enable user + await nc_client.users.enable_user(userid) + user_details = await nc_client.users.get_user_details(userid) + assert user_details.enabled + + await nc_client.users.delete_user(userid) + + +@pytest.mark.asyncio +async def test_get_editable_user_fields(nc_client: NextcloudClient): + editable_fields = await nc_client.users.get_editable_user_fields() + assert "displayname" in editable_fields + assert "email" in editable_fields diff --git a/user-api.rst b/user-api.rst new file mode 100644 index 0000000..183e7b3 --- /dev/null +++ b/user-api.rst @@ -0,0 +1,674 @@ +========================= +Instruction set for users +========================= + +Add a new user +-------------- + +Create a new user on the Nextcloud server. Authentication is done by sending a +basic HTTP authentication header. + +**Syntax: ocs/v1.php/cloud/users** + +* HTTP method: POST +* POST argument: userid - string, the required username for the new user +* POST argument: password - string, the password for the new user, leave empty to send welcome mail +* POST argument: displayName - string, the display name for the new user +* POST argument: email - string, the email for the new user, required if password empty +* POST argument: groups - array, the groups for the new user +* POST argument: subadmin - array, the groups in which the new user is subadmin +* POST argument: quota - string, quota for the new user +* POST argument: language - string, language for the new user + +Status codes: + +* 101 - invalid argument +* 102 - user already exists +* 103 - cannot create sub-admins for admin group +* 104 - group does not exist +* 105 - insufficient privileges for group +* 106 - no group specified (required for sub-admins) +* 107 - hint exceptions +* 108 - an email address is required, to send a password link to the user. +* 109 - sub-admin group does not exist +* 110 - required email address was not provided +* 111 - could not create non-existing user ID + +Example +^^^^^^^ +:: + + $ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users -d userid="Frank" -d password="frankspassword" -H "OCS-APIRequest: true" + +* Creates the user ``Frank`` with password ``frankspassword`` +* optionally groups can be specified by one or more ``groups[]`` query parameters: + ``URL -d groups[]="admin" -D groups[]="Team1"`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + ok + 100 + + + + + +Search/get users +---------------- + +Retrieves a list of users from the Nextcloud server. Authentication is done by +sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users** + +* HTTP method: GET +* url arguments: search - string, optional search string +* url arguments: limit - int, optional limit value +* url arguments: offset - int, optional offset value + +Status codes: + +* 100 - successful + +Example +^^^^^^^ +:: + + $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users?search=Frank -H "OCS-APIRequest: true" + +* Returns list of users matching the search string. + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + Frank + + + + +Get data of a single user +------------------------- + +Retrieves information about a single user. Authentication is done by sending a +Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}** + +* HTTP method: GET + +Status codes: + +* 100 - successful + +Example +^^^^^^^ + +:: + + $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true" + +* Returns information on the user ``Frank`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + true + Frank + 0 + frank@example.org + Frank K. + Frank K. + 0123 / 456 789 +
Foobar 12, 12345 Town
+ https://nextcloud.com + Nextcloud + + group1 + group2 + +
+
+ +Edit data of a single user +-------------------------- + +Edits attributes related to a user. Users are able to edit email, displayname +and password; admins can also edit the quota value. Further restrictions may apply, +check the `List of editable data fields`_ endpoint. Authentication +is done by sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}** + +* HTTP method: PUT +* PUT argument: key, the field to edit: + + + email + + quota + + displayname + + display (**deprecated** use `displayname` instead) + + phone + + address + + website + + twitter + + password + +* PUT argument: value, the new value for the field + +Status codes: + +* 101 - invalid argument +* 107 - password policy (hint exception) +* 112 - Setting the password is not supported by the users backend +* 113 - editing field not allowed / field doesn’t exist + +Examples +^^^^^^^^ + +:: + + $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="email" -d value="franksnewemail@example.org" -H "OCS-APIRequest: true" + +* Updates the email address for the user ``Frank`` + +:: + + $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="quota" -d value="100MB" -H "OCS-APIRequest: true" + +* Updates the quota for the user ``Frank`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + +.. _editable_field_list: + +List of editable data fields +---------------------------- + +Edits attributes related to a user. Users are able to edit email, displayname +and password; admins can also edit the quota value. Authentication is done by +sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/user/fields** + +* HTTP method: GET + +Status codes: + +* 100 - successful + +Examples +^^^^^^^^ + +:: + + $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/user/fields -H "OCS-APIRequest: true" + +* Gets the list of fields + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + ok + 100 + OK + + + displayname + email + phone + address + website + twitter + + + + +Disable a user +-------------- + +Disables a user on the Nextcloud server so that the user cannot login anymore. +Authentication is done by sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/disable** + +* HTTP method: PUT + +Statuscodes: + +* 100 - successful +* 101 - failure + +Example +^^^^^^^ + +:: + + $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/disable -H "OCS-APIRequest: true" + +* Disables the user ``Frank`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + ok + 100 + + + + + +Enable a user +------------- + +Enables a user on the Nextcloud server so that the user can login again. +Authentication is done by sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/enable** + +* HTTP method: PUT + +Statuscodes: + +* 100 - successful +* 101 - failure + +Example +^^^^^^^ + +:: + + $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/enable -H "OCS-APIRequest: true" + +* Enables the user ``Frank`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + ok + 100 + + + + + +Delete a user +------------- + +Deletes a user from the Nextcloud server. Authentication is done by sending a +Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}** + +* HTTP method: DELETE + +Statuscodes: + +* 100 - successful +* 101 - failure + +Example +^^^^^^^ + +:: + + $ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true" + +* Deletes the user ``Frank`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + +Get user's groups +----------------- + +Retrieves a list of groups the specified user is a member of. Authentication is +done by sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/groups** + +* HTTP method: GET + +Status codes: + +* 100 - successful + +Example +^^^^^^^ + +:: + + $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -H "OCS-APIRequest: true" + +* Retrieves a list of groups of which ``Frank`` is a member + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + admin + group1 + + + + +Add user to group +----------------- + +Adds the specified user to the specified group. Authentication is done by +sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/groups** + +* HTTP method: POST +* POST argument: groupid, string - the group to add the user to + +Status codes: + +* 100 - successful +* 101 - no group specified +* 102 - group does not exist +* 103 - user does not exist +* 104 - insufficient privileges +* 105 - failed to add user to group + +Example +^^^^^^^ + +:: + + $ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true" + +* Adds the user ``Frank`` to the group ``newgroup`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + +Remove user from group +---------------------- + +Removes the specified user from the specified group. Authentication is done by +sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/groups** + +* HTTP method: DELETE +* DELETE argument: groupid, string - the group to remove the user from + +Status codes: + +* 100 - successful +* 101 - no group specified +* 102 - group does not exist +* 103 - user does not exist +* 104 - insufficient privileges +* 105 - failed to remove user from group + +Example +^^^^^^^ + +:: + + $ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true" + +* Removes the user ``Frank`` from the group ``newgroup`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + +Promote user to subadmin +------------------------ + +Makes a user the subadmin of a group. Authentication is done by sending a Basic +HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins** + +* HTTP method: POST +* POST argument: groupid, string - the group of which to make the user a + subadmin + +Status codes: + +* 100 - successful +* 101 - user does not exist +* 102 - group does not exist +* 103 - unknown failure + +Example +^^^^^^^ + +:: + + $ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="group" -H "OCS-APIRequest: true" + +* Makes the user ``Frank`` a subadmin of the ``group`` group + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + +Demote user from subadmin +------------------------- + +Removes the subadmin rights for the user specified from the group specified. +Authentication is done by sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins** + +* HTTP method: DELETE +* DELETE argument: groupid, string - the group from which to remove the user's + subadmin rights + +Status codes: + +* 100 - successful +* 101 - user does not exist +* 102 - user is not a subadmin of the group / group does not exist +* 103 - unknown failure + +Example +^^^^^^^ + +:: + + $ curl -X DELETE https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="oldgroup" -H "OCS-APIRequest: true" + +* Removes ``Frank's`` subadmin rights from the ``oldgroup`` group + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + 100 + ok + + + + +Get user's subadmin groups +-------------------------- + +Returns the groups in which the user is a subadmin. Authentication is done by +sending a Basic HTTP Authorization header. + +**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins** + +* HTTP method: GET + +Status codes: + +* 100 - successful +* 101 - user does not exist +* 102 - unknown failure + +Example +^^^^^^^ + +:: + + $ curl -X GET https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -H "OCS-APIRequest: true" + +* Returns the groups of which ``Frank`` is a subadmin + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + ok + 100 + + + + testgroup + + + +Resend the welcome email +------------------------ + +The request to this endpoint triggers the welcome email for this user again. + +**Syntax: ocs/v1.php/cloud/users/{userid}/welcome** + +* HTTP method: POST + +Status codes: + +* 100 - successful +* 101 - email address not available +* 102 - sending email failed + +Example +^^^^^^^ + +:: + + $ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/welcome -H "OCS-APIRequest: true" + +* Sends the welcome email to ``Frank`` + +XML output +^^^^^^^^^^ + +.. code-block:: xml + + + + + ok + 100 + + + +