#!/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-0355 Namespace Delegation implementation. Focus: Security and delegation request handling. """ from unittest.mock import Mock from pytest import fixture, raises 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 disco, pubsub from libervia.backend.core.constants import Const as C from libervia.backend.core.exceptions import CancelError from libervia.backend.plugins.plugin_xep_0297 import XEP_0297 from libervia.backend.plugins.plugin_xep_0355 import ( XEP_0355, NS_DELEGATION, DelegatedIQ, ) from libervia.backend.tools.xml_tools import element_copy def make_delegation_advertisement(server_jid, namespaces): """Helper to create delegation advertisement message.""" msg = domish.Element( (None, "message"), attribs={"from": server_jid.full(), "to": "pubsub.test.example"}, ) delegation = domish.Element((NS_DELEGATION, "delegation")) for ns in namespaces: delegated = domish.Element( (NS_DELEGATION, "delegated"), attribs={"namespace": ns} ) delegation.addChild(delegated) msg.addChild(delegation) return msg def make_forwarded_stanza(server_jid, component_jid, inner_stanza): """Helper to create forwarded stanza IQ.""" iq = domish.Element( (None, "iq"), attribs={ "from": server_jid.full(), "to": component_jid.full(), "type": "set", "id": "test123", }, ) delegation = domish.Element((NS_DELEGATION, "delegation")) forwarded = XEP_0297.build_forwarded(element_copy(inner_stanza)) delegation.addChild(forwarded) iq.addChild(delegation) return iq @fixture def xep_0355(host): """Create XEP-0355 plugin instance.""" return XEP_0355(host) @fixture(autouse=True) def setup_component_client(client): """Set up client as a component for XEP-0355 tests.""" client.is_component = True @fixture def delegation_handler(xep_0355, client): """Create delegation handler for testing.""" handler = xep_0355.get_handler(client) handler.parent = client return handler class TestXEP0355Security: """Security tests for delegation advertisement and forwarding.""" @ed async def test_rejects_advertisement_from_wrong_server(self, xep_0355, client): """Advertisement from wrong server raises StanzaError.""" wrong_server = jid.JID("other.example") msg = make_delegation_advertisement(wrong_server, [pubsub.NS_PUBSUB]) with raises(StanzaError, match="not-allowed"): xep_0355.on_advertise(msg, client) @ed async def test_rejects_advertisement_missing_delegation_element( self, xep_0355, client ): """Advertisement without delegation element raises StanzaError.""" msg = domish.Element( (None, "message"), attribs={"from": client.server_jid.full(), "to": "pubsub.test.example"}, ) with raises(StanzaError, match="not-acceptable"): xep_0355.on_advertise(msg, client) @ed async def test_rejects_delegated_without_namespace(self, xep_0355, client): """Delegated elements without namespace attribute raise StanzaError.""" msg = make_delegation_advertisement(client.server_jid, [pubsub.NS_PUBSUB]) # Add a delegated element without namespace delegation = next(msg.elements(NS_DELEGATION, "delegation")) bad_delegated = domish.Element((NS_DELEGATION, "delegated")) delegation.addChild(bad_delegated) with raises(StanzaError, match="not-acceptable"): xep_0355.on_advertise(msg, client) @ed async def test_rejects_unexpected_element_in_delegation(self, xep_0355, client): """Unexpected elements in delegation raise StanzaError.""" msg = make_delegation_advertisement(client.server_jid, [pubsub.NS_PUBSUB]) # Add an unexpected element delegation = next(msg.elements(NS_DELEGATION, "delegation")) bad_element = domish.Element((NS_DELEGATION, "unknown")) delegation.addChild(bad_element) with raises(StanzaError, match="not-acceptable"): xep_0355.on_advertise(msg, client) @ed async def test_rejects_forwarded_stanza_from_wrong_server(self, xep_0355, client): """Forwarded stanza from non-server JID raises StanzaError.""" wrong_server = jid.JID("other.example") inner_iq = domish.Element( ("jabber:client", "iq"), attribs={ "from": "user@test.example", "to": "pubsub.test.example", "id": "inner1", }, ) iq = make_forwarded_stanza(wrong_server, client.jid, inner_iq) with raises(StanzaError, match="not-allowed"): xep_0355.on_forward(iq, client) @ed async def test_rejects_malformed_forwarding(self, xep_0355, client): """Malformed forwarding structure raises StanzaError.""" # Create IQ without proper structure iq = domish.Element( (None, "iq"), attribs={ "from": client.server_jid.full(), "to": client.jid.full(), "type": "set", "id": "test", }, ) # Missing delegation element iq.addChild(domish.Element((None, "query"))) with raises(StanzaError, match="not-acceptable"): xep_0355.on_forward(iq, client) @ed async def test_cleans_up_on_iq_id_conflict(self, xep_0355, client): """IQ with wrong 'to' address is cleaned up.""" inner_req = domish.Element( ("jabber:client", "iq"), attribs={ "from": "pubsub.test.example", "to": "user@test.example", "id": "inner1", }, ) wrapping_iq = domish.Element( (None, "iq"), attribs={ "from": client.server_jid.full(), "to": client.jid.full(), "id": "wrap1", }, ) mock_timeout = Mock() mock_timeout.called = False mock_timeout.cancel = Mock() xep_0355._current_iqs["inner1"] = DelegatedIQ( client, inner_req, wrapping_iq, mock_timeout ) # Outgoing IQ with wrong 'to' outgoing_iq = domish.Element( ("jabber:client", "iq"), attribs={ "from": "pubsub.test.example", "to": "wrong@test.example", "id": "inner1", "type": "result", }, ) xep_0355.handle_outgoing_iq(outgoing_iq) # Entry should be cleaned up assert "inner1" not in xep_0355._current_iqs # Should not have sent wrapped result assert not client.xmlstream.send.called class TestXEP0355DelegationHandling: """Tests for delegation request handling and response wrapping.""" @ed async def test_dispatches_inner_stanza_from_forwarded(self, xep_0355, client): """Forwarded stanza inner IQ is dispatched to xmlstream.""" inner_iq = domish.Element( ("jabber:client", "iq"), attribs={ "from": "user@test.example/resource", "to": "pubsub.test.example", "type": "set", "id": "inner1", }, ) inner_iq.addChild(domish.Element((pubsub.NS_PUBSUB, "publish"))) iq = make_forwarded_stanza(client.server_jid, client.jid, inner_iq) xep_0355.on_forward(iq, client) # Verify dispatch was called client.xmlstream.dispatch.assert_called_once() dispatched = client.xmlstream.dispatch.call_args.args[0] assert dispatched["id"] == "inner1" @ed async def test_sets_to_bare_jid_if_missing(self, xep_0355, client): """Dispatched stanza gets to=bare_jid if not present.""" inner_iq = domish.Element( ("jabber:client", "iq"), attribs={ "from": "user@test.example/resource", "id": "inner1", "type": "set", }, ) # No 'to' attribute iq = make_forwarded_stanza(client.server_jid, client.jid, inner_iq) xep_0355.on_forward(iq, client) dispatched = client.xmlstream.dispatch.call_args.args[0] assert dispatched["to"] == "user@test.example" @ed async def test_wraps_result_in_delegation_format(self, xep_0355, client): """Result IQ is properly wrapped in delegation format.""" inner_req = domish.Element( ("jabber:client", "iq"), attribs={ "from": "pubsub.test.example", "to": "user@test.example", "id": "inner1", }, ) wrapping_iq = domish.Element( (None, "iq"), attribs={ "from": client.server_jid.full(), "to": client.jid.full(), "id": "wrap1", }, ) # Create response inner_resp = domish.Element( ("jabber:client", "iq"), attribs={ "from": "pubsub.test.example", "to": "user@test.example", "id": "inner1", "type": "result", }, ) result_data = domish.Element( (pubsub.NS_PUBSUB, "published"), attribs={"node": "test_node"} ) inner_resp.addChild(result_data) xep_0355.delegated_result(client, inner_req, inner_resp, wrapping_iq) # Verify send was called with wrapped result sent = client.xmlstream.send.call_args.args[0] assert sent["type"] == "result" assert sent["id"] == "wrap1" assert sent["from"] == wrapping_iq["to"] assert sent["to"] == wrapping_iq["from"] # Verify structure delegation_elt = next(sent.elements(NS_DELEGATION, "delegation")) forwarded_elt = next(delegation_elt.elements(C.NS_FORWARD, "forwarded")) wrapped_iq = next(forwarded_elt.elements("jabber:client", "iq")) assert wrapped_iq["id"] == "inner1" assert wrapped_iq["type"] == "result" @ed async def test_intercepts_outgoing_result_iq(self, xep_0355, client): """Outgoing result IQ matching tracked ID is intercepted and wrapped.""" inner_req = domish.Element( ("jabber:client", "iq"), attribs={ "from": "pubsub.test.example", "to": "user@test.example", "id": "inner1", }, ) wrapping_iq = domish.Element( (None, "iq"), attribs={ "from": client.server_jid.full(), "to": client.jid.full(), "id": "wrap1", }, ) mock_timeout = Mock() mock_timeout.called = False mock_timeout.cancel = Mock() xep_0355._current_iqs["inner1"] = DelegatedIQ( client, inner_req, wrapping_iq, mock_timeout ) # Simulate outgoing response outgoing_iq = domish.Element( ("jabber:client", "iq"), attribs={ "from": "pubsub.test.example", "to": "pubsub.test.example", "id": "inner1", "type": "result", }, ) # Should raise CancelError to prevent normal send with raises(CancelError): xep_0355.handle_outgoing_iq(outgoing_iq) # Verify callback was invoked (send was called) assert client.xmlstream.send.called class TestXEP0355DiscoNesting: """Tests for disco nesting support per XEP-0355 spec.""" @ed async def test_server_queries_pubsub_namespace_gets_pep_identity( self, delegation_handler ): """Server queries for pubsub namespace, component returns PEP identity.""" node_id = f"{NS_DELEGATION}::{pubsub.NS_PUBSUB}" result = await delegation_handler.getDiscoInfo( jid.JID("juliet@capulet.lit/balcony"), jid.JID("capulet.lit"), node_id, ) pep_identities = [r for r in result if isinstance(r, disco.DiscoIdentity)] assert len(pep_identities) >= 1, "Should return at least PEP identity" assert pep_identities[0].category == "pubsub" assert pep_identities[0].type == "pep" @ed async def test_normalizes_pubsub_namespace_variants(self, delegation_handler): """Component normalizes pubsub namespace variants to base namespace.""" node_id_owner = f"{NS_DELEGATION}::{pubsub.NS_PUBSUB}#owner" result_owner = await delegation_handler.getDiscoInfo( jid.JID("juliet@capulet.lit"), jid.JID("capulet.lit"), node_id_owner, ) node_id_base = f"{NS_DELEGATION}::{pubsub.NS_PUBSUB}" result_base = await delegation_handler.getDiscoInfo( jid.JID("juliet@capulet.lit"), jid.JID("capulet.lit"), node_id_base, ) # Both should return PEP identity pep_owner = [r for r in result_owner if isinstance(r, disco.DiscoIdentity)] pep_base = [r for r in result_base if isinstance(r, disco.DiscoIdentity)] assert len(pep_owner) >= 1, "pubsub#owner should return PEP identity" assert pep_owner[0].category == "pubsub" assert pep_owner[0].type == "pep" assert len(pep_base) >= 1, "pubsub base should return PEP identity" assert pep_base[0].category == "pubsub" assert pep_base[0].type == "pep"