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 create ari.ChannelCreateRequest createRef *ari.Key media ari.ExternalMediaOptions issued int createErr error dialErr error variableErr error externalErr error dataErr error hangupErr error dataChecks int hungup []string mediaPeerIP string callerID string noAnswer bool } func (c *testChannels) Create(reference *ari.Key, request ari.ChannelCreateRequest) (*ari.ChannelHandle, error) { c.createRef, c.create = reference, request if c.createErr != nil { return nil, c.createErr } if c.events != nil { select { case c.events <- &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: request.ChannelID, State: "Down"}}: default: } } return ari.NewChannelHandle(ari.NewKey(ari.ChannelKey, request.ChannelID), c, nil), nil } func (c *testChannels) Dial(key *ari.Key, _ string, _ time.Duration) error { c.issued++ if c.dialErr != nil { return c.dialErr } if c.events != nil && !c.noAnswer { select { case c.events <- &ari.ChannelStateChange{EventData: ari.EventData{Type: "ChannelStateChange"}, Channel: ari.ChannelData{ID: key.ID, State: "Up"}}: default: } } return nil } func (c *testChannels) SetVariable(_ *ari.Key, name, value string) error { if name != "CALLERID(num)" { return errors.New("unexpected caller variable") } c.callerID = value return 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 "CHANNEL(pjsip,call-id)": if c.variableErr != nil { return "", c.variableErr } return "isolated-sip-call-id", nil 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 c.hangupErr } func (c *testChannels) Data(key *ari.Key) (*ari.ChannelData, error) { c.dataChecks++ if c.dataErr != nil { return nil, c.dataErr } return &ari.ChannelData{ID: key.ID}, 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 TestNativeCallBindsSIPCallIDBeforeOneDialAndActualRTPBridge(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() boundBeforeDial := false call, err := dialWithClient(ctx, client, NativeDial{ ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118, BindSIPCall: func(executionID, callID string) error { boundBeforeDial = executionID == "exec-1" && callID == "isolated-sip-call-id" && client.channels.issued == 0 return nil }, }) if err != nil { t.Fatal(err) } if call.Media == nil || !boundBeforeDial || client.channels.issued != 1 || client.channels.create.Endpoint != "PJSIP/708915000000001@shuqi" || client.channels.callerID != "BD1234" || client.channels.create.ChannelID != "exec-1" || client.channels.createRef != nil || client.channels.create.Originator != "" || 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 testBindSIPCall(string, string) error { return nil } 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, BindSIPCall: testBindSIPCall}) 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{dialErr: errors.New("connection lost during dial")}, 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, BindSIPCall: testBindSIPCall}); err == nil || !strings.Contains(err.Error(), "outcome unknown") || NativeCallPhase(err) != "dial" { 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, 2) 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, BindSIPCall: testBindSIPCall}); 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) } }) } } type channelDataCause struct{ err error } func (e channelDataCause) Error() string { return "channel read failed" } func (e channelDataCause) Cause() error { return e.err } func TestNativeCallHTTPStatusReadsARICauseWrapper(t *testing.T) { err := fmt.Errorf("channel read: %w", channelDataCause{err: nativeHTTPStatusError{code: 404}}) if got := NativeCallHTTPStatus(err); got != 404 { t.Fatalf("ARI channel data 404 was hidden by Cause wrapper: got %d", got) } } func TestNativeCallPreAnswerReleaseRequiresMissingOriginatedChannel(t *testing.T) { for _, tc := range []struct { name string dataErr error confirmed bool }{ {"absent", channelDataCause{err: nativeHTTPStatusError{code: 404}}, true}, {"still active", nil, false}, {"query failed", nativeHTTPStatusError{code: 503}, false}, } { t.Run(tc.name, func(t *testing.T) { channels := &testChannels{dataErr: tc.dataErr, hangupErr: nativeHTTPStatusError{code: 404}, noAnswer: true} client := &testARIClient{channels: channels, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: make(chan ari.Event)}}} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118, BindSIPCall: testBindSIPCall}) if err == nil || NativeCallPhase(err) != "answer_wait" || NativeCallConfirmedPreAnswerEnd(err) != tc.confirmed || channels.dataChecks != 1 || channels.issued != 1 || client.closed != 1 { t.Fatalf("only the confirmed missing channel can release capacity: err=%v confirmed=%v checks=%d issued=%d closed=%d", err, NativeCallConfirmedPreAnswerEnd(err), channels.dataChecks, channels.issued, client.closed) } }) } } func TestNativeCallAnswerTimeoutNeverCreatesFakeMedia(t *testing.T) { client := &testARIClient{ channels: &testChannels{mediaPeerIP: "127.0.0.1", noAnswer: true}, 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, BindSIPCall: testBindSIPCall}); 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") } }