"""Multi-user permission tests for Login Flow v2 deployment mode. Tests verify that Nextcloud's sharing / ACL enforcement works correctly when resources are accessed through MCP tools by different users, each authenticated via Login Flow v2. Ported from the removed ``tests/server/oauth/test_oauth_*_permissions.py`` tests. The underlying assertions are deployment-mode-agnostic; only the transport changed (OAuth MCP server -> Login Flow v2 MCP server). """ import json import logging import pytest from mcp import ClientSession logger = logging.getLogger(__name__) pytestmark = [pytest.mark.integration, pytest.mark.login_flow] # --------------------------------------------------------------------------- # WebDAV / Files # --------------------------------------------------------------------------- class TestFilePermissions: """Test that MCP file tools respect Nextcloud sharing permissions.""" async def test_file_share_read_permissions( self, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, diana_login_flow_mcp_client: ClientSession, ): """Alice shares a file with Bob (read-only). Bob can read it; Diana (unshared) cannot.""" file_path = "/alice_shared_file_read.txt" file_content = "This file is shared with Bob for reading only." # Alice creates the file result = await alice_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={"path": file_path, "content": file_content}, ) assert not result.isError, f"Alice failed to create file: {result.content}" share_id = None try: # Alice shares with Bob (read-only, permissions=1) result = await alice_login_flow_mcp_client.call_tool( "nc_share_create", arguments={ "path": file_path, "share_with": "bob", "share_type": 0, "permissions": 1, }, ) assert not result.isError, f"Share creation failed: {result.content}" share_data = json.loads(result.content[0].text) share_id = share_data["id"] # Bob reads the file result = await bob_login_flow_mcp_client.call_tool( "nc_webdav_read_file", arguments={"path": file_path} ) assert not result.isError, ( f"Bob could not read shared file: {result.content}" ) response_data = json.loads(result.content[0].text) assert file_content in response_data["content"] # Diana cannot read the file result = await diana_login_flow_mcp_client.call_tool( "nc_webdav_read_file", arguments={"path": file_path} ) assert result.isError, "Diana should not be able to read unshared file" finally: if share_id: await alice_login_flow_mcp_client.call_tool( "nc_share_delete", arguments={"share_id": share_id} ) await alice_login_flow_mcp_client.call_tool( "nc_webdav_delete_resource", arguments={"path": file_path} ) async def test_file_share_write_permissions( self, alice_login_flow_mcp_client: ClientSession, charlie_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, ): """Alice shares a file with Charlie (edit) and Bob (read-only). Charlie can overwrite; Bob cannot.""" file_path = "/alice_shared_file_write.txt" file_content = "This file is shared with Charlie for editing." result = await alice_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={"path": file_path, "content": file_content}, ) assert not result.isError charlie_share_id = None bob_share_id = None try: # Share with Charlie (read+write, permissions=3) result = await alice_login_flow_mcp_client.call_tool( "nc_share_create", arguments={ "path": file_path, "share_with": "charlie", "share_type": 0, "permissions": 3, }, ) assert not result.isError charlie_share_id = json.loads(result.content[0].text)["id"] # Share with Bob (read-only, permissions=1) result = await alice_login_flow_mcp_client.call_tool( "nc_share_create", arguments={ "path": file_path, "share_with": "bob", "share_type": 0, "permissions": 1, }, ) assert not result.isError bob_share_id = json.loads(result.content[0].text)["id"] # Charlie can write result = await charlie_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={ "path": file_path, "content": f"{file_content}\nCharlie added this line.", }, ) assert not result.isError, ( f"Charlie should be able to write: {result.content}" ) # Bob cannot write result = await bob_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={ "path": file_path, "content": "Bob tries to overwrite this.", }, ) assert result.isError, "Bob should be denied write access (read-only)" finally: for sid in (charlie_share_id, bob_share_id): if sid: await alice_login_flow_mcp_client.call_tool( "nc_share_delete", arguments={"share_id": sid} ) await alice_login_flow_mcp_client.call_tool( "nc_webdav_delete_resource", arguments={"path": file_path} ) async def test_folder_share_permissions( self, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, ): """Alice shares a folder with Bob; Bob can list and read its contents.""" folder_path = "/alice_shared_folder" file_in_folder = f"{folder_path}/document.txt" file_content = "Document in Alice's shared folder" result = await alice_login_flow_mcp_client.call_tool( "nc_webdav_create_directory", arguments={"path": folder_path} ) assert not result.isError result = await alice_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={"path": file_in_folder, "content": file_content}, ) assert not result.isError share_id = None try: result = await alice_login_flow_mcp_client.call_tool( "nc_share_create", arguments={ "path": folder_path, "share_with": "bob", "share_type": 0, "permissions": 1, }, ) assert not result.isError share_id = json.loads(result.content[0].text)["id"] # Bob lists the shared folder result = await bob_login_flow_mcp_client.call_tool( "nc_webdav_list_directory", arguments={"path": folder_path} ) assert not result.isError, f"Bob should see shared folder: {result.content}" response_data = json.loads(result.content[0].text) file_names = [f["name"] for f in response_data.get("files", [])] assert "document.txt" in file_names # Bob reads the file result = await bob_login_flow_mcp_client.call_tool( "nc_webdav_read_file", arguments={"path": file_in_folder} ) assert not result.isError assert file_content in json.loads(result.content[0].text)["content"] finally: if share_id: await alice_login_flow_mcp_client.call_tool( "nc_share_delete", arguments={"share_id": share_id} ) await alice_login_flow_mcp_client.call_tool( "nc_webdav_delete_resource", arguments={"path": folder_path} ) async def test_user_isolation_files( self, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, ): """Users can only see their own files when nothing is shared.""" alice_file = "/alice_private_file.txt" bob_file = "/bob_private_file.txt" # Each user creates their own file result = await alice_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={"path": alice_file, "content": "Alice's private file"}, ) assert not result.isError result = await bob_login_flow_mcp_client.call_tool( "nc_webdav_write_file", arguments={"path": bob_file, "content": "Bob's private file"}, ) assert not result.isError try: # Bob lists root — should NOT see Alice's file result = await bob_login_flow_mcp_client.call_tool( "nc_webdav_list_directory", arguments={"path": "/"} ) assert not result.isError bob_visible = [ f["name"] for f in json.loads(result.content[0].text).get("files", []) ] assert "alice_private_file.txt" not in bob_visible, ( "Bob should not see Alice's private file" ) # Alice lists root — should NOT see Bob's file result = await alice_login_flow_mcp_client.call_tool( "nc_webdav_list_directory", arguments={"path": "/"} ) assert not result.isError alice_visible = [ f["name"] for f in json.loads(result.content[0].text).get("files", []) ] assert "bob_private_file.txt" not in alice_visible, ( "Alice should not see Bob's private file" ) finally: await alice_login_flow_mcp_client.call_tool( "nc_webdav_delete_resource", arguments={"path": alice_file} ) await bob_login_flow_mcp_client.call_tool( "nc_webdav_delete_resource", arguments={"path": bob_file} ) # --------------------------------------------------------------------------- # Deck # --------------------------------------------------------------------------- class TestDeckPermissions: """Test that MCP Deck tools respect board ACL permissions.""" async def _add_board_acl( self, nc_client, board_id: int, user: str, permission_type: int = 0 ) -> int: """Add ACL entry. permission_type: 0=view, 1=edit, 2=manage.""" acl = await nc_client.deck.add_acl_rule( board_id=board_id, type=0, participant=user, permission_edit=permission_type >= 1, permission_share=permission_type >= 2, permission_manage=permission_type >= 2, ) return acl.id async def test_deck_board_view_permissions( self, nc_client, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, diana_login_flow_mcp_client: ClientSession, ): """Admin creates a board, adds Bob (view). Bob can see it; Diana cannot.""" board = await nc_client.deck.create_board("Shared Board - View Test", "FF0000") board_id = board.id bob_acl_id = None try: bob_acl_id = await self._add_board_acl(nc_client, board_id, "bob", 0) # Bob can see the board result = await bob_login_flow_mcp_client.call_tool( "deck_get_boards", arguments={} ) assert not result.isError board_ids = [ b["id"] for b in json.loads(result.content[0].text).get("boards", []) ] assert board_id in board_ids, "Bob should see shared board" # Diana cannot see the board result = await diana_login_flow_mcp_client.call_tool( "deck_get_boards", arguments={} ) assert not result.isError board_ids = [ b["id"] for b in json.loads(result.content[0].text).get("boards", []) ] assert board_id not in board_ids, "Diana should not see board without ACL" finally: if bob_acl_id: await nc_client.deck.delete_acl_rule(board_id, bob_acl_id) await nc_client.deck.delete_board(board_id) async def test_deck_board_edit_permissions( self, nc_client, charlie_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, ): """Charlie (edit) can create cards; Bob (view-only) cannot.""" board = await nc_client.deck.create_board("Shared Board - Edit Test", "00FF00") board_id = board.id stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1) stack_id = stack.id charlie_acl_id = None bob_acl_id = None try: charlie_acl_id = await self._add_board_acl( nc_client, board_id, "charlie", 1 ) bob_acl_id = await self._add_board_acl(nc_client, board_id, "bob", 0) # Charlie creates a card result = await charlie_login_flow_mcp_client.call_tool( "deck_create_card", arguments={ "board_id": board_id, "stack_id": stack_id, "title": "Charlie's Card", "description": "Created by Charlie with edit permission", }, ) assert not result.isError, f"Charlie should create cards: {result.content}" card_id = json.loads(result.content[0].text).get("id") if card_id: await nc_client.deck.delete_card(board_id, stack_id, card_id) # Bob cannot create a card result = await bob_login_flow_mcp_client.call_tool( "deck_create_card", arguments={ "board_id": board_id, "stack_id": stack_id, "title": "Bob's Card", "description": "Bob trying to create a card", }, ) assert result.isError, "Bob should be denied card creation (view-only)" finally: for acl_id in (charlie_acl_id, bob_acl_id): if acl_id: await nc_client.deck.delete_acl_rule(board_id, acl_id) await nc_client.deck.delete_board(board_id) async def test_deck_user_isolation( self, nc_client, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, ): """Users can only see their own boards when nothing is shared.""" alice_board = await nc_client.deck.create_board( "Alice's Private Board", "FF00FF" ) bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF") try: # Alice should NOT see Bob's board result = await alice_login_flow_mcp_client.call_tool( "deck_get_boards", arguments={} ) assert not result.isError board_ids = [ b["id"] for b in json.loads(result.content[0].text).get("boards", []) ] assert bob_board.id not in board_ids, ( "Alice should not see Bob's private board" ) # Bob should NOT see Alice's board result = await bob_login_flow_mcp_client.call_tool( "deck_get_boards", arguments={} ) assert not result.isError board_ids = [ b["id"] for b in json.loads(result.content[0].text).get("boards", []) ] assert alice_board.id not in board_ids, ( "Bob should not see Alice's private board" ) finally: await nc_client.deck.delete_board(alice_board.id) await nc_client.deck.delete_board(bob_board.id) # --------------------------------------------------------------------------- # Notes # --------------------------------------------------------------------------- class TestNotesPermissions: """Test that MCP Notes tools respect user isolation. Nextcloud Notes are inherently single-user (no sharing API). These tests verify that notes created by one user are invisible to others. """ async def test_user_isolation_notes( self, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, ): """Notes created by Alice are invisible to Bob and vice versa.""" # Alice creates a note result = await alice_login_flow_mcp_client.call_tool( "nc_notes_create_note", arguments={ "title": "Alice's Private Note", "content": "This is Alice's private content.", "category": "PermTest", }, ) assert not result.isError alice_note_id = json.loads(result.content[0].text)["id"] # Bob creates a note result = await bob_login_flow_mcp_client.call_tool( "nc_notes_create_note", arguments={ "title": "Bob's Private Note", "content": "This is Bob's private content.", "category": "PermTest", }, ) assert not result.isError bob_note_id = json.loads(result.content[0].text)["id"] try: # Alice searches — should NOT see Bob's note result = await alice_login_flow_mcp_client.call_tool( "nc_notes_search_notes", arguments={"query": "PermTest"} ) assert not result.isError alice_visible_ids = [ n["id"] for n in json.loads(result.content[0].text).get("results", []) ] assert bob_note_id not in alice_visible_ids, ( "Alice should not see Bob's private note" ) # Bob searches — should NOT see Alice's note result = await bob_login_flow_mcp_client.call_tool( "nc_notes_search_notes", arguments={"query": "PermTest"} ) assert not result.isError bob_visible_ids = [ n["id"] for n in json.loads(result.content[0].text).get("results", []) ] assert alice_note_id not in bob_visible_ids, ( "Bob should not see Alice's private note" ) finally: await alice_login_flow_mcp_client.call_tool( "nc_notes_delete_note", arguments={"note_id": alice_note_id} ) await bob_login_flow_mcp_client.call_tool( "nc_notes_delete_note", arguments={"note_id": bob_note_id} ) # --------------------------------------------------------------------------- # Smoke: all multi-user clients initialised # --------------------------------------------------------------------------- class TestMultiUserSmoke: """Quick check that all multi-user MCP clients are functional.""" async def test_all_clients_can_list_tools( self, alice_login_flow_mcp_client: ClientSession, bob_login_flow_mcp_client: ClientSession, charlie_login_flow_mcp_client: ClientSession, diana_login_flow_mcp_client: ClientSession, ): for name, client in [ ("alice", alice_login_flow_mcp_client), ("bob", bob_login_flow_mcp_client), ("charlie", charlie_login_flow_mcp_client), ("diana", diana_login_flow_mcp_client), ]: tools = await client.list_tools() assert len(tools.tools) > 0, f"{name} MCP client has no tools" logger.info("%s MCP client working (%s tools)", name, len(tools.tools))