package sipguardian import ( "strings" "testing" "time" "go.uber.org/zap" ) // ============================================================================= // SIP Message Parsing Tests (sipmsg.go) // ============================================================================= func TestParseSIPRequest(t *testing.T) { raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776asdhds\r\n" + "From: \"Alice\" ;tag=1928301774\r\n" + "To: \r\n" + "Call-ID: a84b4c76e66710@pc33.example.com\r\n" + "CSeq: 314159 INVITE\r\n" + "Contact: \r\n" + "Content-Type: application/sdp\r\n" + "Content-Length: 4\r\n" + "\r\n" + "test" msg, err := ParseSIPMessage([]byte(raw)) if err != nil { t.Fatalf("Failed to parse SIP request: %v", err) } if !msg.IsRequest { t.Error("Expected IsRequest to be true") } if msg.Method != "INVITE" { t.Errorf("Expected method INVITE, got %s", msg.Method) } if msg.RequestURI != "sip:bob@example.com" { t.Errorf("Expected RequestURI sip:bob@example.com, got %s", msg.RequestURI) } if msg.SIPVersion != "SIP/2.0" { t.Errorf("Expected SIP/2.0, got %s", msg.SIPVersion) } if string(msg.Body) != "test" { t.Errorf("Expected body 'test', got '%s'", string(msg.Body)) } } func TestParseSIPResponse(t *testing.T) { raw := "SIP/2.0 200 OK\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776asdhds;received=10.0.0.1\r\n" + "From: \"Alice\" ;tag=1928301774\r\n" + "To: ;tag=a6c85cf\r\n" + "Call-ID: a84b4c76e66710@pc33.example.com\r\n" + "CSeq: 314159 INVITE\r\n" + "Contact: \r\n" + "Content-Length: 0\r\n" + "\r\n" msg, err := ParseSIPMessage([]byte(raw)) if err != nil { t.Fatalf("Failed to parse SIP response: %v", err) } if msg.IsRequest { t.Error("Expected IsRequest to be false for response") } if msg.StatusCode != 200 { t.Errorf("Expected status code 200, got %d", msg.StatusCode) } if msg.ReasonPhrase != "OK" { t.Errorf("Expected reason phrase 'OK', got '%s'", msg.ReasonPhrase) } } func TestSIPMessageGetHeader(t *testing.T) { raw := "REGISTER sip:example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test123@host\r\n" + "CSeq: 1 REGISTER\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) // Test case-insensitive lookup via := msg.GetHeader("via") if via == nil { t.Error("Failed to get Via header (lowercase)") } Via := msg.GetHeader("Via") if Via == nil { t.Error("Failed to get Via header (mixed case)") } // Test GetCallID helper callID := msg.GetCallID() if callID != "test123@host" { t.Errorf("Expected Call-ID 'test123@host', got '%s'", callID) } // Test GetFromTag helper fromTag := msg.GetFromTag() if fromTag != "123" { t.Errorf("Expected From tag '123', got '%s'", fromTag) } } func TestSIPMessageCompactHeaders(t *testing.T) { // Test compact header forms (RFC 3261 Section 7.3.3) raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "v: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" + "f: ;tag=123\r\n" + "t: \r\n" + "i: compact-call-id@host\r\n" + "CSeq: 1 INVITE\r\n" + "m: \r\n" + "l: 0\r\n" + "\r\n" msg, err := ParseSIPMessage([]byte(raw)) if err != nil { t.Fatalf("Failed to parse message with compact headers: %v", err) } // Via (v) if msg.GetHeader("Via") == nil { t.Error("Failed to get Via via compact form 'v'") } // Call-ID (i) if msg.GetCallID() != "compact-call-id@host" { t.Error("Failed to get Call-ID via compact form 'i'") } // Contact (m) if msg.GetHeader("Contact") == nil { t.Error("Failed to get Contact via compact form 'm'") } } func TestSIPMessageSerialize(t *testing.T) { raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test@host\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) serialized := msg.Serialize() // Parse again and verify round-trip msg2, err := ParseSIPMessage(serialized) if err != nil { t.Fatalf("Failed to parse serialized message: %v", err) } if msg.Method != msg2.Method { t.Errorf("Method mismatch after round-trip: %s vs %s", msg.Method, msg2.Method) } if msg.GetCallID() != msg2.GetCallID() { t.Errorf("Call-ID mismatch after round-trip") } } func TestSIPMessageHeaderManipulation(t *testing.T) { raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test@host\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) // Test PrependHeader (for Via insertion) msg.PrependHeader("Via", "SIP/2.0/UDP proxy.example.com:5060;branch=z9hG4bKproxy") vias := msg.GetHeaders("Via") if len(vias) != 2 { t.Errorf("Expected 2 Via headers after prepend, got %d", len(vias)) } if !strings.Contains(vias[0].Value, "proxy.example.com") { t.Error("Prepended Via should be first") } // Test RemoveFirstHeader msg.RemoveFirstHeader("Via") vias = msg.GetHeaders("Via") if len(vias) != 1 { t.Errorf("Expected 1 Via header after removal, got %d", len(vias)) } // Test SetHeader msg.SetHeader("User-Agent", "Test/1.0") ua := msg.GetHeader("User-Agent") if ua == nil || ua.Value != "Test/1.0" { t.Error("SetHeader failed") } // Test RemoveHeader msg.RemoveHeader("User-Agent") if msg.GetHeader("User-Agent") != nil { t.Error("RemoveHeader failed") } } func TestSIPMessageClone(t *testing.T) { raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test@host\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 4\r\n" + "\r\n" + "test" msg, _ := ParseSIPMessage([]byte(raw)) clone := msg.Clone() // Modify original msg.SetHeader("Via", "modified") msg.Body = []byte("modified") // Clone should be unchanged if clone.GetHeader("Via").Value == "modified" { t.Error("Clone was affected by original modification") } if string(clone.Body) != "test" { t.Error("Clone body was affected by original modification") } } // ============================================================================= // Header Parsing Tests (sipheaders.go) // ============================================================================= func TestParseViaHeader(t *testing.T) { tests := []struct { name string input string expected ViaHeader }{ { name: "basic UDP", input: "SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776asdhds", expected: ViaHeader{ Protocol: "SIP/2.0", Transport: "UDP", Host: "192.168.1.100", Port: 5060, Branch: "z9hG4bK776asdhds", }, }, { name: "with received and rport", input: "SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776;received=10.0.0.1;rport=12345", expected: ViaHeader{ Protocol: "SIP/2.0", Transport: "UDP", Host: "192.168.1.100", Port: 5060, Branch: "z9hG4bK776", Received: "10.0.0.1", RPort: 12345, }, }, { name: "TCP transport", input: "SIP/2.0/TCP proxy.example.com:5060;branch=z9hG4bKabc", expected: ViaHeader{ Protocol: "SIP/2.0", Transport: "TCP", Host: "proxy.example.com", Port: 5060, Branch: "z9hG4bKabc", }, }, { name: "default port", input: "SIP/2.0/UDP 192.168.1.100;branch=z9hG4bK123", expected: ViaHeader{ Protocol: "SIP/2.0", Transport: "UDP", Host: "192.168.1.100", Port: 5060, // Default Branch: "z9hG4bK123", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { via, err := ParseViaHeader(tt.input) if err != nil { t.Fatalf("ParseViaHeader failed: %v", err) } if via.Protocol != tt.expected.Protocol { t.Errorf("Protocol: got %s, want %s", via.Protocol, tt.expected.Protocol) } if via.Transport != tt.expected.Transport { t.Errorf("Transport: got %s, want %s", via.Transport, tt.expected.Transport) } if via.Host != tt.expected.Host { t.Errorf("Host: got %s, want %s", via.Host, tt.expected.Host) } if via.Port != tt.expected.Port { t.Errorf("Port: got %d, want %d", via.Port, tt.expected.Port) } if via.Branch != tt.expected.Branch { t.Errorf("Branch: got %s, want %s", via.Branch, tt.expected.Branch) } }) } } func TestViaHeaderSerialize(t *testing.T) { via := &ViaHeader{ Protocol: "SIP/2.0", Transport: "UDP", Host: "192.168.1.100", Port: 5060, Branch: "z9hG4bK776", Received: "10.0.0.1", RPort: 12345, } serialized := via.Serialize() // Parse it back via2, err := ParseViaHeader(serialized) if err != nil { t.Fatalf("Failed to parse serialized Via: %v", err) } if via2.Host != via.Host || via2.Branch != via.Branch || via2.Received != via.Received { t.Error("Via header round-trip failed") } } func TestParseContactHeader(t *testing.T) { tests := []struct { name string input string uri string dname string }{ { name: "simple URI", input: "", uri: "sip:alice@192.168.1.100:5060", dname: "", }, { name: "with display name", input: "\"Alice\" ;expires=3600", uri: "sip:alice@example.com", dname: "Alice", }, { name: "star contact (REGISTER)", input: "*", uri: "*", dname: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { contact, err := ParseContactHeader(tt.input) if err != nil { t.Fatalf("ParseContactHeader failed: %v", err) } if contact.URI != tt.uri { t.Errorf("URI: got %s, want %s", contact.URI, tt.uri) } if contact.DisplayName != tt.dname { t.Errorf("DisplayName: got %s, want %s", contact.DisplayName, tt.dname) } }) } } func TestParseFromToHeader(t *testing.T) { tests := []struct { name string input string uri string tag string dname string }{ { name: "From with tag", input: "\"Bob\" ;tag=a6c85cf", uri: "sip:bob@example.com", tag: "a6c85cf", dname: "Bob", }, { name: "To without tag", input: "", uri: "sip:alice@example.com", tag: "", dname: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { header, err := ParseFromToHeader(tt.input) if err != nil { t.Fatalf("ParseFromToHeader failed: %v", err) } if header.URI != tt.uri { t.Errorf("URI: got %s, want %s", header.URI, tt.uri) } if header.Tag != tt.tag { t.Errorf("Tag: got %s, want %s", header.Tag, tt.tag) } if header.DisplayName != tt.dname { t.Errorf("DisplayName: got %s, want %s", header.DisplayName, tt.dname) } }) } } func TestExtractHostFromURI(t *testing.T) { tests := []struct { uri string expected string }{ {"sip:alice@example.com", "example.com"}, {"sip:bob@192.168.1.100:5060", "192.168.1.100"}, {"sips:secure@tls.example.com:5061;transport=tls", "tls.example.com"}, {"sip:user@host;param=value", "host"}, } for _, tt := range tests { t.Run(tt.uri, func(t *testing.T) { host := ExtractHostFromURI(tt.uri) if host != tt.expected { t.Errorf("ExtractHostFromURI(%s) = %s, want %s", tt.uri, host, tt.expected) } }) } } func TestRewriteURIHost(t *testing.T) { tests := []struct { uri string newHost string newPort int expected string }{ { uri: "sip:alice@192.168.1.100:5060", newHost: "proxy.example.com", newPort: 5060, expected: "sip:alice@proxy.example.com", }, { uri: "sip:bob@internal.lan:5060;transport=udp", newHost: "external.example.com", newPort: 5080, expected: "sip:bob@external.example.com:5080;transport=udp", }, } for _, tt := range tests { t.Run(tt.uri, func(t *testing.T) { result := RewriteURIHost(tt.uri, tt.newHost, tt.newPort) if result != tt.expected { t.Errorf("RewriteURIHost(%s, %s, %d) = %s, want %s", tt.uri, tt.newHost, tt.newPort, result, tt.expected) } }) } } func TestGenerateBranch(t *testing.T) { branch := GenerateBranch() // RFC 3261: Branch must start with "z9hG4bK" if !strings.HasPrefix(branch, "z9hG4bK") { t.Errorf("Branch %s does not start with z9hG4bK", branch) } // Should be unique branch2 := GenerateBranch() if branch == branch2 { t.Error("GenerateBranch should generate unique values") } } // ============================================================================= // Dialog State Tests (dialog_state.go) // ============================================================================= func TestDialogManagerCreateAndGet(t *testing.T) { logger := zap.NewNop() dm := NewDialogManager(logger, 5*time.Minute) // Create a mock SIP message raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK776test\r\n" + "From: ;tag=fromtag123\r\n" + "To: \r\n" + "Call-ID: testcall@example.com\r\n" + "CSeq: 1 INVITE\r\n" + "Contact: \r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) // Create dialog state := dm.CreateDialog(msg, "192.168.1.100", 5060) if state == nil { t.Fatal("CreateDialog returned nil") } if state.CallID != "testcall@example.com" { t.Errorf("CallID mismatch: got %s", state.CallID) } if state.FromTag != "fromtag123" { t.Errorf("FromTag mismatch: got %s", state.FromTag) } // Get by Call-ID retrieved := dm.GetDialogByCallID("testcall@example.com", "fromtag123") if retrieved == nil { t.Fatal("GetDialogByCallID returned nil") } if retrieved != state { t.Error("Retrieved dialog doesn't match created dialog") } // Get by branch byBranch := dm.GetDialogByBranch("z9hG4bK776test") if byBranch == nil { t.Fatal("GetDialogByBranch returned nil") } if byBranch != state { t.Error("Retrieved dialog by branch doesn't match") } } func TestDialogManagerUpdateAndConfirm(t *testing.T) { logger := zap.NewNop() dm := NewDialogManager(logger, 5*time.Minute) raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bKupdate\r\n" + "From: ;tag=from456\r\n" + "To: \r\n" + "Call-ID: updatetest@example.com\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) dm.CreateDialog(msg, "192.168.1.100", 5060) // Update with To-tag (from 200 OK) dm.UpdateDialog("updatetest@example.com", "from456", "totag789") state := dm.GetDialogByCallID("updatetest@example.com", "from456") if state.ToTag != "totag789" { t.Errorf("ToTag not updated: got %s", state.ToTag) } // Confirm dialog dm.ConfirmDialog("updatetest@example.com", "from456") if !state.IsConfirmed { t.Error("Dialog not confirmed") } // Terminate dialog dm.TerminateDialog("updatetest@example.com", "from456") if !state.IsTerminated { t.Error("Dialog not terminated") } } func TestDialogManagerRemove(t *testing.T) { logger := zap.NewNop() dm := NewDialogManager(logger, 5*time.Minute) raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bKremove\r\n" + "From: ;tag=removetag\r\n" + "To: \r\n" + "Call-ID: removetest@example.com\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) dm.CreateDialog(msg, "192.168.1.100", 5060) // Remove dialog dm.RemoveDialog("removetest@example.com", "removetag") // Should be gone if dm.GetDialogByCallID("removetest@example.com", "removetag") != nil { t.Error("Dialog not removed from call-id index") } if dm.GetDialogByBranch("z9hG4bKremove") != nil { t.Error("Dialog not removed from branch index") } } func TestDialogManagerStats(t *testing.T) { logger := zap.NewNop() dm := NewDialogManager(logger, 5*time.Minute) // Create multiple dialogs in different states for i := 0; i < 3; i++ { raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bKstats" + string(rune('a'+i)) + "\r\n" + "From: ;tag=statstag" + string(rune('a'+i)) + "\r\n" + "To: \r\n" + "Call-ID: statstest" + string(rune('a'+i)) + "@example.com\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) dm.CreateDialog(msg, "192.168.1.100", 5060) } // Confirm one dm.ConfirmDialog("statstesta@example.com", "statstaga") // Terminate one dm.TerminateDialog("statstestb@example.com", "statstagb") stats := dm.GetStats() if stats["total_dialogs"].(int) != 3 { t.Errorf("Expected 3 total dialogs, got %v", stats["total_dialogs"]) } if stats["confirmed_dialogs"].(int) != 1 { t.Errorf("Expected 1 confirmed dialog, got %v", stats["confirmed_dialogs"]) } if stats["terminated_dialogs"].(int) != 1 { t.Errorf("Expected 1 terminated dialog, got %v", stats["terminated_dialogs"]) } } func TestTransactionManager(t *testing.T) { logger := zap.NewNop() tm := NewTransactionManager(logger, 32*time.Second) // Create transaction state := tm.CreateTransaction("z9hG4bKtx123", "INVITE", "192.168.1.100", 5060, "SIP/2.0/UDP original:5060") if state == nil { t.Fatal("CreateTransaction returned nil") } // Retrieve retrieved := tm.GetTransaction("z9hG4bKtx123") if retrieved == nil { t.Fatal("GetTransaction returned nil") } if retrieved.Method != "INVITE" { t.Errorf("Method mismatch: got %s", retrieved.Method) } // Remove tm.RemoveTransaction("z9hG4bKtx123") if tm.GetTransaction("z9hG4bKtx123") != nil { t.Error("Transaction not removed") } } // ============================================================================= // Topology Hider Tests (topology.go) // ============================================================================= func TestTopologyHiderConfig(t *testing.T) { th := &TopologyHider{ Enabled: true, ProxyHost: "proxy.example.com", ProxyPort: 5060, RewriteVia: true, RewriteContact: true, AnonymizeCallID: false, HidePrivateIPs: true, StripHeaders: []string{"Server", "User-Agent"}, } if !th.Enabled { t.Error("Expected Enabled to be true") } if len(th.StripHeaders) != 2 { t.Errorf("Expected 2 strip headers, got %d", len(th.StripHeaders)) } } func TestIsDialogCreating(t *testing.T) { tests := []struct { method string expected bool }{ {"INVITE", true}, {"SUBSCRIBE", true}, {"REFER", true}, {"NOTIFY", true}, {"REGISTER", false}, {"OPTIONS", false}, {"BYE", false}, {"CANCEL", false}, } for _, tt := range tests { t.Run(tt.method, func(t *testing.T) { raw := tt.method + " sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test@host\r\n" + "CSeq: 1 " + tt.method + "\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) if msg.IsDialogCreating() != tt.expected { t.Errorf("%s: IsDialogCreating() = %v, want %v", tt.method, msg.IsDialogCreating(), tt.expected) } }) } } func TestIsDialogTerminating(t *testing.T) { byeMsg := "BYE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" + "From: ;tag=123\r\n" + "To: ;tag=456\r\n" + "Call-ID: test@host\r\n" + "CSeq: 2 BYE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(byeMsg)) if !msg.IsDialogTerminating() { t.Error("BYE should be dialog terminating") } inviteMsg := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test@host\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg2, _ := ParseSIPMessage([]byte(inviteMsg)) if msg2.IsDialogTerminating() { t.Error("INVITE should not be dialog terminating") } } func TestSensitiveHeadersList(t *testing.T) { expected := []string{ "P-Preferred-Identity", "P-Asserted-Identity", "Remote-Party-ID", "P-Charging-Vector", "P-Charging-Function-Addresses", "Server", "User-Agent", "X-Asterisk-HangupCause", "X-Asterisk-HangupCauseCode", } if len(SensitiveHeaders) != len(expected) { t.Errorf("Expected %d sensitive headers, got %d", len(expected), len(SensitiveHeaders)) } for _, h := range expected { found := false for _, sh := range SensitiveHeaders { if sh == h { found = true break } } if !found { t.Errorf("Missing sensitive header: %s", h) } } } func TestUpdateContentLength(t *testing.T) { raw := "INVITE sip:bob@example.com SIP/2.0\r\n" + "Via: SIP/2.0/UDP 192.168.1.100:5060;branch=z9hG4bK123\r\n" + "From: ;tag=123\r\n" + "To: \r\n" + "Call-ID: test@host\r\n" + "CSeq: 1 INVITE\r\n" + "Content-Length: 0\r\n" + "\r\n" msg, _ := ParseSIPMessage([]byte(raw)) // Set a body msg.Body = []byte("v=0\r\no=- 12345 12345 IN IP4 192.168.1.100\r\ns=-\r\n") msg.UpdateContentLength() cl := msg.GetHeader("Content-Length") if cl == nil { t.Fatal("Content-Length header missing") } if cl.Value != "48" { t.Errorf("Content-Length should be 48, got %s", cl.Value) } }