github.com/pion/webrtc/v4@v4.0.1/sdp_test.go (about) 1 // SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly> 2 // SPDX-License-Identifier: MIT 3 4 //go:build !js 5 // +build !js 6 7 package webrtc 8 9 import ( 10 "crypto/ecdsa" 11 "crypto/elliptic" 12 "crypto/rand" 13 "strings" 14 "testing" 15 16 "github.com/pion/sdp/v3" 17 "github.com/pion/transport/v3/test" 18 "github.com/stretchr/testify/assert" 19 ) 20 21 func TestExtractFingerprint(t *testing.T) { 22 t.Run("Good Session Fingerprint", func(t *testing.T) { 23 s := &sdp.SessionDescription{ 24 Attributes: []sdp.Attribute{{Key: "fingerprint", Value: "foo bar"}}, 25 } 26 27 fingerprint, hash, err := extractFingerprint(s) 28 assert.NoError(t, err) 29 assert.Equal(t, fingerprint, "bar") 30 assert.Equal(t, hash, "foo") 31 }) 32 33 t.Run("Good Media Fingerprint", func(t *testing.T) { 34 s := &sdp.SessionDescription{ 35 MediaDescriptions: []*sdp.MediaDescription{ 36 {Attributes: []sdp.Attribute{{Key: "fingerprint", Value: "foo bar"}}}, 37 }, 38 } 39 40 fingerprint, hash, err := extractFingerprint(s) 41 assert.NoError(t, err) 42 assert.Equal(t, fingerprint, "bar") 43 assert.Equal(t, hash, "foo") 44 }) 45 46 t.Run("No Fingerprint", func(t *testing.T) { 47 s := &sdp.SessionDescription{} 48 49 _, _, err := extractFingerprint(s) 50 assert.Equal(t, ErrSessionDescriptionNoFingerprint, err) 51 }) 52 53 t.Run("Invalid Fingerprint", func(t *testing.T) { 54 s := &sdp.SessionDescription{ 55 Attributes: []sdp.Attribute{{Key: "fingerprint", Value: "foo"}}, 56 } 57 58 _, _, err := extractFingerprint(s) 59 assert.Equal(t, ErrSessionDescriptionInvalidFingerprint, err) 60 }) 61 62 t.Run("Conflicting Fingerprint", func(t *testing.T) { 63 s := &sdp.SessionDescription{ 64 Attributes: []sdp.Attribute{{Key: "fingerprint", Value: "foo"}}, 65 MediaDescriptions: []*sdp.MediaDescription{ 66 {Attributes: []sdp.Attribute{{Key: "fingerprint", Value: "foo blah"}}}, 67 }, 68 } 69 70 _, _, err := extractFingerprint(s) 71 assert.Equal(t, ErrSessionDescriptionConflictingFingerprints, err) 72 }) 73 } 74 75 func TestExtractICEDetails(t *testing.T) { 76 const defaultUfrag = "defaultPwd" 77 const defaultPwd = "defaultUfrag" 78 79 t.Run("Missing ice-pwd", func(t *testing.T) { 80 s := &sdp.SessionDescription{ 81 MediaDescriptions: []*sdp.MediaDescription{ 82 {Attributes: []sdp.Attribute{{Key: "ice-ufrag", Value: defaultUfrag}}}, 83 }, 84 } 85 86 _, _, _, err := extractICEDetails(s, nil) 87 assert.Equal(t, err, ErrSessionDescriptionMissingIcePwd) 88 }) 89 90 t.Run("Missing ice-ufrag", func(t *testing.T) { 91 s := &sdp.SessionDescription{ 92 MediaDescriptions: []*sdp.MediaDescription{ 93 {Attributes: []sdp.Attribute{{Key: "ice-pwd", Value: defaultPwd}}}, 94 }, 95 } 96 97 _, _, _, err := extractICEDetails(s, nil) 98 assert.Equal(t, err, ErrSessionDescriptionMissingIceUfrag) 99 }) 100 101 t.Run("ice details at session level", func(t *testing.T) { 102 s := &sdp.SessionDescription{ 103 Attributes: []sdp.Attribute{ 104 {Key: "ice-ufrag", Value: defaultUfrag}, 105 {Key: "ice-pwd", Value: defaultPwd}, 106 }, 107 MediaDescriptions: []*sdp.MediaDescription{}, 108 } 109 110 ufrag, pwd, _, err := extractICEDetails(s, nil) 111 assert.Equal(t, ufrag, defaultUfrag) 112 assert.Equal(t, pwd, defaultPwd) 113 assert.NoError(t, err) 114 }) 115 116 t.Run("ice details at media level", func(t *testing.T) { 117 s := &sdp.SessionDescription{ 118 MediaDescriptions: []*sdp.MediaDescription{ 119 { 120 Attributes: []sdp.Attribute{ 121 {Key: "ice-ufrag", Value: defaultUfrag}, 122 {Key: "ice-pwd", Value: defaultPwd}, 123 }, 124 }, 125 }, 126 } 127 128 ufrag, pwd, _, err := extractICEDetails(s, nil) 129 assert.Equal(t, ufrag, defaultUfrag) 130 assert.Equal(t, pwd, defaultPwd) 131 assert.NoError(t, err) 132 }) 133 134 t.Run("Conflict ufrag", func(t *testing.T) { 135 s := &sdp.SessionDescription{ 136 Attributes: []sdp.Attribute{{Key: "ice-ufrag", Value: "invalidUfrag"}}, 137 MediaDescriptions: []*sdp.MediaDescription{ 138 {Attributes: []sdp.Attribute{{Key: "ice-ufrag", Value: defaultUfrag}, {Key: "ice-pwd", Value: defaultPwd}}}, 139 }, 140 } 141 142 _, _, _, err := extractICEDetails(s, nil) 143 assert.Equal(t, err, ErrSessionDescriptionConflictingIceUfrag) 144 }) 145 146 t.Run("Conflict pwd", func(t *testing.T) { 147 s := &sdp.SessionDescription{ 148 Attributes: []sdp.Attribute{{Key: "ice-pwd", Value: "invalidPwd"}}, 149 MediaDescriptions: []*sdp.MediaDescription{ 150 {Attributes: []sdp.Attribute{{Key: "ice-ufrag", Value: defaultUfrag}, {Key: "ice-pwd", Value: defaultPwd}}}, 151 }, 152 } 153 154 _, _, _, err := extractICEDetails(s, nil) 155 assert.Equal(t, err, ErrSessionDescriptionConflictingIcePwd) 156 }) 157 } 158 159 func TestTrackDetailsFromSDP(t *testing.T) { 160 t.Run("Tracks unknown, audio and video with RTX", func(t *testing.T) { 161 s := &sdp.SessionDescription{ 162 MediaDescriptions: []*sdp.MediaDescription{ 163 { 164 MediaName: sdp.MediaName{ 165 Media: "foobar", 166 }, 167 Attributes: []sdp.Attribute{ 168 {Key: "mid", Value: "0"}, 169 {Key: "sendrecv"}, 170 {Key: "ssrc", Value: "1000 msid:unknown_trk_label unknown_trk_guid"}, 171 }, 172 }, 173 { 174 MediaName: sdp.MediaName{ 175 Media: "audio", 176 }, 177 Attributes: []sdp.Attribute{ 178 {Key: "mid", Value: "1"}, 179 {Key: "sendrecv"}, 180 {Key: "ssrc", Value: "2000 msid:audio_trk_label audio_trk_guid"}, 181 }, 182 }, 183 { 184 MediaName: sdp.MediaName{ 185 Media: "video", 186 }, 187 Attributes: []sdp.Attribute{ 188 {Key: "mid", Value: "2"}, 189 {Key: "sendrecv"}, 190 {Key: "ssrc-group", Value: "FID 3000 4000"}, 191 {Key: "ssrc", Value: "3000 msid:video_trk_label video_trk_guid"}, 192 {Key: "ssrc", Value: "4000 msid:rtx_trk_label rtx_trck_guid"}, 193 }, 194 }, 195 { 196 MediaName: sdp.MediaName{ 197 Media: "video", 198 }, 199 Attributes: []sdp.Attribute{ 200 {Key: "mid", Value: "3"}, 201 {Key: "sendonly"}, 202 {Key: "msid", Value: "video_stream_id video_trk_id"}, 203 {Key: "ssrc", Value: "5000"}, 204 }, 205 }, 206 { 207 MediaName: sdp.MediaName{ 208 Media: "video", 209 }, 210 Attributes: []sdp.Attribute{ 211 {Key: "sendonly"}, 212 {Key: sdpAttributeRid, Value: "f send pt=97;max-width=1280;max-height=720"}, 213 }, 214 }, 215 }, 216 } 217 218 tracks := trackDetailsFromSDP(nil, s) 219 assert.Equal(t, 3, len(tracks)) 220 if trackDetail := trackDetailsForSSRC(tracks, 1000); trackDetail != nil { 221 assert.Fail(t, "got the unknown track ssrc:1000 which should have been skipped") 222 } 223 if track := trackDetailsForSSRC(tracks, 2000); track == nil { 224 assert.Fail(t, "missing audio track with ssrc:2000") 225 } else { 226 assert.Equal(t, RTPCodecTypeAudio, track.kind) 227 assert.Equal(t, SSRC(2000), track.ssrcs[0]) 228 assert.Equal(t, "audio_trk_label", track.streamID) 229 } 230 if track := trackDetailsForSSRC(tracks, 3000); track == nil { 231 assert.Fail(t, "missing video track with ssrc:3000") 232 } else { 233 assert.Equal(t, RTPCodecTypeVideo, track.kind) 234 assert.Equal(t, SSRC(3000), track.ssrcs[0]) 235 assert.Equal(t, "video_trk_label", track.streamID) 236 } 237 if track := trackDetailsForSSRC(tracks, 4000); track != nil { 238 assert.Fail(t, "got the rtx track ssrc:3000 which should have been skipped") 239 } 240 if track := trackDetailsForSSRC(tracks, 5000); track == nil { 241 assert.Fail(t, "missing video track with ssrc:5000") 242 } else { 243 assert.Equal(t, RTPCodecTypeVideo, track.kind) 244 assert.Equal(t, SSRC(5000), track.ssrcs[0]) 245 assert.Equal(t, "video_trk_id", track.id) 246 assert.Equal(t, "video_stream_id", track.streamID) 247 } 248 }) 249 250 t.Run("inactive and recvonly tracks ignored", func(t *testing.T) { 251 s := &sdp.SessionDescription{ 252 MediaDescriptions: []*sdp.MediaDescription{ 253 { 254 MediaName: sdp.MediaName{ 255 Media: "video", 256 }, 257 Attributes: []sdp.Attribute{ 258 {Key: "inactive"}, 259 {Key: "ssrc", Value: "6000"}, 260 }, 261 }, 262 { 263 MediaName: sdp.MediaName{ 264 Media: "video", 265 }, 266 Attributes: []sdp.Attribute{ 267 {Key: "recvonly"}, 268 {Key: "ssrc", Value: "7000"}, 269 }, 270 }, 271 }, 272 } 273 assert.Equal(t, 0, len(trackDetailsFromSDP(nil, s))) 274 }) 275 276 t.Run("ssrc-group after ssrc", func(t *testing.T) { 277 s := &sdp.SessionDescription{ 278 MediaDescriptions: []*sdp.MediaDescription{ 279 { 280 MediaName: sdp.MediaName{ 281 Media: "video", 282 }, 283 Attributes: []sdp.Attribute{ 284 {Key: "mid", Value: "0"}, 285 {Key: "sendrecv"}, 286 {Key: "ssrc", Value: "3000 msid:video_trk_label video_trk_guid"}, 287 {Key: "ssrc", Value: "4000 msid:rtx_trk_label rtx_trck_guid"}, 288 {Key: "ssrc-group", Value: "FID 3000 4000"}, 289 }, 290 }, 291 { 292 MediaName: sdp.MediaName{ 293 Media: "video", 294 }, 295 Attributes: []sdp.Attribute{ 296 {Key: "mid", Value: "1"}, 297 {Key: "sendrecv"}, 298 {Key: "ssrc-group", Value: "FID 5000 6000"}, 299 {Key: "ssrc", Value: "5000 msid:video_trk_label video_trk_guid"}, 300 {Key: "ssrc", Value: "6000 msid:rtx_trk_label rtx_trck_guid"}, 301 }, 302 }, 303 }, 304 } 305 306 tracks := trackDetailsFromSDP(nil, s) 307 assert.Equal(t, 2, len(tracks)) 308 assert.Equal(t, SSRC(4000), *tracks[0].repairSsrc) 309 assert.Equal(t, SSRC(6000), *tracks[1].repairSsrc) 310 }) 311 } 312 313 func TestHaveApplicationMediaSection(t *testing.T) { 314 t.Run("Audio only", func(t *testing.T) { 315 s := &sdp.SessionDescription{ 316 MediaDescriptions: []*sdp.MediaDescription{ 317 { 318 MediaName: sdp.MediaName{ 319 Media: "audio", 320 }, 321 Attributes: []sdp.Attribute{ 322 {Key: "sendrecv"}, 323 {Key: "ssrc", Value: "2000"}, 324 }, 325 }, 326 }, 327 } 328 329 assert.False(t, haveApplicationMediaSection(s)) 330 }) 331 332 t.Run("Application", func(t *testing.T) { 333 s := &sdp.SessionDescription{ 334 MediaDescriptions: []*sdp.MediaDescription{ 335 { 336 MediaName: sdp.MediaName{ 337 Media: mediaSectionApplication, 338 }, 339 }, 340 }, 341 } 342 343 assert.True(t, haveApplicationMediaSection(s)) 344 }) 345 } 346 347 func TestMediaDescriptionFingerprints(t *testing.T) { 348 engine := &MediaEngine{} 349 assert.NoError(t, engine.RegisterDefaultCodecs()) 350 351 api := NewAPI(WithMediaEngine(engine)) 352 353 sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 354 assert.NoError(t, err) 355 356 certificate, err := GenerateCertificate(sk) 357 assert.NoError(t, err) 358 359 media := []mediaSection{ 360 { 361 id: "video", 362 transceivers: []*RTPTransceiver{{ 363 kind: RTPCodecTypeVideo, 364 api: api, 365 codecs: engine.getCodecsByKind(RTPCodecTypeVideo), 366 }}, 367 }, 368 { 369 id: "audio", 370 transceivers: []*RTPTransceiver{{ 371 kind: RTPCodecTypeAudio, 372 api: api, 373 codecs: engine.getCodecsByKind(RTPCodecTypeAudio), 374 }}, 375 }, 376 { 377 id: "application", 378 data: true, 379 }, 380 } 381 382 for i := 0; i < 2; i++ { 383 media[i].transceivers[0].setSender(&RTPSender{}) 384 media[i].transceivers[0].setDirection(RTPTransceiverDirectionSendonly) 385 } 386 387 fingerprintTest := func(SDPMediaDescriptionFingerprints bool, expectedFingerprintCount int) func(t *testing.T) { 388 return func(t *testing.T) { 389 s := &sdp.SessionDescription{} 390 391 dtlsFingerprints, err := certificate.GetFingerprints() 392 assert.NoError(t, err) 393 394 s, err = populateSDP(s, false, 395 dtlsFingerprints, 396 SDPMediaDescriptionFingerprints, 397 false, true, engine, sdp.ConnectionRoleActive, []ICECandidate{}, ICEParameters{}, media, ICEGatheringStateNew, nil) 398 assert.NoError(t, err) 399 400 sdparray, err := s.Marshal() 401 assert.NoError(t, err) 402 403 assert.Equal(t, strings.Count(string(sdparray), "sha-256"), expectedFingerprintCount) 404 } 405 } 406 407 t.Run("Per-Media Description Fingerprints", fingerprintTest(true, 3)) 408 t.Run("Per-Session Description Fingerprints", fingerprintTest(false, 1)) 409 } 410 411 func TestPopulateSDP(t *testing.T) { 412 t.Run("rid", func(t *testing.T) { 413 se := SettingEngine{} 414 415 me := &MediaEngine{} 416 assert.NoError(t, me.RegisterDefaultCodecs()) 417 api := NewAPI(WithMediaEngine(me)) 418 419 tr := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} 420 tr.setDirection(RTPTransceiverDirectionRecvonly) 421 rids := []*simulcastRid{ 422 { 423 id: "ridkey", 424 attrValue: "some", 425 }, 426 { 427 id: "ridPaused", 428 attrValue: "some2", 429 paused: true, 430 }, 431 } 432 mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tr}, rids: rids}} 433 434 d := &sdp.SessionDescription{} 435 436 offerSdp, err := populateSDP(d, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, me, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, mediaSections, ICEGatheringStateComplete, nil) 437 assert.Nil(t, err) 438 439 // Test contains rid map keys 440 var ridFound int 441 for _, desc := range offerSdp.MediaDescriptions { 442 if desc.MediaName.Media != "video" { 443 continue 444 } 445 ridsInSDP := getRids(desc) 446 for _, rid := range ridsInSDP { 447 if rid.id == "ridkey" && !rid.paused { 448 ridFound++ 449 } 450 if rid.id == "ridPaused" && rid.paused { 451 ridFound++ 452 } 453 } 454 } 455 assert.Equal(t, 2, ridFound, "All rid keys should be present") 456 }) 457 t.Run("SetCodecPreferences", func(t *testing.T) { 458 se := SettingEngine{} 459 460 me := &MediaEngine{} 461 assert.NoError(t, me.RegisterDefaultCodecs()) 462 api := NewAPI(WithMediaEngine(me)) 463 me.pushCodecs(me.videoCodecs, RTPCodecTypeVideo) 464 me.pushCodecs(me.audioCodecs, RTPCodecTypeAudio) 465 466 tr := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} 467 tr.setDirection(RTPTransceiverDirectionRecvonly) 468 codecErr := tr.SetCodecPreferences([]RTPCodecParameters{ 469 { 470 RTPCodecCapability: RTPCodecCapability{MimeTypeVP8, 90000, 0, "", nil}, 471 PayloadType: 96, 472 }, 473 }) 474 assert.NoError(t, codecErr) 475 476 mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tr}}} 477 478 d := &sdp.SessionDescription{} 479 480 offerSdp, err := populateSDP(d, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, me, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, mediaSections, ICEGatheringStateComplete, nil) 481 assert.Nil(t, err) 482 483 // Test codecs 484 foundVP8 := false 485 for _, desc := range offerSdp.MediaDescriptions { 486 if desc.MediaName.Media != "video" { 487 continue 488 } 489 for _, a := range desc.Attributes { 490 if strings.Contains(a.Key, "rtpmap") { 491 if a.Value == "98 VP9/90000" { 492 t.Fatal("vp9 should not be present in sdp") 493 } else if a.Value == "96 VP8/90000" { 494 foundVP8 = true 495 } 496 } 497 } 498 } 499 assert.Equal(t, true, foundVP8, "vp8 should be present in sdp") 500 }) 501 t.Run("ice-lite", func(t *testing.T) { 502 se := SettingEngine{} 503 se.SetLite(true) 504 505 offerSdp, err := populateSDP(&sdp.SessionDescription{}, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, &MediaEngine{}, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, []mediaSection{}, ICEGatheringStateComplete, nil) 506 assert.Nil(t, err) 507 508 var found bool 509 // ice-lite is an session-level attribute 510 for _, a := range offerSdp.Attributes { 511 if a.Key == sdp.AttrKeyICELite { 512 // ice-lite does not have value (e.g. ":<value>") and it should be an empty string 513 if a.Value == "" { 514 found = true 515 break 516 } 517 } 518 } 519 assert.Equal(t, true, found, "ICELite key should be present") 520 }) 521 t.Run("rejected track", func(t *testing.T) { 522 se := SettingEngine{} 523 524 me := &MediaEngine{} 525 registerCodecErr := me.RegisterCodec(RTPCodecParameters{ 526 RTPCodecCapability: RTPCodecCapability{MimeType: MimeTypeVP8, ClockRate: 90000, Channels: 0, SDPFmtpLine: "", RTCPFeedback: nil}, 527 PayloadType: 96, 528 }, RTPCodecTypeVideo) 529 assert.NoError(t, registerCodecErr) 530 api := NewAPI(WithMediaEngine(me)) 531 532 videoTransceiver := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} 533 audioTransceiver := &RTPTransceiver{kind: RTPCodecTypeAudio, api: api, codecs: []RTPCodecParameters{}} 534 mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{videoTransceiver}}, {id: "audio", transceivers: []*RTPTransceiver{audioTransceiver}}} 535 536 d := &sdp.SessionDescription{} 537 538 offerSdp, err := populateSDP(d, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, me, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, mediaSections, ICEGatheringStateComplete, nil) 539 assert.NoError(t, err) 540 541 // Test codecs 542 foundRejectedTrack := false 543 for _, desc := range offerSdp.MediaDescriptions { 544 if desc.MediaName.Media != "audio" { 545 continue 546 } 547 assert.True(t, desc.ConnectionInformation != nil, "connection information must be provided for rejected tracks") 548 assert.Equal(t, desc.MediaName.Formats, []string{"0"}, "rejected tracks have 0 for Formats") 549 assert.Equal(t, desc.MediaName.Port, sdp.RangedPort{Value: 0}, "rejected tracks have 0 for Port") 550 foundRejectedTrack = true 551 } 552 assert.Equal(t, true, foundRejectedTrack, "rejected track wasn't present") 553 }) 554 t.Run("allow mixed extmap", func(t *testing.T) { 555 se := SettingEngine{} 556 offerSdp, err := populateSDP(&sdp.SessionDescription{}, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, &MediaEngine{}, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, []mediaSection{}, ICEGatheringStateComplete, nil) 557 assert.Nil(t, err) 558 559 var found bool 560 // session-level attribute 561 for _, a := range offerSdp.Attributes { 562 if a.Key == sdp.AttrKeyExtMapAllowMixed { 563 if a.Value == "" { 564 found = true 565 break 566 } 567 } 568 } 569 assert.Equal(t, true, found, "AllowMixedExtMap key should be present") 570 571 offerSdp, err = populateSDP(&sdp.SessionDescription{}, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, false, &MediaEngine{}, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, []mediaSection{}, ICEGatheringStateComplete, nil) 572 assert.Nil(t, err) 573 574 found = false 575 // session-level attribute 576 for _, a := range offerSdp.Attributes { 577 if a.Key == sdp.AttrKeyExtMapAllowMixed { 578 if a.Value == "" { 579 found = true 580 break 581 } 582 } 583 } 584 assert.Equal(t, false, found, "AllowMixedExtMap key should not be present") 585 }) 586 t.Run("bundle all", func(t *testing.T) { 587 se := SettingEngine{} 588 589 me := &MediaEngine{} 590 assert.NoError(t, me.RegisterDefaultCodecs()) 591 api := NewAPI(WithMediaEngine(me)) 592 593 tr := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} 594 tr.setDirection(RTPTransceiverDirectionRecvonly) 595 mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tr}}} 596 597 d := &sdp.SessionDescription{} 598 599 offerSdp, err := populateSDP(d, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, me, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, mediaSections, ICEGatheringStateComplete, nil) 600 assert.Nil(t, err) 601 602 bundle, ok := offerSdp.Attribute(sdp.AttrKeyGroup) 603 assert.True(t, ok) 604 assert.Equal(t, "BUNDLE video", bundle) 605 }) 606 t.Run("bundle matched", func(t *testing.T) { 607 se := SettingEngine{} 608 609 me := &MediaEngine{} 610 assert.NoError(t, me.RegisterDefaultCodecs()) 611 api := NewAPI(WithMediaEngine(me)) 612 613 tra := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} 614 tra.setDirection(RTPTransceiverDirectionRecvonly) 615 mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tra}}} 616 617 trv := &RTPTransceiver{kind: RTPCodecTypeAudio, api: api, codecs: me.audioCodecs} 618 trv.setDirection(RTPTransceiverDirectionRecvonly) 619 mediaSections = append(mediaSections, mediaSection{id: "audio", transceivers: []*RTPTransceiver{trv}}) 620 621 d := &sdp.SessionDescription{} 622 623 matchedBundle := "audio" 624 offerSdp, err := populateSDP(d, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, me, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, mediaSections, ICEGatheringStateComplete, &matchedBundle) 625 assert.Nil(t, err) 626 627 bundle, ok := offerSdp.Attribute(sdp.AttrKeyGroup) 628 assert.True(t, ok) 629 assert.Equal(t, "BUNDLE audio", bundle) 630 631 mediaVideo := offerSdp.MediaDescriptions[0] 632 mid, ok := mediaVideo.Attribute(sdp.AttrKeyMID) 633 assert.True(t, ok) 634 assert.Equal(t, "video", mid) 635 assert.True(t, mediaVideo.MediaName.Port.Value == 0) 636 }) 637 t.Run("empty bundle group", func(t *testing.T) { 638 se := SettingEngine{} 639 640 me := &MediaEngine{} 641 assert.NoError(t, me.RegisterDefaultCodecs()) 642 api := NewAPI(WithMediaEngine(me)) 643 644 tra := &RTPTransceiver{kind: RTPCodecTypeVideo, api: api, codecs: me.videoCodecs} 645 tra.setDirection(RTPTransceiverDirectionRecvonly) 646 mediaSections := []mediaSection{{id: "video", transceivers: []*RTPTransceiver{tra}}} 647 648 d := &sdp.SessionDescription{} 649 650 matchedBundle := "" 651 offerSdp, err := populateSDP(d, false, []DTLSFingerprint{}, se.sdpMediaLevelFingerprints, se.candidates.ICELite, true, me, connectionRoleFromDtlsRole(defaultDtlsRoleOffer), []ICECandidate{}, ICEParameters{}, mediaSections, ICEGatheringStateComplete, &matchedBundle) 652 assert.Nil(t, err) 653 654 _, ok := offerSdp.Attribute(sdp.AttrKeyGroup) 655 assert.False(t, ok) 656 }) 657 } 658 659 func TestGetRIDs(t *testing.T) { 660 m := []*sdp.MediaDescription{ 661 { 662 MediaName: sdp.MediaName{ 663 Media: "video", 664 }, 665 Attributes: []sdp.Attribute{ 666 {Key: "sendonly"}, 667 {Key: sdpAttributeRid, Value: "f send pt=97;max-width=1280;max-height=720"}, 668 }, 669 }, 670 } 671 672 rids := getRids(m[0]) 673 674 assert.NotEmpty(t, rids, "Rid mapping should be present") 675 found := false 676 for _, rid := range rids { 677 if rid.id == "f" { 678 found = true 679 break 680 } 681 } 682 if !found { 683 assert.Fail(t, "rid values should contain 'f'") 684 } 685 } 686 687 func TestCodecsFromMediaDescription(t *testing.T) { 688 t.Run("Codec Only", func(t *testing.T) { 689 codecs, err := codecsFromMediaDescription(&sdp.MediaDescription{ 690 MediaName: sdp.MediaName{ 691 Media: "audio", 692 Formats: []string{"111"}, 693 }, 694 Attributes: []sdp.Attribute{ 695 {Key: "rtpmap", Value: "111 opus/48000/2"}, 696 }, 697 }) 698 699 assert.Equal(t, codecs, []RTPCodecParameters{ 700 { 701 RTPCodecCapability: RTPCodecCapability{MimeTypeOpus, 48000, 2, "", []RTCPFeedback{}}, 702 PayloadType: 111, 703 }, 704 }) 705 assert.NoError(t, err) 706 }) 707 708 t.Run("Codec with fmtp/rtcp-fb", func(t *testing.T) { 709 codecs, err := codecsFromMediaDescription(&sdp.MediaDescription{ 710 MediaName: sdp.MediaName{ 711 Media: "audio", 712 Formats: []string{"111"}, 713 }, 714 Attributes: []sdp.Attribute{ 715 {Key: "rtpmap", Value: "111 opus/48000/2"}, 716 {Key: "fmtp", Value: "111 minptime=10;useinbandfec=1"}, 717 {Key: "rtcp-fb", Value: "111 goog-remb"}, 718 {Key: "rtcp-fb", Value: "111 ccm fir"}, 719 {Key: "rtcp-fb", Value: "* ccm fir"}, 720 {Key: "rtcp-fb", Value: "* nack"}, 721 }, 722 }) 723 724 assert.Equal(t, codecs, []RTPCodecParameters{ 725 { 726 RTPCodecCapability: RTPCodecCapability{MimeTypeOpus, 48000, 2, "minptime=10;useinbandfec=1", []RTCPFeedback{{"goog-remb", ""}, {"ccm", "fir"}, {"nack", ""}}}, 727 PayloadType: 111, 728 }, 729 }) 730 assert.NoError(t, err) 731 }) 732 } 733 734 func TestRtpExtensionsFromMediaDescription(t *testing.T) { 735 extensions, err := rtpExtensionsFromMediaDescription(&sdp.MediaDescription{ 736 MediaName: sdp.MediaName{ 737 Media: "audio", 738 Formats: []string{"111"}, 739 }, 740 Attributes: []sdp.Attribute{ 741 {Key: "extmap", Value: "1 " + sdp.ABSSendTimeURI}, 742 {Key: "extmap", Value: "3 " + sdp.SDESMidURI}, 743 }, 744 }) 745 746 assert.NoError(t, err) 747 assert.Equal(t, extensions[sdp.ABSSendTimeURI], 1) 748 assert.Equal(t, extensions[sdp.SDESMidURI], 3) 749 } 750 751 // Assert that FEC and RTX SSRCes are present if they are enabled in the MediaEngine 752 func Test_SSRC_Groups(t *testing.T) { 753 const offerWithRTX = `v=0 754 o=- 930222930247584370 1727933945 IN IP4 0.0.0.0 755 s=- 756 t=0 0 757 a=msid-semantic:WMS* 758 a=fingerprint:sha-256 11:3F:1C:8D:D4:1D:8D:E7:E1:3E:AF:38:06:0D:1D:40:22:DC:FE:C9:93:E4:80:D8:0B:17:9F:2E:C1:CA:C8:3D 759 a=extmap-allow-mixed 760 a=group:BUNDLE 0 1 761 m=audio 9 UDP/TLS/RTP/SAVPF 101 762 c=IN IP4 0.0.0.0 763 a=setup:actpass 764 a=mid:0 765 a=ice-ufrag:yIgpPUMarFReduuM 766 a=ice-pwd:VmnVaqCByWiOTatFoDBbMGhSFGlsxviz 767 a=rtcp-mux 768 a=rtcp-rsize 769 a=rtpmap:101 opus/90000 770 a=rtcp-fb:101 transport-cc 771 a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 772 a=ssrc:3566446228 cname:stream-id 773 a=ssrc:3566446228 msid:stream-id audio-id 774 a=ssrc:3566446228 mslabel:stream-id 775 a=ssrc:3566446228 label:audio-id 776 a=msid:stream-id audio-id 777 a=sendrecv 778 m=video 9 UDP/TLS/RTP/SAVPF 96 97 779 c=IN IP4 0.0.0.0 780 a=setup:actpass 781 a=mid:1 782 a=ice-ufrag:yIgpPUMarFReduuM 783 a=ice-pwd:VmnVaqCByWiOTatFoDBbMGhSFGlsxviz 784 a=rtpmap:96 VP8/90000 785 a=rtcp-fb:96 nack 786 a=rtcp-fb:96 nack pli 787 a=rtcp-fb:96 transport-cc 788 a=rtpmap:97 rtx/90000 789 a=fmtp:97 apt=96 790 a=ssrc-group:FID 1701050765 2578535262 791 a=ssrc:1701050765 cname:stream-id 792 a=ssrc:1701050765 msid:stream-id track-id 793 a=ssrc:1701050765 mslabel:stream-id 794 a=ssrc:1701050765 label:track-id 795 a=msid:stream-id track-id 796 a=sendrecv 797 ` 798 799 const offerNoRTX = `v=0 800 o=- 930222930247584370 1727933945 IN IP4 0.0.0.0 801 s=- 802 t=0 0 803 a=msid-semantic:WMS* 804 a=fingerprint:sha-256 11:3F:1C:8D:D4:1D:8D:E7:E1:3E:AF:38:06:0D:1D:40:22:DC:FE:C9:93:E4:80:D8:0B:17:9F:2E:C1:CA:C8:3D 805 a=extmap-allow-mixed 806 a=group:BUNDLE 0 1 807 m=audio 9 UDP/TLS/RTP/SAVPF 101 808 a=mid:0 809 a=ice-ufrag:yIgpPUMarFReduuM 810 a=ice-pwd:VmnVaqCByWiOTatFoDBbMGhSFGlsxviz 811 a=rtcp-mux 812 a=rtcp-rsize 813 a=rtpmap:101 opus/90000 814 a=rtcp-fb:101 transport-cc 815 a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 816 a=ssrc:3566446228 cname:stream-id 817 a=ssrc:3566446228 msid:stream-id audio-id 818 a=ssrc:3566446228 mslabel:stream-id 819 a=ssrc:3566446228 label:audio-id 820 a=msid:stream-id audio-id 821 a=sendrecv 822 m=video 9 UDP/TLS/RTP/SAVPF 96 823 c=IN IP4 0.0.0.0 824 a=setup:actpass 825 a=mid:1 826 a=ice-ufrag:yIgpPUMarFReduuM 827 a=ice-pwd:VmnVaqCByWiOTatFoDBbMGhSFGlsxviz 828 a=rtpmap:96 VP8/90000 829 a=rtcp-fb:96 nack 830 a=rtcp-fb:96 nack pli 831 a=rtcp-fb:96 transport-cc 832 a=ssrc-group:FID 1701050765 2578535262 833 a=ssrc:1701050765 cname:stream-id 834 a=ssrc:1701050765 msid:stream-id track-id 835 a=ssrc:1701050765 mslabel:stream-id 836 a=ssrc:1701050765 label:track-id 837 a=msid:stream-id track-id 838 a=sendrecv 839 ` 840 defer test.CheckRoutines(t)() 841 842 for _, testCase := range []struct { 843 name string 844 enableRTXInMediaEngine bool 845 rtxExpected bool 846 remoteOffer string 847 }{ 848 {"Offer", true, true, ""}, 849 {"Offer no Local Groups", false, false, ""}, 850 {"Answer", true, true, offerWithRTX}, 851 {"Answer No Local Groups", false, false, offerWithRTX}, 852 {"Answer No Remote Groups", true, false, offerNoRTX}, 853 } { 854 t.Run(testCase.name, func(t *testing.T) { 855 checkRTXSupport := func(s *sdp.SessionDescription) { 856 // RTX is never enabled for audio 857 assert.Nil(t, trackDetailsFromSDP(nil, s)[0].repairSsrc) 858 859 // RTX is conditionally enabled for video 860 if testCase.rtxExpected { 861 assert.NotNil(t, trackDetailsFromSDP(nil, s)[1].repairSsrc) 862 } else { 863 assert.Nil(t, trackDetailsFromSDP(nil, s)[1].repairSsrc) 864 } 865 } 866 867 m := &MediaEngine{} 868 assert.NoError(t, m.RegisterCodec(RTPCodecParameters{ 869 RTPCodecCapability: RTPCodecCapability{MimeType: MimeTypeOpus, ClockRate: 90000, Channels: 0, SDPFmtpLine: "", RTCPFeedback: nil}, 870 PayloadType: 101, 871 }, RTPCodecTypeAudio)) 872 assert.NoError(t, m.RegisterCodec(RTPCodecParameters{ 873 RTPCodecCapability: RTPCodecCapability{MimeType: MimeTypeVP8, ClockRate: 90000, Channels: 0, SDPFmtpLine: "", RTCPFeedback: nil}, 874 PayloadType: 96, 875 }, RTPCodecTypeVideo)) 876 if testCase.enableRTXInMediaEngine { 877 assert.NoError(t, m.RegisterCodec(RTPCodecParameters{ 878 RTPCodecCapability: RTPCodecCapability{MimeType: MimeTypeRTX, ClockRate: 90000, Channels: 0, SDPFmtpLine: "apt=96", RTCPFeedback: nil}, 879 PayloadType: 97, 880 }, RTPCodecTypeVideo)) 881 } 882 883 peerConnection, err := NewAPI(WithMediaEngine(m)).NewPeerConnection(Configuration{}) 884 assert.NoError(t, err) 885 886 audioTrack, err := NewTrackLocalStaticSample(RTPCodecCapability{MimeType: MimeTypeOpus}, "audio-id", "stream-id") 887 assert.NoError(t, err) 888 889 _, err = peerConnection.AddTrack(audioTrack) 890 assert.NoError(t, err) 891 892 videoTrack, err := NewTrackLocalStaticSample(RTPCodecCapability{MimeType: MimeTypeVP8}, "video-id", "stream-id") 893 assert.NoError(t, err) 894 895 _, err = peerConnection.AddTrack(videoTrack) 896 assert.NoError(t, err) 897 898 if testCase.remoteOffer == "" { 899 offer, err := peerConnection.CreateOffer(nil) 900 assert.NoError(t, err) 901 checkRTXSupport(offer.parsed) 902 } else { 903 assert.NoError(t, peerConnection.SetRemoteDescription(SessionDescription{Type: SDPTypeOffer, SDP: testCase.remoteOffer})) 904 answer, err := peerConnection.CreateAnswer(nil) 905 assert.NoError(t, err) 906 checkRTXSupport(answer.parsed) 907 } 908 909 assert.NoError(t, peerConnection.Close()) 910 }) 911 } 912 }