#!/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 . """ Tests for XEP-0356 Privileged Entity implementation. """ from unittest.mock import MagicMock from pytest import fixture, raises from pytest_twisted import ensureDeferred as ed from twisted.internet import reactor from twisted.words.protocols.jabber import jid from twisted.words.xish import domish from libervia.backend import G from libervia.backend.plugins.plugin_xep_0356 import ( XEP_0356, NS_PRIVILEGE, ClientPrivilegeData, RosterCacheEntry, IMPORT_NAME, PrivilegeError, ) from tx_xmpp.xmppim import RosterItem, NS_ROSTER @fixture def xep_0356(host): """Create XEP-0356 plugin instance.""" return XEP_0356(host) @fixture(autouse=True) def setup_component_client(client): """Set up client as a component for XEP-0356 tests.""" client.is_component = True _priv_data = ClientPrivilegeData() client.get_plugin_data = lambda import_name, data_class: _priv_data def make_privilege_advertisement(privileges): """Helper to create privilege advertisement message. @param privileges: Dict of privilege type -> access level e.g., {"roster": "both", "message": "outgoing"} """ msg = domish.Element( (None, "message"), attribs={"from": "test.example", "to": "pubsub.test.example"}, ) privilege = domish.Element((NS_PRIVILEGE, "privilege")) for access, perm_type in privileges.items(): perm_attrs = {"access": access} if perm_type: perm_attrs["type"] = perm_type if access == "roster" and perm_type in ("get", "both"): perm_attrs["push"] = "true" perm = domish.Element((NS_PRIVILEGE, "perm"), attribs=perm_attrs) privilege.addChild(perm) msg.addChild(privilege) return msg def make_iq_privilege_advertisement(namespaces): """Helper to create IQ privilege advertisement. @param namespaces: Dict of namespace -> type e.g., {"http://jabber.org/protocol/pubsub": "set"} """ msg = domish.Element( (None, "message"), attribs={"from": "test.example", "to": "pubsub.test.example"}, ) privilege = domish.Element((NS_PRIVILEGE, "privilege")) perm = domish.Element((NS_PRIVILEGE, "perm"), attribs={"access": "iq"}) for ns, perm_type in namespaces.items(): ns_elt = domish.Element( (NS_PRIVILEGE, "namespace"), attribs={"ns": ns, "type": perm_type}, ) perm.addChild(ns_elt) privilege.addChild(perm) msg.addChild(privilege) return msg class TestXEP0356Advertisement: """Tests for privilege advertisement handling.""" @ed async def test_parses_roster_privilege_both(self, xep_0356, client): """Roster privilege with type='both' sets access correctly.""" msg = make_privilege_advertisement({"roster": "both"}) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert data.privileges.roster_access == "both" assert xep_0356.can_access_roster(client) assert xep_0356.can_modify_roster(client) assert xep_0356.can_receive_roster_pushes(client) @ed async def test_parses_roster_privilege_get(self, xep_0356, client): """Roster privilege with type='get' allows read-only access.""" msg = make_privilege_advertisement({"roster": "get"}) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert data.privileges.roster_access == "get" assert xep_0356.can_access_roster(client) assert not xep_0356.can_modify_roster(client) assert xep_0356.can_receive_roster_pushes(client) @ed async def test_parses_roster_privilege_set(self, xep_0356, client): """Roster privilege with type='set' allows write-only access.""" msg = make_privilege_advertisement({"roster": "set"}) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert data.privileges.roster_access == "set" assert not xep_0356.can_access_roster(client) assert xep_0356.can_modify_roster(client) assert not xep_0356.can_receive_roster_pushes(client) @ed async def test_parses_message_privilege(self, xep_0356, client): """Message privilege is correctly set.""" msg = make_privilege_advertisement({"message": "outgoing"}) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert data.privileges.message_privilege == "outgoing" assert xep_0356.can_send_messages(client) @ed async def test_parses_presence_privilege_managed_entity(self, xep_0356, client): """Presence privilege with managed_entity type.""" msg = make_privilege_advertisement({"presence": "managed_entity"}) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert data.privileges.presence_privilege == "managed_entity" assert xep_0356.can_receive_managed_presence(client) assert not xep_0356.can_receive_roster_presence(client) @ed async def test_parses_presence_privilege_roster(self, xep_0356, client): """Presence privilege with roster type.""" msg = make_privilege_advertisement({"presence": "roster"}) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert data.privileges.presence_privilege == "roster" assert xep_0356.can_receive_managed_presence(client) assert xep_0356.can_receive_roster_presence(client) @ed async def test_parses_iq_privilege(self, xep_0356, client): """IQ privilege with namespace is correctly set.""" msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "set"} ) xep_0356.on_advertise(msg, client) data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert "http://jabber.org/protocol/pubsub" in data.privileges.iq_permissions assert xep_0356.can_send_iq(client, "http://jabber.org/protocol/pubsub", "set") assert not xep_0356.can_send_iq( client, "http://jabber.org/protocol/pubsub", "get" ) @ed async def test_parses_iq_privilege_both(self, xep_0356, client): """IQ privilege with type='both' allows get and set.""" msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "both"} ) xep_0356.on_advertise(msg, client) assert xep_0356.can_send_iq(client, "http://jabber.org/protocol/pubsub", "get") assert xep_0356.can_send_iq(client, "http://jabber.org/protocol/pubsub", "set") @ed async def test_rejects_advertisement_from_wrong_server(self, xep_0356, client): """Advertisement from wrong server raises StanzaError.""" from twisted.words.protocols.jabber.error import StanzaError msg = make_privilege_advertisement({"roster": "both"}) msg["from"] = "other.example" with raises(StanzaError, match="not-allowed"): xep_0356.on_advertise(msg, client) class TestXEP0356RosterAccess: """Tests for roster access operations.""" @ed async def test_get_roster_without_privilege_raises(self, xep_0356, client): """get_roster raises PrivilegeError without roster privilege.""" user_jid = jid.JID("user@test.example") with raises(PrivilegeError, match="Roster access not permitted"): await xep_0356.get_roster(client, user_jid) @ed async def test_get_roster_sends_request(self, xep_0356, client, client_IQ): """get_roster sends proper roster get request.""" # Grant privilege msg = make_privilege_advertisement({"roster": "both"}) xep_0356.on_advertise(msg, client) user_jid = jid.JID("user@test.example") contact_jid = jid.JID("contact@test.example") # Create mock IQ with roster response response_elt = domish.Element((None, "iq")) response_elt["type"] = "result" item = domish.Element((NS_ROSTER, "item")) item["jid"] = contact_jid.full() item["subscription"] = "both" response_elt.addChild(item) client.IQ = client_IQ(response_elt=response_elt) # Call get_roster roster = await xep_0356.get_roster(client, user_jid) # We check that the request was sent correctly assert len(client.IQ.sent_iqs) == 1 sent_iq = client.IQ.sent_iqs[0] assert sent_iq["type"] == "get" assert sent_iq["to"] == user_jid.full() assert len(list(sent_iq.elements("jabber:iq:roster", "query"))) == 1 # We check that roster was parsed correctly assert len(roster) == 1 assert contact_jid in roster assert isinstance(roster[contact_jid], RosterItem) assert roster[contact_jid].subscriptionTo assert roster[contact_jid].subscriptionFrom @ed async def test_get_roster_uses_cache(self, xep_0356, client, client_IQ): """get_roster uses cached roster if not expired.""" # Grant privilege msg = make_privilege_advertisement({"roster": "both"}) xep_0356.on_advertise(msg, client) user_jid = jid.JID("user@test.example") # Pre-populate cache data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) cached_item = RosterItem(user_jid) cached_item.subscriptionTo = True data.roster_cache[user_jid] = RosterCacheEntry( roster={user_jid: cached_item}, version="v1", # Fresh timestamp timestamp=reactor.seconds(), ) client.IQ = client_IQ() roster = await xep_0356.get_roster(client, user_jid) # We check that cache was used (no IQ sent) assert len(client.IQ.sent_iqs) == 0 assert len(roster) == 1 @ed async def test_on_roster_push_updates_cache(self, xep_0356, client): """on_roster_push updates the roster cache.""" data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) # We want to be sure that roster is empty assert len(data.roster_cache) == 0 # Grant roster push privilege msg = make_privilege_advertisement({"roster": "both"}) xep_0356.on_advertise(msg, client) owner_jid = jid.JID("user@test.example") # Create roster push stanza iq = domish.Element((None, "iq")) iq["from"] = owner_jid.full() iq["type"] = "set" query = domish.Element(("jabber:iq:roster", "query")) item = domish.Element(("jabber:iq:roster", "item")) item["jid"] = "contact@test.example" item["subscription"] = "both" query.addChild(item) iq.addChild(query) xep_0356.on_roster_push(client, iq) # We check that cache was updated assert owner_jid in data.roster_cache cached_entry = data.roster_cache[owner_jid] assert isinstance(cached_entry, RosterCacheEntry) cached_roster = cached_entry.roster assert len(cached_roster) == 1 contact_jid = jid.JID("contact@test.example") assert contact_jid in cached_roster assert isinstance(cached_roster[contact_jid], RosterItem) @ed async def test_on_roster_push_remove_item(self, xep_0356, client): """on_roster_push removes item when subscription='remove'.""" # Grant roster push privilege msg = make_privilege_advertisement({"roster": "both"}) xep_0356.on_advertise(msg, client) owner_jid = jid.JID("user@test.example") # Pre-populate cache with an item data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) existing_item = RosterItem(jid.JID("contact@test.example")) data.roster_cache[owner_jid] = RosterCacheEntry( roster={jid.JID("contact@test.example"): existing_item}, version="v1", timestamp=reactor.seconds(), ) # Create roster push with remove iq = domish.Element((None, "iq")) iq["from"] = owner_jid.full() iq["type"] = "set" query = domish.Element(("jabber:iq:roster", "query")) item = domish.Element(("jabber:iq:roster", "item")) item["jid"] = "contact@test.example" item["subscription"] = "remove" query.addChild(item) iq.addChild(query) xep_0356.on_roster_push(client, iq) # We check that item was removed cached_entry = data.roster_cache[owner_jid] cached_roster = cached_entry.roster assert len(cached_roster) == 0 @ed async def test_on_roster_push_without_privilege(self, xep_0356, client): """on_roster_push is ignored without roster push privilege.""" # Don't grant privilege owner_jid = jid.JID("user@test.example") iq = domish.Element((None, "iq")) iq["from"] = owner_jid.full() iq["type"] = "set" query = domish.Element(("jabber:iq:roster", "query")) iq.addChild(query) xep_0356.on_roster_push(client, iq) # We check that cache was not updated data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData) assert owner_jid not in data.roster_cache class TestXEP0356PrivilegedMessages: """Tests for privileged message sending.""" @ed async def test_send_privileged_message_without_privilege_raises( self, xep_0356, client ): """send_privileged_message raises PrivilegeError without message privilege.""" message = domish.Element((None, "message")) message["from"] = "user@test.example" with raises(PrivilegeError, match="Message privilege not permitted"): await xep_0356.send_privileged_message(client, message) @ed async def test_send_privileged_message_without_from_raises(self, xep_0356, client): """send_privileged_message raises PrivilegeError if message has no 'from'.""" # Grant privilege msg = make_privilege_advertisement({"message": "outgoing"}) xep_0356.on_advertise(msg, client) message = domish.Element((None, "message")) with raises(PrivilegeError, match='Message must have a "from" attribute'): await xep_0356.send_privileged_message(client, message) @ed async def test_send_privileged_message_wrong_server_raises(self, xep_0356, client): """send_privileged_message raises if from is not from our server.""" # Grant privilege msg = make_privilege_advertisement({"message": "outgoing"}) xep_0356.on_advertise(msg, client) message = domish.Element((None, "message")) message["from"] = "user@other.example" with raises(PrivilegeError, match="from_jid must be a bare JID from our server"): await xep_0356.send_privileged_message(client, message) @ed async def test_send_privileged_message_with_resource_raises(self, xep_0356, client): """send_privileged_message raises if from has a resource.""" # Grant privilege msg = make_privilege_advertisement({"message": "outgoing"}) xep_0356.on_advertise(msg, client) message = domish.Element((None, "message")) message["from"] = "user@test.example/resource" with raises(PrivilegeError, match="from_jid must be a bare JID from our server"): await xep_0356.send_privileged_message(client, message) @ed async def test_send_privileged_message_sends_wrapped_message(self, xep_0356, client): """send_privileged_message sends properly wrapped message.""" # Grant privilege msg = make_privilege_advertisement({"message": "outgoing"}) xep_0356.on_advertise(msg, client) from_jid = jid.JID("user@test.example") to_jid = jid.JID("other@test.example") inner_msg = domish.Element( (None, "message"), attribs={"from": from_jid.full(), "to": to_jid.full()}, ) body = domish.Element((None, "body")) body.textContent = "Hello" inner_msg.addChild(body) # Track sent messages sent_messages = [] def mock_send(elt): sent_messages.append(elt) client.xmlstream.send = mock_send # Call send_privileged_message await xep_0356.send_privileged_message(client, inner_msg) # We check that message was sent assert len(sent_messages) == 1 wrapper = sent_messages[0] assert wrapper.name == "message" assert wrapper["from"] == client.jid.full() assert wrapper["to"] == client.server_jid.full() assert "id" in wrapper.attributes # We check that privilege wrapper privilege = next(wrapper.elements(NS_PRIVILEGE, "privilege")) assert privilege is not None class TestXEP0356PrivilegedIQ: """Tests for privileged IQ sending.""" @ed async def test_send_privileged_iq_without_privilege_raises(self, xep_0356, client): """send_privileged_iq raises PrivilegeError without IQ privilege.""" iq = domish.Element(("jabber:client", "iq")) iq["type"] = "set" iq["to"] = "user@test.example" payload = domish.Element(("http://jabber.org/protocol/pubsub", "publish")) iq.addChild(payload) with raises(PrivilegeError, match="IQ privilege not permitted"): await xep_0356.send_privileged_iq(client, iq) @ed async def test_send_privileged_iq_no_to_raises(self, xep_0356, client): """send_privileged_iq raises if IQ has no 'to' attribute.""" # Grant privilege msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "set"} ) xep_0356.on_advertise(msg, client) iq = domish.Element(("jabber:client", "iq")) iq["type"] = "set" payload = domish.Element(("http://jabber.org/protocol/pubsub", "publish")) iq.addChild(payload) with raises(PrivilegeError, match="IQ must have a 'to' attribute"): await xep_0356.send_privileged_iq(client, iq) @ed async def test_send_privileged_iq_wrong_server_raises(self, xep_0356, client): """send_privileged_iq raises if to is not from our server.""" # Grant privilege msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "set"} ) xep_0356.on_advertise(msg, client) iq = domish.Element(("jabber:client", "iq")) iq["type"] = "set" iq["to"] = "user@other.example" payload = domish.Element(("http://jabber.org/protocol/pubsub", "publish")) iq.addChild(payload) with raises(PrivilegeError, match="user_jid must be a bare JID from our server"): await xep_0356.send_privileged_iq(client, iq) @ed async def test_send_privileged_iq_no_payload_raises(self, xep_0356, client): """send_privileged_iq raises if IQ has no payload.""" # Grant privilege msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "set"} ) xep_0356.on_advertise(msg, client) iq = domish.Element(("jabber:client", "iq")) iq["type"] = "set" iq["to"] = "user@test.example" with raises(PrivilegeError, match="IQ has no payload"): await xep_0356.send_privileged_iq(client, iq) @ed async def test_send_privileged_iq_unauthorized_namespace_raises( self, xep_0356, client ): """send_privileged_iq raises for unauthorized namespace.""" # Grant privilege for pubsub only msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "set"} ) xep_0356.on_advertise(msg, client) iq = domish.Element(("jabber:client", "iq")) iq["type"] = "set" iq["to"] = "user@test.example" payload = domish.Element(("http://jabber.org/protocol/bookmarks", "bookmarks")) iq.addChild(payload) with raises(PrivilegeError, match="IQ privilege not permitted"): await xep_0356.send_privileged_iq(client, iq) @ed async def test_send_privileged_iq_sends_wrapped_iq(self, xep_0356, client, client_IQ): """send_privileged_iq sends properly wrapped IQ.""" # Grant privilege msg = make_iq_privilege_advertisement( {"http://jabber.org/protocol/pubsub": "set"} ) xep_0356.on_advertise(msg, client) user_jid = jid.JID("user@test.example") inner_iq = domish.Element( ("jabber:client", "iq"), attribs={"type": "set", "id": "inner1"} ) pubsub = domish.Element(("http://jabber.org/protocol/pubsub", "publish")) pubsub["node"] = "test_node" inner_iq.addChild(pubsub) inner_iq["to"] = user_jid.full() # Create mock response response_elt = domish.Element((None, "iq")) response_elt["type"] = "result" privilege = domish.Element((NS_PRIVILEGE, "privilege")) forwarded = domish.Element(("urn:xmpp:forward:0", "forwarded")) inner_response = domish.Element(("jabber:client", "iq")) inner_response["type"] = "result" inner_response["id"] = "inner1" forwarded.addChild(inner_response) privilege.addChild(forwarded) response_elt.addChild(privilege) client.IQ = client_IQ(response_elt=response_elt) result = await xep_0356.send_privileged_iq(client, inner_iq) # We check that the request was sent correctly assert len(client.IQ.sent_iqs) == 1 sent_iq = client.IQ.sent_iqs[0] assert sent_iq["type"] == "set" assert sent_iq["to"] == user_jid.full() # We check that privilege wrapper priv_iq = next(sent_iq.elements(NS_PRIVILEGE, "privileged_iq")) assert priv_iq is not None # We check that result was unwrapped assert result["type"] == "result" assert result["id"] == "inner1" @ed async def test_unwrap_privileged_iq_result_invalid_raises(self, xep_0356): """_unwrap_privileged_iq_result raises for invalid response format.""" # Invalid response (no privilege wrapper) invalid_response = domish.Element((None, "iq")) invalid_response["type"] = "result" with raises(PrivilegeError, match="Invalid response format"): xep_0356._unwrap_privileged_iq_result(invalid_response) class TestXEP0356PresenceHandling: """Tests for presence handling.""" @ed async def test_profile_connected_does_not_register_observers(self, xep_0356, client): """profile_connected no longer registers observers (done by handler instead).""" assert client.is_component # Mock addObserver to track calls observer_calls = [] def mock_add_observer(xpath, handler, client=None): observer_calls.append((xpath, handler, client)) client.xmlstream.addObserver = mock_add_observer # Call profile_connected xep_0356.profile_connected(client) # We check that no observers were registered (handler does it instead) assert len(observer_calls) == 0 @ed async def test_profile_connected_skips_non_component(self, xep_0356, host): """profile_connected skips non-component clients.""" # Create a non-component client non_component_client = MagicMock() non_component_client.is_component = False non_component_client.jid = jid.JID("user@test.example/resource") # Mock addObserver to track calls observer_calls = [] def mock_add_observer(xpath, handler, client=None): observer_calls.append((xpath, handler, client)) non_component_client.xmlstream.addObserver = mock_add_observer xep_0356.profile_connected(non_component_client) # We check that observer was NOT registered assert len(observer_calls) == 0 @ed async def test_on_presence_triggers_async_point(self, xep_0356, client): """_on_presence triggers async_point for any presence.""" # Create presence stanza presence = domish.Element((None, "presence")) presence["from"] = "user@test.example" trigger_point_calls = [] async def mock_async_point(point_name, *args): trigger_point_calls.append((point_name, args)) G.host.trigger.async_point = mock_async_point await xep_0356.on_presence(presence, client) assert len(trigger_point_calls) == 1 point_name, args = trigger_point_calls[0] assert point_name == "XEP-0356_presence_received" assert len(args) == 3 assert args[0] == client assert args[1] == jid.JID("user@test.example") assert args[2] == presence @ed async def test_on_presence_with_unavailable_type(self, xep_0356, client): """_on_presence triggers async_point for unavailable presence.""" presence = domish.Element((None, "presence")) presence["from"] = "user.test.example" presence["type"] = "unavailable" trigger_point_calls = [] async def mock_async_point(point_name, *args): trigger_point_calls.append((point_name, args)) G.host.trigger.async_point = mock_async_point 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