mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
addRoute split the PATTERN with splitPath, which cuts on every "/" and knows
nothing about brace groups. routers/web/githttp.go registers
m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", repo.GetTextFile(""))
so the constraint was cut in half into "{file:[^" and "]*}". compileSegment
finds no parameter in that fragment and panics by design — but this is
registration, not request handling, so the panic happens before the listener
opens. The process dies at startup and the whole forge is down; v1.26.28 could
never serve a byte, which read as a data problem because a corrupt queue store
was failing slightly earlier in the same boot and masking it.
splitPattern splits only at depth 0, tracking brace depth rather than a boolean
so a nested constraint like {n:[0-9]{2}} still works. splitPath is left exactly
as it was and still backs request routing: in a request path "{" is an ordinary
character and "/" always separates, so making it brace-aware would be a
different, real bug.
Tests cover the live route end to end (registers, matches /objects/info/packs,
and still refuses to span a separator), the nested-brace case, and an explicit
assertion that splitPath did NOT inherit the new behaviour.
Co-authored-by: Hanzo Dev <[email protected]>
156 lines
6.5 KiB
Go
156 lines
6.5 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// The cases that decide whether a forge routes correctly. Each one is a way the
|
|
// tree can be wrong that a passing build would not reveal.
|
|
func TestMuxMatching(t *testing.T) {
|
|
type route struct{ method, pattern string }
|
|
routes := []route{
|
|
{"GET", "/user/settings"}, // static must beat {username}
|
|
{"GET", "/{username}"}, //
|
|
{"GET", "/{username}/{reponame}"}, // two params, then...
|
|
{"GET", "/{username}/{reponame}/issues/{id}"},
|
|
{"GET", "/repo/{sha:[a-f0-9]{7,40}}"}, // regexp-constrained
|
|
{"GET", "/repo/latest"}, // static beside it
|
|
{"GET", "/cran/src/PACKAGES{format}"}, // parameter mid-segment
|
|
{"GET", "/files/*"}, // trailing wildcard
|
|
{"POST", "/{username}/{reponame}"}, // same path, other method
|
|
}
|
|
m := NewMux()
|
|
for _, r := range routes {
|
|
pattern := r.pattern
|
|
m.Method(r.method, pattern, http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) {
|
|
resp.Header().Set("X-Pattern", pattern)
|
|
}))
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name, method, path, wantPattern string
|
|
wantParams map[string]string
|
|
}{
|
|
{"static beats param", "GET", "/user/settings", "/user/settings", nil},
|
|
{"param when no static", "GET", "/someone", "/{username}", map[string]string{"username": "someone"}},
|
|
{"two params", "GET", "/o/r", "/{username}/{reponame}", map[string]string{"username": "o", "reponame": "r"}},
|
|
{"deep under params", "GET", "/o/r/issues/7", "/{username}/{reponame}/issues/{id}",
|
|
map[string]string{"username": "o", "reponame": "r", "id": "7"}},
|
|
{"regexp matches", "GET", "/repo/abcdef1234", "/repo/{sha:[a-f0-9]{7,40}}", map[string]string{"sha": "abcdef1234"}},
|
|
{"static beside regexp", "GET", "/repo/latest", "/repo/latest", nil},
|
|
{"mid-segment param", "GET", "/cran/src/PACKAGES.gz", "/cran/src/PACKAGES{format}", map[string]string{"format": ".gz"}},
|
|
{"wildcard captures rest", "GET", "/files/a/b/c.txt", "/files/*", map[string]string{"*": "a/b/c.txt"}},
|
|
{"method is routed", "POST", "/o/r", "/{username}/{reponame}", map[string]string{"username": "o", "reponame": "r"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
|
rc := NewRouteContext()
|
|
req = WithRouteContext(req, rc)
|
|
resp := httptest.NewRecorder()
|
|
m.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, tc.wantPattern, resp.Header().Get("X-Pattern"), "routed to the wrong handler")
|
|
for k, v := range tc.wantParams {
|
|
assert.Equal(t, v, rc.Param(k), "param %q", k)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("regexp declines and the next candidate wins", func(t *testing.T) {
|
|
// "zzz" is not hex so /repo/{sha:...} must decline — but declining does
|
|
// not mean 404: /{username}/{reponame} still matches "/repo/zzz", and it
|
|
// should, because "repo" is a legal username. The assertion is that the
|
|
// constrained route did NOT capture it.
|
|
req := httptest.NewRequest("GET", "/repo/zzz", nil)
|
|
rc := NewRouteContext()
|
|
req = WithRouteContext(req, rc)
|
|
resp := httptest.NewRecorder()
|
|
m.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, "/{username}/{reponame}", resp.Header().Get("X-Pattern"))
|
|
assert.Empty(t, rc.Param("sha"), "the constrained route captured a value it should have rejected")
|
|
assert.Equal(t, "repo", rc.Param("username"))
|
|
})
|
|
|
|
t.Run("unregistered method is not served by another", func(t *testing.T) {
|
|
req := httptest.NewRequest("DELETE", "/user/settings", nil)
|
|
req = WithRouteContext(req, NewRouteContext())
|
|
resp := httptest.NewRecorder()
|
|
m.ServeHTTP(resp, req)
|
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
|
})
|
|
}
|
|
|
|
// A failed branch must leave no captured parameters behind, or the branch tried
|
|
// next inherits values from a route that did not match.
|
|
func TestMuxBacktrackingLeavesNoParams(t *testing.T) {
|
|
m := NewMux()
|
|
m.Method("GET", "/{owner}/{repo}/tree", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
m.Method("GET", "/{single}", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
|
|
req := httptest.NewRequest("GET", "/onlyone", nil)
|
|
rc := NewRouteContext()
|
|
req = WithRouteContext(req, rc)
|
|
m.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
assert.Equal(t, "onlyone", rc.Param("single"))
|
|
assert.Empty(t, rc.Param("owner"), "owner was captured by a branch that failed")
|
|
assert.Equal(t, []string{"single"}, rc.ParamNames())
|
|
}
|
|
|
|
func TestMuxHeadFallsBackToGet(t *testing.T) {
|
|
m := NewMux()
|
|
m.Method("GET", "/thing", http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) {
|
|
resp.Header().Set("X-Hit", "get")
|
|
}))
|
|
req := httptest.NewRequest("HEAD", "/thing", nil)
|
|
req = WithRouteContext(req, NewRouteContext())
|
|
resp := httptest.NewRecorder()
|
|
m.ServeHTTP(resp, req)
|
|
assert.Equal(t, "get", resp.Header().Get("X-Hit"))
|
|
}
|
|
|
|
// A route constraint may contain "/", and splitting the pattern on every "/"
|
|
// cuts it in half. This is the real registration from routers/web/githttp.go;
|
|
// before splitPattern it made compileSegment panic at startup, so the forge
|
|
// never opened a listener and git.hanzo.ai served 503.
|
|
func TestMuxPatternConstraintContainingSlash(t *testing.T) {
|
|
m := NewMux()
|
|
m.Method("GET", "/objects/info/{file:[^/]*}", http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) {
|
|
resp.WriteHeader(http.StatusTeapot)
|
|
}))
|
|
|
|
// The constraint must survive as ONE segment, not "{file:[^" + "]*}".
|
|
assert.Equal(t, []string{"objects", "info", "{file:[^/]*}"}, splitPattern("/objects/info/{file:[^/]*}"))
|
|
|
|
resp := httptest.NewRecorder()
|
|
m.ServeHTTP(resp, httptest.NewRequest("GET", "/objects/info/packs", nil))
|
|
assert.Equal(t, http.StatusTeapot, resp.Code)
|
|
|
|
// [^/]* must still refuse to span a separator.
|
|
resp = httptest.NewRecorder()
|
|
m.ServeHTTP(resp, httptest.NewRequest("GET", "/objects/info/a/b", nil))
|
|
assert.NotEqual(t, http.StatusTeapot, resp.Code)
|
|
}
|
|
|
|
// A constraint may nest one level of braces, so the split tracks depth rather
|
|
// than a boolean "inside a brace" flag.
|
|
func TestSplitPatternNestedBraces(t *testing.T) {
|
|
assert.Equal(t, []string{"a", "{n:[0-9]{2}}", "b"}, splitPattern("/a/{n:[0-9]{2}}/b"))
|
|
assert.Equal(t, []string{"repo", "{name}"}, splitPattern("/repo/{name}"))
|
|
assert.Nil(t, splitPattern("/"))
|
|
}
|
|
|
|
// splitPath stays brace-blind: in a REQUEST path "{" is an ordinary character
|
|
// and "/" always separates, so it must NOT inherit splitPattern's behaviour.
|
|
func TestSplitPathIsNotPatternAware(t *testing.T) {
|
|
assert.Equal(t, []string{"objects", "info", "{file:[^", "]*}"}, splitPath("/objects/info/{file:[^/]*}"))
|
|
}
|