telegabber/calls/signaling/xmppsig/callee_test.go
2026-05-25 06:27:20 -07:00

224 lines
6.7 KiB
Go

package xmppsig
import (
"context"
"strings"
"testing"
"time"
"dev.narayana.im/narayana/telegabber/calls/signaling"
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
"gosrc.io/xmpp/stanza"
)
// cross-side teardown for XMPP-originated calls:
// inbound terminate -> Session observer -> bridge.Terminate -> both halves Terminate
func TestCalleeOnTerminated_SessionTerminate_TearsDownTGCaller(t *testing.T) {
callee, _, tgSide := newBoundCallee(t)
callee.OnTerminated(jingle.ReasonSuccess)
waitUntil(t, "tg-Caller.Terminate", time.Second, tgSide.isTerminated)
if tgSide.reason() != signaling.ReasonHangup {
t.Errorf("tg-side reason = %v, want ReasonHangup", tgSide.reason())
}
}
// inbound JMI <retract> in StateRinging drives the same teardown
func TestCalleeInboundRetract_TearsDownTGCaller(t *testing.T) {
callee, sess, tgSide := newBoundCallee(t)
// simulate the post-propose StateRinging by hand
sess.HandleJMI("alice@xmpp.example/desktop", &jingle.JMIPropose{
ID: sess.SID(),
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
})
waitUntil(t, "session in ringing", time.Second, func() bool {
return sess.State() == jingle.StateRinging
})
mgr := callee.a.cfg.Manager
ret := stanza.NewMessage(stanza.Attrs{
Type: stanza.MessageTypeChat,
From: "alice@xmpp.example/desktop",
To: "gw.example",
Id: "ret-1",
})
ret.Extensions = []stanza.MsgExtension{&jingle.JMIRetract{ID: sess.SID()}}
mgr.HandleMessage(&ret)
waitUntil(t, "tg-Caller.Terminate", time.Second, tgSide.isTerminated)
if tgSide.reason() != signaling.ReasonPeerGone {
t.Errorf("tg-side reason = %v, want ReasonPeerGone", tgSide.reason())
}
}
// outgoing JMI <ringing>/<proceed> `from` must match propose's `to`,
// otherwise libdino's from.equals_bare(peer_state.jid) silently drops
// the proceed and no session-initiate follows
func TestCallee_RingingProceedFromMatchesProposeTo(t *testing.T) {
send := &recSender{}
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
a := NewAdapter(AdapterConfig{
Sender: send, LocalJID: "gw.example", Manager: mgr,
PCFactory: pcFactory(t),
})
// realistic XMPP-originated propose
prop := jingle.IncomingProposal{
SID: "incoming-sid-1",
From: "alice@xmpp.example/desktop",
To: "12345@gw.example",
Media: []string{"audio"},
}
_, sess, err := a.NewCallee(prop)
if err != nil {
t.Fatalf("NewCallee: %v", err)
}
mgr.Register(sess)
// Bring the session into StateRinging via the same path production
// uses, so Ringing/AcceptProposal pass their state guards.
sess.HandleJMI(prop.From, &jingle.JMIPropose{
ID: prop.SID,
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
})
waitUntil(t, "session in ringing", time.Second, func() bool {
return sess.State() == jingle.StateRinging
})
if err := sess.Ringing(context.Background()); err != nil {
t.Fatalf("Ringing: %v", err)
}
if err := sess.AcceptProposal(context.Background()); err != nil {
t.Fatalf("AcceptProposal: %v", err)
}
// every JMI <message>: from's bare must equal propose.To and from must
// be a full JID (Conversations crashes on bare-JID `with`)
wantBare := prop.To
wantFrom := prop.To + "/" + prop.SID
dontWantFrom := "gw.example"
var sawRinging, sawProceed bool
for _, pkt := range send.snapshot() {
var msg *stanza.Message
switch m := pkt.(type) {
case *stanza.Message:
msg = m
case stanza.Message:
msg = &m
default:
continue
}
if msg.From == dontWantFrom {
t.Errorf("outbound JMI message from=%q (the bare component domain); want bare=%q with a resource", msg.From, wantBare)
}
if msg.From != wantFrom {
t.Errorf("outbound JMI message from=%q; want %q", msg.From, wantFrom)
}
if !strings.Contains(msg.From, "/") {
t.Errorf("outbound JMI message from=%q is bare; Conversations routes bare-JID updates through a crash-prone proposal-state lambda", msg.From)
}
for _, ext := range msg.Extensions {
switch ext.(type) {
case *jingle.JMIRinging:
sawRinging = true
case *jingle.JMIProceed:
sawProceed = true
}
}
}
if !sawRinging {
t.Error("never observed an outbound <ringing>")
}
if !sawProceed {
t.Error("never observed an outbound <proceed>")
}
}
// <ringing> must come from Callee.Ringing (driven by tdlib Pending), not
// from Callee.Accept; otherwise back-to-back ringing+proceed produces a
// millisecond ringback window in Dino/anotherim and "discovering devices"
// in Conversations
func TestCalleeAccept_DoesNotEmitRinging(t *testing.T) {
send := &recSender{}
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
a := NewAdapter(AdapterConfig{
Sender: send, LocalJID: "gw.example", Manager: mgr,
PCFactory: pcFactory(t),
})
prop := jingle.IncomingProposal{
SID: "sid-accept-no-ring",
From: "alice@xmpp.example/desktop",
To: "12345@gw.example",
Media: []string{"audio"},
}
callee, sess, err := a.NewCallee(prop)
if err != nil {
t.Fatalf("NewCallee: %v", err)
}
mgr.Register(sess)
sess.HandleJMI(prop.From, &jingle.JMIPropose{
ID: prop.SID,
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
})
waitUntil(t, "session in ringing", time.Second, func() bool {
return sess.State() == jingle.StateRinging
})
// Accept without Ringing: <proceed> must appear, <ringing> must not
callee.Accept()
waitUntil(t, "proceed emitted", time.Second, func() bool {
for _, pkt := range send.snapshot() {
msg, ok := pkt.(*stanza.Message)
if !ok {
continue
}
for _, ext := range msg.Extensions {
if _, isProceed := ext.(*jingle.JMIProceed); isProceed {
return true
}
}
}
return false
})
for _, pkt := range send.snapshot() {
msg, ok := pkt.(*stanza.Message)
if !ok {
continue
}
for _, ext := range msg.Extensions {
if _, isRinging := ext.(*jingle.JMIRinging); isRinging {
t.Fatalf("Accept emitted <ringing>; <ringing> must come exclusively from Callee.Ringing (split was deliberate, see callee.go)")
}
}
}
}
func newBoundCallee(t *testing.T) (*Callee, *jingle.Session, *recBridgeSide) {
t.Helper()
send := &recSender{}
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
a := NewAdapter(AdapterConfig{
Sender: send, LocalJID: "gw.example", Manager: mgr,
PCFactory: pcFactory(t),
})
callee, sess, err := a.NewCallee(jingle.IncomingProposal{
SID: "incoming-sid-1", From: "alice@xmpp.example/desktop", To: "gw.example", Media: []string{"audio"},
})
if err != nil {
t.Fatalf("NewCallee: %v", err)
}
// register so inbound stanzas in integration tests find the session
mgr.Register(sess)
tgSide := &recBridgeSide{}
br := signaling.New(signaling.Config{
Caller: tgSide, Callee: callee, Timers: noopTimers{},
})
callee.Bind(br)
// bridge.Start moves out of StateIdle so terminate fires for real
br.Start()
return callee, sess, tgSide
}