#!/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 . import base64 from unittest.mock import MagicMock, patch from pytest import fixture from pytest import raises from twisted.words.protocols.jabber import jid from twisted.words.xish import domish from libervia.backend.plugins.plugin_xep_0166 import XEP_0166, NS_JINGLE from libervia.backend.plugins.plugin_xep_0166.models import ( ContentChangePayload, TransportData, ) from libervia.backend.plugins.plugin_xep_0167 import XEP_0167, mapping from libervia.backend.plugins.plugin_xep_0167.constants import ( NS_JINGLE_RTP, NS_JINGLE_RTP_INFO, ) from libervia.backend.plugins.plugin_xep_0176 import NS_JINGLE_ICE_UDP from libervia.backend.tools import xml_tools from libervia.backend.tools.common import data_format @fixture(autouse=True) def no_application_register(monkeypatch): """Do not register the application in XEP-0166""" monkeypatch.setattr(XEP_0166, "register_application", lambda *a, **kw: None) class TestXEP0167Mapping: def test_senders_to_sdp(self): """Senders are mapped to SDP attribute""" assert mapping.senders_to_sdp("both", {"role": "initiator"}) == "a=sendrecv" assert mapping.senders_to_sdp("none", {"role": "initiator"}) == "a=inactive" assert mapping.senders_to_sdp("initiator", {"role": "initiator"}) == "a=recvonly" assert mapping.senders_to_sdp("responder", {"role": "initiator"}) == "a=sendonly" def test_generate_sdp_from_session(self): """SDP is correctly generated from session data""" session = { "local_jid": jid.JID("toto@example.org/test"), "metadata": {}, "contents": { "audio": { "application_data": { "media": "audio", "local_data": { "payload_types": { 96: { "name": "opus", "clockrate": 48000, "parameters": {"sprop-stereo": "1"}, } } }, }, "transport_data": { "local_ice_data": { "ufrag": "ufrag", "pwd": "pwd", "candidates": [ { "foundation": "1", "component_id": 1, "transport": "UDP", "priority": 1, "address": "10.0.0.1", "port": 12345, "type": "host", } ], } }, "senders": "both", } }, } expected_sdp = ( "v=0\r\n" f"o={base64.b64encode('toto@example.org/test'.encode()).decode()} 1 1 IN IP4 0.0.0.0\r\n" "s=-\r\n" "t=0 0\r\n" "a=msid-semantic:WMS *\r\n" "a=ice-options:trickle\r\n" "m=audio 9 UDP/TLS/RTP/SAVPF 96\r\n" "c=IN IP4 0.0.0.0\r\n" "a=mid:audio\r\n" "a=sendrecv\r\n" "a=rtpmap:96 opus/48000\r\n" "a=fmtp:96 sprop-stereo=1\r\n" "a=ice-ufrag:ufrag\r\n" "a=ice-pwd:pwd\r\n" "a=candidate:1 1 UDP 1 10.0.0.1 12345 typ host\r\n" ) assert mapping.generate_sdp_from_session(session, True) == expected_sdp def test_generate_content_add_sdp(self): """The m-line of a content added by the peer is correctly generated""" content_data = { "application_data": { "media": "video", "peer_data": { "payload_types": { 96: { "name": "VP8", "clockrate": 90000, "parameters": {"max-fs": "12288"}, } }, "rtcp-mux": True, }, }, "transport_data": { "peer_ice_data": { "ufrag": "ufrag", "pwd": "pwd", "fingerprint": { "hash": "sha-256", "setup": "actpass", "fingerprint": "AB:CD:EF", }, "candidates": [ { "foundation": "1", "component_id": 1, "transport": "UDP", "priority": 1, "address": "10.0.0.2", "port": 54321, "type": "host", } ], } }, "senders": "initiator", } session = {"role": "responder"} expected_sdp = ( "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" "a=mid:video\r\n" "c=IN IP4 0.0.0.0\r\n" "a=sendonly\r\n" "a=rtpmap:96 VP8/90000\r\n" "a=fmtp:96 max-fs=12288\r\n" "a=rtcp-mux\r\n" "a=fingerprint:sha-256 AB:CD:EF\r\n" "a=setup:actpass\r\n" "a=ice-ufrag:ufrag\r\n" "a=ice-pwd:pwd\r\n" "a=candidate:1 1 UDP 1 10.0.0.2 54321 typ host\r\n" ) assert ( mapping.generate_content_add_sdp(content_data, session, "video") == expected_sdp ) def test_parse_sdp(self): """SDP is correctly parsed to session data""" sdp = ( "v=0\r\n" "o=toto@example.org/test 1 1 IN IP4 0.0.0.0\r\n" "s=-\r\n" "t=0 0\r\n" "a=sendrecv\r\n" "a=msid-semantic:WMS *\r\n" "m=audio 9999 UDP/TLS/RTP/SAVPF 96\r\n" "c=IN IP4 0.0.0.0\r\n" "a=mid:audio\r\n" "a=rtpmap:96 opus/48000\r\n" "a=fmtp:96 sprop-stereo=1\r\n" "a=ice-ufrag:ufrag\r\n" "a=ice-pwd:pwd\r\n" "a=candidate:1 1 UDP 1 10.0.0.1 12345 typ host\r\n" ) expected_session = { "audio": { "application_data": { "media": "audio", "msid-semantic": "WMS", "payload_types": { 96: { "clockrate": 48000, "id": 96, "name": "opus", "parameters": {"sprop-stereo": "1"}, } }, "senders": "both", }, "id": "audio", "senders": "both", "transport_data": { "candidates": [ { "address": "10.0.0.1", "component_id": 1, "foundation": "1", "port": 12345, "priority": 1, "transport": "UDP", "type": "host", } ], "port": 9999, "pwd": "pwd", "ufrag": "ufrag", }, }, "metadata": {}, } assert mapping.parse_sdp(sdp, "responder") == expected_session def test_parse_sdp_session_level_ice(self): """Session-level transport attributes apply to all media unless overridden at the media level media """ sdp = ( "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\nt=0 0\r\n" "a=fingerprint:sha-256 00:11:22\r\n" "a=ice-pwd:spwd\r\n" "m=audio 1 UDP/TLS/RTP/SAVPF 96\r\n" "a=fingerprint:sha-256 33:44:55\r\n" "a=ice-ufrag:sufrag\r\n" "m=video 2 UDP/TLS/RTP/SAVPF 97\r\n" "a=ice-ufrag:sufrag\r\n" ) parsed = mapping.parse_sdp(sdp, "initiator") # audio: the media-level fingerprint wins assert parsed["audio"]["transport_data"]["fingerprint"] == { "hash": "sha-256", "fingerprint": "33:44:55", } # video: no media-level fingerprint, the session one applies (the audio # override must not have polluted it) assert parsed["video"]["transport_data"]["fingerprint"] == { "hash": "sha-256", "fingerprint": "00:11:22", } # the session-level ice-pwd applies to both media assert parsed["audio"]["transport_data"]["pwd"] == "spwd" assert parsed["video"]["transport_data"]["pwd"] == "spwd" def test_parse_mline(self): """Test parse_mline on a standalone m-line block""" mline = ( "m=audio 49170 UDP/TLS/RTP/SAVPF 0 96\r\n" "c=IN IP4 0.0.0.0\r\n" "a=rtcp-mux\r\n" "a=sendonly\r\n" "a=rtpmap:96 opus/48000/2\r\n" "a=ice-ufrag:abc123\r\n" "a=ice-pwd:def456\r\n" "a=fingerprint:sha-256 00:11:22\r\n" ) media, media_data = mapping.parse_mline(mline, "responder") assert media == "audio" assert media_data["application_data"]["senders"] == "responder" assert media_data["application_data"]["payload_types"][96]["name"] == "opus" assert media_data["transport_data"]["port"] == 49170 assert media_data["transport_data"]["ufrag"] == "abc123" assert media_data["transport_data"]["pwd"] == "def456" assert media_data["transport_data"]["fingerprint"]["fingerprint"] == "00:11:22" # session attributes (as forwarded by parse_sdp) are applied to the media: # with no media-level direction, the session one is used (a media-level # a=sendonly would override it, remapped with the role) mline_no_dir = mline.replace("a=sendonly\r\n", "") media, media_data = mapping.parse_mline( mline_no_dir, "responder", {"sendonly": "initiator"} ) assert media_data["application_data"]["senders"] == "initiator" def test_build_description(self): """ element is generated from media data""" session = {"metadata": {}} media_data = { "payload_types": { 96: { "channels": "2", "clockrate": "48000", "id": "96", "maxptime": "60", "name": "opus", "ptime": "20", "parameters": {"sprop-stereo": "1"}, } }, "bandwidth": "AS:40000", "rtcp-mux": True, "encryption": [ { "tag": "1", "crypto-suite": "AES_CM_128_HMAC_SHA1_80", "key-params": "inline:DPSKYRle84Ua2MjbScjadpCvFQH5Tutuls2N4/xx", "session-params": "", } ], } description_element = mapping.build_description("audio", media_data, session) # Assertions assert description_element.name == "description" assert description_element.uri == NS_JINGLE_RTP assert description_element["media"] == "audio" # Payload types payload_types = list(description_element.elements(NS_JINGLE_RTP, "payload-type")) assert len(payload_types) == 1 assert payload_types[0].name == "payload-type" assert payload_types[0]["id"] == "96" assert payload_types[0]["channels"] == "2" assert payload_types[0]["clockrate"] == "48000" assert payload_types[0]["maxptime"] == "60" assert payload_types[0]["name"] == "opus" assert payload_types[0]["ptime"] == "20" # Parameters parameters = list(payload_types[0].elements(NS_JINGLE_RTP, "parameter")) assert len(parameters) == 1 assert parameters[0].name == "parameter" assert parameters[0]["name"] == "sprop-stereo" assert parameters[0]["value"] == "1" # Bandwidth bandwidth = list(description_element.elements(NS_JINGLE_RTP, "bandwidth")) assert len(bandwidth) == 1 assert bandwidth[0]["type"] == "AS:40000" # RTCP-mux rtcp_mux = list(description_element.elements(NS_JINGLE_RTP, "rtcp-mux")) assert len(rtcp_mux) == 1 # Encryption encryption = list(description_element.elements(NS_JINGLE_RTP, "encryption")) assert len(encryption) == 1 assert encryption[0]["required"] == "1" crypto = list(encryption[0].elements("crypto")) assert len(crypto) == 1 assert crypto[0]["tag"] == "1" assert crypto[0]["crypto-suite"] == "AES_CM_128_HMAC_SHA1_80" assert ( crypto[0]["key-params"] == "inline:DPSKYRle84Ua2MjbScjadpCvFQH5Tutuls2N4/xx" ) assert crypto[0]["session-params"] == "" def test_parse_description(self): """Parsing to a dict is successful""" description_element = xml_tools.parse(""" """) parsed_data = mapping.parse_description(description_element) # Assertions assert parsed_data["payload_types"] == { 96: { "channels": "2", "clockrate": "48000", "maxptime": "60", "name": "opus", "ptime": "20", "parameters": {"sprop-stereo": "1"}, } } assert parsed_data["bandwidth"] == "AS:40000" assert parsed_data["rtcp-mux"] is True assert parsed_data["encryption_required"] is True assert parsed_data["encryption"] == [ { "tag": "1", "crypto-suite": "AES_CM_128_HMAC_SHA1_80", "key-params": "inline:DPSKYRle84Ua2MjbScjadpCvFQH5Tutuls2N4/xx", "session-params": "", } ] class TestXEP0167: def test_jingle_session_info(self, host, client): """Bridge's call_info method is called with correct parameters.""" xep_0167 = XEP_0167(host) session = {"id": "123"} mock_call_info = MagicMock() host.bridge.call_info = mock_call_info jingle_elt = xml_tools.parse(""" """) xep_0167.jingle_session_info(client, "mute", session, "content_name", jingle_elt) mock_call_info.assert_called_with( session["id"], "mute", data_format.serialise({"name": "mute_name"}), client.profile, ) def test_jingle_session_info_invalid_actions(self, host, client): """When receiving invalid actions, no further action is taken.""" xep_0167 = XEP_0167(host) session = {"id": "123"} mock_call_info = MagicMock() host.bridge.call_info = mock_call_info jingle_elt = xml_tools.parse(""" """) xep_0167.jingle_session_info( client, "invalid", session, "content_name", jingle_elt ) mock_call_info.assert_not_called() def test_send_info(self, host, client): """A jingle element with the correct info is created and sent.""" xep_0167 = XEP_0167(host) session_id = "123" extra = {"name": "test"} iq_elt = xml_tools.parse(""" """) jingle_elt = iq_elt.firstChildElement() mock_send = MagicMock() iq_elt.send = mock_send with patch.object( xep_0167._j, "build_session_info", return_value=(iq_elt, jingle_elt) ): xep_0167.send_info(client, session_id, "mute", extra) info_elt = jingle_elt.firstChildElement() assert info_elt.name == "active" assert info_elt.uri == NS_JINGLE_RTP_INFO mock_send.assert_called() def test_send_info_invalid_actions(self, host, client): """When trying to send invalid actions, an error is raised.""" xep_0167 = XEP_0167(host) session_id = "123" extra = {"name": "test"} with raises(ValueError, match="Unkown info type 'invalid_action'"): xep_0167.send_info(client, session_id, "invalid_action", extra) def test_content_change_payload_content_add(self, host, client): """For a content-add, the offered m-line SDP is returned in the payload""" xep_0167 = XEP_0167(host) session = { "id": "123", "role": "responder", "contents": { "video": { "application_data": { "media": "video", "peer_data": { "payload_types": {96: {"name": "VP8", "clockrate": 90000}}, }, }, "transport_data": { "peer_ice_data": { "ufrag": "uf", "pwd": "pw", "candidates": [], } }, "senders": "initiator", } }, } payload = xep_0167.get_content_change_payload( client, XEP_0166.A_CONTENT_ADD, session, "video" ) assert payload.media == "video" assert payload.sdp assert "m=video 9 UDP/TLS/RTP/SAVPF 96" in payload.sdp assert "a=rtpmap:96 VP8/90000" in payload.sdp assert "a=ice-ufrag:uf" in payload.sdp def test_content_change_payload_content_modify(self, host, client): """For a content-modify, the media and new direction are returned (no SDP)""" xep_0167 = XEP_0167(host) session = { "id": "123", "role": "responder", "contents": { "video": { "application_data": {"media": "video"}, "senders": "both", } }, } payload = xep_0167.get_content_change_payload( client, XEP_0166.A_CONTENT_MODIFY, session, "video" ) assert payload.model_dump(exclude_none=True) == { "media": "video", "direction": "sendrecv", } async def test_content_modify(self, host, client, client_IQ): """The modified senders attribute is updated and acknowledged""" xep_0166 = XEP_0166(host) client.IQ = client_IQ() mock_application = MagicMock() mock_application.handler.get_content_change_payload.return_value = ( ContentChangePayload() ) session = { "id": "123", "local_jid": jid.JID("client1@example.org/1"), "peer_jid": jid.JID("client2@example.net/2"), "role": "responder", "contents": { "video": { "senders": "initiator", "creator": XEP_0166.ROLE_INITIATOR, "application": mock_application, "transport": MagicMock(), } }, } mock_send = MagicMock() client.send = mock_send request = domish.Element((None, "iq")) request["type"] = "set" jingle_elt = request.addElement("jingle", NS_JINGLE) jingle_elt["sid"] = "123" jingle_elt["action"] = XEP_0166.A_CONTENT_MODIFY content_elt = jingle_elt.addElement("content", NS_JINGLE) content_elt["name"] = "video" content_elt["senders"] = "both" await xep_0166.on_content_modify(client, request, jingle_elt, session) # the direction is updated assert session["contents"]["video"]["senders"] == "both" assert session["modified_content_names"] == ["video"] # no contrary content-modify is sent, only the IQ result stanzas = [call.args[0] for call in mock_send.call_args_list] assert stanzas[0]["type"] == "result" async def test_call_update(self, host, client, client_IQ): """New m-lines of the offered SDP are added with a content-add IQ""" xep_0167 = XEP_0167(host) client.IQ = client_IQ() session_id = "123" session = { "id": session_id, "local_jid": client.jid, "peer_jid": jid.JID("room@conference.test.example"), "role": "initiator", "contents": {}, } client.jingle_sessions = {session_id: session} lines = [ "v=0", "o=- 0 0 IN IP4 127.0.0.1", "s=-", "t=0 0", "m=audio 9 UDP/TLS/RTP/SAVPF 111", "c=IN IP4 0.0.0.0", "a=rtcp-mux", "a=mid:1-0", "a=sendonly", "a=rtpmap:111 opus/48000/2", "a=ice-ufrag:uf1", "a=ice-pwd:pw1", ] added = await xep_0167.call_update( client, session_id, {"sdp": "\r\n".join(lines)} ) assert added == ["1-0"] # the new content is sent with a content-add IQ iq_elt = client.IQ.sent_iqs[0] jingle_elt = list(iq_elt.elements(NS_JINGLE, "jingle"))[0] assert jingle_elt["action"] == XEP_0166.A_CONTENT_ADD content_elts = list(jingle_elt.elements(NS_JINGLE, "content")) assert [c["name"] for c in content_elts] == ["1-0"] def test_content_change_payload_content_accept(self, host, client): """For a content-accept, the answer m-line is generated""" xep_0167 = XEP_0167(host) # the real ICE-UDP transport handler, so the whole parsing chain is tested # (XEP-0176 parsing + XEP-0320 fingerprint) transport = TransportData(NS_JINGLE_ICE_UDP, host.plugins["XEP-0176"], 100) desc_elt = domish.Element( (NS_JINGLE_RTP, "description"), attribs={"media": "video"} ) pt_elt = desc_elt.addElement("payload-type") pt_elt["id"] = "96" pt_elt["name"] = "VP8" pt_elt["clockrate"] = "90000" # the peer transport element, parsed by the XEP-0176 handler (fingerprint # included by the XEP-0320 trigger) peer_transport_elt = domish.Element( (NS_JINGLE_ICE_UDP, "transport"), attribs={"ufrag": "uf", "pwd": "pw"} ) fingerprint_elt = peer_transport_elt.addElement( ("urn:xmpp:jingle:apps:dtls:0", "fingerprint"), content="AB:CD" ) fingerprint_elt["hash"] = "sha-256" fingerprint_elt["setup"] = "actpass" session = { "id": "123", "role": "responder", "contents": { "video": { "application_data": {"media": "video"}, "transport": transport, "transport_data": {}, "desc_elt": desc_elt, "transport_elt": peer_transport_elt, "senders": "responder", } }, } payload = xep_0167.get_content_change_payload( client, XEP_0166.A_CONTENT_ACCEPT, session, "video" ) assert payload.media == "video" # the peer data are parsed and stored in the session assert session["contents"]["video"]["application_data"]["peer_data"] assert session["contents"]["video"]["transport_data"]["peer_ice_data"] assert payload.sdp assert "m=video 9 UDP/TLS/RTP/SAVPF 96" in payload.sdp # the direction is strict (senders=responder, we are the responder: the peer receives) assert "a=recvonly" in payload.sdp assert "a=fingerprint:sha-256 AB:CD" in payload.sdp assert "a=ice-ufrag:uf" in payload.sdp def test_call_content_accept(self, host, client, client_IQ): """A content-accept IQ is sent, built from the frontend answer m-line""" xep_0167 = XEP_0167(host) transport_elt = domish.Element( (NS_JINGLE_ICE_UDP, "transport"), attribs={"ufrag": "uf", "pwd": "pw"} ) mock_transport = MagicMock() mock_transport.handler.build_transport = MagicMock(return_value=transport_elt) client.IQ = client_IQ() session = { "id": "123", "added_content_names": ["video"], "local_jid": jid.JID("client1@example.org/1"), "peer_jid": jid.JID("client2@example.net/2"), "role": "responder", "contents": { "video": { "application_data": {"media": "video"}, "transport": mock_transport, "transport_data": {}, "senders": "initiator", } }, } client.jingle_sessions = {"123": session} host.get_client = MagicMock(return_value=client) mock_send = MagicMock() client.send = mock_send mline = ( "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" "c=IN IP4 0.0.0.0\r\n" "a=recvonly\r\n" "a=rtpmap:96 VP8/90000\r\n" "a=rtcp-mux\r\n" "a=fingerprint:sha-256 AA:BB\r\n" "a=setup:active\r\n" "a=ice-ufrag:uf\r\n" "a=ice-pwd:pw\r\n" "a=candidate:1 1 UDP 1 10.0.0.1 12345 typ host\r\n" ) xep_0167._call_content_accept("123", "video", mline, client.profile) assert "added_content_names" not in session # local data are stored in the session assert session["contents"]["video"]["application_data"]["local_data"] assert ( session["contents"]["video"]["transport_data"]["local_ice_data"]["ufrag"] == "uf" ) sent_iq = mock_send.call_args.args[0] jingle_elt = sent_iq.firstChildElement() assert jingle_elt["action"] == XEP_0166.A_CONTENT_ACCEPT content_elt = jingle_elt.firstChildElement() assert content_elt["name"] == "video" assert content_elt["creator"] == XEP_0166.ROLE_INITIATOR assert content_elt["senders"] == "initiator" desc_elts = list(content_elt.elements(NS_JINGLE_RTP, "description")) assert len(desc_elts) == 1 desc_elt = desc_elts[0] assert desc_elt["media"] == "video" payload_elts = list(desc_elt.elements(NS_JINGLE_RTP, "payload-type")) assert len(payload_elts) == 1 assert payload_elts[0]["name"] == "VP8" transport_elts = content_elt.elements(NS_JINGLE_ICE_UDP, "transport") transport_elt = next(transport_elts) assert transport_elt["ufrag"] == "uf" assert transport_elt["pwd"] == "pw"