package sipcall import ( "context" "os" "path/filepath" "strings" "testing" "time" ) const testTimeline = "1\t1.0\t192.0.2.1\t192.0.2.10\tINVITE sip:186@192.0.2.10 SIP/2.0\t\tINVITE\t10\tz9hG4bK-test\t\t192.0.2.1\taudio\t10000\n2\t1.1\t192.0.2.10\t192.0.2.1\t\t200\tINVITE\t10\tz9hG4bK-test\tSIP/2.0 200 OK\t192.0.2.10\taudio\t12000\n3\t2.1\t192.0.2.10\t192.0.2.1\t\t200\tBYE\t11\tz9hG4bK-bye\tSIP/2.0 200 OK\t\t\t\n" func TestDecodedEvidenceUsesOriginalINVITEAndNotBYE(t *testing.T) { e, ports, err := parseTimeline([]byte(testTimeline)) if err != nil || e.Packets != 3 || e.FinalINVITECode != 200 || len(ports) != 2 { t.Fatalf("%+v %v %v", e, ports, err) } bad := strings.Replace(testTimeline, "\t200\tINVITE\t10\t", "\t480\tINVITE\t10\t", 1) e, _, err = parseTimeline([]byte(bad)) if err != nil || e.FinalINVITECode != 480 { t.Fatal("BYE 200 must not turn a rejected INVITE into a connected call") } if _, _, err = parseTimeline([]byte("unexpected\n")); err == nil { t.Fatal("malformed decoder output accepted") } if _, _, err = parseTimeline(nil); err == nil { t.Fatal("missing original INVITE accepted") } conflict := testTimeline + strings.Replace(strings.Split(testTimeline, "\n")[1], "\t200\t", "\t480\t", 1) + "\n" if _, _, err = parseTimeline([]byte(conflict)); err == nil { t.Fatal("conflicting final INVITE response accepted") } } func fakeTools(t *testing.T, broken bool) string { t.Helper() dir := t.TempDir() tcpdump := `#!/bin/sh out="" while [ "$#" -gt 0 ]; do if [ "$1" = "-w" ]; then shift; out="$1"; fi shift done /bin/dd if=/dev/zero of="$out" bs=24 count=1 2>/dev/null echo "tcpdump: listening on any" >&2 trap 'exit 0' INT TERM while :; do /bin/sleep 0.05; done ` if broken { tcpdump = "#!/bin/sh\necho 'permission denied' >&2\nexit 1\n" } decoder := "#!/bin/sh\ncase \"$*\" in\n*'rtp &&'*) printf '160\\t192.0.2.10\\t192.0.2.1\\n160\\t192.0.2.1\\t192.0.2.10\\n' ;;\n*) /bin/cat '" + filepath.Join(dir, "timeline.tsv") + "' ;;\nesac\n" for name, data := range map[string]string{"tcpdump": tcpdump, "tshark": decoder, "timeline.tsv": testTimeline} { mode := os.FileMode(0700) if name == "timeline.tsv" { mode = 0600 } if err := os.WriteFile(filepath.Join(dir, name), []byte(data), mode); err != nil { t.Fatal(err) } } t.Setenv("PATH", dir) return dir } func TestDebugCaptureOwnsProcessAndProducesPrivateLinkedEvidence(t *testing.T) { fakeTools(t, false) c := fixtureConfig(t) dir := t.TempDir() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cap, err := startCapture(ctx, c, dir) if err != nil { t.Fatal(err) } if err = cap.Check(); err != nil { t.Fatal(err) } e, err := cap.Stop(ctx, "sip-id-123") if err != nil { t.Fatal(err) } if len(e.PCAPSHA256) != 64 || e.FinalINVITECode != 200 || e.RTPFromServer != 1 || e.RTPToServer != 1 || e.RTPBytes != 320 { t.Fatalf("wrong evidence: %+v", e) } for _, path := range []string{e.PCAP, e.SIPTimeline, filepath.Join(dir, "tcpdump.log"), filepath.Join(dir, "rtp.tsv")} { info, err := os.Stat(path) if err != nil || info.Mode().Perm() != 0600 { t.Fatalf("private artifact expected: %s %v", path, err) } } if err = cap.Check(); err == nil { t.Fatal("ended capture still reported ready") } } func TestDebugCaptureFailureDoesNotSilentlyDisableEvidence(t *testing.T) { fakeTools(t, true) c := fixtureConfig(t) if _, err := startCapture(context.Background(), c, t.TempDir()); err == nil { t.Fatal("broken tcpdump accepted") } else if !strings.Contains(err.Error(), "tcpdump.log") || !strings.Contains(err.Error(), "do not sudo sip-call") { t.Fatalf("capture failure lacks log path or permission guidance: %v", err) } t.Setenv("PATH", t.TempDir()) if _, err := startCapture(context.Background(), c, t.TempDir()); err == nil { t.Fatal("missing tcpdump accepted") } } func TestDebugCaptureRejectsUnsafeCallIDAndCancelledStartup(t *testing.T) { fakeTools(t, false) c := fixtureConfig(t) cap, err := startCapture(context.Background(), c, t.TempDir()) if err != nil { t.Fatal(err) } if _, err = cap.Stop(context.Background(), "id\" || ip"); err == nil { t.Fatal("unsafe filter correlation accepted") } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err = startCapture(ctx, c, t.TempDir()); err == nil { t.Fatal("cancelled capture accepted") } }