|
| 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