telegabber/xmpp/jingle/convert_test.go
2026-05-27 08:37:47 -07:00

231 lines
7.1 KiB
Go

package jingle
import (
"bytes"
"encoding/xml"
"reflect"
"testing"
)
// exercises BUNDLE + opus + fmtp + extmap + ssrc + DTLS-SRTP + ICE-UDP
const sampleSDP = "v=0\r\n" +
"o=- 0 0 IN IP4 0.0.0.0\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"a=group:BUNDLE 0\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"a=mid:0\r\n" +
"a=sendrecv\r\n" +
"a=rtcp-mux\r\n" +
"a=ice-ufrag:abc\r\n" +
"a=ice-pwd:thispasswordisatleast22chars\r\n" +
"a=ice-options:trickle\r\n" +
"a=fingerprint:sha-256 AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89\r\n" +
"a=setup:actpass\r\n" +
"a=rtpmap:111 opus/48000/2\r\n" +
"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
"a=rtcp-fb:111 transport-cc\r\n" +
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
"a=ssrc:12345 cname:abcdef\r\n" +
"a=ssrc:12345 msid:stream track\r\n" +
"a=candidate:1 1 udp 2113937151 192.0.2.1 10000 typ host generation 0\r\n"
func sampleJingle() *JingleIQ {
j, err := SDPToJingle(sampleSDP, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: "abc123"})
if err != nil {
panic(err)
}
j.Action = "session-initiate"
j.Initiator = "alice@example.com/foo"
return j
}
func TestRoundtripJingleStructViaSDP(t *testing.T) {
in := sampleJingle()
opts := ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: in.SID}
sdp, err := JingleToSDP(in, opts)
if err != nil {
t.Fatalf("JingleToSDP: %v", err)
}
out, err := SDPToJingle(sdp, opts)
if err != nil {
t.Fatalf("SDPToJingle: %v", err)
}
// Action / Initiator / Responder live on the IQ envelope and are not in
// SDP, so the roundtrip can only restore Contents, Group, and SID.
if out.SID != in.SID {
t.Errorf("SID lost: %q -> %q", in.SID, out.SID)
}
if !reflect.DeepEqual(in.Group, out.Group) {
t.Errorf("Group mismatch:\nin: %+v\nout: %+v", in.Group, out.Group)
}
if len(in.Contents) != len(out.Contents) {
t.Fatalf("content count: in=%d out=%d", len(in.Contents), len(out.Contents))
}
for i := range in.Contents {
assertContentEqual(t, &in.Contents[i], &out.Contents[i])
}
}
// only fields that survive a round trip; RtcpMux gets set unconditionally
// when BUNDLE is in play, candidate id gets dropped, etc.
func assertContentEqual(t *testing.T, a, b *Content) {
t.Helper()
if a.Name != b.Name || a.Creator != b.Creator {
t.Errorf("content header: %+v vs %+v", a, b)
}
// empty input means "both", which sendersFromMM omits
if want, got := normalizeSenders(a.Senders), normalizeSenders(b.Senders); want != got {
t.Errorf("senders: %q vs %q", want, got)
}
if !reflect.DeepEqual(a.Description, b.Description) {
t.Errorf("description differs:\nin: %+v\nout: %+v", a.Description, b.Description)
}
assertTransportEqual(t, a.Transport, b.Transport)
}
func normalizeSenders(s string) string {
if s == "" {
return "both"
}
return s
}
func assertTransportEqual(t *testing.T, a, b *Transport) {
t.Helper()
if a.Ufrag != b.Ufrag || a.Pwd != b.Pwd {
t.Errorf("ufrag/pwd: in=(%q,%q) out=(%q,%q)", a.Ufrag, a.Pwd, b.Ufrag, b.Pwd)
}
if !reflect.DeepEqual(a.Fingerprint, b.Fingerprint) {
t.Errorf("fingerprint differs:\nin: %+v\nout: %+v", a.Fingerprint, b.Fingerprint)
}
if (a.Trickle == nil) != (b.Trickle == nil) {
t.Errorf("trickle presence differs: in=%v out=%v", a.Trickle != nil, b.Trickle != nil)
}
if (a.Renomination == nil) != (b.Renomination == nil) {
t.Errorf("renomination presence differs: in=%v out=%v", a.Renomination != nil, b.Renomination != nil)
}
if len(a.Candidates) != len(b.Candidates) {
t.Fatalf("candidate count: in=%d out=%d", len(a.Candidates), len(b.Candidates))
}
for i := range a.Candidates {
ac, bc := a.Candidates[i], b.Candidates[i]
// id and network are not present in SDP candidate form; ignore.
ac.ID, bc.ID = "", ""
ac.Network, bc.Network = "", ""
if !reflect.DeepEqual(ac, bc) {
t.Errorf("candidate %d differs:\nin: %+v\nout: %+v", i, ac, bc)
}
}
}
func TestJingleXMLRoundtrip(t *testing.T) {
in := sampleJingle()
raw1, err := xml.MarshalIndent(in, "", " ")
if err != nil {
t.Fatalf("marshal in: %v", err)
}
var mid JingleIQ
if err := xml.Unmarshal(raw1, &mid); err != nil {
t.Fatalf("unmarshal: %v\nraw:\n%s", err, raw1)
}
raw2, err := xml.MarshalIndent(&mid, "", " ")
if err != nil {
t.Fatalf("marshal mid: %v", err)
}
if !bytes.Equal(raw1, raw2) {
t.Errorf("XML not idempotent under marshal/unmarshal\n---first---\n%s\n---second---\n%s", raw1, raw2)
}
}
func TestParseSDP_AttrsAndMedia(t *testing.T) {
sdp := "v=0\r\n" +
"o=- 0 0 IN IP4 0.0.0.0\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"a=group:BUNDLE 0\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"a=mid:0\r\n" +
"a=rtpmap:111 opus/48000/2\r\n"
doc, err := parseSDP(sdp)
if err != nil {
t.Fatalf("parseSDP: %v", err)
}
if mmFirst(doc.sessionAttrs, "group", "") != "BUNDLE 0" {
t.Errorf("session group: %q", mmFirst(doc.sessionAttrs, "group", ""))
}
if len(doc.media) != 1 {
t.Fatalf("expected 1 m=block, got %d", len(doc.media))
}
m := doc.media[0]
if m.media != "audio" || m.port != "9" || m.protocol != "UDP/TLS/RTP/SAVPF" {
t.Errorf("m= header: %+v", m)
}
if !reflect.DeepEqual(m.formats, []string{"111"}) {
t.Errorf("formats: %v", m.formats)
}
if m.connection != "IN IP4 0.0.0.0" {
t.Errorf("connection: %q", m.connection)
}
if mmFirst(m.attrs, "mid", "") != "0" {
t.Errorf("mid: %q", mmFirst(m.attrs, "mid", ""))
}
if mmFirst(m.attrs, "rtpmap", "") != "111 opus/48000/2" {
t.Errorf("rtpmap: %q", mmFirst(m.attrs, "rtpmap", ""))
}
}
func TestJingleToSDP_RejectsMalformed(t *testing.T) {
cases := []struct {
name string
mutate func(*JingleIQ)
}{
{"missing fingerprint", func(j *JingleIQ) { j.Contents[0].Transport.Fingerprint = nil }},
{"non-UDP candidate", func(j *JingleIQ) { j.Contents[0].Transport.Candidates[0].Protocol = "tcp" }},
{"CR/LF in ssrc param value", func(j *JingleIQ) {
j.Contents[0].Description.Sources[0].Parameters[0].Value = "bad\nvalue"
}},
{"whitespace in group semantics", func(j *JingleIQ) { j.Group.Semantics = "BUNDLE BAD" }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
j := sampleJingle()
tc.mutate(j)
if _, err := JingleToSDP(j, ConvertOpts{Initiator: true}); err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestSDPToJingle_FingerprintSessionFallback(t *testing.T) {
// Media block omits fingerprint+setup; session block provides them.
sdp := "v=0\r\n" +
"o=- 0 0 IN IP4 0.0.0.0\r\n" +
"s=-\r\n" +
"t=0 0\r\n" +
"a=fingerprint:sha-256 AA:BB:CC\r\n" +
"a=setup:actpass\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
"c=IN IP4 0.0.0.0\r\n" +
"a=ice-ufrag:u\r\n" +
"a=ice-pwd:p\r\n" +
"a=mid:0\r\n" +
"a=rtpmap:111 opus/48000/2\r\n"
j, err := SDPToJingle(sdp, ConvertOpts{Initiator: true})
if err != nil {
t.Fatalf("SDPToJingle: %v", err)
}
if len(j.Contents) != 1 {
t.Fatalf("contents: %d", len(j.Contents))
}
fp := j.Contents[0].Transport.Fingerprint
if fp == nil {
t.Fatal("fingerprint not pulled up from session level")
}
if fp.Hash != "sha-256" || fp.Text != "AA:BB:CC" || fp.Setup != "actpass" {
t.Errorf("fingerprint: %+v", fp)
}
}