289 lines
14 KiB
Go
289 lines
14 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/asterisk"
|
|
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
|
"git.ipao.vip/rogee/go-sip/internal/media"
|
|
)
|
|
|
|
type endedRealMedia struct{}
|
|
|
|
func (endedRealMedia) ReadPayload(context.Context) ([]byte, error) { return nil, io.EOF }
|
|
func (endedRealMedia) SendPCM16(context.Context, []byte, int) error { return nil }
|
|
func (endedRealMedia) Stats() media.RTPStats { return media.RTPStats{} }
|
|
|
|
func TestRealCallRequiresLiveCaptureBoundToSignedIdentity(t *testing.T) {
|
|
root := t.TempDir()
|
|
id, trunk, target := "event-1", "trunk-1", "15003164745"
|
|
marker := filepath.Join(root, id+".active")
|
|
if err := verifyCallEvidence(root, id, trunk, target); err == nil {
|
|
t.Fatal("missing SIP/RTP evidence arm must block the call")
|
|
}
|
|
if err := os.WriteFile(marker, []byte(fmt.Sprintf("%d\t%s\t%s\n", os.Getpid(), trunk, target)), 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := verifyCallEvidence(root, id, trunk, target); err == nil {
|
|
t.Fatal("a live process other than tcpdump must not arm real dialing")
|
|
}
|
|
if err := verifyCallEvidence(root, id, trunk, "15830461047"); err == nil {
|
|
t.Fatal("capture bound to a different callee cannot authorize a real call")
|
|
}
|
|
if err := verifyCallEvidence(root, "../event-1", trunk, target); err == nil {
|
|
t.Fatal("call ID cannot escape the evidence directory")
|
|
}
|
|
if err := os.WriteFile(marker, []byte(fmt.Sprintf("%d\t%s\t%s\n", 2147483647, trunk, target)), 0640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := verifyCallEvidence(root, id, trunk, target); err == nil {
|
|
t.Fatal("terminated tcpdump must block the call")
|
|
}
|
|
}
|
|
|
|
func TestCaptureProcessMustWriteThisCall(t *testing.T) {
|
|
id := "event-1"
|
|
args := []string{"/usr/bin/tcpdump", "-i", "any", "-nn", "-s0", "-U", "-w", "/var/lib/sip-go-agent/evidence/event-1/capture.pcap", "udp port 5060 or (udp portrange 10000-20000)"}
|
|
if !capturesCall(args, id) {
|
|
t.Fatal("the host script's tcpdump command must be recognized")
|
|
}
|
|
for _, bad := range [][]string{
|
|
{"/usr/bin/tcpdump", "-w", "/var/lib/sip-go-agent/evidence/other/capture.pcap"},
|
|
{"/usr/bin/tcpdump", "-w", "/var/lib/sip-go-agent/evidence/event-1/other.pcap"},
|
|
{"/usr/bin/tcpdump", "-w", "event-1/capture.pcap"},
|
|
{"/usr/bin/tcpdump", "/var/lib/sip-go-agent/evidence/event-1/capture.pcap"},
|
|
} {
|
|
if capturesCall(bad, id) {
|
|
t.Fatalf("unrelated capture command accepted: %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallReportsOnlyEndedObservedCall(t *testing.T) {
|
|
fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
var originate, hangup int
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
Delivery: fixture.Delivery,
|
|
originator: func(ctx context.Context, dial asterisk.NativeDial) (realMedia, error) {
|
|
originate++
|
|
if dial.ExecutionID != approved.SourceEventID || dial.DialedCallee != approved.DialedCallee || dial.CallerID != approved.CallerID || dial.TrunkID != approved.SelectedTrunkID || dial.MediaPayloadType != 118 {
|
|
t.Errorf("signed SIP identity or media profile changed: %+v", dial)
|
|
}
|
|
return realMedia{ctx: ctx, session: endedRealMedia{}, close: func() error { hangup++; return nil }}, nil
|
|
},
|
|
}
|
|
err := runner.Run(context.Background(), approved)
|
|
if err == nil || originate != 1 || hangup != 1 || strings.Join(stub.calls, ",") != "end,result" || puts.Load() != 0 {
|
|
t.Fatalf("real call must end and report actual media failure once: err=%v originate=%d hangup=%d calls=%v puts=%d", err, originate, hangup, stub.calls, puts.Load())
|
|
}
|
|
var result struct {
|
|
Outcome string `json:"outcome"`
|
|
Recording map[string]any `json:"recording"`
|
|
}
|
|
if err := json.Unmarshal(stub.result, &result); err != nil || result.Outcome != "failed" || len(result.Recording) != 0 {
|
|
t.Fatalf("empty media must not become an answered recording: result=%+v err=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallReportsConfirmedMediaSetupFailure(t *testing.T) {
|
|
fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
var originate, hangup int
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
Delivery: fixture.Delivery,
|
|
originator: func(ctx context.Context, _ asterisk.NativeDial) (realMedia, error) {
|
|
originate++
|
|
return realMedia{ctx: ctx, close: func() error { hangup++; return nil }}, nil // answered channel, but no RTP capture session
|
|
},
|
|
}
|
|
err := runner.Run(context.Background(), approved)
|
|
if err == nil || originate != 1 || hangup != 1 || strings.Join(stub.calls, ",") != "end,result" || puts.Load() != 0 {
|
|
t.Fatalf("confirmed ended call without RTP still needs true failed result and empty recording: err=%v originate=%d hangup=%d calls=%v puts=%d", err, originate, hangup, stub.calls, puts.Load())
|
|
}
|
|
var result struct {
|
|
Outcome string `json:"outcome"`
|
|
Recording map[string]any `json:"recording"`
|
|
ReasonMessage string `json:"reason_message"`
|
|
}
|
|
if err := json.Unmarshal(stub.result, &result); err != nil || result.Outcome != "failed" || len(result.Recording) != 0 || result.ReasonMessage == "" {
|
|
t.Fatalf("false success after media setup failure: %+v %v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallMissingMediaWithUnknownHangupDoesNotReport(t *testing.T) {
|
|
fixture, stub, _, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
Delivery: fixture.Delivery,
|
|
originator: func(ctx context.Context, _ asterisk.NativeDial) (realMedia, error) {
|
|
return realMedia{ctx: ctx, close: func() error { return errors.New("hangup response missing") }}, nil
|
|
},
|
|
}
|
|
if err := runner.Run(context.Background(), approved); err == nil || len(stub.calls) != 0 {
|
|
t.Fatalf("ambiguous hangup cannot be reported as a confirmed end: err=%v calls=%v", err, stub.calls)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallDoesNotReportUnknownHangup(t *testing.T) {
|
|
fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
var originate, hangup int
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
Delivery: fixture.Delivery,
|
|
originator: func(ctx context.Context, _ asterisk.NativeDial) (realMedia, error) {
|
|
originate++
|
|
return realMedia{ctx: ctx, session: endedRealMedia{}, close: func() error { hangup++; return errors.New("ARI hangup outcome unknown") }}, nil
|
|
},
|
|
}
|
|
if err := runner.Run(context.Background(), approved); err == nil || originate != 1 || hangup != 1 || len(stub.calls) != 0 || puts.Load() != 0 {
|
|
t.Fatalf("unknown hangup must not forge call end or retry: err=%v originate=%d hangup=%d calls=%v puts=%d", err, originate, hangup, stub.calls, puts.Load())
|
|
}
|
|
}
|
|
|
|
func hepRealResultFrame(callID, payload string) []byte {
|
|
b := []byte("HEP3\x00\x00")
|
|
for _, chunk := range []struct {
|
|
kind uint16
|
|
body []byte
|
|
}{{11, []byte{1}}, {17, []byte(callID)}, {15, []byte(payload)}} {
|
|
p := make([]byte, 6+len(chunk.body))
|
|
binary.BigEndian.PutUint16(p[2:4], chunk.kind)
|
|
binary.BigEndian.PutUint16(p[4:6], uint16(len(p)))
|
|
copy(p[6:], chunk.body)
|
|
b = append(b, p...)
|
|
}
|
|
binary.BigEndian.PutUint16(b[4:6], uint16(len(b)))
|
|
return b
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallReportsExactSIPResponseBeforeAnswer(t *testing.T) {
|
|
fixture, stub, _, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
mirror := asterisk.NewHEPMirror()
|
|
const callID = "isolated-480"
|
|
const common = "Via: SIP/2.0/UDP 127.0.0.1;branch=z9hG4bK-real\r\nFrom: <sip:a@localhost>;tag=x\r\nTo: <sip:b@localhost>\r\nCall-ID: isolated-480\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n"
|
|
invite := "INVITE sip:b@localhost SIP/2.0\r\n" + common
|
|
raw := "SIP/2.0 480 Temporarily Unavailable\r\n" + common
|
|
runner := &ApprovedRecordedRealCall{
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
Mirror: mirror, Delivery: fixture.Delivery,
|
|
originator: func(_ context.Context, dial asterisk.NativeDial) (realMedia, error) {
|
|
if dial.BindSIPCall == nil || dial.BindSIPCall(dial.ExecutionID, callID) != nil {
|
|
t.Fatal("real dial did not bind the original SIP Call-ID")
|
|
}
|
|
for _, sip := range []string{invite, raw} {
|
|
if err := mirror.Observe(hepRealResultFrame(callID, sip)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return realMedia{}, &asterisk.NativeCallFailure{Phase: "answer_wait", ConfirmedEnd: true, Cause: errors.New("unanswered")}
|
|
},
|
|
}
|
|
if err := runner.Run(context.Background(), approved); err == nil || strings.Join(stub.calls, ",") != "end,result" {
|
|
t.Fatalf("verified SIP end did not reach real result: %v calls=%v", err, stub.calls)
|
|
}
|
|
var result struct {
|
|
ReasonCode *int `json:"reason_code"`
|
|
StatusLine string `json:"status_line"`
|
|
Raw string `json:"raw"`
|
|
}
|
|
if err := json.Unmarshal(stub.result, &result); err != nil || result.ReasonCode == nil || *result.ReasonCode != 480 || result.StatusLine != "SIP/2.0 480 Temporarily Unavailable" || result.Raw != raw {
|
|
t.Fatalf("original SIP 480 not delivered verbatim: %+v err=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallReportsVerifiedPreAnswerEndWithoutRecording(t *testing.T) {
|
|
fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
originate := 0
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second, Delivery: fixture.Delivery,
|
|
originator: func(context.Context, asterisk.NativeDial) (realMedia, error) {
|
|
originate++
|
|
return realMedia{}, &asterisk.NativeCallFailure{Phase: "answer_wait", ConfirmedEnd: true, Cause: errors.New("no answer")}
|
|
},
|
|
}
|
|
if err := runner.Run(context.Background(), approved); err == nil || originate != 1 || strings.Join(stub.calls, ",") != "end,result" || puts.Load() != 0 {
|
|
t.Fatalf("confirmed pre-answer end must report actual failure once without recording: err=%v originate=%d calls=%v puts=%d", err, originate, stub.calls, puts.Load())
|
|
}
|
|
var result struct {
|
|
Outcome string `json:"outcome"`
|
|
Recording map[string]any `json:"recording"`
|
|
ReasonMessage string `json:"reason_message"`
|
|
ReasonCode *int `json:"reason_code"`
|
|
Raw *string `json:"raw"`
|
|
SIPCaptureError *string `json:"sip_capture_error"`
|
|
}
|
|
if err := json.Unmarshal(stub.result, &result); err != nil || result.Outcome != "failed" || len(result.Recording) != 0 || result.ReasonMessage != "call ended before answer" || result.ReasonCode != nil || result.Raw != nil || result.SIPCaptureError == nil || *result.SIPCaptureError == "" {
|
|
t.Fatalf("pre-answer SIP rejection must not be recorded as answered: result=%+v err=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallDoesNotReportUnverifiedPreAnswerEnd(t *testing.T) {
|
|
fixture, stub, _, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second, Delivery: fixture.Delivery,
|
|
originator: func(context.Context, asterisk.NativeDial) (realMedia, error) {
|
|
return realMedia{}, &asterisk.NativeCallFailure{Phase: "answer_wait", Cause: errors.New("channel state unavailable")}
|
|
},
|
|
}
|
|
if err := runner.Run(context.Background(), approved); err == nil || len(stub.calls) != 0 {
|
|
t.Fatalf("unverified call end must remain occupied: err=%v calls=%v", err, stub.calls)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallCannotOriginateTwiceOrReportUnknownOrigination(t *testing.T) {
|
|
fixture, stub, _, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second
|
|
originate := 0
|
|
runner := &ApprovedRecordedRealCall{Mirror: asterisk.NewHEPMirror(),
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
Delivery: fixture.Delivery,
|
|
originator: func(context.Context, asterisk.NativeDial) (realMedia, error) {
|
|
originate++
|
|
return realMedia{}, errors.New("ARI request outcome unknown")
|
|
},
|
|
}
|
|
prepared, err := runner.Prepare(approved)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := prepared(context.Background()); err == nil {
|
|
t.Fatal("uncertain origination must not become a completed call")
|
|
}
|
|
if err := prepared(context.Background()); err == nil || originate != 1 || len(stub.calls) != 0 {
|
|
t.Fatalf("same accepted instruction must not reoriginate or report: err=%v originate=%d calls=%v", err, originate, stub.calls)
|
|
}
|
|
}
|
|
|
|
func TestApprovedRecordedRealCallRejectsMissingDeliveryBeforeOrigination(t *testing.T) {
|
|
_, _, _, _, approved := recordedMockFixture(t, nil, time.Second)
|
|
called := false
|
|
runner := &ApprovedRecordedRealCall{
|
|
MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second,
|
|
originator: func(context.Context, asterisk.NativeDial) (realMedia, error) {
|
|
called = true
|
|
return realMedia{session: callflow.NewMemorySession(nil)}, nil
|
|
},
|
|
}
|
|
if _, err := runner.Prepare(approved); err == nil || called {
|
|
t.Fatalf("no validated delivery must block before originating: err=%v called=%v", err, called)
|
|
}
|
|
}
|