Files
summercms/modules/surf/overlap_edges_test.go
Jakub Zych 22a5ebdeda test(13-06): pin that a family falls through a member whose constraint fails
- TestOverlapConstraintFallsThrough: an earlier member that matches the
  literals but not its Where constraint must let a later member answer;
  the routes.php pairs cannot show this, since each member's own handler
  re-checks its constraints and the pairs differ in their literals (T-13-23)
2026-10-03 11:27:44 +02:00

173 lines
7.3 KiB
Go

package surf
import (
"net/http"
"strings"
"testing"
)
// rackTable is a three-member family joined transitively: token/{token}
// conflicts with {rackId}/items, which conflicts with items/{id}, while
// token/{token} and items/{id} differ in a literal and never conflict.
var rackTable = []overlapRoute{
{name: "rack-token", method: "GET", path: "/racks/token/{token}"},
{name: "rack-items", method: "GET", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}},
{name: "rack-items-delete", method: "DELETE", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}},
{name: "item-show", method: "GET", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}},
{name: "item-put", method: "PUT", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}},
{name: "rack-create", method: "POST", path: "/racks/{kind}/new"},
}
func TestOverlapFamilyOfThree(t *testing.T) {
// The conflict closure holds the three GET routes; the other methods
// join the family at compile time through its generated pattern.
r := buildOverlapRouter(t, rackTable)
fams, err := overlapFamilies(r.routes)
if err != nil {
t.Fatal(err)
}
if len(fams) != 1 || fams[0].pattern != "/racks/{surfOverlap1}/{surfOverlap2}" {
t.Fatalf("families = %+v, want one family on one pattern", fams)
}
var members []string
for _, idx := range fams[0].members {
members = append(members, r.routes[idx].method+" "+r.routes[idx].path)
}
if strings.Join(members, ",") != "GET /racks/token/{token},GET /racks/{rackId}/items,GET /racks/items/{id}" {
t.Fatalf("family members = %v", members)
}
h := compileOverlap(t, rackTable)
for _, c := range []struct{ method, path, want string }{
{"GET", "/racks/token/abc", "rack-token token=abc"},
{"GET", "/racks/token/7", "rack-token token=7"},
{"GET", "/racks/7/items", "rack-items rackId=7"},
{"DELETE", "/racks/7/items", "rack-items-delete rackId=7"},
{"GET", "/racks/items/9", "item-show id=9"},
{"PUT", "/racks/items/9", "item-put id=9"},
{"POST", "/racks/steel/new", "rack-create kind=steel"},
} {
rec := serve(h, c.method, c.path)
if rec.Code != http.StatusOK || rec.Body.String() != c.want {
t.Errorf("%s %s = %d %q, want 200 %q", c.method, c.path, rec.Code, rec.Body.String(), c.want)
}
}
for _, path := range []string{"/racks/items/items", "/racks/x7/items", "/racks/items/9x"} {
if rec := serve(h, "GET", path); rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" {
t.Errorf("GET %s = %d %q, want the bare 404", path, rec.Code, rec.Body.String())
}
}
}
func TestOverlapHeadAndAllow(t *testing.T) {
h := compileOverlap(t, rackTable)
// A GET member answers HEAD, with its own handler.
for _, path := range []string{"/racks/token/abc", "/racks/7/items", "/racks/items/9"} {
if rec := serve(h, "HEAD", path); rec.Code != http.StatusOK {
t.Errorf("HEAD %s = %d, want 200", path, rec.Code)
}
}
// Allow lists the methods of every route that matches the path, sorted,
// with HEAD beside GET, as ServeMux does for unrelated routes.
for _, c := range []struct{ method, path, allow string }{
{"POST", "/racks/items/9", "GET, HEAD, PUT"},
{"PATCH", "/racks/7/items", "DELETE, GET, HEAD"},
{"PUT", "/racks/token/abc", "GET, HEAD"},
{"GET", "/racks/steel/new", "POST"},
} {
rec := serve(h, c.method, c.path)
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != c.allow {
t.Errorf("%s %s = %d Allow %q, want 405 %q", c.method, c.path, rec.Code, rec.Header().Get("Allow"), c.allow)
}
}
// A method a member serves, whose constraint fails, is the bare 404,
// never a 405 naming the member.
if rec := serve(h, "DELETE", "/racks/abc/items"); rec.Code != http.StatusNotFound || rec.Header().Get("Allow") != "" {
t.Errorf("DELETE /racks/abc/items = %d Allow %q, want the bare 404", rec.Code, rec.Header().Get("Allow"))
}
// A path no member and no route matches stays the router's 404.
if rec := serve(h, "GET", "/racks/a/b/c"); rec.Code != http.StatusNotFound {
t.Errorf("GET /racks/a/b/c = %d, want 404", rec.Code)
}
}
func TestOverlapFamilyAcrossPlugins(t *testing.T) {
r := New(nil)
register := func(plugin string, rt overlapRoute) {
r.BindPlugin(plugin)
mw := "mw." + rt.name
if err := r.RegisterMiddleware(plugin, mw, markMiddleware(rt.name)); err != nil {
t.Fatal(err)
}
r.Get(rt.path, overlapHandler(rt), mw)
for p, re := range rt.where {
r.Where(p, re)
}
}
register("acme.alpha", overlapRoute{name: "alpha-token", path: "/bins/token/{token}"})
register("acme.beta", overlapRoute{name: "beta-items", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}})
h, err := r.compile()
if err != nil {
t.Fatalf("a family across plugins must compile: %v", err)
}
for _, c := range []struct{ path, body, mw string }{
{"/bins/token/q", "alpha-token token=q", "alpha-token"},
{"/bins/3/items", "beta-items binId=3", "beta-items"},
} {
rec := serve(h, "GET", c.path)
if rec.Code != http.StatusOK || rec.Body.String() != c.body || strings.Join(rec.Header().Values("X-Mw"), ",") != c.mw {
t.Errorf("GET %s = %d %q mw %v, want %q with only %s's middleware", c.path, rec.Code, rec.Body.String(), rec.Header().Values("X-Mw"), c.body, c.mw)
}
}
plugins := map[string]string{}
for _, info := range r.Routes() {
plugins[info.Pattern] = info.PluginID
}
if plugins["/bins/token/{token}"] != "acme.alpha" || plugins["/bins/{binId}/items"] != "acme.beta" {
t.Fatalf("route table plugins = %v", plugins)
}
}
func TestOverlapUnsupportedShapes(t *testing.T) {
for name, table := range map[string][]overlapRoute{
"trailing-slash": {
{name: "dir", method: "GET", path: "/bins/token/{token}/"},
{name: "items", method: "GET", path: "/bins/{binId}/items/", where: map[string]string{"binId": "[0-9]+"}},
},
"multi-segment": {
{name: "rest", method: "GET", path: "/bins/token/{rest...}"},
{name: "items", method: "GET", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}},
},
} {
t.Run(name, func(t *testing.T) {
_, err := buildOverlapRouter(t, table).compile()
if err == nil || !strings.Contains(err.Error(), "route conflict") || !strings.Contains(err.Error(), `plugin "acme.shelves"`) {
t.Fatalf("err = %v, want a route conflict naming both routes and their plugin", err)
}
})
}
}
// TestOverlapConstraintFallsThrough: when an earlier member matches the
// literals but not its constraint, the family tries the next member
// instead of answering for the first one, as Laravel's router walks its
// routes in order.
func TestOverlapConstraintFallsThrough(t *testing.T) {
h := compileOverlap(t, []overlapRoute{
{name: "by-number", method: "GET", path: "/drawers/{id}/open", where: map[string]string{"id": "[0-9]+"}},
{name: "by-name", method: "GET", path: "/drawers/top/{action}"},
})
for _, c := range []struct{ path, want string }{
{"/drawers/top/open", "by-name action=open"},
{"/drawers/7/open", "by-number id=7"},
{"/drawers/top/close", "by-name action=close"},
} {
rec := serve(h, "GET", c.path)
if rec.Code != http.StatusOK || rec.Body.String() != c.want {
t.Errorf("GET %s = %d %q, want 200 %q", c.path, rec.Code, rec.Body.String(), c.want)
}
}
if rec := serve(h, "GET", "/drawers/x/open"); rec.Code != http.StatusNotFound {
t.Errorf("GET /drawers/x/open = %d, want 404", rec.Code)
}
}