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:
parent
81bd3dcf01
commit
42c2f88b30
9 changed files with 610 additions and 79 deletions
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue