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
9 changed files with 610 additions and 79 deletions

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}"
)

View file

@ -37,7 +37,6 @@ from libervia.backend.plugins.plugin_xep_0356 import (
IMPORT_NAME,
PrivilegeError,
)
from tx_xmpp import disco
from tx_xmpp.xmppim import RosterItem, NS_ROSTER
@ -672,12 +671,12 @@ class TestXEP0356PresenceHandling:
trigger_point_calls = []
def mock_trigger_point(point_name, *args):
async def mock_async_point(point_name, *args):
trigger_point_calls.append((point_name, args))
G.host.trigger.point = mock_trigger_point
G.host.trigger.async_point = mock_async_point
xep_0356._on_presence(presence, client)
await xep_0356._on_presence(presence, client)
assert len(trigger_point_calls) == 1
point_name, args = trigger_point_calls[0]
@ -696,13 +695,73 @@ class TestXEP0356PresenceHandling:
trigger_point_calls = []
def mock_trigger_point(point_name, *args):
async def mock_async_point(point_name, *args):
trigger_point_calls.append((point_name, args))
G.host.trigger.point = mock_trigger_point
G.host.trigger.async_point = mock_async_point
xep_0356._on_presence(presence, client)
await xep_0356._on_presence(presence, client)
# We check that async_point was triggered
assert len(trigger_point_calls) == 1
assert trigger_point_calls[0][0] == "XEP-0356_presence_received"
@ed
async def test_presence_tracking_add_and_remove_resource(self, xep_0356, client):
"""_track_presence adds and removes resources from the presence data."""
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
bare_jid = jid.JID("user@test.example")
assert len(data.presence_data.online_resources) == 0
presence_available = domish.Element((None, "presence"))
presence_available["from"] = "user@test.example/resource1"
await xep_0356._on_presence(presence_available, client)
assert bare_jid in data.presence_data.online_resources
assert "resource1" in data.presence_data.online_resources[bare_jid]
presence_unavailable = domish.Element((None, "presence"))
presence_unavailable["from"] = "user@test.example/resource1"
presence_unavailable["type"] = "unavailable"
await xep_0356._on_presence(presence_unavailable, client)
assert bare_jid in data.presence_data.online_resources
assert "resource1" not in data.presence_data.online_resources[bare_jid]
@ed
async def test_presence_tracking_multiple_resources(self, xep_0356, client):
"""_track_presence tracks multiple resources for the same bare JID."""
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
bare_jid = jid.JID("user@test.example")
assert len(data.presence_data.online_resources) == 0
presence1 = domish.Element((None, "presence"))
presence1["from"] = "user@test.example/resource1"
await xep_0356._on_presence(presence1, client)
assert bare_jid in data.presence_data.online_resources
assert "resource1" in data.presence_data.online_resources[bare_jid]
presence2 = domish.Element((None, "presence"))
presence2["from"] = "user@test.example/resource2"
await xep_0356._on_presence(presence2, client)
assert "resource1" in data.presence_data.online_resources[bare_jid]
assert "resource2" in data.presence_data.online_resources[bare_jid]
@ed
async def test_presence_tracking_ignores_bare_jid(self, xep_0356, client):
"""_track_presence ignores presence from bare JIDs (no resource)."""
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
bare_jid = jid.JID("user@test.example")
assert len(data.presence_data.online_resources) == 0
presence = domish.Element((None, "presence"))
presence["from"] = "user@test.example"
await xep_0356._on_presence(presence, client)
assert bare_jid not in data.presence_data.online_resources