libervia-backend/tests/unit/test_component_pubsub.py
Goffi 42c2f88b30 component pubsub: PEP presence access model and auto-subscription
- Implement `presence` access model for PEP nodes.
- Add PEP auto-subscription (`+notify` handling)
- New `get_pep_last_items` storage method to retrieve in a single query items to send to
  auto-subscribers.
- Online resource tracking.

This completes the PEP support.

rel 476
2026-04-15 20:51:51 +02:00

1483 lines
53 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
"""
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}"
)