Files
fiber/router_precedence_test.go
zeekayandHanzo Dev 2d1966c7f8 router: exempt constraint-disambiguated same-position params from the ambiguous-conflict panic
A plain :id and a constrained :id<int> (or two differently-constrained params)
at the same position can match disjoint value sets, so they are not a genuine
conflict. Only unconstrained equal-specificity ties panic; constrained ties fall
through to sorted insertion and keep registration order, matching upstream
fiber's constraint routing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 13:11:16 -07:00

243 lines
10 KiB
Go

// ⚡️ Fiber is an Express inspired web framework written in Go with ☕️
// 🤖 GitHub Repository: https://github.com/gofiber/fiber
// 📌 API Documentation: https://docs.gofiber.io
package fiber
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// hitBody drives a request through the app and returns the response body, which
// each test uses as a marker for which handler was selected.
func hitBody(tb testing.TB, app *App, method, path string) string {
tb.Helper()
resp, err := app.Test(httptest.NewRequest(method, path, http.NoBody))
require.NoError(tb, err, "app.Test(%s %s)", method, path)
body, err := io.ReadAll(resp.Body)
require.NoError(tb, err, "read body")
return string(body)
}
// Test 1: the most specific pattern wins even when the broad wildcard is
// registered first. This is the concrete zip case: `/v1/iam/*` before
// `/v1/iam/keys`.
func Test_Precedence_StaticBeatsWildcard_RegisteredFirst(t *testing.T) {
t.Parallel()
app := New()
app.Get("/v1/iam/*", func(c Ctx) error { return c.SendString("wildcard") })
app.Get("/v1/iam/keys", func(c Ctx) error { return c.SendString("static") })
require.Equal(t, "static", hitBody(t, app, MethodGet, "/v1/iam/keys"))
// The wildcard still serves everything else under the prefix.
require.Equal(t, "wildcard", hitBody(t, app, MethodGet, "/v1/iam/tokens"))
}
// Test 2: a static sibling beats a named parameter at the same depth,
// regardless of registration order.
func Test_Precedence_StaticBeatsParam(t *testing.T) {
t.Parallel()
// param registered first
app := New()
app.Get("/users/:id", func(c Ctx) error { return c.SendString("param") })
app.Get("/users/new", func(c Ctx) error { return c.SendString("static") })
require.Equal(t, "static", hitBody(t, app, MethodGet, "/users/new"))
require.Equal(t, "param", hitBody(t, app, MethodGet, "/users/42"))
// static registered first — same outcome, order independent
app2 := New()
app2.Get("/users/new", func(c Ctx) error { return c.SendString("static") })
app2.Get("/users/:id", func(c Ctx) error { return c.SendString("param") })
require.Equal(t, "static", hitBody(t, app2, MethodGet, "/users/new"))
require.Equal(t, "param", hitBody(t, app2, MethodGet, "/users/42"))
}
// Test 3: a deeper static route beats a shallower wildcard.
func Test_Precedence_DeepStaticBeatsShallowWildcard(t *testing.T) {
t.Parallel()
app := New()
app.Get("/api/*", func(c Ctx) error { return c.SendString("wildcard") })
app.Get("/api/v1/users", func(c Ctx) error { return c.SendString("deep-static") })
require.Equal(t, "deep-static", hitBody(t, app, MethodGet, "/api/v1/users"))
require.Equal(t, "wildcard", hitBody(t, app, MethodGet, "/api/v2/orders"))
}
// Test 4: an ambiguous conflict — two DISTINCT patterns of equal specificity
// that overlap (neither is more specific) — panics loudly at registration,
// naming both patterns, instead of one silently shadowing the other. This is
// the Go 1.22 net/http.ServeMux "neither more specific" conflict rule.
func Test_Precedence_AmbiguousConflictPanics(t *testing.T) {
t.Parallel()
app := New()
app.Get("/users/:id", func(c Ctx) error { return c.SendString("id") })
require.PanicsWithValue(t,
"fiber: route conflict: GET /users/:name conflicts with GET /users/:id (equal specificity, ambiguous match)",
func() {
app.Get("/users/:name", func(c Ctx) error { return c.SendString("name") })
},
)
}
// The ambiguous-conflict check is by specificity, so a param sitting where a
// wildcard sits at the same depth is NOT a conflict (the param is more
// specific) and must not panic.
func Test_Precedence_NonConflictingOverlapNoPanic(t *testing.T) {
t.Parallel()
app := New()
require.NotPanics(t, func() {
app.Get("/a/:id", func(c Ctx) error { return c.SendString("param") })
app.Get("/a/*", func(c Ctx) error { return c.SendString("wildcard") })
})
require.Equal(t, "param", hitBody(t, app, MethodGet, "/a/42"))
}
// Re-registering the SAME method + pattern keeps fiber's documented handler
// merge (both handler sets run in order); it is not a conflict because it never
// shadows. This is the deliberate divergence from a strict "identical pattern
// panics" rule — breaking merge would fail upstream tests and regress the common
// app.All-after-a-specific-method pattern.
func Test_Precedence_IdenticalPatternMerges(t *testing.T) {
t.Parallel()
app := New()
var ran string
app.Get("/v1/x", func(c Ctx) error { ran += "1"; return c.Next() })
app.Get("/v1/x", func(c Ctx) error { ran += "2"; return c.SendString(ran) })
require.Len(t, app.stack[app.methodInt(MethodGet)], 1)
require.Equal(t, "12", hitBody(t, app, MethodGet, "/v1/x"))
}
// Test 5: middleware keeps declaration order — it is not sorted by specificity.
func Test_Precedence_MiddlewareOrderPreserved(t *testing.T) {
t.Parallel()
app := New()
var order string
app.Use(func(c Ctx) error { order += "A"; return c.Next() })
app.Use(func(c Ctx) error { order += "B"; return c.Next() })
app.Use(func(c Ctx) error { order += "C"; return c.Next() })
app.Get("/x", func(c Ctx) error { order += "H"; return c.SendString(order) })
require.Equal(t, "ABCH", hitBody(t, app, MethodGet, "/x"))
}
// Middleware is a precedence barrier: endpoints are sorted by specificity only
// within the run between barriers, which keeps middleware-before-handler
// semantics exact. Fiber stores middleware and endpoints in one ordered stack,
// so an endpoint cannot both beat an earlier route on precedence AND stay
// wrapped by a middleware that sits between them — the two orderings share a
// single dimension. This fork preserves the middleware ordering (the dangerous
// one to break) and sorts endpoints within each middleware context.
func Test_Precedence_MiddlewareBarrierWithSortedEndpoints(t *testing.T) {
t.Parallel()
// Common case: middleware first, then both endpoints in one context. The
// specific route wins precedence AND the middleware wraps it.
app := New()
var pre string
app.Use(func(c Ctx) error { pre += "M"; return c.Next() })
app.Get("/v1/iam/*", func(c Ctx) error { return c.SendString("wildcard") })
app.Get("/v1/iam/keys", func(c Ctx) error { return c.SendString("static:" + pre) })
require.Equal(t, "static:M", hitBody(t, app, MethodGet, "/v1/iam/keys"))
// Barrier case: a middleware declared BETWEEN two endpoints splits them into
// separate contexts, so their relative order is registration order (not
// specificity). The earlier wildcard therefore wins here — the deliberate,
// documented cost of never reordering endpoints across a middleware.
app2 := New()
app2.Get("/v1/iam/*", func(c Ctx) error { return c.SendString("wildcard") })
app2.Use(func(c Ctx) error { return c.Next() })
app2.Get("/v1/iam/keys", func(c Ctx) error { return c.SendString("static") })
require.Equal(t, "wildcard", hitBody(t, app2, MethodGet, "/v1/iam/keys"))
}
// Test 6: a route added at runtime (after startup, followed by RebuildTree)
// lands in the correct precedence position, not merely at the end.
func Test_Precedence_RuntimeAddedRouteSorted(t *testing.T) {
t.Parallel()
app := New()
app.Get("/v1/iam/*", func(c Ctx) error { return c.SendString("wildcard") })
// Before the static route exists, the wildcard serves the path.
require.Equal(t, "wildcard", hitBody(t, app, MethodGet, "/v1/iam/keys"))
// Add the more specific route at runtime and rebuild the tree.
app.Get("/v1/iam/keys", func(c Ctx) error { return c.SendString("static") })
app.RebuildTree()
require.Equal(t, "static", hitBody(t, app, MethodGet, "/v1/iam/keys"))
require.Equal(t, "wildcard", hitBody(t, app, MethodGet, "/v1/iam/tokens"))
}
// Constraint-disambiguated params at the same position (e.g. :id<int> vs a
// plain :slug) match disjoint value sets, so they are NOT an ambiguous conflict
// and must not panic. They keep registration order, matching upstream fiber's
// constraint routing.
func Test_Precedence_ConstrainedParamNoPanic(t *testing.T) {
t.Parallel()
app := New()
require.NotPanics(t, func() {
app.Get("/order/:id<int>", func(c Ctx) error { return c.SendString("int") })
app.Get("/order/:slug", func(c Ctx) error { return c.SendString("slug") })
})
require.Equal(t, "int", hitBody(t, app, MethodGet, "/order/42"))
require.Equal(t, "slug", hitBody(t, app, MethodGet, "/order/abc"))
}
// A route whose first segment is dynamic (`:x`) has no static 3-char prefix, so
// App.buildTree copies it into EVERY prefix bucket. This verifies that sorted
// insertion still orders it correctly against static-prefixed routes inside the
// bucket the matcher actually walks — i.e. the tree fast-path honors specificity
// because each bucket is a sub-sequence of the sorted stack.
func Test_Precedence_DynamicFirstSegmentBucketing(t *testing.T) {
t.Parallel()
app := New()
// Registered general-first; the dynamic-first-segment route lands in all buckets.
app.Get("/:seg/foo", func(c Ctx) error { return c.SendString("param-first") })
app.Get("/v1/foo", func(c Ctx) error { return c.SendString("static-first") })
// Force a tree rebuild to exercise the bucketed fast-path explicitly.
app.RebuildTree()
require.Equal(t, "static-first", hitBody(t, app, MethodGet, "/v1/foo"))
require.Equal(t, "param-first", hitBody(t, app, MethodGet, "/other/foo"))
}
// compareRoutes unit coverage for the ordering rules.
func Test_Precedence_CompareRoutes(t *testing.T) {
t.Parallel()
mk := func(p string) *Route { return &Route{Path: p, routeParser: parseRoute(p)} }
cases := []struct {
name string
a, b string
want int // -1 a first, +1 b first, 0 tie
}{
{"static-before-wildcard", "/v1/iam/keys", "/v1/iam/*", -1},
{"static-before-param", "/users/new", "/users/:id", -1},
{"deep-static-before-shallow-wildcard", "/api/v1/users", "/api/*", -1},
{"param-before-wildcard", "/a/:id", "/a/*", -1},
{"wildcard-after-static", "/v1/iam/*", "/v1/iam/keys", +1},
{"structural-tie", "/users/:id", "/users/:name", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := compareRoutes(mk(tc.a), mk(tc.b))
switch tc.want {
case -1:
require.Negative(t, got, "%s vs %s", tc.a, tc.b)
case +1:
require.Positive(t, got, "%s vs %s", tc.a, tc.b)
default:
require.Zero(t, got, "%s vs %s", tc.a, tc.b)
}
})
}
}