From 81bd3dcf01bde66eb9b56c53f47c306491bbd0bf Mon Sep 17 00:00:00 2001 From: Goffi Date: Tue, 14 Apr 2026 23:16:18 +0200 Subject: [PATCH] component pubsub: PEP implementation: New implementations of XEP-0355 and XEP-0356 are integrated to handle PEP. The implementation is not complete yet, as auto-subscribe and presence permissions are not implemented yet. rel 476 --- .../plugins/plugin_comp_pubsub/__init__.py | 154 +++++++---- tests/unit/test_component_pubsub.py | 243 ++++++++++-------- 2 files changed, 244 insertions(+), 153 deletions(-) diff --git a/libervia/backend/plugins/plugin_comp_pubsub/__init__.py b/libervia/backend/plugins/plugin_comp_pubsub/__init__.py index ec85d004..91ea33f4 100644 --- a/libervia/backend/plugins/plugin_comp_pubsub/__init__.py +++ b/libervia/backend/plugins/plugin_comp_pubsub/__init__.py @@ -20,13 +20,13 @@ from dataclasses import dataclass, field, fields from typing import Literal, cast import shortuuid - from twisted.internet import defer from twisted.words.protocols.jabber import error, jid from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish from tx_xmpp import disco, itx_xmpp from tx_xmpp import pubsub, rsm +from tx_xmpp.pubsub import NS_PUBSUB_EVENT from zope.interface import implementer from libervia.backend import G @@ -44,19 +44,20 @@ from libervia.backend.memory.sqla_mapping import ( PubsubSub, SubscriptionState, ) -from libervia.backend.plugins.plugin_xep_0167 import NS_AV_CONFERENCES from libervia.backend.plugins.plugin_xep_0060 import NodeMetadata, XEP_0060 +from libervia.backend.plugins.plugin_xep_0167 import NS_AV_CONFERENCES +from libervia.backend.plugins.plugin_xep_0355 import XEP_0355, DelegationData +from libervia.backend.plugins.plugin_xep_0356 import XEP_0356 from libervia.backend.plugins.plugin_xep_0496 import LINK_VAR, PARENT_VAR, XEP_0496 from libervia.backend.plugins.plugin_xep_0499 import ( DiscoPubsubItem, + DiscoPubsubNode, ExtDiscoOptions, ExtDiscoResult, - DiscoPubsubNode, XEP_0499, ) from libervia.backend.tools.config import ComponentSettings from libervia.backend.tools.xml_tools import element_copy -from tx_xmpp.pubsub import NS_PUBSUB_EVENT log = getLogger(__name__) @@ -69,7 +70,7 @@ PLUGIN_INFO = { C.PI_MODES: [C.PLUG_MODE_COMPONENT], C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT, C.PI_PROTOCOLS: [], - C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0496", "XEP-0499"], + C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0355", "XEP-0356", "XEP-0496", "XEP-0499"], C.PI_RECOMMENDATIONS: [], C.PI_MAIN: "PubsubComponent", C.PI_HANDLER: C.BOOL_TRUE, @@ -187,6 +188,8 @@ class PubsubResource(pubsub.PubSubResource): def __init__(self, service: "PubsubService") -> None: super().__init__() self.service = service + self.xep_0355 = self.service.component.xep_0355 + self.xep_0356 = cast(XEP_0356, G.host.plugins["XEP-0356"]) self.xep_0496 = cast(XEP_0496, G.host.plugins["XEP-0496"]) @property @@ -260,7 +263,7 @@ class PubsubResource(pubsub.PubSubResource): # We only return results for requests without a specific node return [] - nodes = await G.storage.get_pubsub_nodes(self.client, self.client.jid) + nodes = await G.storage.get_pubsub_nodes(self.client, service) return [cast(str, node.name) for node in nodes] def getConfigurationOptions(self) -> dict[str, dict]: @@ -290,13 +293,14 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid if request.items is None or len(request.items) == 0: raise error.StanzaError("bad-request", text="no item to publish") node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -307,7 +311,7 @@ class PubsubResource(pubsub.PubSubResource): raise error.StanzaError("item-not-found", text="Node does not exist") has_publish_rights = await self.xep_0496.has_publish_rights( - self.client, self.client.jid, node, requestor_jid + self.client, service_jid, node, requestor_jid ) if not has_publish_rights: raise error.StanzaError("forbidden", text="Insufficient privileges") @@ -361,6 +365,7 @@ class PubsubResource(pubsub.PubSubResource): assert request.nodeIdentifier is not None assert request.subscriber is not None subscriber_jid = request.subscriber.userhostJID() + service_jid = request.recipient if request.recipient else client.jid if subscriber_jid != requestor_jid: raise error.StanzaError( @@ -368,14 +373,14 @@ class PubsubResource(pubsub.PubSubResource): ) node = await G.storage.get_pubsub_node( - client, client.jid, request.nodeIdentifier, with_affiliations=True + client, service_jid, request.nodeIdentifier, with_affiliations=True ) if node is None: raise error.StanzaError("item-not-found", text="Node does not exist") has_access = await self.xep_0496.has_access( - self.client, self.client.jid, node, subscriber_jid + self.client, service_jid, node, subscriber_jid ) if not has_access: raise error.StanzaError( @@ -460,6 +465,7 @@ class PubsubResource(pubsub.PubSubResource): assert request.nodeIdentifier is not None assert request.subscriber is not None subscriber_jid = request.subscriber.userhostJID() + service_jid = request.recipient if request.recipient else client.jid # Check that the requestor matches the subscriber if subscriber_jid != requestor_jid: @@ -467,7 +473,9 @@ class PubsubResource(pubsub.PubSubResource): "forbidden", text="Cannot unsubscribe on behalf of another entity" ) - node = await G.storage.get_pubsub_node(client, client.jid, request.nodeIdentifier) + node = await G.storage.get_pubsub_node( + client, service_jid, request.nodeIdentifier + ) if node is None: raise error.StanzaError("item-not-found", text="Node does not exist") @@ -495,10 +503,11 @@ class PubsubResource(pubsub.PubSubResource): client = self.client assert request.sender is not None requestor_jid = request.sender.userhostJID() + service_jid = request.recipient if request.recipient else client.jid subscriptions_data = await G.storage.get_pubsub_subscriptions( client, - service=client.jid, + service=service_jid, entity=requestor_jid, node_name=request.nodeIdentifier, ) @@ -537,10 +546,11 @@ class PubsubResource(pubsub.PubSubResource): client = self.client assert request.sender is not None requestor_jid = request.sender.userhostJID() + service_jid = request.recipient if request.recipient else client.jid affiliations = await G.storage.get_pubsub_affiliations( client, - client.jid, + service_jid, entity=requestor_jid, node_name=request.nodeIdentifier, ) @@ -561,6 +571,19 @@ class PubsubResource(pubsub.PubSubResource): client = self.client assert request.sender is not None requestor_jid = request.sender.userhostJID() + service_jid = request.recipient if request.recipient else client.jid + + is_pep = self.xep_0355.is_pep(client, service_jid) + + if is_pep and requestor_jid != service_jid.userhostJID(): + log.warning( + f"{requestor_jid} tried to create a PEP node on" + f" {service_jid.userhostJID()}'s service, hack attempt?" + ) + raise error.StanzaError( + "forbidden", + text="Only the owner of the PEP service can create PEP nodes.", + ) node_id = request.nodeIdentifier or f"generic/{shortuuid.uuid()}" @@ -612,12 +635,13 @@ class PubsubResource(pubsub.PubSubResource): node = await G.storage.set_pubsub_node( client=client, - service=client.jid, + service=service_jid, name=node_id, access_model=config.access_model, publish_model=config.publish_model, affiliations=[owner_affiliation], max_items=db_max_items, + pep=is_pep, ) except exceptions.ConflictError: raise error.StanzaError("conflict", text=f"node {node_id!r} already exists.") @@ -626,7 +650,7 @@ class PubsubResource(pubsub.PubSubResource): try: parent_node, linked_node = await self.xep_0496.check_relationships( self.client, - self.client.jid, + service_jid, node, requestor_jid, config.parent, @@ -702,10 +726,11 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -772,10 +797,11 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -786,7 +812,7 @@ class PubsubResource(pubsub.PubSubResource): raise error.StanzaError("item-not-found", text="Node does not exist") has_access = await self.xep_0496.has_access( - self.client, self.client.jid, node, requestor_jid + self.client, service_jid, node, requestor_jid ) if not has_access: raise error.StanzaError( @@ -911,7 +937,7 @@ class PubsubResource(pubsub.PubSubResource): new_parent_node, new_linked_node = ( await self.xep_0496.check_relationships( self.client, - self.client.jid, + service_jid, node, requestor_jid, new_parent, @@ -975,10 +1001,11 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -988,7 +1015,7 @@ class PubsubResource(pubsub.PubSubResource): raise error.StanzaError("item-not-found", text="Node does not exist") has_access = await self.xep_0496.has_access( - self.client, self.client.jid, node, requestor_jid + self.client, service_jid, node, requestor_jid ) if not has_access: raise error.StanzaError("forbidden", text="Insufficient privileges") @@ -1027,13 +1054,14 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid if not request.itemIdentifiers: raise error.StanzaError("bad-request", text="No item identifiers provided") node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -1102,10 +1130,11 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -1146,10 +1175,11 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_affiliations=True, with_parent=True, @@ -1160,7 +1190,7 @@ class PubsubResource(pubsub.PubSubResource): raise error.StanzaError("item-not-found", text="Node does not exist") has_access = await self.xep_0496.has_access( - self.client, self.client.jid, node, requestor_jid + self.client, service_jid, node, requestor_jid ) if not has_access: raise error.StanzaError( @@ -1203,7 +1233,7 @@ class PubsubResource(pubsub.PubSubResource): if node_name not in nodes_map: fetched_node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, node_name, with_affiliations=True, with_parent=True, @@ -1219,7 +1249,7 @@ class PubsubResource(pubsub.PubSubResource): if nodes_to_delete: await G.storage.delete_pubsub_node( - profiles=None, services=[client.jid], names=nodes_to_delete + profiles=None, services=[service_jid], names=nodes_to_delete ) def affiliationsGet( @@ -1240,16 +1270,17 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( - client, client.jid, request.nodeIdentifier, with_affiliations=True + client, service_jid, request.nodeIdentifier, with_affiliations=True ) if node is None: raise error.StanzaError("item-not-found", text="Node does not exist") has_access = await self.xep_0496.has_access( - self.client, self.client.jid, node, requestor_jid + self.client, service_jid, node, requestor_jid ) if not has_access: raise error.StanzaError( @@ -1291,16 +1322,17 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( - client, client.jid, request.nodeIdentifier, with_affiliations=True + client, service_jid, request.nodeIdentifier, with_affiliations=True ) if node is None: raise error.StanzaError("item-not-found", text="Node does not exist") has_access = await self.xep_0496.has_access( - self.client, self.client.jid, node, requestor_jid + self.client, service_jid, node, requestor_jid ) if not has_access: raise error.StanzaError( @@ -1348,7 +1380,7 @@ class PubsubResource(pubsub.PubSubResource): if affiliations_to_set: await G.storage.set_pubsub_affiliations( - client, client.jid, request.nodeIdentifier, affiliations_to_set + client, service_jid, request.nodeIdentifier, affiliations_to_set ) def subscriptionsGet( @@ -1371,10 +1403,11 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( client, - client.jid, + service_jid, request.nodeIdentifier, with_subscriptions=True, with_affiliations=True, @@ -1433,9 +1466,10 @@ class PubsubResource(pubsub.PubSubResource): requestor_jid = request.sender.userhostJID() assert request.nodeIdentifier is not None + service_jid = request.recipient if request.recipient else client.jid node = await G.storage.get_pubsub_node( - client, client.jid, request.nodeIdentifier, with_affiliations=True + client, service_jid, request.nodeIdentifier, with_affiliations=True ) if node is None: @@ -1550,6 +1584,7 @@ class PubsubResource(pubsub.PubSubResource): async def _send_notification( self, + node: PubsubNode, event_type: NotificationEventType, node_id: str, subscriber: jid.JID, @@ -1557,13 +1592,14 @@ class PubsubResource(pubsub.PubSubResource): ) -> None: """Send a single notification to a subscriber. + @param node: The pubsub node (to check if it's a PEP node). @param event_type: The type of event. @param node_id: The node identifier. @param subscriber: The subscriber JID. @param item_elts: The items to include in the notification. """ message_elt = domish.Element((None, "message")) - message_elt["from"] = self.client.jid.full() + message_elt["from"] = node.service.full() message_elt["to"] = subscriber.full() message_elt["id"] = f"{shortuuid.uuid()}" @@ -1594,7 +1630,10 @@ class PubsubResource(pubsub.PubSubResource): f"Unknown notification event type: {event_type!r}" ) - await self.client.a_send(message_elt) + if node.pep: + await self.xep_0356.send_privileged_message(self.client, message_elt) + else: + await self.client.a_send(message_elt) async def _send_notifications( self, @@ -1616,7 +1655,9 @@ class PubsubResource(pubsub.PubSubResource): deferreds = [ defer.ensureDeferred( - self._send_notification(event_type, node.name, recipient_jid, item_elts) + self._send_notification( + node, event_type, node.name, recipient_jid, item_elts + ) ) for recipient_jid, __ in recipients.items() ] @@ -1631,8 +1672,8 @@ class PubsubResource(pubsub.PubSubResource): class PubsubService(rsm.PubSubService): def __init__(self, component: "PubsubComponent"): - super().__init__(resource=PubsubResource(self)) self.component = component + super().__init__(resource=PubsubResource(self)) self.discoidentity = { "category": "pubsub", "type": "service", @@ -1654,11 +1695,19 @@ class PubsubService(rsm.PubSubService): assert query_elt is not None node_name = query_elt.getAttribute("node") - requestor_jid = client.jid + try: + service_jid = jid.JID(iq_elt["to"]) + except (KeyError, RuntimeError): + service_jid = client.jid + + try: + requestor_jid = jid.JID(iq_elt["from"]) + except (KeyError, RuntimeError): + requestor_jid = client.jid nodes = await G.storage.get_pubsub_nodes( client, - service=client.jid, + service=service_jid, node_name=node_name, linked_node=ext_disco_options.linked_nodes, depth=ext_disco_options.depth, @@ -1672,24 +1721,24 @@ class PubsubService(rsm.PubSubService): for node in nodes: if ext_disco_options.depth == 0: - disco_node = DiscoPubsubNode.from_sqlalchemy(node, client.jid) + disco_node = DiscoPubsubNode.from_sqlalchemy(node, service_jid) if "items" in ext_disco_options.type: try: disco_node.items = [ - DiscoPubsubItem.from_sqlalchemy(item, node.name, client.jid) + DiscoPubsubItem.from_sqlalchemy(item, node.name, service_jid) for item in node.items ] except Exception: pass else: disco_node = DiscoPubsubNode.from_sqlalchemy_full_hierarchy( - node, client.jid + node, service_jid ) filtered_node = ( await self.component.ext_disco.filter_node_hierarchy_by_access( client, - client.jid, + service_jid, disco_node, requestor_jid, depth=ext_disco_options.depth, @@ -1714,9 +1763,10 @@ class PubsubComponent: verbose = 0 def __init__(self, host): - self._clientt: SatXMPPComponent | None = None - self.pubsub_service = PubsubService(self) + self._client: SatXMPPComponent | None = None self.ext_disco = cast(XEP_0499, host.plugins.get("XEP-0499")) + self.xep_0355 = cast(XEP_0355, G.host.plugins["XEP-0355"]) + self.pubsub_service = PubsubService(self) @property def client(self) -> SatXMPPComponent: @@ -1729,6 +1779,18 @@ class PubsubComponent: def profile_connecting(self, client: SatXMPPEntity) -> None: self._client = cast(SatXMPPComponent, client) self.ext_disco.register_handler(self.pubsub_service.on_extended_disco) + delegation_data = client.get_plugin_data("XEP_0355", DelegationData) + delegation_data.delegation_received.addCallback(self._on_delegation_received) + + def _on_delegation_received(self, __) -> None: + """Check that pubsub is delegated. + + NS_PUBSUB is needed to manage PEP nodes. + """ + if pubsub.NS_PUBSUB not in self.xep_0355.delegated_namespaces: + log.warning( + "Didn't get pubsub delegation from server, can't act as a PEP service" + ) @implementer(itx_xmpp.IDisco) diff --git a/tests/unit/test_component_pubsub.py b/tests/unit/test_component_pubsub.py index 1e2b4f9d..87c826e9 100644 --- a/tests/unit/test_component_pubsub.py +++ b/tests/unit/test_component_pubsub.py @@ -42,19 +42,34 @@ from libervia.backend.plugins.plugin_xep_0496 import XEP_0496 @fixture(autouse=True) -def setup_xep_0496(host, client): - """Set up XEP-0496 plugin instance for pubsub tests. +def prepare_for_tests(host, client): + """Set up important data for testing the pubsub component. - This autouse fixture 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. + 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, @@ -68,6 +83,7 @@ def make_pubsub_request( 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() @@ -83,6 +99,7 @@ def make_pubsub_request( request.maxItems = maxItems request.nodeType = nodeType request.notify = notify + request.recipient = recipient return request @@ -132,17 +149,17 @@ class TestPubsubResourceCreate: """Tests for node creation.""" @ed - async def test_creates_node_with_owner_affiliation(self, host, client): + async def test_creates_node_with_owner_affiliation(self, host, pubsub_resource): """creating a node sets the creator as owner""" - resource = PubsubResource(MagicMock(client=client)) 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 resource._create(request) + await pubsub_resource._create(request) # Verify that the creator is set as owner call_args = host.memory.storage.set_pubsub_node.call_args @@ -153,14 +170,16 @@ class TestPubsubResourceCreate: assert affiliations[0].affiliation == Affiliation.owner @ed - async def test_generates_node_id_if_not_provided(self, host, client): + async def test_generates_node_id_if_not_provided(self, host, pubsub_resource): """creating a node without ID generates one""" - resource = PubsubResource(MagicMock(client=client)) - request = make_pubsub_request(sender=jid.JID("user@test.example")) + 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 resource._create(request) + await pubsub_resource._create(request) # Verify a node ID was generated and used call_args = host.memory.storage.set_pubsub_node.call_args @@ -172,14 +191,14 @@ class TestPubsubResourceCreate: assert node_id.startswith("generic/") @ed - async def test_raises_conflict_if_node_exists(self, host, client): + 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 - resource = PubsubResource(MagicMock(client=client)) 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 @@ -187,16 +206,16 @@ class TestPubsubResourceCreate: host.memory.storage.set_pubsub_node = AsyncMock(side_effect=ConflictError()) with raises(StanzaError, match="conflict"): - await resource._create(request) + await pubsub_resource._create(request) class TestPubsubResourcePublish: """Tests for item publishing.""" @ed - async def test_rejects_empty_items(self, host, client): + async def test_rejects_empty_items(self, host, client, pubsub_resource): """publishing empty items raises bad-request""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -207,9 +226,9 @@ class TestPubsubResourcePublish: await resource._publish(request) @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """publishing to nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -222,9 +241,11 @@ class TestPubsubResourcePublish: await resource._publish(request) @ed - async def test_publishers_model_requires_publisher_affiliation(self, host, client): + async def test_publishers_model_requires_publisher_affiliation( + self, host, client, pubsub_resource + ): """publishers model requires publisher or owner affiliation""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -244,9 +265,9 @@ class TestPubsubResourcePublish: await resource._publish(request) @ed - async def test_publishers_model_allows_owner(self, host, client): + async def test_publishers_model_allows_owner(self, host, client, pubsub_resource): """publishers model allows owners to publish""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -268,9 +289,9 @@ class TestPubsubResourcePublish: host.memory.storage.cache_pubsub_items.assert_called_once() @ed - async def test_publishers_model_allows_publisher(self, host, client): + async def test_publishers_model_allows_publisher(self, host, client, pubsub_resource): """publishers model allows publishers to publish""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("publisher@test.example"), nodeIdentifier="test_node", @@ -292,9 +313,11 @@ class TestPubsubResourcePublish: host.memory.storage.cache_pubsub_items.assert_called_once() @ed - async def test_subscribers_model_requires_subscription(self, host, client): + async def test_subscribers_model_requires_subscription( + self, host, client, pubsub_resource + ): """subscribers model requires subscription to publish""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -314,9 +337,11 @@ class TestPubsubResourcePublish: await resource._publish(request) @ed - async def test_subscribers_model_allows_subscribed_member(self, host, client): + async def test_subscribers_model_allows_subscribed_member( + self, host, client, pubsub_resource + ): """subscribers model allows subscribed members to publish""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -339,9 +364,9 @@ class TestPubsubResourcePublish: host.memory.storage.cache_pubsub_items.assert_called_once() @ed - async def test_generates_item_id_if_missing(self, host, client): + async def test_generates_item_id_if_missing(self, host, client, pubsub_resource): """publishing an item without ID generates one""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource item = make_item_element("") # Empty ID request = make_pubsub_request( sender=jid.JID("owner@test.example"), @@ -370,9 +395,9 @@ class TestPubsubResourcePublish: assert len(items_data[0].getAttribute("id")) > 0 @ed - async def test_sets_publisher_attribute(self, host, client): + async def test_sets_publisher_attribute(self, host, client, pubsub_resource): """publishing sets the publisher attribute on items""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource item = make_item_element("item1") request = make_pubsub_request( sender=jid.JID("user@test.example/resource"), @@ -405,9 +430,9 @@ class TestPubsubResourceSubscribe: """Tests for subscription management.""" @ed - async def test_rejects_subscribing_for_others(self, host, client): + async def test_rejects_subscribing_for_others(self, host, client, pubsub_resource): """subscribing for another user raises forbidden""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -418,9 +443,9 @@ class TestPubsubResourceSubscribe: await resource._subscribe(request) @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """subscribing to nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -433,9 +458,9 @@ class TestPubsubResourceSubscribe: await resource._subscribe(request) @ed - async def test_rejects_outcast_users(self, host, client): + async def test_rejects_outcast_users(self, host, client, pubsub_resource): """outcast users cannot subscribe""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("outcast@test.example"), nodeIdentifier="test_node", @@ -454,9 +479,9 @@ class TestPubsubResourceSubscribe: await resource._subscribe(request) @ed - async def test_open_model_allows_anyone(self, host, client): + async def test_open_model_allows_anyone(self, host, client, pubsub_resource): """open access model allows anyone to subscribe""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -481,9 +506,11 @@ class TestPubsubResourceSubscribe: ) @ed - async def test_whitelist_model_requires_membership(self, host, client): + async def test_whitelist_model_requires_membership( + self, host, client, pubsub_resource + ): """whitelist access model requires member affiliation""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -501,9 +528,9 @@ class TestPubsubResourceSubscribe: await resource._subscribe(request) @ed - async def test_whitelist_model_allows_member(self, host, client): + async def test_whitelist_model_allows_member(self, host, client, pubsub_resource): """whitelist access model allows members to subscribe""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("member@test.example"), nodeIdentifier="test_node", @@ -531,9 +558,9 @@ class TestPubsubResourceSubscribe: ) @ed - async def test_rejects_duplicate_pending(self, host, client): + async def test_rejects_duplicate_pending(self, host, client, pubsub_resource): """subscribing when already pending raises not-authorized""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -558,9 +585,9 @@ class TestPubsubResourceUnsubscribe: """Tests for unsubscription.""" @ed - async def test_rejects_unsubscribing_for_others(self, host, client): + async def test_rejects_unsubscribing_for_others(self, host, client, pubsub_resource): """unsubscribing for another user raises forbidden""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -571,9 +598,9 @@ class TestPubsubResourceUnsubscribe: await resource._unsubscribe(request) @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """unsubscribing from nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -586,9 +613,9 @@ class TestPubsubResourceUnsubscribe: await resource._unsubscribe(request) @ed - async def test_removes_subscription(self, host, client): + async def test_removes_subscription(self, host, client, pubsub_resource): """unsubscribing removes the subscription""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -617,9 +644,9 @@ class TestPubsubResourceItems: """Tests for item retrieval.""" @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """retrieving items from nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -631,9 +658,9 @@ class TestPubsubResourceItems: await resource._items(request) @ed - async def test_open_model_allows_anyone(self, host, client): + async def test_open_model_allows_anyone(self, host, client, pubsub_resource): """open access model allows anyone to retrieve items""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -651,9 +678,11 @@ class TestPubsubResourceItems: host.memory.storage.get_items.assert_called_once() @ed - async def test_whitelist_model_rejects_non_members(self, host, client): + async def test_whitelist_model_rejects_non_members( + self, host, client, pubsub_resource + ): """whitelist access model rejects non-members""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -670,9 +699,9 @@ class TestPubsubResourceItems: await resource._items(request) @ed - async def test_whitelist_model_allows_member(self, host, client): + async def test_whitelist_model_allows_member(self, host, client, pubsub_resource): """whitelist access model allows members to retrieve items""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("member@test.example"), nodeIdentifier="test_node", @@ -697,9 +726,9 @@ class TestPubsubResourceRetract: """Tests for item retraction.""" @ed - async def test_rejects_empty_identifiers(self, host, client): + async def test_rejects_empty_identifiers(self, host, client, pubsub_resource): """retracting without item identifiers raises bad-request""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -710,9 +739,9 @@ class TestPubsubResourceRetract: await resource._retract(request) @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """retracting from nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -725,9 +754,9 @@ class TestPubsubResourceRetract: await resource._retract(request) @ed - async def test_owner_can_retract_any_item(self, host, client): + async def test_owner_can_retract_any_item(self, host, client, pubsub_resource): """owners can retract any item from their node""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -760,9 +789,9 @@ class TestPubsubResourceRetract: assert call_args.args[1] == ["item1"] @ed - async def test_publisher_can_retract_any_item(self, host, client): + async def test_publisher_can_retract_any_item(self, host, client, pubsub_resource): """publishers can retract any item from their node""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("publisher@test.example"), nodeIdentifier="test_node", @@ -794,9 +823,9 @@ class TestPubsubResourceRetract: assert call_args.args[1] == ["item1"] @ed - async def test_user_can_only_retract_own_items(self, host, client): + async def test_user_can_only_retract_own_items(self, host, client, pubsub_resource): """non-owners can only retract their own items""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -824,9 +853,9 @@ class TestPubsubResourceRetract: await resource._retract(request) @ed - async def test_user_can_retract_own_items(self, host, client): + async def test_user_can_retract_own_items(self, host, client, pubsub_resource): """users can retract their own items""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -863,9 +892,9 @@ class TestPubsubResourcePurge: """Tests for node purging.""" @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """purging nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -877,9 +906,9 @@ class TestPubsubResourcePurge: await resource._purge(request) @ed - async def test_only_owners_can_purge(self, host, client): + async def test_only_owners_can_purge(self, host, client, pubsub_resource): """only owners can purge a node""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -898,9 +927,9 @@ class TestPubsubResourcePurge: await resource._purge(request) @ed - async def test_owner_can_purge(self, host, client): + async def test_owner_can_purge(self, host, client, pubsub_resource): """owners can purge their node""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -929,9 +958,9 @@ class TestPubsubResourceDelete: """Tests for node deletion.""" @ed - async def test_rejects_nonexistent_node(self, host, client): + async def test_rejects_nonexistent_node(self, host, client, pubsub_resource): """deleting nonexistent node raises item_not_found""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="nonexistent_node", @@ -943,9 +972,9 @@ class TestPubsubResourceDelete: await resource._delete(request) @ed - async def test_only_owners_can_delete(self, host, client): + async def test_only_owners_can_delete(self, host, client, pubsub_resource): """only owners can delete a node""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -964,9 +993,9 @@ class TestPubsubResourceDelete: await resource._delete(request) @ed - async def test_owner_can_delete(self, host, client): + async def test_owner_can_delete(self, host, client, pubsub_resource): """owners can delete their node""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -995,9 +1024,9 @@ class TestPubsubResourceConfigure: """Tests for node configuration.""" @ed - async def test_only_owners_can_get_config(self, host, client): + async def test_only_owners_can_get_config(self, host, client, pubsub_resource): """only owners can get node configuration""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -1016,9 +1045,9 @@ class TestPubsubResourceConfigure: await resource._configureGet(request) @ed - async def test_owner_can_get_config(self, host, client): + async def test_owner_can_get_config(self, host, client, pubsub_resource): """owners can get node configuration""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -1041,9 +1070,9 @@ class TestPubsubResourceConfigure: assert result["pubsub#publish_model"] == "subscribers" @ed - async def test_only_owners_can_set_config(self, host, client): + async def test_only_owners_can_set_config(self, host, client, pubsub_resource): """only owners can set node configuration""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -1063,9 +1092,9 @@ class TestPubsubResourceConfigure: await resource._configureSet(request) @ed - async def test_owner_can_set_config(self, host, client): + async def test_owner_can_set_config(self, host, client, pubsub_resource): """owners can set node configuration""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -1098,9 +1127,9 @@ class TestPubsubResourceAffiliations: """Tests for affiliation management.""" @ed - async def test_only_owners_can_get_affiliations(self, host, client): + async def test_only_owners_can_get_affiliations(self, host, client, pubsub_resource): """only owners can retrieve all affiliations""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -1119,9 +1148,9 @@ class TestPubsubResourceAffiliations: await resource._affiliationsGet(request) @ed - async def test_only_owners_can_set_affiliations(self, host, client): + async def test_only_owners_can_set_affiliations(self, host, client, pubsub_resource): """only owners can set affiliations""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -1141,9 +1170,9 @@ class TestPubsubResourceAffiliations: await resource._affiliationsSet(request) @ed - async def test_owner_cannot_demote_self(self, host, client): + async def test_owner_cannot_demote_self(self, host, client, pubsub_resource): """owners cannot change their own affiliation to non-owner""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -1167,9 +1196,9 @@ class TestPubsubResourceSubscriptionsGetSet: """Tests for subscription management by owners.""" @ed - async def test_only_owners_can_get_subscriptions(self, host, client): + async def test_only_owners_can_get_subscriptions(self, host, client, pubsub_resource): """only owners can retrieve all subscriptions""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -1188,9 +1217,9 @@ class TestPubsubResourceSubscriptionsGetSet: await resource._subscriptionsGet(request) @ed - async def test_only_owners_can_set_subscriptions(self, host, client): + async def test_only_owners_can_set_subscriptions(self, host, client, pubsub_resource): """only owners can set subscriptions""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("user@test.example"), nodeIdentifier="test_node", @@ -1216,9 +1245,9 @@ class TestPubsubResourceSubscriptionsGetSet: await resource._subscriptionsSet(request) @ed - async def test_owner_can_set_subscriptions(self, host, client): + async def test_owner_can_set_subscriptions(self, host, client, pubsub_resource): """owners can set subscriptions for entities""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node", @@ -1252,9 +1281,9 @@ class TestPubsubResourceSubscriptionsGetSet: ) @ed - async def test_cannot_subscribe_outcast(self, host, client): + async def test_cannot_subscribe_outcast(self, host, client, pubsub_resource): """owners cannot subscribe outcast entities""" - resource = PubsubResource(MagicMock(client=client)) + resource = pubsub_resource request = make_pubsub_request( sender=jid.JID("owner@test.example"), nodeIdentifier="test_node",