component conferences: update to manage the new single Jingle session and the ICE restart:
The Jingle Audio/Video Conferences spec has been updated to work with a single Jingle session. This patch updates the component to handle it, and implements ICE restart management. Closing is better handled, and session are replaced in case of duplication (happens notably in web frontend on tab reload).
This commit is contained in:
parent
7277e48f7f
commit
cb570ab3cf
2 changed files with 842 additions and 76 deletions
318
tests/unit/test_plugin_comp_conferences.py
Normal file
318
tests/unit/test_plugin_comp_conferences.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
"""Unit tests for the conferences component (ICE restart paths)."""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest import fixture
|
||||
from twisted.internet import defer
|
||||
from twisted.words.protocols.jabber import jid
|
||||
|
||||
from libervia.backend.plugins.plugin_comp_conferences import Conference
|
||||
|
||||
# the SDP line endings are CRLF, as in real offers (RFC 4566)
|
||||
UPLINK_OFFER_SDP = ("""v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
a=group:BUNDLE 0 1
|
||||
m=audio 9 UDP/TLS/RTP/SAVPF 111
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:0
|
||||
a=sendonly
|
||||
a=ice-ufrag:uf1
|
||||
a=ice-pwd:pw1
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:1
|
||||
a=sendonly
|
||||
a=ice-ufrag:uf1
|
||||
a=ice-pwd:pw1
|
||||
""").replace("\n", "\r\n")
|
||||
|
||||
# answer of the renegotiated uplink offer (an ICE restart by the user): new ICE
|
||||
# credentials, with inline candidates
|
||||
UPLINK_ANSWER_SDP = ("""v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
a=group:BUNDLE 0 1
|
||||
m=audio 9 UDP/TLS/RTP/SAVPF 111
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:0
|
||||
a=recvonly
|
||||
a=ice-ufrag:uf3
|
||||
a=ice-pwd:pw3
|
||||
a=candidate:1 1 udp 2122260223 192.168.1.1 30000 typ host
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:1
|
||||
a=recvonly
|
||||
a=ice-ufrag:uf3
|
||||
a=ice-pwd:pw3
|
||||
a=candidate:2 1 udp 2122260223 192.168.1.1 30001 typ host
|
||||
""").replace("\n", "\r\n")
|
||||
|
||||
# new offer for an existing stream (an ICE restart by the SFU): new ICE credentials, with
|
||||
# inline candidates
|
||||
DOWNLINK_REOFFER_SDP = ("""v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
a=group:BUNDLE 0 1
|
||||
m=audio 9 UDP/TLS/RTP/SAVPF 111
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:0
|
||||
a=sendonly
|
||||
a=ice-ufrag:uf9
|
||||
a=ice-pwd:pw9
|
||||
a=candidate:1 1 udp 2122260223 192.168.1.1 30000 typ host
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:1
|
||||
a=sendonly
|
||||
a=ice-ufrag:uf9
|
||||
a=ice-pwd:pw9
|
||||
a=candidate:2 1 udp 2122260223 192.168.1.1 30001 typ host
|
||||
""").replace("\n", "\r\n")
|
||||
|
||||
|
||||
class MockProtocol:
|
||||
"""Records the data sent to the SFU."""
|
||||
|
||||
client_id = "c0ffee"
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_data(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
def sendClose(self):
|
||||
pass
|
||||
|
||||
|
||||
class MockIceUdp:
|
||||
"""Records the calls to ice_candidates_add (the transport-info send path)."""
|
||||
|
||||
def __init__(self, session: dict):
|
||||
self.session = session
|
||||
self.calls = []
|
||||
|
||||
def ice_candidates_add(self, client, session_id, content_ice_data):
|
||||
# like XEP-0176: the session's local ICE data is updated with the new credentials
|
||||
for name, ice_data in content_ice_data.items():
|
||||
local_ice_data = (
|
||||
self.session["contents"]
|
||||
.get(name, {})
|
||||
.get("transport_data", {})
|
||||
.get("local_ice_data")
|
||||
)
|
||||
if local_ice_data is not None:
|
||||
local_ice_data.update(ufrag=ice_data["ufrag"], pwd=ice_data["pwd"])
|
||||
self.calls.append((client, session_id, content_ice_data))
|
||||
return defer.succeed(None)
|
||||
|
||||
|
||||
@fixture
|
||||
def conference():
|
||||
"""A Conference with mocked parent (SFU protocol and XEP-0176)."""
|
||||
session = {
|
||||
"id": "sess123",
|
||||
"local_jid": jid.JID("conferences.test.example"),
|
||||
"peer_jid": jid.JID("user@test.example"),
|
||||
"role": "responder",
|
||||
"contents": {},
|
||||
"streams": {},
|
||||
"uplink_offer_sdp": UPLINK_OFFER_SDP,
|
||||
}
|
||||
parent = SimpleNamespace(
|
||||
client=SimpleNamespace(get_virtual_client=lambda jid_: object()),
|
||||
_ice_udp=MockIceUdp(session),
|
||||
)
|
||||
conf = Conference(
|
||||
parent,
|
||||
"testroom",
|
||||
Path("testroom.json"),
|
||||
"ws://localhost",
|
||||
"http://localhost",
|
||||
session,
|
||||
)
|
||||
conf._protocol = MockProtocol()
|
||||
return conf
|
||||
|
||||
|
||||
def test_restart_uplink_same_credentials(conference):
|
||||
"""No renegotiation if the uplink credentials are unchanged."""
|
||||
conference._restart_uplink(
|
||||
conference.session, {"ufrag": "uf1", "pwd": "pw1", "candidates": []}
|
||||
)
|
||||
assert conference._protocol.sent == []
|
||||
|
||||
|
||||
def test_restart_uplink_new_credentials(conference):
|
||||
"""New uplink credentials trigger a renegotiation offer to the SFU."""
|
||||
conference._restart_uplink(
|
||||
conference.session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []}
|
||||
)
|
||||
(offer,) = conference._protocol.sent
|
||||
assert offer["type"] == "offer"
|
||||
assert offer["id"] == conference.galene_id
|
||||
assert offer["sdp"].count("a=ice-ufrag:uf2") == 2
|
||||
assert offer["sdp"].count("a=ice-pwd:pw2") == 2
|
||||
# the CRLF line endings are preserved (a mangled SDP would not be parsable)
|
||||
assert offer["sdp"].count("\r\n") == UPLINK_OFFER_SDP.count("\r\n")
|
||||
# the offer is stored for the next restart
|
||||
assert conference.session["uplink_offer_sdp"] == offer["sdp"]
|
||||
assert isinstance(conference.session["answer_sdp_d"], defer.Deferred)
|
||||
# the renegotiation is pending: the answer will carry new credentials
|
||||
assert conference.session["uplink_renegotiating"] is True
|
||||
|
||||
|
||||
def test_restart_uplink_no_stored_offer(conference):
|
||||
"""Without the stored uplink offer, nothing is re-offered."""
|
||||
del conference.session["uplink_offer_sdp"]
|
||||
conference._restart_uplink(
|
||||
conference.session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []}
|
||||
)
|
||||
assert conference._protocol.sent == []
|
||||
|
||||
|
||||
def test_restart_uplink_answer_consumed(conference):
|
||||
"""The answer of the renegotiation offer is consumed by on_answer()."""
|
||||
conference._restart_uplink(
|
||||
conference.session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []}
|
||||
)
|
||||
conference.on_answer({"sdp": "v=0\n"})
|
||||
assert conference.session.pop("answer_sdp_d", None) is None
|
||||
|
||||
|
||||
def test_restart_uplink_answer_credentials_forwarded(conference):
|
||||
"""The answer's ICE credentials and inline candidates are forwarded to the user."""
|
||||
session = conference.session
|
||||
for name in ("0", "1"):
|
||||
session["contents"][name] = {
|
||||
"transport_data": {
|
||||
"local_ice_data": {
|
||||
"ufrag": "uf_old",
|
||||
"pwd": "pw_old",
|
||||
"candidates": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
conference._restart_uplink(session, {"ufrag": "uf2", "pwd": "pw2", "candidates": []})
|
||||
conference.on_answer({"sdp": UPLINK_ANSWER_SDP})
|
||||
assert "uplink_renegotiating" not in session
|
||||
calls = conference.parent._ice_udp.calls
|
||||
# two transport-info with the new credentials (one per content), then the two inline
|
||||
# candidates
|
||||
assert [next(iter(data)) for data in (c[2] for c in calls)] == [
|
||||
"0",
|
||||
"1",
|
||||
"0",
|
||||
"1",
|
||||
]
|
||||
for _, session_id, data in calls:
|
||||
assert session_id == "sess123"
|
||||
((name, ice_data),) = data.items()
|
||||
assert (ice_data["ufrag"], ice_data["pwd"]) == ("uf3", "pw3")
|
||||
assert calls[2][2]["0"]["candidates"]
|
||||
assert calls[3][2]["1"]["candidates"]
|
||||
|
||||
|
||||
def test_on_answer_initial_no_credentials_forwarded(conference):
|
||||
"""Without an ICE restart, the answer is not forwarded to the user."""
|
||||
session = conference.session
|
||||
for name in ("0", "1"):
|
||||
session["contents"][name] = {
|
||||
"transport_data": {
|
||||
"local_ice_data": {
|
||||
"ufrag": "uf3",
|
||||
"pwd": "pw3",
|
||||
"candidates": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
session["answer_sdp_d"] = defer.Deferred()
|
||||
conference.on_answer({"sdp": UPLINK_ANSWER_SDP})
|
||||
assert conference.parent._ice_udp.calls == []
|
||||
|
||||
|
||||
async def test_renegotiation_downlink(conference):
|
||||
"""A re-offered stream: new credentials to the user, stored answer to the SFU."""
|
||||
session = conference.session
|
||||
session["streams"] = {"down1": ["2-0", "2-1"]}
|
||||
session["stream_answer_sdp"] = {"down1": "ANSWER_SDP"}
|
||||
# the contents of the stream, with the new local ICE data (set in production by the
|
||||
# ice_candidates_add calls of _handle_renegotiation)
|
||||
for name, media in (("2-0", "audio"), ("2-1", "video")):
|
||||
session["contents"][name] = {
|
||||
"application_data": {"media": media},
|
||||
"transport_data": {
|
||||
"local_ice_data": {"ufrag": "uf9", "pwd": "pw9", "candidates": []}
|
||||
},
|
||||
}
|
||||
await conference._handle_renegotiation({"id": "down1", "sdp": DOWNLINK_REOFFER_SDP})
|
||||
# two transport-info with the new credentials (one per content), then the two inline
|
||||
# candidates
|
||||
calls = conference.parent._ice_udp.calls
|
||||
assert len(calls) == 4
|
||||
for _, session_id, data in calls:
|
||||
assert session_id == "sess123"
|
||||
((name, ice_data),) = data.items()
|
||||
assert (ice_data["ufrag"], ice_data["pwd"]) == ("uf9", "pw9")
|
||||
assert [next(iter(data)) for data in (c[2] for c in calls)] == [
|
||||
"2-0",
|
||||
"2-1",
|
||||
"2-0",
|
||||
"2-1",
|
||||
]
|
||||
assert calls[2][2]["2-0"]["candidates"]
|
||||
assert calls[3][2]["2-1"]["candidates"]
|
||||
answer = [m for m in conference._protocol.sent if m["type"] == "answer"]
|
||||
assert len(answer) == 1
|
||||
assert answer[0]["id"] == "down1"
|
||||
assert answer[0]["sdp"] == "ANSWER_SDP"
|
||||
|
||||
|
||||
async def test_renegotiation_downlink_no_stored_answer(conference):
|
||||
"""Without the stored answer, no answer is re-sent to the SFU."""
|
||||
session = conference.session
|
||||
session["streams"] = {"down1": ["2-0", "2-1"]}
|
||||
for name, media in (("2-0", "audio"), ("2-1", "video")):
|
||||
session["contents"][name] = {
|
||||
"application_data": {"media": media},
|
||||
"transport_data": {
|
||||
"local_ice_data": {"ufrag": "uf9", "pwd": "pw9", "candidates": []}
|
||||
},
|
||||
}
|
||||
await conference._handle_renegotiation({"id": "down1", "sdp": DOWNLINK_REOFFER_SDP})
|
||||
assert all(m["type"] != "answer" for m in conference._protocol.sent)
|
||||
|
||||
|
||||
async def test_offer_mids_renamed(conference):
|
||||
"""The mids of a new downlink stream are prefixed with the stream counter."""
|
||||
session = conference.session
|
||||
added = []
|
||||
|
||||
class MockRtp:
|
||||
async def call_update(
|
||||
self, client, session_id, call_data, jingle_child_elts=None
|
||||
):
|
||||
added.append(call_data)
|
||||
return ["1-0", "1-1"]
|
||||
|
||||
class MockAvConf:
|
||||
def build_origin_elt(self, origin):
|
||||
return None
|
||||
|
||||
conference.parent._rtp = MockRtp()
|
||||
conference.parent._av_conf = MockAvConf()
|
||||
data = {"id": "down1", "sdp": DOWNLINK_REOFFER_SDP, "username": "user@test.example"}
|
||||
await conference.on_offer(data)
|
||||
(call_data,) = added
|
||||
sdp = call_data["sdp"]
|
||||
assert "a=mid:1-0" in sdp
|
||||
assert "a=mid:1-1" in sdp
|
||||
# the CRLF line endings are preserved (a mangled SDP would not be parsable)
|
||||
assert sdp.count("\r\n") == DOWNLINK_REOFFER_SDP.count("\r\n")
|
||||
assert session["streams"] == {"down1": ["1-0", "1-1"]}
|
||||
Loading…
Reference in a new issue