#!/usr/bin/env python3 # Libervia: an XMPP client # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """ Tests for the PubsubComponent implementation. """ from unittest.mock import MagicMock, AsyncMock, patch from pytest import raises, fixture from pytest_twisted import ensureDeferred as ed from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber.error import StanzaError from twisted.words.xish import domish from tx_xmpp import pubsub from tx_xmpp import rsm from tx_xmpp.xmppim import RosterItem from libervia.backend.memory.sqla_mapping import ( AccessModel, Affiliation, PublishModel, PubsubItem, PubsubNode, PubsubAffiliation, PubsubSub, SubscriptionState, ) from libervia.backend.plugins.plugin_comp_pubsub import PubsubResource from libervia.backend.plugins.plugin_xep_0496 import XEP_0496 @fixture(autouse=True) def prepare_for_tests(host, client): """Set up important data for testing the pubsub component. This creates a real XEP_0496 instance so that the actual permission-checking logic is tested rather than mocked behavior. It also links the client to the host so PubsubResource can access the plugin, and mark it as a component to reflect real workflow. """ xep_0496 = XEP_0496(host) host.plugins["XEP-0496"] = xep_0496 client.is_component = True client.host = host return xep_0496 @fixture def pubsub_resource(client): """Create a PubsubResource with proper mocking. This fixture creates a PubsubResource with a mock service that has xep_0355.is_pep configured to return False by default (since pubsub.test.example is not a PEP service). """ mock_service = MagicMock(client=client) mock_service.component.xep_0355.is_pep.return_value = False return PubsubResource(mock_service) def make_pubsub_request( sender: jid.JID, nodeIdentifier: str | None = None, subscriber: jid.JID | None = None, items: list[domish.Element] | None = None, itemIdentifiers: list[str] | None = None, options: dict | None = None, affiliations: dict[jid.JID, str] | None = None, subscriptions: list[pubsub.Subscription] | None = None, rsm_params: rsm.RSMRequest | None = None, maxItems: int | None = None, nodeType: str | None = None, notify: bool = False, recipient: jid.JID | None = None, ) -> pubsub.PubSubRequest: """Helper to create a PubSubRequest for testing.""" request = pubsub.PubSubRequest() request.sender = sender request.nodeIdentifier = nodeIdentifier request.subscriber = subscriber request.items = items request.itemIdentifiers = itemIdentifiers request.options = options request.affiliations = affiliations request.subscriptions = subscriptions request.rsm = rsm_params request.maxItems = maxItems request.nodeType = nodeType request.notify = notify request.recipient = recipient return request def make_pubsub_node( name: str, access_model: AccessModel = AccessModel.open, publish_model: PublishModel = PublishModel.publishers, affiliations: list[PubsubAffiliation] | None = None, subscriptions: list[PubsubSub] | None = None, pep: bool = False, ) -> PubsubNode: """Helper to create a PubsubNode for testing.""" node = PubsubNode(name=name) node.access_model = access_model node.publish_model = publish_model node.affiliations = affiliations or [] node.subscriptions = subscriptions or [] node.pep = pep return node def make_affiliation(jid_obj: jid.JID, affiliation: Affiliation) -> PubsubAffiliation: """Helper to create a PubsubAffiliation for testing.""" aff = PubsubAffiliation() aff.entity = jid_obj aff.affiliation = affiliation return aff def make_subscription(jid_obj: jid.JID, state: str) -> PubsubSub: """Helper to create a PubsubSub for testing.""" sub = PubsubSub() sub.subscriber = jid_obj sub.state = getattr(SubscriptionState, state.upper()) return sub def make_item_element(item_id: str, publisher: str | None = None) -> domish.Element: """Helper to create an item element for testing.""" item = domish.Element((pubsub.NS_PUBSUB, "item")) item["id"] = item_id if publisher: item["publisher"] = publisher item.addElement(("content", "test")) return item class TestPubsubResourceCreate: """Tests for node creation.""" @ed async def test_creates_node_with_owner_affiliation(self, host, pubsub_resource): """creating a node sets the creator as owner""" request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", recipient=pubsub_resource.service.client.pubsub_service, ) host.memory.storage.set_pubsub_node = AsyncMock() await pubsub_resource._create(request) # Verify that the creator is set as owner call_args = host.memory.storage.set_pubsub_node.call_args assert call_args is not None affiliations = call_args.kwargs.get("affiliations", []) assert len(affiliations) == 1 assert affiliations[0].entity == jid.JID("user@test.example") assert affiliations[0].affiliation == Affiliation.owner @ed async def test_generates_node_id_if_not_provided(self, host, pubsub_resource): """creating a node without ID generates one""" request = make_pubsub_request( sender=jid.JID("user@test.example"), recipient=pubsub_resource.service.client.pubsub_service, ) host.memory.storage.set_pubsub_node = AsyncMock() await pubsub_resource._create(request) # Verify a node ID was generated and used call_args = host.memory.storage.set_pubsub_node.call_args assert call_args is not None node_id = call_args.kwargs.get("name") assert node_id is not None assert len(node_id) > 0 # Node ID should start with "generic/" when auto-generated assert node_id.startswith("generic/") @ed async def test_raises_conflict_if_node_exists(self, host, pubsub_resource): """creating an existing node raises conflict""" from libervia.backend.core.exceptions import ConflictError request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="existing_node", recipient=pubsub_resource.service.client.pubsub_service, ) # The ConflictError is generated by storage, so we actually check that the # Exception is propagated correctly. host.memory.storage.set_pubsub_node = AsyncMock(side_effect=ConflictError()) with raises(StanzaError, match="conflict"): await pubsub_resource._create(request) class TestPubsubResourcePublish: """Tests for item publishing.""" @ed async def test_rejects_empty_items(self, host, client, pubsub_resource): """publishing empty items raises bad-request""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", items=[], ) with raises(StanzaError, match="bad-request"): await resource._publish(request) @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """publishing to nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", items=[make_item_element("item1")], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._publish(request) @ed async def test_publishers_model_requires_publisher_affiliation( self, host, client, pubsub_resource ): """publishers model requires publisher or owner affiliation""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", items=[make_item_element("item1")], ) node = make_pubsub_node( name="test_node", publish_model=PublishModel.publishers, affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) with raises(StanzaError, match="forbidden"): await resource._publish(request) @ed async def test_publishers_model_allows_owner(self, host, client, pubsub_resource): """publishers model allows owners to publish""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", items=[make_item_element("item1")], ) node = make_pubsub_node( name="test_node", publish_model=PublishModel.publishers, affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.cache_pubsub_items = AsyncMock() await resource._publish(request) host.memory.storage.cache_pubsub_items.assert_called_once() @ed async def test_publishers_model_allows_publisher(self, host, client, pubsub_resource): """publishers model allows publishers to publish""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("publisher@test.example"), nodeIdentifier="test_node", items=[make_item_element("item1")], ) node = make_pubsub_node( name="test_node", publish_model=PublishModel.publishers, affiliations=[ make_affiliation(jid.JID("publisher@test.example"), Affiliation.publisher) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.cache_pubsub_items = AsyncMock() await resource._publish(request) host.memory.storage.cache_pubsub_items.assert_called_once() @ed async def test_subscribers_model_requires_subscription( self, host, client, pubsub_resource ): """subscribers model requires subscription to publish""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", items=[make_item_element("item1")], ) node = make_pubsub_node( name="test_node", publish_model=PublishModel.subscribers, affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) with raises(StanzaError, match="forbidden"): await resource._publish(request) @ed async def test_subscribers_model_allows_subscribed_member( self, host, client, pubsub_resource ): """subscribers model allows subscribed members to publish""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", items=[make_item_element("item1")], ) node = make_pubsub_node( name="test_node", publish_model=PublishModel.subscribers, affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], subscriptions=[make_subscription(jid.JID("user@test.example"), "subscribed")], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.cache_pubsub_items = AsyncMock() await resource._publish(request) host.memory.storage.cache_pubsub_items.assert_called_once() @ed async def test_generates_item_id_if_missing(self, host, client, pubsub_resource): """publishing an item without ID generates one""" resource = pubsub_resource item = make_item_element("") # Empty ID request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", items=[item], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.cache_pubsub_items = AsyncMock() await resource._publish(request) # Verify item was processed and has an ID generated call_args = host.memory.storage.cache_pubsub_items.call_args assert call_args is not None # cache_pubsub_items is called with (client, node, items_data) items_data = call_args.args[2] assert len(items_data) == 1 assert items_data[0].getAttribute("id") is not None assert len(items_data[0].getAttribute("id")) > 0 @ed async def test_sets_publisher_attribute(self, host, client, pubsub_resource): """publishing sets the publisher attribute on items""" resource = pubsub_resource item = make_item_element("item1") request = make_pubsub_request( sender=jid.JID("user@test.example/resource"), nodeIdentifier="test_node", items=[item], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.cache_pubsub_items = AsyncMock() await resource._publish(request) # Verify the item has publisher attribute set call_args = host.memory.storage.cache_pubsub_items.call_args assert call_args is not None # cache_pubsub_items is called with (client, node, items_data) items_data = call_args.args[2] assert len(items_data) == 1 published_item = items_data[0] assert published_item.getAttribute("publisher") == "user@test.example/resource" class TestPubsubResourceSubscribe: """Tests for subscription management.""" @ed async def test_rejects_subscribing_for_others(self, host, client, pubsub_resource): """subscribing for another user raises forbidden""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("other@test.example"), ) with raises(StanzaError, match="forbidden"): await resource._subscribe(request) @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """subscribing to nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", subscriber=jid.JID("user@test.example"), ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._subscribe(request) @ed async def test_rejects_outcast_users(self, host, client, pubsub_resource): """outcast users cannot subscribe""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("outcast@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("outcast@test.example"), ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("outcast@test.example"), Affiliation.outcast) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) with raises(StanzaError, match="forbidden"): await resource._subscribe(request) @ed async def test_open_model_allows_anyone(self, host, client, pubsub_resource): """open access model allows anyone to subscribe""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("user@test.example"), ) node = make_pubsub_node( name="test_node", access_model=AccessModel.open, ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.set_pubsub_subscriptions = AsyncMock() await resource._subscribe(request) host.memory.storage.set_pubsub_subscriptions.assert_called_once() call_args = host.memory.storage.set_pubsub_subscriptions.call_args subscriptions_dict = call_args.args[1] assert ( subscriptions_dict[jid.JID("user@test.example")] == SubscriptionState.SUBSCRIBED ) @ed async def test_whitelist_model_requires_membership( self, host, client, pubsub_resource ): """whitelist access model requires member affiliation""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("user@test.example"), ) node = make_pubsub_node( name="test_node", access_model=AccessModel.whitelist, affiliations=[], # No affiliation for this user ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) with raises(StanzaError, match="forbidden"): await resource._subscribe(request) @ed async def test_whitelist_model_allows_member(self, host, client, pubsub_resource): """whitelist access model allows members to subscribe""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("member@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("member@test.example"), ) node = make_pubsub_node( name="test_node", access_model=AccessModel.whitelist, affiliations=[ make_affiliation(jid.JID("member@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.set_pubsub_subscriptions = AsyncMock() await resource._subscribe(request) host.memory.storage.set_pubsub_subscriptions.assert_called_once() call_args = host.memory.storage.set_pubsub_subscriptions.call_args subscriptions_dict = call_args.args[1] assert ( subscriptions_dict[jid.JID("member@test.example")] == SubscriptionState.SUBSCRIBED ) @ed async def test_rejects_duplicate_pending(self, host, client, pubsub_resource): """subscribing when already pending raises not-authorized""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("user@test.example"), ) node = make_pubsub_node( name="test_node", access_model=AccessModel.open, ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) pending_sub = make_subscription(jid.JID("user@test.example"), "pending") host.memory.storage.get_pubsub_subscriptions = AsyncMock( return_value=[pending_sub] ) with raises(StanzaError, match="not-authorized"): await resource._subscribe(request) class TestPubsubResourceUnsubscribe: """Tests for unsubscription.""" @ed async def test_rejects_unsubscribing_for_others(self, host, client, pubsub_resource): """unsubscribing for another user raises forbidden""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("other@test.example"), ) with raises(StanzaError, match="forbidden"): await resource._unsubscribe(request) @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """unsubscribing from nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", subscriber=jid.JID("user@test.example"), ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._unsubscribe(request) @ed async def test_removes_subscription(self, host, client, pubsub_resource): """unsubscribing removes the subscription""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriber=jid.JID("user@test.example"), ) node = make_pubsub_node( name="test_node", subscriptions=[make_subscription(jid.JID("user@test.example"), "subscribed")], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.set_pubsub_subscriptions = AsyncMock() await resource._unsubscribe(request) # Verify that the subscription was set to None (removed) host.memory.storage.set_pubsub_subscriptions.assert_called_once() call_args = host.memory.storage.set_pubsub_subscriptions.call_args assert call_args is not None subscriptions_dict = call_args.args[1] assert jid.JID("user@test.example") in subscriptions_dict assert subscriptions_dict[jid.JID("user@test.example")] is None class TestPubsubResourceItems: """Tests for item retrieval.""" @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """retrieving items from nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._items(request) @ed async def test_open_model_allows_anyone(self, host, client, pubsub_resource): """open access model allows anyone to retrieve items""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", access_model=AccessModel.open, ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.get_items = AsyncMock(return_value=([], {})) await resource._items(request) host.memory.storage.get_items.assert_called_once() @ed async def test_whitelist_model_rejects_non_members( self, host, client, pubsub_resource ): """whitelist access model rejects non-members""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", access_model=AccessModel.whitelist, affiliations=[], # No affiliation for this user ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) with raises(StanzaError, match="forbidden"): await resource._items(request) @ed async def test_whitelist_model_allows_member(self, host, client, pubsub_resource): """whitelist access model allows members to retrieve items""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("member@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", access_model=AccessModel.whitelist, affiliations=[ make_affiliation(jid.JID("member@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.get_items = AsyncMock(return_value=([], {})) await resource._items(request) host.memory.storage.get_items.assert_called_once() @ed async def test_presence_model_rejects_non_contacts( self, host, client, pubsub_resource ): """presence access model rejects non-contacts""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("stranger@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", access_model=AccessModel.presence, pep=True, ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) # Mock XEP-0356 roster to not include the stranger mock_xep_0356 = MagicMock() mock_xep_0356.get_roster = AsyncMock(return_value={}) host.plugins["XEP-0356"] = mock_xep_0356 with raises(StanzaError, match="forbidden"): await resource._items(request) @ed async def test_presence_model_rejects_contact_from_subscription( self, host, client, pubsub_resource ): """presence access model rejects when "to" subscription is missing""" requestor_jid = jid.JID("contact@test.example") resource = pubsub_resource request = make_pubsub_request( sender=requestor_jid, nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", access_model=AccessModel.presence, pep=True, ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) mock_xep_0356 = MagicMock() roster_item = RosterItem( entity=requestor_jid, subscriptionTo=False, subscriptionFrom=True, ) mock_xep_0356.get_roster = AsyncMock(return_value={requestor_jid: roster_item}) host.plugins["XEP-0356"] = mock_xep_0356 with raises(StanzaError, match="forbidden"): await resource._items(request) @ed async def test_presence_model_allows_contact_from_subscription( self, host, client, pubsub_resource ): """presence access model allows when requestor has "from" subscription""" resource = pubsub_resource requestor_jid = jid.JID("contact@test.example") request = make_pubsub_request( sender=requestor_jid, nodeIdentifier="test_node", recipient=client.pubsub_service, ) node = make_pubsub_node( name="test_node", access_model=AccessModel.presence, pep=True, ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.get_items = AsyncMock(return_value=([], {})) mock_xep_0356 = MagicMock() host.plugins["XEP-0356"] = mock_xep_0356 host.plugins["XEP-0496"]._xep_0356 = mock_xep_0356 roster_item = RosterItem( entity=requestor_jid, subscriptionTo=False, subscriptionFrom=True, ) mock_xep_0356.get_roster = AsyncMock(return_value={requestor_jid: roster_item}) await resource._items(request) host.memory.storage.get_items.assert_called_once() class TestPubsubResourceRetract: """Tests for item retraction.""" @ed async def test_rejects_empty_identifiers(self, host, client, pubsub_resource): """retracting without item identifiers raises bad-request""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", itemIdentifiers=[], ) with raises(StanzaError, match="bad-request"): await resource._retract(request) @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """retracting from nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", itemIdentifiers=["item1"], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._retract(request) @ed async def test_owner_can_retract_any_item(self, host, client, pubsub_resource): """owners can retract any item from their node""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", itemIdentifiers=["item1"], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) # Create an item published by someone else item_elt = make_item_element("item1") item_elt["publisher"] = "other@test.example" item_wrapper = MagicMock() item_wrapper.data = item_elt item_wrapper.name = "item1" host.memory.storage.get_items = AsyncMock(return_value=([item_wrapper], {})) host.memory.storage.delete_pubsub_items = AsyncMock() with patch.object(resource, "_send_notifications", new=AsyncMock()): await resource._retract(request) host.memory.storage.delete_pubsub_items.assert_called_once() call_args = host.memory.storage.delete_pubsub_items.call_args assert call_args.args[0] == node assert call_args.args[1] == ["item1"] @ed async def test_publisher_can_retract_any_item(self, host, client, pubsub_resource): """publishers can retract any item from their node""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("publisher@test.example"), nodeIdentifier="test_node", itemIdentifiers=["item1"], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("publisher@test.example"), Affiliation.publisher) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) item_elt = make_item_element("item1") item_elt["publisher"] = "other@test.example" item_wrapper = MagicMock() item_wrapper.data = item_elt item_wrapper.name = "item1" host.memory.storage.get_items = AsyncMock(return_value=([item_wrapper], {})) host.memory.storage.delete_pubsub_items = AsyncMock() with patch.object(resource, "_send_notifications", new=AsyncMock()): await resource._retract(request) host.memory.storage.delete_pubsub_items.assert_called_once() call_args = host.memory.storage.delete_pubsub_items.call_args assert call_args.args[0] == node assert call_args.args[1] == ["item1"] @ed async def test_user_can_only_retract_own_items(self, host, client, pubsub_resource): """non-owners can only retract their own items""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", itemIdentifiers=["item1"], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False # Item published by someone else item_elt = make_item_element("item1") item_elt["publisher"] = "other@test.example" item_wrapper = MagicMock() item_wrapper.data = item_elt item_wrapper.name = "item1" host.memory.storage.get_items = AsyncMock(return_value=([item_wrapper], {})) with raises(StanzaError, match="forbidden"): await resource._retract(request) @ed async def test_user_can_retract_own_items(self, host, client, pubsub_resource): """users can retract their own items""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", itemIdentifiers=["item1"], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.publisher) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) # Item published by the user item_elt = make_item_element("item1") item_elt["publisher"] = "user@test.example/resource" item_wrapper = MagicMock() item_wrapper.data = item_elt item_wrapper.name = "item1" host.memory.storage.get_items = AsyncMock(return_value=([item_wrapper], {})) host.memory.storage.delete_pubsub_items = AsyncMock() with patch.object(resource, "_send_notifications", new=AsyncMock()): await resource._retract(request) host.memory.storage.delete_pubsub_items.assert_called_once() call_args = host.memory.storage.delete_pubsub_items.call_args assert call_args.args[0] == node assert call_args.args[1] == ["item1"] class TestPubsubResourcePurge: """Tests for node purging.""" @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """purging nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._purge(request) @ed async def test_only_owners_can_purge(self, host, client, pubsub_resource): """only owners can purge a node""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.publisher) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._purge(request) @ed async def test_owner_can_purge(self, host, client, pubsub_resource): """owners can purge their node""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.delete_pubsub_items = AsyncMock() host.memory.is_admin_jid.return_value = False with patch.object(resource, "_send_notifications", new=AsyncMock()): await resource._purge(request) host.memory.storage.delete_pubsub_items.assert_called_once() call_args = host.memory.storage.delete_pubsub_items.call_args assert call_args.args[0] == node assert call_args.kwargs.get("items_names") is None class TestPubsubResourceDelete: """Tests for node deletion.""" @ed async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """deleting nonexistent node raises item_not_found""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=None) with raises(StanzaError, match="item-not-found"): await resource._delete(request) @ed async def test_only_owners_can_delete(self, host, client, pubsub_resource): """only owners can delete a node""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.publisher) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._delete(request) @ed async def test_owner_can_delete(self, host, client, pubsub_resource): """owners can delete their node""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.delete_pubsub_node = AsyncMock() host.memory.is_admin_jid.return_value = False # Mock methods that would fail with test node (no id, storage.session() issues) resource.xep_0496.get_linking_nodes_by_id = AsyncMock(return_value=[]) resource.xep_0496.get_descendant_nodes = AsyncMock(return_value=[]) with patch.object(resource, "_send_notifications", new=AsyncMock()): await resource._delete(request) host.memory.storage.delete_pubsub_node.assert_called_once() class TestPubsubResourceConfigure: """Tests for node configuration.""" @ed async def test_only_owners_can_get_config(self, host, client, pubsub_resource): """only owners can get node configuration""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._configureGet(request) @ed async def test_owner_can_get_config(self, host, client, pubsub_resource): """owners can get node configuration""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", access_model=AccessModel.whitelist, publish_model=PublishModel.subscribers, affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False result = await resource._configureGet(request) assert result["pubsub#access_model"] == "whitelist" assert result["pubsub#publish_model"] == "subscribers" @ed async def test_only_owners_can_set_config(self, host, client, pubsub_resource): """only owners can set node configuration""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", options={"pubsub#access_model": "whitelist"}, ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._configureSet(request) @ed async def test_owner_can_set_config(self, host, client, pubsub_resource): """owners can set node configuration""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", options={"pubsub#access_model": "whitelist"}, ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.update_pubsub_node = AsyncMock() host.memory.is_admin_jid.return_value = False await resource._configureSet(request) # Verify that the configuration was updated with the correct values host.memory.storage.update_pubsub_node.assert_called_once() call_args = host.memory.storage.update_pubsub_node.call_args assert call_args is not None # update_pubsub_node is called with (node, update_values) update_values = call_args.args[1] assert "access_model" in update_values assert update_values["access_model"] == AccessModel.whitelist class TestPubsubResourceAffiliations: """Tests for affiliation management.""" @ed async def test_only_owners_can_get_affiliations(self, host, client, pubsub_resource): """only owners can retrieve all affiliations""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._affiliationsGet(request) @ed async def test_only_owners_can_set_affiliations(self, host, client, pubsub_resource): """only owners can set affiliations""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", affiliations={jid.JID("other@test.example"): "member"}, ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._affiliationsSet(request) @ed async def test_owner_cannot_demote_self(self, host, client, pubsub_resource): """owners cannot change their own affiliation to non-owner""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", affiliations={jid.JID("owner@test.example"): "publisher"}, ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._affiliationsSet(request) class TestPubsubResourceSubscriptionsGetSet: """Tests for subscription management by owners.""" @ed async def test_only_owners_can_get_subscriptions(self, host, client, pubsub_resource): """only owners can retrieve all subscriptions""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._subscriptionsGet(request) @ed async def test_only_owners_can_set_subscriptions(self, host, client, pubsub_resource): """only owners can set subscriptions""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", subscriptions=[ pubsub.Subscription( nodeIdentifier="test_node", subscriber=jid.JID("other@test.example"), state="subscribed", ) ], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("user@test.example"), Affiliation.member) ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="forbidden"): await resource._subscriptionsSet(request) @ed async def test_owner_can_set_subscriptions(self, host, client, pubsub_resource): """owners can set subscriptions for entities""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", subscriptions=[ pubsub.Subscription( nodeIdentifier="test_node", subscriber=jid.JID("user@test.example"), state="subscribed", ) ], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner), ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.storage.set_pubsub_subscriptions = AsyncMock() host.memory.is_admin_jid.return_value = False await resource._subscriptionsSet(request) host.memory.storage.set_pubsub_subscriptions.assert_called_once() call_args = host.memory.storage.set_pubsub_subscriptions.call_args subscriptions_dict = call_args.args[1] assert ( subscriptions_dict[jid.JID("user@test.example")] == SubscriptionState.SUBSCRIBED ) @ed async def test_cannot_subscribe_outcast(self, host, client, pubsub_resource): """owners cannot subscribe outcast entities""" resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", subscriptions=[ pubsub.Subscription( nodeIdentifier="test_node", subscriber=jid.JID("outcast@test.example"), state="subscribed", ) ], ) node = make_pubsub_node( name="test_node", affiliations=[ make_affiliation(jid.JID("owner@test.example"), Affiliation.owner), make_affiliation(jid.JID("outcast@test.example"), Affiliation.outcast), ], ) host.memory.storage.get_pubsub_node = AsyncMock(return_value=node) host.memory.is_admin_jid.return_value = False with raises(StanzaError, match="not-acceptable"): await resource._subscriptionsSet(request) class TestPubsubAutoSubscribe: """Tests for PEP auto-subscription.""" @ed async def test_auto_subscribe_skips_no_notify_capability( self, host, client, pubsub_resource ): """auto-subscription skips contacts without +notify capability""" client.is_component = True contact_jid = jid.JID("contact@test.example/resource1") presence_elt = domish.Element((None, "presence")) presence_elt["from"] = contact_jid.full() # Mock caps hash caps_hash = "abc123" host.memory.get_entity_datum = MagicMock(return_value=caps_hash) # Mock disco info WITHOUT +notify feature mock_disco_info = MagicMock() mock_disco_info.features = [ "http://jabber.org/protocol/caps", "http://jabber.org/protocol/tune", # No +notify ] host.memory.disco.hashes = {caps_hash: mock_disco_info} await pubsub_resource._on_presence_received(client, contact_jid, presence_elt) # Check that no query was made (no +notify) host.memory.storage.get_pep_last_items.assert_not_called() @ed async def test_auto_subscribe_skips_no_presence_subscription( self, host, client, pubsub_resource ): """auto-subscription skips contacts without presence subscription""" client.is_component = True contact_jid = jid.JID("stranger@test.example/resource1") requestor_jid = contact_jid.userhostJID() presence_elt = domish.Element((None, "presence")) presence_elt["from"] = contact_jid.full() # Mock caps hash with +notify caps_hash = "abc123" host.memory.get_entity_datum = MagicMock(return_value=caps_hash) mock_disco_info = MagicMock() mock_disco_info.features = ["http://jabber.org/protocol/tune+notify"] host.memory.disco.hashes = {caps_hash: mock_disco_info} # Mock get_roster to return empty roster (no presence subscription) mock_xep_0356 = MagicMock() mock_xep_0356.get_roster = AsyncMock(return_value={}) host.plugins["XEP-0356"] = mock_xep_0356 pubsub_resource.xep_0356 = mock_xep_0356 host.memory.storage.get_pep_last_items = AsyncMock(return_value=[]) await pubsub_resource._on_presence_received(client, contact_jid, presence_elt) mock_xep_0356.get_roster.assert_called_once_with(client, requestor_jid) # Verify get_pep_last_items was called with only self-subscription, not the # stranger host.memory.storage.get_pep_last_items.assert_called_once() call_args = host.memory.storage.get_pep_last_items.call_args # We want the second positional argument (list of publishers) publishers_arg = call_args[0][1] assert publishers_arg == [ requestor_jid ], f"Expected only self-subscription, got {publishers_arg}"