package asterisk import ( "context" "errors" "fmt" "net" "strings" "testing" "time" "github.com/CyCoreSystems/ari/v5" ) type testARIClient struct { ari.Client channels *testChannels bridges *testBridges bus *testBus closed int } func (c *testARIClient) Channel() ari.Channel { return c.channels } func (c *testARIClient) Bridge() ari.Bridge { return c.bridges } func (c *testARIClient) Bus() ari.Bus { return c.bus } func (c *testARIClient) Close() { c.closed++ } type testBus struct { ari.Bus sub ari.Subscription } func (b *testBus) Subscribe(_ *ari.Key, _ ...string) ari.Subscription { return b.sub } type testChannels struct { ari.Channel events chan ari.Event originate ari.OriginateRequest media ari.ExternalMediaOptions issued int originateErr error externalErr error hungup []string mediaPeerIP string } func (c *testChannels) Originate(_ *ari.Key, request ari.OriginateRequest) (*ari.ChannelHandle, error) { c.issued++ c.originate = request if c.originateErr != nil { return nil, c.originateErr } if c.events != nil { c.events <- &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: request.ChannelID}} } return ari.NewChannelHandle(ari.NewKey(ari.ChannelKey, request.ChannelID), c, nil), nil } func (c *testChannels) Get(key *ari.Key) *ari.ChannelHandle { return ari.NewChannelHandle(key, c, nil) } func (c *testChannels) ExternalMedia(key *ari.Key, options ari.ExternalMediaOptions) (*ari.ChannelHandle, error) { c.media = options if c.externalErr != nil { return nil, c.externalErr } return ari.NewChannelHandle(key, c, nil), nil } func (c *testChannels) GetVariable(_ *ari.Key, name string) (string, error) { switch name { case "UNICASTRTP_LOCAL_ADDRESS": return c.mediaPeerIP, nil case "UNICASTRTP_LOCAL_PORT": return "17000", nil } return "", errors.New("unexpected ARI variable") } func (c *testChannels) Hangup(key *ari.Key, _ string) error { c.hungup = append(c.hungup, key.ID) return nil } type testBridges struct { ari.Bridge attached []string deleted int createErr error } func (b *testBridges) Create(key *ari.Key, _, _ string) (*ari.BridgeHandle, error) { if b.createErr != nil { return nil, b.createErr } return ari.NewBridgeHandle(key, b, nil), nil } func (b *testBridges) Get(key *ari.Key) *ari.BridgeHandle { return ari.NewBridgeHandle(key, b, nil) } func (b *testBridges) AddChannel(_ *ari.Key, channelID string) error { b.attached = append(b.attached, channelID) return nil } func (b *testBridges) Delete(_ *ari.Key) error { b.deleted++; return nil } type nativeHTTPStatusError struct{ code int } func (e nativeHTTPStatusError) Error() string { return "private ARI response" } func (e nativeHTTPStatusError) Code() int { return e.code } func TestOnlyPreSubmitNativeFailureCanBeConfirmedNeverDialed(t *testing.T) { for _, phase := range []string{"validate", "ari_open", "rtp_listen", "subscribe"} { if !NativeCallNeverSubmitted(errors.Join(&NativeCallFailure{Phase: phase, Cause: errors.New("failed")}, errors.New("cleanup"))) { t.Fatalf("%s cannot have created an outbound call", phase) } } for _, phase := range []string{"originate", "answer_wait", "bridge_create", "external_media_create", "unclassified"} { if NativeCallNeverSubmitted(&NativeCallFailure{Phase: phase, Cause: errors.New("unknown")}) { t.Fatalf("%s might have originated", phase) } } } func TestNativeCallFailureReportsStageAndHTTPStatusWithoutResponseBody(t *testing.T) { cause := fmt.Errorf("request failed: %w", nativeHTTPStatusError{code: 404}) err := errors.Join(&NativeCallFailure{Phase: "originate", Cause: cause}, errors.New("cleanup failed")) if NativeCallPhase(err) != "originate" || NativeCallHTTPStatus(err) != 404 { t.Fatalf("must expose classified cause without duplicating call: %v", err) } if strings.Contains((&NativeCallFailure{Phase: "originate", Cause: errors.New("private-credential")}).Error(), "private-credential") { t.Fatal("diagnostic must not leak native ARI response body") } } func TestNativeCallUsesOneARIOriginateAndActualRTPBridge(t *testing.T) { events := make(chan ari.Event, 2) client := &testARIClient{ channels: &testChannels{events: events, mediaPeerIP: "127.0.0.1"}, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: events}}, } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() call, err := dialWithClient(ctx, client, NativeDial{ ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118, }) if err != nil { t.Fatal(err) } if call.Media == nil || client.channels.issued != 1 || client.channels.originate.Endpoint != "PJSIP/708915000000001@shuqi" || client.channels.originate.CallerID != "BD1234" || client.channels.originate.ChannelID != "exec-1" || client.channels.media.Format != "slin16" || client.channels.media.App != "go-sip-agent" || len(client.bridges.attached) != 2 || client.bridges.attached[0] != "exec-1" { t.Fatal("native channel/ExternalMedia was not attached exactly once to the approved bridge") } if host, _, err := net.SplitHostPort(client.channels.media.ExternalHost); err != nil || host != "127.0.0.1" { t.Fatal("native RTP capture must bind the local Cell only") } if err := call.Close(); err != nil || len(client.channels.hungup) != 2 || client.bridges.deleted != 1 || client.closed != 1 { t.Fatalf("native call resources must be closed exactly once: err=%v", err) } } func TestNativeCallStopsMediaOnMatchingCarrierHangup(t *testing.T) { events := make(chan ari.Event, 3) client := &testARIClient{channels: &testChannels{events: events, mediaPeerIP: "127.0.0.1"}, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: events}}} ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() call, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}) if err != nil { t.Fatal(err) } defer call.Close() events <- &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "someone-else"}, Cause: 16} select { case <-call.Context.Done(): t.Fatal("another caller cannot cancel this approved call") case <-time.After(10 * time.Millisecond): } events <- &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "exec-1"}, Cause: 16} select { case <-call.Context.Done(): if cause := context.Cause(call.Context); cause == nil || !strings.Contains(cause.Error(), "channel ended") { t.Fatalf("real call must expose carrier hangup cause, got %v", cause) } case <-time.After(100 * time.Millisecond): t.Fatal("carrier hangup left real AI/media running") } if err := call.Close(); err != nil || len(client.channels.hungup) != 1 || client.channels.hungup[0] != "exec-1-media" { t.Fatalf("destroyed carrier channel must not be hung up again: hungup=%v err=%v", client.channels.hungup, err) } } func TestNativeCallUnknownOriginationMustAttemptHangupWithoutRetry(t *testing.T) { client := &testARIClient{ channels: &testChannels{originateErr: errors.New("connection lost during originate")}, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: make(chan ari.Event)}}, } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "outcome unknown") || NativeCallPhase(err) != "originate" { t.Fatalf("unknown originate outcome must never be reported as a completed call: %v", err) } if client.channels.issued != 1 || len(client.channels.hungup) != 1 || client.channels.hungup[0] != "exec-1" || client.closed != 1 { t.Fatal("unknown originate outcome must not dial twice and must attempt to stop the issued channel") } } func TestNativeCallUnknownBridgeOrMediaCreationCleansKnownIdentities(t *testing.T) { for _, step := range []string{"bridge", "external-media"} { t.Run(step, func(t *testing.T) { events := make(chan ari.Event, 1) channels := &testChannels{events: events, mediaPeerIP: "127.0.0.1"} bridges := &testBridges{} if step == "bridge" { bridges.createErr = errors.New("lost bridge response") } else { channels.externalErr = errors.New("lost external-media response") } client := &testARIClient{channels: channels, bridges: bridges, bus: &testBus{sub: answerEvents{events: events}}} ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || NativeCallPhase(err) != map[string]string{"bridge": "bridge_create", "external-media": "external_media_create"}[step] { t.Fatalf("unknown bridge/media outcome needs its exact failure phase: %v", err) } wantHungup := 1 if step == "external-media" { wantHungup = 2 } if channels.issued != 1 || len(channels.hungup) != wantHungup || bridges.deleted != 1 || client.closed != 1 { t.Fatalf("known ARI identities must be cleaned without a second dial: hungup=%v deleted=%d closed=%d", channels.hungup, bridges.deleted, client.closed) } }) } } func TestNativeCallAnswerTimeoutNeverCreatesFakeMedia(t *testing.T) { client := &testARIClient{ channels: &testChannels{mediaPeerIP: "127.0.0.1"}, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: make(chan ari.Event)}}, } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "deadline") { t.Fatalf("unanswered channel must fail rather than synthesize media: %v", err) } if client.channels.issued != 1 || len(client.channels.hungup) == 0 || client.closed != 1 { t.Fatal("timed-out real channel cannot leak or be originated a second time") } }