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