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 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 / `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 : 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 ") } if !sawProceed { t.Error("never observed an outbound ") } } // 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: must appear, 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 ; 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 }