Skip to content

Commit 29804d7

Browse files
authored
Add SDP connection related unit tests. (#1634)
1 parent 6509209 commit 29804d7

1 file changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
//-----------------------------------------------------------------------------
2+
// Filename: RTPSessionConnectionAddressUnitTest.cs
3+
//
4+
// Description: Characterization tests for connection-address and hold
5+
// semantics in RTPSession. Covers the corners of
6+
// SetLocalTrackStreamStatus / GetSdpConnectionAddress that earlier
7+
// categories did not touch:
8+
//
9+
// - IPv6 c= line round-trips through the SDP parser
10+
// - IPv6 hold form (c=IN IP6 ::) flips LocalTrack Inactive
11+
// - The magic ICE port "9" (SDP.IGNORE_RTP_PORT_NUMBER) on an Any
12+
// address keeps LocalTrack ACTIVE (the WebRTC offer shape)
13+
// - The magic port also skips DestinationEndPoint overwrite, so the
14+
// ICE layer remains the source of truth for routing
15+
//
16+
// Category 10 in the SDP-refactor test plan.
17+
//
18+
// History:
19+
// 23 May 2026 Claude Code - Opus 4.7 Created.
20+
//
21+
// License:
22+
// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
23+
//-----------------------------------------------------------------------------
24+
25+
using System.Net;
26+
using Microsoft.Extensions.Logging;
27+
using SIPSorcery.Net.UnitTests.Helpers;
28+
using SIPSorcery.SIP.App;
29+
using SIPSorceryMedia.Abstractions;
30+
using Xunit;
31+
32+
namespace SIPSorcery.Net.UnitTests
33+
{
34+
[Trait("Category", "unit")]
35+
public class RTPSessionConnectionAddressUnitTest
36+
{
37+
private readonly ILogger logger;
38+
39+
public RTPSessionConnectionAddressUnitTest(Xunit.Abstractions.ITestOutputHelper output)
40+
{
41+
logger = SIPSorcery.UnitTests.TestLogHelper.InitTestLogger(output);
42+
}
43+
44+
/// <summary>
45+
/// An IPv6 session-level c= line is parsed with
46+
/// ConnectionAddressType == "IP6" and the IPv6 address preserved.
47+
/// Sanity check for the parser before relying on it in the
48+
/// hold/mismatch tests below.
49+
/// </summary>
50+
[Fact]
51+
public void IPv6OfferConnectionLine_ParsesAsIp6Family()
52+
{
53+
SDP offer = SDP.ParseSDPDescription(
54+
@"v=0
55+
o=- 1000 0 IN IP6 ::1
56+
s=-
57+
c=IN IP6 ::1
58+
t=0 0
59+
m=audio 20000 RTP/AVP 0
60+
a=rtpmap:0 PCMU/8000
61+
a=sendrecv");
62+
63+
Assert.NotNull(offer.Connection);
64+
Assert.Equal("IP6", offer.Connection.ConnectionAddressType);
65+
Assert.Equal("::1", offer.Connection.ConnectionAddress);
66+
}
67+
68+
/// <summary>
69+
/// IPv6 hold form: c=IN IP6 :: (IPv6Any). The Any-address rule in
70+
/// SetLocalTrackStreamStatus treats IPv6Any the same as IPv4
71+
/// 0.0.0.0 — the LocalTrack flips to Inactive when the port is
72+
/// not the magic 9.
73+
/// </summary>
74+
[Fact]
75+
public void IPv6HoldFormWithNormalPort_SetsLocalInactive()
76+
{
77+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
78+
{
79+
SDP offer = SDP.ParseSDPDescription(
80+
@"v=0
81+
o=- 1000 0 IN IP6 ::1
82+
s=-
83+
c=IN IP6 ::
84+
t=0 0
85+
m=audio 20000 RTP/AVP 0
86+
a=rtpmap:0 PCMU/8000
87+
a=sendonly");
88+
89+
Assert.Equal(SetDescriptionResultEnum.OK,
90+
session.SetRemoteDescription(SdpType.offer, offer));
91+
92+
Assert.Equal(MediaStreamStatusEnum.Inactive,
93+
session.AudioStream.LocalTrack.StreamStatus);
94+
}
95+
}
96+
97+
/// <summary>
98+
/// The WebRTC offer shape: c=IN IP4 0.0.0.0 with m=audio 9 ...
99+
/// The Any-address rule in SetLocalTrackStreamStatus has an
100+
/// explicit "if port != 9" gate so this combination does NOT
101+
/// flip LocalTrack to Inactive — the ICE layer is expected to
102+
/// drive the actual destination, and SDP routing is deliberately
103+
/// suppressed.
104+
/// </summary>
105+
[Fact]
106+
public void IPv4AnyAddressWithMagicPort9_KeepsLocalTrackActive()
107+
{
108+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
109+
{
110+
SDP offer = SDP.ParseSDPDescription(
111+
@"v=0
112+
o=- 1000 0 IN IP4 0.0.0.0
113+
s=-
114+
c=IN IP4 0.0.0.0
115+
t=0 0
116+
m=audio 9 RTP/AVP 0
117+
a=rtpmap:0 PCMU/8000
118+
a=sendrecv");
119+
120+
Assert.Equal(SetDescriptionResultEnum.OK,
121+
session.SetRemoteDescription(SdpType.offer, offer));
122+
123+
// Local must NOT be flipped to inactive — port 9 means
124+
// "ICE drives the destination", not "media disabled".
125+
Assert.Equal(MediaStreamStatusEnum.SendRecv,
126+
session.AudioStream.LocalTrack.StreamStatus);
127+
}
128+
}
129+
130+
/// <summary>
131+
/// Same rule for IPv6: c=IN IP6 :: with m=audio 9 must NOT flip
132+
/// LocalTrack to Inactive. Verifies the magic port 9 exception
133+
/// fires on both address families.
134+
/// </summary>
135+
[Fact]
136+
public void IPv6AnyAddressWithMagicPort9_KeepsLocalTrackActive()
137+
{
138+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
139+
{
140+
SDP offer = SDP.ParseSDPDescription(
141+
@"v=0
142+
o=- 1000 0 IN IP6 ::1
143+
s=-
144+
c=IN IP6 ::
145+
t=0 0
146+
m=audio 9 RTP/AVP 0
147+
a=rtpmap:0 PCMU/8000
148+
a=sendrecv");
149+
150+
Assert.Equal(SetDescriptionResultEnum.OK,
151+
session.SetRemoteDescription(SdpType.offer, offer));
152+
153+
Assert.Equal(MediaStreamStatusEnum.SendRecv,
154+
session.AudioStream.LocalTrack.StreamStatus);
155+
}
156+
}
157+
158+
/// <summary>
159+
/// When the offer uses port 9 (IGNORE_RTP_PORT_NUMBER), the
160+
/// destination-endpoint assignment in SetRemoteDescription is
161+
/// skipped — leaving any previously-set DestinationEndPoint
162+
/// in place (or null for a fresh session). This is the WebRTC
163+
/// pattern: ICE will set DestinationEndPoint via
164+
/// SetGlobalDestination once candidates pair, and SDP routing
165+
/// must not override it.
166+
/// </summary>
167+
[Fact]
168+
public void OfferWithPort9_DoesNotOverwriteDestinationEndPoint()
169+
{
170+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
171+
{
172+
SDP offer = SDP.ParseSDPDescription(
173+
@"v=0
174+
o=- 1000 0 IN IP4 0.0.0.0
175+
s=-
176+
c=IN IP4 0.0.0.0
177+
t=0 0
178+
m=audio 9 RTP/AVP 0
179+
a=rtpmap:0 PCMU/8000
180+
a=sendrecv");
181+
182+
Assert.Equal(SetDescriptionResultEnum.OK,
183+
session.SetRemoteDescription(SdpType.offer, offer));
184+
185+
// No real address came from the SDP, so DestinationEndPoint
186+
// must not have been clobbered with 0.0.0.0:9.
187+
if (session.AudioStream.DestinationEndPoint != null)
188+
{
189+
Assert.NotEqual(SDP.IGNORE_RTP_PORT_NUMBER,
190+
session.AudioStream.DestinationEndPoint.Port);
191+
}
192+
}
193+
}
194+
195+
/// <summary>
196+
/// A normal-port offer (non-9) on a routable address sets the
197+
/// LocalTrack DestinationEndPoint to that address + port. This
198+
/// is the SIP path — SDP carries the destination.
199+
/// </summary>
200+
[Fact]
201+
public void OfferWithNormalPortAndRoutableAddress_SetsDestinationEndPoint()
202+
{
203+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
204+
{
205+
SDP offer = SDP.ParseSDPDescription(SdpFixtures.AudioOnlyOfferPcmu);
206+
207+
Assert.Equal(SetDescriptionResultEnum.OK,
208+
session.SetRemoteDescription(SdpType.offer, offer));
209+
210+
Assert.NotNull(session.AudioStream.DestinationEndPoint);
211+
Assert.Equal("192.0.2.10", session.AudioStream.DestinationEndPoint.Address.ToString());
212+
Assert.Equal(20000, session.AudioStream.DestinationEndPoint.Port);
213+
}
214+
}
215+
216+
/// <summary>
217+
/// CreateOffer with the unspecified IPAddress.Any falls back to
218+
/// the local stack's interface selection — the resulting SDP c=
219+
/// line is populated. The specific value depends on the runtime
220+
/// network, so the test only asserts non-null.
221+
/// </summary>
222+
[Fact]
223+
public void CreateOfferWithIPAddressAny_StillProducesCLine()
224+
{
225+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
226+
{
227+
SDP sdp = session.CreateOffer(IPAddress.Any);
228+
229+
Assert.NotNull(sdp);
230+
Assert.NotNull(sdp.Connection);
231+
Assert.False(string.IsNullOrEmpty(sdp.Connection.ConnectionAddress));
232+
}
233+
}
234+
235+
/// <summary>
236+
/// RemoteTrack.StreamStatus must reflect the announced direction
237+
/// even when the connection address is the "any" hold form. The
238+
/// LocalTrack flips Inactive (Quirk #10) but the RemoteTrack
239+
/// keeps the original announced direction, so callers can still
240+
/// see what the remote SAID it wanted to do.
241+
/// </summary>
242+
[Fact]
243+
public void HoldOffer_RemoteTrackKeepsAnnouncedDirection()
244+
{
245+
using (var session = new RtpSessionBuilder().WithAudioTrack().Build())
246+
{
247+
SDP hold = SDP.ParseSDPDescription(SdpFixtures.AudioOfferHoldNullConnectionAddress);
248+
Assert.Equal(SetDescriptionResultEnum.OK,
249+
session.SetRemoteDescription(SdpType.offer, hold));
250+
251+
// Local goes Inactive (per Quirk #10 / Category 7).
252+
Assert.Equal(MediaStreamStatusEnum.Inactive,
253+
session.AudioStream.LocalTrack.StreamStatus);
254+
// Remote-side track preserves the announced sendonly.
255+
Assert.Equal(MediaStreamStatusEnum.SendOnly,
256+
session.AudioStream.RemoteTrack.StreamStatus);
257+
}
258+
}
259+
}
260+
}

0 commit comments

Comments
 (0)