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
This commit is contained in:
Goffi 2026-04-15 13:50:43 +02:00
commit 42c2f88b30

View file

@ -28,10 +28,12 @@ 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,
@ -109,6 +111,7 @@ def make_pubsub_node(
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)
@ -116,6 +119,7 @@ def make_pubsub_node(
node.publish_model = publish_model
node.affiliations = affiliations or []
node.subscriptions = subscriptions or []
node.pep = pep
return node
@ -721,6 +725,103 @@ class TestPubsubResourceItems:
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."""
@ -1295,7 +1396,6 @@ class TestPubsubResourceSubscriptionsGetSet:
)
],
)
node = make_pubsub_node(
name="test_node",
affiliations=[
@ -1308,3 +1408,76 @@ class TestPubsubResourceSubscriptionsGetSet:
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}"
)