mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
Adds a 3-parameter form to Header(), Query(), and BodyJSON() matchers
for numeric comparisons: Header("Content-Length", "gt", "1024"). Extracts
the duplicated comparison setup and dispatch into shared compareOp and
parseCompareParams helpers. Includes tests for all six operators across
header, query, and body JSON matchers.
1013 lines
26 KiB
Go
1013 lines
26 KiB
Go
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))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatcherBodyRegexp(t *testing.T) {
|
|
// Rule strings use the predicate parser (strconv.Unquote), so inner quotes
|
|
// are escaped. In YAML config users write the equivalent backtick form:
|
|
// BodyRegexp(`"model"\s*:\s*"gpt-4"`).
|
|
m, err := NewMatcher(`BodyRegexp("\"model\"\\s*:\\s*\"gpt-4\"")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
body string
|
|
want bool
|
|
}{
|
|
{"exact model", `{"model":"gpt-4"}`, true},
|
|
{"spaced model", `{"model": "gpt-4", "max_tokens": 1}`, true},
|
|
{"different model", `{"model":"claude"}`, false},
|
|
{"empty body", ``, false},
|
|
{"unrelated", `{"hello":"world"}`, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
req := &routing.Request{Body: []byte(tc.body)}
|
|
assert.Equal(t, tc.want, m.Match(req))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatcherBodyRegexp_NilBody(t *testing.T) {
|
|
m, err := NewMatcher(`BodyRegexp("foo")`)
|
|
require.NoError(t, err)
|
|
assert.False(t, m.Match(&routing.Request{}))
|
|
}
|
|
|
|
func TestMatcherBodyRegexp_Invalid(t *testing.T) {
|
|
_, err := NewMatcher(`BodyRegexp("[invalid")`)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestMatcherBodyRegexp_CaseInsensitive(t *testing.T) {
|
|
m, err := NewMatcher(`BodyRegexp("(?i)\"content-type\"\\s*:\\s*\"application/json\"")`)
|
|
require.NoError(t, err)
|
|
req := &routing.Request{Body: []byte(`{"Content-Type": "application/json"}`)}
|
|
assert.True(t, m.Match(req))
|
|
}
|
|
|
|
func TestMatcherHeaderCompare_gt(t *testing.T) {
|
|
// 3-param form: Header(key, op, value) with op="gt"
|
|
m, err := NewMatcher(`Header("Content-Length", "gt", "1024")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"greater", http.Header{"Content-Length": {"2048"}}, true},
|
|
{"equal", http.Header{"Content-Length": {"1024"}}, false},
|
|
{"less", http.Header{"Content-Length": {"512"}}, 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 TestMatcherHeaderCompare_ge(t *testing.T) {
|
|
m, err := NewMatcher(`Header("Content-Length", "ge", "1024")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"greater", http.Header{"Content-Length": {"2048"}}, true},
|
|
{"equal", http.Header{"Content-Length": {"1024"}}, true},
|
|
{"less", http.Header{"Content-Length": {"512"}}, 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 TestMatcherHeaderCompare_lt(t *testing.T) {
|
|
m, err := NewMatcher(`Header("Content-Length", "lt", "1024")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"less", http.Header{"Content-Length": {"512"}}, true},
|
|
{"equal", http.Header{"Content-Length": {"1024"}}, false},
|
|
{"greater", http.Header{"Content-Length": {"2048"}}, 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 TestMatcherHeaderCompare_le(t *testing.T) {
|
|
m, err := NewMatcher(`Header("Content-Length", "le", "1024")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"less", http.Header{"Content-Length": {"512"}}, true},
|
|
{"equal", http.Header{"Content-Length": {"1024"}}, true},
|
|
{"greater", http.Header{"Content-Length": {"2048"}}, 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 TestMatcherHeaderCompare_MultiValue(t *testing.T) {
|
|
m, err := NewMatcher(`Header("X-Values", "gt", "100")`)
|
|
require.NoError(t, err)
|
|
|
|
req := &routing.Request{
|
|
Header: http.Header{"X-Values": {"50", "200"}},
|
|
}
|
|
assert.True(t, m.Match(req), "should match when any value satisfies")
|
|
}
|
|
|
|
func TestMatcherHeaderCompare_NonNumeric(t *testing.T) {
|
|
m, err := NewMatcher(`Header("X-Value", "gt", "100")`)
|
|
require.NoError(t, err)
|
|
|
|
req := &routing.Request{
|
|
Header: http.Header{"X-Value": {"abc"}},
|
|
}
|
|
assert.False(t, m.Match(req), "non-numeric value should not match")
|
|
}
|
|
|
|
func TestMatcherHeaderCompare_Missing(t *testing.T) {
|
|
m, err := NewMatcher(`Header("X-Missing", "gt", "100")`)
|
|
require.NoError(t, err)
|
|
|
|
req := &routing.Request{Header: http.Header{}}
|
|
assert.False(t, m.Match(req))
|
|
}
|
|
|
|
func TestMatcherHeaderCompare_eq(t *testing.T) {
|
|
m, err := NewMatcher(`Header("Content-Length", "eq", "1024")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"equal", http.Header{"Content-Length": {"1024"}}, true},
|
|
{"not equal", http.Header{"Content-Length": {"2048"}}, 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 TestMatcherHeaderCompare_ne(t *testing.T) {
|
|
m, err := NewMatcher(`Header("Content-Length", "ne", "1024")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
want bool
|
|
}{
|
|
{"not equal", http.Header{"Content-Length": {"2048"}}, true},
|
|
{"equal", http.Header{"Content-Length": {"1024"}}, 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 TestMatcherQueryCompare(t *testing.T) {
|
|
// 3-param form: Query(key, op, value)
|
|
tests := []struct {
|
|
desc string
|
|
rule string
|
|
query url.Values
|
|
want bool
|
|
}{
|
|
{"gt match", `Query("page", "gt", "2")`, url.Values{"page": {"3"}}, true},
|
|
{"gt equal", `Query("page", "gt", "2")`, url.Values{"page": {"2"}}, false},
|
|
{"ge match", `Query("page", "ge", "2")`, url.Values{"page": {"2"}}, true},
|
|
{"ge less", `Query("page", "ge", "2")`, url.Values{"page": {"1"}}, false},
|
|
{"lt match", `Query("page", "lt", "5")`, url.Values{"page": {"3"}}, true},
|
|
{"lt equal", `Query("page", "lt", "5")`, url.Values{"page": {"5"}}, false},
|
|
{"le match", `Query("page", "le", "5")`, url.Values{"page": {"5"}}, true},
|
|
{"le greater", `Query("page", "le", "5")`, url.Values{"page": {"6"}}, false},
|
|
{"eq match", `Query("page", "eq", "5")`, url.Values{"page": {"5"}}, true},
|
|
{"eq no match", `Query("page", "eq", "5")`, url.Values{"page": {"6"}}, false},
|
|
{"ne match", `Query("page", "ne", "5")`, url.Values{"page": {"6"}}, true},
|
|
{"ne no match", `Query("page", "ne", "5")`, url.Values{"page": {"5"}}, false},
|
|
{"missing key", `Query("nonexistent", "gt", "0")`, url.Values{"page": {"1"}}, false},
|
|
{"non-numeric", `Query("page", "gt", "0")`, url.Values{"page": {"abc"}}, false},
|
|
{"nil query", `Query("page", "gt", "0")`, nil, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
m, err := NewMatcher(tc.rule)
|
|
require.NoError(t, err)
|
|
req := &routing.Request{Query: tc.query}
|
|
assert.Equal(t, tc.want, m.Match(req))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatcherBodyJSONCompare(t *testing.T) {
|
|
// 3-param form: BodyJSON(path, op, value)
|
|
tests := []struct {
|
|
desc string
|
|
rule string
|
|
body string
|
|
want bool
|
|
}{
|
|
{"gt match", `BodyJSON("age", "gt", "18")`, `{"age":21}`, true},
|
|
{"gt equal", `BodyJSON("age", "gt", "18")`, `{"age":18}`, false},
|
|
{"ge match", `BodyJSON("age", "ge", "18")`, `{"age":18}`, true},
|
|
{"ge less", `BodyJSON("age", "ge", "18")`, `{"age":17}`, false},
|
|
{"eq match", `BodyJSON("age", "eq", "18")`, `{"age":18}`, true},
|
|
{"eq no match", `BodyJSON("age", "eq", "18")`, `{"age":17}`, false},
|
|
{"ne match", `BodyJSON("age", "ne", "18")`, `{"age":17}`, true},
|
|
{"ne no match", `BodyJSON("age", "ne", "18")`, `{"age":18}`, false},
|
|
{"lt match", `BodyJSON("price", "lt", "100")`, `{"price":50}`, true},
|
|
{"lt equal", `BodyJSON("price", "lt", "100")`, `{"price":100}`, false},
|
|
{"le match", `BodyJSON("price", "le", "100")`, `{"price":100}`, true},
|
|
{"le greater", `BodyJSON("price", "le", "100")`, `{"price":101}`, false},
|
|
{"nested path", `BodyJSON("user.age", "gt", "18")`, `{"user":{"age":25}}`, true},
|
|
{"empty body", `BodyJSON("age", "gt", "0")`, ``, false},
|
|
{"missing field", `BodyJSON("nonexistent", "gt", "0")`, `{"age":21}`, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
m, err := NewMatcher(tc.rule)
|
|
require.NoError(t, err)
|
|
req := &routing.Request{Body: []byte(tc.body)}
|
|
assert.Equal(t, tc.want, m.Match(req))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatcherCompareInvalidOp(t *testing.T) {
|
|
// Invalid operator in 3-param form → error
|
|
tests := []string{
|
|
`Header("X", "==", "100")`,
|
|
`Query("x", "==", "100")`,
|
|
`BodyJSON("x", "==", "100")`,
|
|
}
|
|
|
|
for _, rule := range tests {
|
|
t.Run(rule, func(t *testing.T) {
|
|
_, err := NewMatcher(rule)
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatcherCompareInvalidValue(t *testing.T) {
|
|
// Non-numeric compare value in 3-param form → error
|
|
tests := []string{
|
|
`Header("Content-Length", "gt", "not-a-number")`,
|
|
`Query("page", "gt", "not-a-number")`,
|
|
`BodyJSON("age", "gt", "not-a-number")`,
|
|
}
|
|
|
|
for _, rule := range tests {
|
|
t.Run(rule, func(t *testing.T) {
|
|
_, err := NewMatcher(rule)
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatcherCompareWithBoolean(t *testing.T) {
|
|
// 3-param Header combined with Path via &&
|
|
m, err := NewMatcher(`Header("Content-Length", "gt", "100") && Path("/upload")`)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
header http.Header
|
|
path string
|
|
want bool
|
|
}{
|
|
{"both match", http.Header{"Content-Length": {"200"}}, "/upload", true},
|
|
{"header no match", http.Header{"Content-Length": {"50"}}, "/upload", false},
|
|
{"path no match", http.Header{"Content-Length": {"200"}}, "/download", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
req := &routing.Request{Header: tc.header, Path: tc.path}
|
|
assert.Equal(t, tc.want, m.Match(req))
|
|
})
|
|
}
|
|
}
|