caddy/modules/caddyhttp/urlpatternmatcher_test.go
Kévin Dunglas 13a4c3f43c
caddyhttp: add URL pattern request matcher (#7787)
* caddyhttp: add url_pattern request matcher

Match requests against a URLPattern (https://urlpattern.spec.whatwg.org/),
supporting named groups, wildcards and regexp components beyond the path
matcher. Relative patterns match any origin; absolute patterns or base_url
scope to scheme and host.

Exposes a url_pattern CEL function and publishes captured groups as
{http.url_pattern.<component>.<group>} placeholders.

* caddyhttp: add Caddyfile adapt test for url_pattern matcher
2026-06-22 10:25:43 -06:00

241 lines
5.8 KiB
Go

package caddyhttp
import (
"context"
"crypto/tls"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
)
func TestURLPatternMatcher(t *testing.T) {
for _, tc := range []struct {
name string
match MatchURLPattern
host string
tls bool
input string
expect bool
provisionErr bool
}{
{
name: "literal path matches",
match: MatchURLPattern{Pattern: "/foo"},
host: "example.com",
input: "/foo",
expect: true,
},
{
name: "literal path mismatch",
match: MatchURLPattern{Pattern: "/foo"},
host: "example.com",
input: "/bar",
expect: false,
},
{
name: "named group matches",
match: MatchURLPattern{Pattern: "/books/:id"},
host: "example.com",
input: "/books/123",
expect: true,
},
{
name: "named group requires segment",
match: MatchURLPattern{Pattern: "/books/:id"},
host: "example.com",
input: "/books",
expect: false,
},
{
name: "wildcard spans segments",
match: MatchURLPattern{Pattern: "/files/*"},
host: "example.com",
input: "/files/a/b/c",
expect: true,
},
{
name: "absolute pattern matches host and scheme",
match: MatchURLPattern{Pattern: "https://example.com/foo"},
host: "example.com",
tls: true,
input: "/foo",
expect: true,
},
{
name: "absolute pattern rejects scheme mismatch",
match: MatchURLPattern{Pattern: "https://example.com/foo"},
host: "example.com",
input: "/foo",
expect: false,
},
{
name: "absolute pattern rejects host mismatch",
match: MatchURLPattern{Pattern: "https://example.com/foo"},
host: "other.com",
tls: true,
input: "/foo",
expect: false,
},
{
name: "ignore_case matches mixed case",
match: MatchURLPattern{Pattern: "/foo", IgnoreCase: true},
host: "example.com",
input: "/FOO",
expect: true,
},
{
name: "case sensitive by default",
match: MatchURLPattern{Pattern: "/foo"},
host: "example.com",
input: "/FOO",
expect: false,
},
{
name: "base_url scopes to host",
match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"},
host: "example.com",
tls: true,
input: "/search?q=caddy",
expect: true,
},
{
name: "base_url rejects other host",
match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"},
host: "other.com",
tls: true,
input: "/search",
expect: false,
},
{
name: "invalid pattern fails provisioning",
match: MatchURLPattern{Pattern: "https://[invalid"},
provisionErr: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
err := tc.match.Provision(caddy.Context{})
if tc.provisionErr {
require.Error(t, err)
return
}
require.NoError(t, err)
u, err := url.ParseRequestURI(tc.input)
require.NoError(t, err)
req := &http.Request{URL: u, Host: tc.host}
if tc.tls {
req.TLS = &tls.ConnectionState{}
}
actual, err := tc.match.MatchWithError(req)
require.NoError(t, err)
assert.Equal(t, tc.expect, actual)
})
}
}
func TestURLPatternMatcherUnmarshalCaddyfile(t *testing.T) {
for _, tc := range []struct {
name string
input string
expect MatchURLPattern
expectErr bool
}{
{
name: "pattern only",
input: `url_pattern /books/:id`,
expect: MatchURLPattern{Pattern: "/books/:id"},
},
{
name: "base_url and ignore_case",
input: `url_pattern /search {
base_url https://example.com
ignore_case
}`,
expect: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com", IgnoreCase: true},
},
{
name: "missing pattern",
input: `url_pattern`,
expectErr: true,
},
{
name: "unknown option",
input: `url_pattern /foo {
nope
}`,
expectErr: true,
},
{
name: "base_url without value",
input: `url_pattern /foo {
base_url
}`,
expectErr: true,
},
{
name: "ignore_case with stray arg",
input: `url_pattern /foo {
ignore_case yes
}`,
expectErr: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
var m MatchURLPattern
err := m.UnmarshalCaddyfile(caddyfile.NewTestDispenser(tc.input))
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expect, m)
})
}
}
// TestURLPatternMatcherGroups checks that captured groups are exposed as
// component-scoped placeholders, mirroring the URLPattern result object.
func TestURLPatternMatcherGroups(t *testing.T) {
m := MatchURLPattern{Pattern: "/books/:id/chapters/:chapter"}
require.NoError(t, m.Provision(caddy.Context{}))
u, err := url.ParseRequestURI("/books/42/chapters/7")
require.NoError(t, err)
repl := caddy.NewReplacer()
ctx := context.WithValue(context.Background(), caddy.ReplacerCtxKey, repl)
req := (&http.Request{URL: u, Host: "example.com"}).WithContext(ctx)
ok, err := m.MatchWithError(req)
require.NoError(t, err)
require.True(t, ok)
id, _ := repl.GetString("http.url_pattern.pathname.id")
assert.Equal(t, "42", id)
chapter, _ := repl.GetString("http.url_pattern.pathname.chapter")
assert.Equal(t, "7", chapter)
}
// TestURLPatternMatcherRelative checks that a relative pattern matches the
// request path regardless of the request's host.
func TestURLPatternMatcherRelative(t *testing.T) {
m := MatchURLPattern{Pattern: "/books/:id"}
require.NoError(t, m.Provision(caddy.Context{}))
for _, host := range []string{"example.com", "other.org", "192.0.2.1:8080"} {
u, err := url.ParseRequestURI("/books/42")
require.NoError(t, err)
ok, err := m.MatchWithError(&http.Request{URL: u, Host: host})
require.NoError(t, err)
assert.Truef(t, ok, "expected match on host %q", host)
}
}