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

260 lines
6.5 KiB
Go

package jingle
import (
"context"
"sync"
"testing"
"time"
"github.com/pion/webrtc/v4"
"gosrc.io/xmpp/stanza"
)
// wires one Manager's outbound into the other's HandlePacket without
// sockets; each Send fires on a fresh goroutine so trickle/ordering
// bugs surface naturally
type linkedSender struct {
target *Manager
wg sync.WaitGroup
}
func (ls *linkedSender) Send(p stanza.Packet) error {
ls.wg.Add(1)
go func() {
defer ls.wg.Done()
ls.target.HandlePacket(nil, p)
}()
return nil
}
func (ls *linkedSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
ch := make(chan stanza.IQ, 1)
return ch, ls.Send(iq)
}
func (ls *linkedSender) SendRaw(string) error { return nil }
func (ls *linkedSender) wait() { ls.wg.Wait() }
// two Managers cross-linked through linkedSenders
func pair() (*Manager, *Manager, *linkedSender, *linkedSender) {
mA := &Manager{LocalJID: "a@x/r"}
mB := &Manager{LocalJID: "b@x/r"}
sA := &linkedSender{target: mB}
sB := &linkedSender{target: mA}
mA.Sender = sA
mB.Sender = sB
return mA, mB, sA, sB
}
// pion PC with t.Cleanup
func makePC(t *testing.T) *webrtc.PeerConnection {
t.Helper()
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
t.Fatalf("NewPeerConnection: %v", err)
}
t.Cleanup(func() { _ = pc.Close() })
return pc
}
func waitFor(t *testing.T, what string, d time.Duration, fn func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("waitFor(%s): timeout after %v", what, d)
}
// JMI preamble without media setup
func TestJMIProposeRingProceed(t *testing.T) {
mA, mB, sA, sB := pair()
var bSess *Session
var bSessMu sync.Mutex
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
s, err := New(SessionOpts{
PC: makePC(t), Sender: sB,
LocalJID: mB.LocalJID, RemoteJID: p.From,
SID: p.SID, Role: RoleResponder, Media: p.Media,
})
if err != nil {
return nil, nil, err
}
bSessMu.Lock()
bSess = s
bSessMu.Unlock()
return s, nil, nil
}
aSess, err := New(SessionOpts{
PC: makePC(t), Sender: sA,
LocalJID: mA.LocalJID, RemoteJID: "b@x",
SID: "sid-1", Role: RoleInitiator, Media: []string{"audio"},
})
if err != nil {
t.Fatal(err)
}
mA.Register(aSess)
if err := aSess.Propose(context.Background()); err != nil {
t.Fatalf("Propose: %v", err)
}
waitFor(t, "B reach ringing", 2*time.Second, func() bool {
bSessMu.Lock()
defer bSessMu.Unlock()
return bSess != nil && bSess.State() == StateRinging
})
if err := bSess.AcceptProposal(context.Background()); err != nil {
t.Fatalf("AcceptProposal: %v", err)
}
// A enters StateProceeded; media setup will then drive it forward
// (CreateOffer, etc.) - that's tested elsewhere. Allow either
// StateProceeded or StateActive since the goroutine may have raced
// ahead, depending on how fast pion gathers.
waitFor(t, "A leave Proposed", 2*time.Second, func() bool {
s := aSess.State()
return s == StateProceeded || s == StateActive || s == StateTerminated
})
aSess.Close()
bSess.Close()
sA.wait()
sB.wait()
}
// <reject>: caller -> Terminated when callee declines while ringing
func TestJMIRejectFromCallee(t *testing.T) {
mA, mB, sA, sB := pair()
var bSess *Session
var bSessMu sync.Mutex
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
s, _ := New(SessionOpts{
PC: makePC(t), Sender: sB,
LocalJID: mB.LocalJID, RemoteJID: p.From,
SID: p.SID, Role: RoleResponder, Media: p.Media,
})
bSessMu.Lock()
bSess = s
bSessMu.Unlock()
return s, nil, nil
}
aSess, _ := New(SessionOpts{
PC: makePC(t), Sender: sA,
LocalJID: mA.LocalJID, RemoteJID: "b@x",
SID: "sid-rej", Role: RoleInitiator, Media: []string{"audio"},
})
mA.Register(aSess)
if err := aSess.Propose(context.Background()); err != nil {
t.Fatal(err)
}
waitFor(t, "B reach ringing", 2*time.Second, func() bool {
bSessMu.Lock()
defer bSessMu.Unlock()
return bSess != nil && bSess.State() == StateRinging
})
if err := bSess.Decline(context.Background(), ReasonDecline); err != nil {
t.Fatalf("Decline: %v", err)
}
waitFor(t, "A terminated", 2*time.Second, func() bool {
return aSess.State() == StateTerminated
})
}
// <retract>: callee -> Terminated when caller cancels while ringing
func TestJMIRetractByCaller(t *testing.T) {
mA, mB, sA, sB := pair()
var bSess *Session
var bSessMu sync.Mutex
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
s, _ := New(SessionOpts{
PC: makePC(t), Sender: sB,
LocalJID: mB.LocalJID, RemoteJID: p.From,
SID: p.SID, Role: RoleResponder, Media: p.Media,
})
bSessMu.Lock()
bSess = s
bSessMu.Unlock()
return s, nil, nil
}
aSess, _ := New(SessionOpts{
PC: makePC(t), Sender: sA,
LocalJID: mA.LocalJID, RemoteJID: "b@x",
SID: "sid-ret", Role: RoleInitiator, Media: []string{"audio"},
})
mA.Register(aSess)
if err := aSess.Propose(context.Background()); err != nil {
t.Fatal(err)
}
waitFor(t, "B reach ringing", 2*time.Second, func() bool {
bSessMu.Lock()
defer bSessMu.Unlock()
return bSess != nil && bSess.State() == StateRinging
})
if err := aSess.Retract(context.Background()); err != nil {
t.Fatalf("Retract: %v", err)
}
waitFor(t, "B terminated", 2*time.Second, func() bool {
return bSess.State() == StateTerminated
})
}
// duplicate-SID guard
func TestJMIDuplicateProposeIgnored(t *testing.T) {
mA, mB, sA, sB := pair()
calls := 0
var mu sync.Mutex
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
mu.Lock()
calls++
mu.Unlock()
s, _ := New(SessionOpts{
PC: makePC(t), Sender: sB,
LocalJID: mB.LocalJID, RemoteJID: p.From,
SID: p.SID, Role: RoleResponder, Media: p.Media,
})
return s, nil, nil
}
aSess, _ := New(SessionOpts{
PC: makePC(t), Sender: sA,
LocalJID: mA.LocalJID, RemoteJID: "b@x",
SID: "dup-sid", Role: RoleInitiator, Media: []string{"audio"},
})
mA.Register(aSess)
_ = aSess.Propose(context.Background())
_ = aSess.Propose(context.Background()) // second one rejected by state guard, but if forced through...
// To make the test actually drive a duplicate, send a raw second propose:
dup := newJMIMessage(mA.LocalJID, mB.LocalJID, &JMIPropose{
ID: "dup-sid",
Descriptions: []JMIDescription{{Media: "audio"}},
})
if err := sA.Send(dup); err != nil {
t.Fatal(err)
}
// Allow dispatch to complete.
sA.wait()
sB.wait()
mu.Lock()
defer mu.Unlock()
if calls != 1 {
t.Errorf("OnProposal called %d times, want 1", calls)
}
}