package routing import ( "net" "net/http" "net/url" "testing" "github.com/go-gost/core/routing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsASCII(t *testing.T) { tests := []struct { input string want bool }{ {"", true}, {"hello", true}, {"Hello World 123!", true}, {"/path/to/resource", true}, {"café", false}, {"日本語", false}, {"\xff", false}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { assert.Equal(t, tc.want, IsASCII(tc.input)) }) } } func TestParseHost(t *testing.T) { tests := []struct { input string want string }{ {"example.com", "example.com"}, {"example.com:8080", "example.com"}, {"192.168.1.1", "192.168.1.1"}, {"192.168.1.1:443", "192.168.1.1"}, {"[::1]:8080", "::1"}, {"[fe80::1]:443", "fe80::1"}, {"[::1]", "::1"}, {"", ""}, {":8080", ""}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { assert.Equal(t, tc.want, parseHost(tc.input)) }) } } func TestMatcherClientIP(t *testing.T) { m, err := NewMatcher(`ClientIP("192.168.1.1")`) require.NoError(t, err) tests := []struct { desc string req *routing.Request want bool }{ { desc: "exact match", req: &routing.Request{ClientIP: net.ParseIP("192.168.1.1")}, want: true, }, { desc: "no match", req: &routing.Request{ClientIP: net.ParseIP("10.0.0.1")}, want: false, }, { desc: "nil ClientIP", req: &routing.Request{ClientIP: nil}, want: false, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { assert.Equal(t, tc.want, m.Match(tc.req)) }) } } func TestMatcherClientIP_CIDR(t *testing.T) { m, err := NewMatcher(`ClientIP("192.168.1.0/24")`) require.NoError(t, err) tests := []struct { desc string ip string want bool }{ {"in range", "192.168.1.50", true}, {"network addr", "192.168.1.0", true}, {"broadcast", "192.168.1.255", true}, {"outside range", "192.168.2.1", false}, {"different class", "10.0.0.1", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{ClientIP: net.ParseIP(tc.ip)} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherClientIP_Invalid(t *testing.T) { _, err := NewMatcher(`ClientIP("not-an-ip")`) assert.Error(t, err) } func TestMatcherProto(t *testing.T) { m, err := NewMatcher(`Proto("http")`) require.NoError(t, err) tests := []struct { desc string proto string want bool }{ {"lowercase match", "http", true}, {"uppercase no match", "HTTP", false}, {"wrong proto", "socks5", false}, {"empty", "", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Protocol: tc.proto} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherNetwork(t *testing.T) { m, err := NewMatcher(`Network("tcp")`) require.NoError(t, err) tests := []struct { desc string network string want bool }{ {"exact match", "tcp", true}, {"prefix match tcp4", "tcp4", true}, {"prefix match tcp6", "tcp6", true}, {"uppercase no match", "TCP", false}, {"wrong network udp", "udp", false}, {"wrong network udp4", "udp4", false}, {"empty", "", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Network: tc.network} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherMethod(t *testing.T) { m, err := NewMatcher(`Method("GET")`) require.NoError(t, err) tests := []struct { desc string method string want bool }{ {"exact match", "GET", true}, {"case mismatch", "get", false}, {"wrong method", "POST", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Method: tc.method} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherPath(t *testing.T) { m, err := NewMatcher(`Path("/api/v1")`) require.NoError(t, err) tests := []struct { desc string path string want bool }{ {"exact match", "/api/v1", true}, {"prefix only", "/api/v1/users", false}, {"no match", "/api/v2", false}, {"empty", "", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Path: tc.path} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherPath_NoLeadingSlash(t *testing.T) { _, err := NewMatcher(`Path("api")`) assert.Error(t, err) } func TestMatcherPathPrefix(t *testing.T) { m, err := NewMatcher(`PathPrefix("/api")`) require.NoError(t, err) tests := []struct { desc string path string want bool }{ {"exact match", "/api", true}, {"prefix match", "/api/v1/users", true}, {"no match", "/other", false}, {"partial no match", "/apifoo", true}, // HasPrefix matches this } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Path: tc.path} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherPathPrefix_NoLeadingSlash(t *testing.T) { _, err := NewMatcher(`PathPrefix("api")`) assert.Error(t, err) } func TestMatcherPathRegexp(t *testing.T) { m, err := NewMatcher(`PathRegexp("^/api/v[0-9]+")`) require.NoError(t, err) tests := []struct { desc string path string want bool }{ {"match v1", "/api/v1/users", true}, {"match v2", "/api/v2", true}, {"no version", "/api/users", false}, {"wrong path", "/other", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Path: tc.path} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherPathRegexp_Invalid(t *testing.T) { _, err := NewMatcher(`PathRegexp("[invalid")`) assert.Error(t, err) } func TestMatcherHost(t *testing.T) { m, err := NewMatcher(`Host("example.com")`) require.NoError(t, err) tests := []struct { desc string host string want bool }{ {"exact match", "example.com", true}, {"case insensitive", "EXAMPLE.COM", true}, {"subdomain no match", "foo.example.com", false}, {"with port", "example.com:8080", true}, {"no match", "other.com", false}, {"empty", "", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Host: tc.host} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherHost_Wildcard(t *testing.T) { m, err := NewMatcher(`Host("*.example.com")`) require.NoError(t, err) tests := []struct { desc string host string want bool }{ {"subdomain match", "foo.example.com", true}, {"deep subdomain", "a.b.example.com", true}, {"bare domain no match", "example.com", false}, {"false suffix no match", "xexample.com", false}, {"subdomain with port", "foo.example.com:8080", true}, {"empty", "", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Host: tc.host} assert.Equal(t, tc.want, m.Match(req), tc.desc) }) } } func TestMatcherHost_NonASCII(t *testing.T) { _, err := NewMatcher(`Host("example.cóm")`) assert.Error(t, err) } func TestMatcherHostRegexp(t *testing.T) { m, err := NewMatcher(`HostRegexp(".*\\.example\\.com")`) require.NoError(t, err) tests := []struct { desc string host string want bool }{ {"subdomain", "foo.example.com", true}, {"bare domain", "example.com", false}, {"no match", "other.com", false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Host: tc.host} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherHostRegexp_Invalid(t *testing.T) { _, err := NewMatcher(`HostRegexp("[invalid")`) assert.Error(t, err) } func TestMatcherHostRegexp_NonASCII(t *testing.T) { _, err := NewMatcher(`HostRegexp("example.cóm")`) assert.Error(t, err) } func TestMatcherHeader(t *testing.T) { m, err := NewMatcher(`Header("X-Custom")`) require.NoError(t, err) tests := []struct { desc string header http.Header want bool }{ {"present", http.Header{"X-Custom": {"value"}}, true}, {"absent", http.Header{"Other": {"value"}}, false}, {"nil header", nil, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Header: tc.header} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherHeaderWithValue(t *testing.T) { m, err := NewMatcher(`Header("X-Custom", "secret")`) require.NoError(t, err) tests := []struct { desc string header http.Header want bool }{ {"matching value", http.Header{"X-Custom": {"secret"}}, true}, {"wrong value", http.Header{"X-Custom": {"other"}}, false}, {"multiple values", http.Header{"X-Custom": {"other", "secret"}}, true}, {"absent", http.Header{}, false}, {"nil", nil, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Header: tc.header} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherHeaderRegexp(t *testing.T) { m, err := NewMatcher(`HeaderRegexp("X-Custom", "^v[0-9]+$")`) require.NoError(t, err) tests := []struct { desc string header http.Header want bool }{ {"match v1", http.Header{"X-Custom": {"v1"}}, true}, {"match v12", http.Header{"X-Custom": {"v12"}}, true}, {"no match", http.Header{"X-Custom": {"invalid"}}, false}, {"nil", nil, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Header: tc.header} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherHeaderRegexp_KeyOnly(t *testing.T) { // With 1 param, HeaderRegexp delegates to Header (key existence check) m, err := NewMatcher(`HeaderRegexp("X-Custom")`) require.NoError(t, err) req := &routing.Request{Header: http.Header{"X-Custom": {"anything"}}} assert.True(t, m.Match(req)) req = &routing.Request{Header: http.Header{}} assert.False(t, m.Match(req)) } func TestMatcherHeaderRegexp_Invalid(t *testing.T) { _, err := NewMatcher(`HeaderRegexp("X-Custom", "[invalid")`) assert.Error(t, err) } func TestMatcherQuery(t *testing.T) { m, err := NewMatcher(`Query("page")`) require.NoError(t, err) tests := []struct { desc string query url.Values want bool }{ {"present", url.Values{"page": {"1"}}, true}, {"absent", url.Values{"other": {"1"}}, false}, {"nil", nil, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Query: tc.query} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherQueryWithValue(t *testing.T) { m, err := NewMatcher(`Query("page", "2")`) require.NoError(t, err) tests := []struct { desc string query url.Values want bool }{ {"matching value", url.Values{"page": {"2"}}, true}, {"wrong value", url.Values{"page": {"1"}}, false}, {"multi-value", url.Values{"page": {"1", "2"}}, true}, {"absent", url.Values{}, false}, {"nil", nil, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Query: tc.query} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherQueryRegexp(t *testing.T) { m, err := NewMatcher(`QueryRegexp("page", "^[0-9]+$")`) require.NoError(t, err) tests := []struct { desc string query url.Values want bool }{ {"numeric", url.Values{"page": {"42"}}, true}, {"non-numeric", url.Values{"page": {"abc"}}, false}, {"nil", nil, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &routing.Request{Query: tc.query} assert.Equal(t, tc.want, m.Match(req)) }) } } func TestMatcherQueryRegexp_KeyOnly(t *testing.T) { m, err := NewMatcher(`QueryRegexp("page")`) require.NoError(t, err) req := &routing.Request{Query: url.Values{"page": {"1"}}} assert.True(t, m.Match(req)) req = &routing.Request{Query: url.Values{}} assert.False(t, m.Match(req)) } func TestMatcherQueryRegexp_Invalid(t *testing.T) { _, err := NewMatcher(`QueryRegexp("page", "[invalid")`) assert.Error(t, err) } func TestMatcherBooleanAnd(t *testing.T) { m, err := NewMatcher(`Host("example.com") && Path("/api")`) require.NoError(t, err) req := &routing.Request{Host: "example.com", Path: "/api"} assert.True(t, m.Match(req)) req = &routing.Request{Host: "example.com", Path: "/other"} assert.False(t, m.Match(req)) req = &routing.Request{Host: "other.com", Path: "/api"} assert.False(t, m.Match(req)) } func TestMatcherBooleanOr(t *testing.T) { m, err := NewMatcher(`Host("a.com") || Host("b.com")`) require.NoError(t, err) req := &routing.Request{Host: "a.com"} assert.True(t, m.Match(req)) req = &routing.Request{Host: "b.com"} assert.True(t, m.Match(req)) req = &routing.Request{Host: "c.com"} assert.False(t, m.Match(req)) } func TestMatcherBooleanNot(t *testing.T) { m, err := NewMatcher(`!Host("example.com")`) require.NoError(t, err) req := &routing.Request{Host: "other.com"} assert.True(t, m.Match(req)) req = &routing.Request{Host: "example.com"} assert.False(t, m.Match(req)) } func TestMatcherBooleanComplex(t *testing.T) { // (Host a.com OR Host b.com) AND Path /api m, err := NewMatcher(`(Host("a.com") || Host("b.com")) && PathPrefix("/api")`) require.NoError(t, err) req := &routing.Request{Host: "a.com", Path: "/api/v1"} assert.True(t, m.Match(req)) req = &routing.Request{Host: "c.com", Path: "/api/v1"} assert.False(t, m.Match(req)) req = &routing.Request{Host: "a.com", Path: "/other"} assert.False(t, m.Match(req)) } func TestMatcherBooleanDeMorgan(t *testing.T) { // !(Host a.com && Host b.com) => !Host a.com || !Host b.com m, err := NewMatcher(`!(Host("a.com") && Path("/secret"))`) require.NoError(t, err) // Both match → AND is true → NOT inverts to false req := &routing.Request{Host: "a.com", Path: "/secret"} assert.False(t, m.Match(req)) // Only host matches → AND is false → NOT inverts to true req = &routing.Request{Host: "a.com", Path: "/public"} assert.True(t, m.Match(req)) // Neither matches → AND is false → NOT inverts to true req = &routing.Request{Host: "b.com", Path: "/public"} assert.True(t, m.Match(req)) } func TestMatcherInvalidRules(t *testing.T) { tests := []struct { desc string rule string }{ {"empty", ""}, {"unknown matcher", `Unknown("value")`}, {"missing close paren", `Host("example.com"`}, {"trailing and", `Host("a.com") &&`}, {"trailing or", `Host("a.com") ||`}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { _, err := NewMatcher(tc.rule) assert.Error(t, err, "expected error for rule: %s", tc.rule) }) } } func TestMatcherNilReceiver(t *testing.T) { var m *matcher assert.False(t, m.Match(&routing.Request{})) } func TestMatcherParameterCount(t *testing.T) { tests := []struct { desc string rule string ok bool }{ {"Host 0 params", `Host()`, false}, {"Host 1 param", `Host("a.com")`, true}, {"Host 2 params", `Host("a.com", "b.com")`, false}, {"Header 1 param", `Header("X-Foo")`, true}, {"Header 2 params", `Header("X-Foo", "bar")`, true}, {"Header 3 params", `Header("X-Foo", "bar", "baz")`, false}, {"Query 1 param", `Query("key")`, true}, {"Query 2 params", `Query("key", "val")`, true}, {"ClientIP 0 params", `ClientIP()`, false}, {"Path 0 params", `Path()`, false}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { _, err := NewMatcher(tc.rule) if tc.ok { assert.NoError(t, err) } else { assert.Error(t, err) } }) } } func TestMatcherCaseInsensitiveMatcherName(t *testing.T) { tests := []struct { desc string rule string host string want bool }{ {"lowercase", `host("example.com")`, "example.com", true}, {"uppercase", `HOST("example.com")`, "example.com", true}, {"title case", `Host("example.com")`, "example.com", true}, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { m, err := NewMatcher(tc.rule) require.NoError(t, err) req := &routing.Request{Host: tc.host} assert.Equal(t, tc.want, m.Match(req)) }) } }