#!/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"