mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
342 lines
13 KiB
Go
342 lines
13 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/test"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/modules/web/types"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func routeParamsToMap(rc *RouteContext) map[string]string {
|
|
names := rc.ParamNames()
|
|
m := make(map[string]string, len(names))
|
|
for _, key := range names {
|
|
if key == "*" && rc.Param(key) == "" {
|
|
continue // a Mount contributes an empty "*"
|
|
}
|
|
m[key] = rc.Param(key)
|
|
}
|
|
return util.Iif(len(m) == 0, nil, m)
|
|
}
|
|
|
|
type testResult struct {
|
|
method string
|
|
pathParams map[string]string
|
|
handlerMarks []string
|
|
routePattern *string
|
|
}
|
|
|
|
type testRecorder struct {
|
|
res testResult
|
|
}
|
|
|
|
func (r *testRecorder) reset() {
|
|
r.res = testResult{}
|
|
}
|
|
|
|
func (r *testRecorder) handle(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
|
|
mark := util.OptionalArg(optMark, "")
|
|
return func(resp http.ResponseWriter, req *http.Request) {
|
|
rc := GetRouteContext(req.Context())
|
|
r.res.method = req.Method
|
|
r.res.pathParams = routeParamsToMap(rc)
|
|
r.res.routePattern = new(rc.RoutePattern())
|
|
if mark != "" {
|
|
r.res.handlerMarks = append(r.res.handlerMarks, mark)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *testRecorder) provider(optMark ...string) func(next http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
|
r.handle(optMark...)(resp, req)
|
|
next.ServeHTTP(resp, req)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (r *testRecorder) stop(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
|
|
mark := util.OptionalArg(optMark, "")
|
|
return func(resp http.ResponseWriter, req *http.Request) {
|
|
if stop := req.FormValue("stop"); stop != "" && (mark == "" || mark == stop) {
|
|
r.handle(stop)(resp, req)
|
|
resp.WriteHeader(http.StatusOK)
|
|
} else if mark != "" {
|
|
r.res.handlerMarks = append(r.res.handlerMarks, mark)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *testRecorder) test(t *testing.T, rt *Router, methodPath string, expected testResult) {
|
|
r.reset()
|
|
methodPathFields := strings.Fields(methodPath)
|
|
req, err := http.NewRequest(methodPathFields[0], methodPathFields[1], nil)
|
|
assert.NoError(t, err)
|
|
|
|
buff := &bytes.Buffer{}
|
|
httpRecorder := httptest.NewRecorder()
|
|
httpRecorder.Body = buff
|
|
rt.ServeHTTP(httpRecorder, req)
|
|
if expected.routePattern == nil {
|
|
r.res.routePattern = nil
|
|
}
|
|
assert.Equal(t, expected, r.res)
|
|
}
|
|
|
|
func TestPathProcessor(t *testing.T) {
|
|
testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
|
|
rc := NewRouteContext()
|
|
rc.RouteMethod = "GET"
|
|
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
|
|
shouldProcess := expectedPathParams != nil
|
|
assert.Equal(t, shouldProcess, p.matchPath(rc, uri), "use pattern %s to process uri %s", pattern, uri)
|
|
assert.Equal(t, expectedPathParams, routeParamsToMap(rc), "use pattern %s to process uri %s", pattern, uri)
|
|
}
|
|
|
|
// the "<...>" is intentionally designed to distinguish from chi's path parameters, because:
|
|
// 1. their behaviors are totally different, we do not want to mislead developers
|
|
// 2. we can write regexp in "<name:\w{3,4}>" easily and parse it easily
|
|
testProcess("/<p1>/<p2>", "/a/b", map[string]string{"p1": "a", "p2": "b"})
|
|
testProcess("/<p1:*>", "", map[string]string{"p1": ""}) // this is a special case, because chi router could use empty path
|
|
testProcess("/<p1:*>", "/", map[string]string{"p1": ""})
|
|
testProcess("/<p1:*>/<p2>", "/a", map[string]string{"p1": "", "p2": "a"})
|
|
testProcess("/<p1:*>/<p2>", "/a/b", map[string]string{"p1": "a", "p2": "b"})
|
|
testProcess("/<p1:*>/<p2>", "/a/b/c", map[string]string{"p1": "a/b", "p2": "c"})
|
|
testProcess("/<p1:*>/part/<p2>", "/a/part/c", map[string]string{"p1": "a", "p2": "c"})
|
|
testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"})
|
|
testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil)
|
|
testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"})
|
|
}
|
|
|
|
func TestRouter(t *testing.T) {
|
|
type resultStruct = testResult
|
|
resRecorder := &testRecorder{}
|
|
h := resRecorder.handle
|
|
stopMark := resRecorder.stop
|
|
|
|
r := NewRouter()
|
|
r.NotFound(h("not-found:/"))
|
|
r.Get("/{username}/{reponame}/{type:issues|pulls}", h("list-issues-a")) // this one will never be called
|
|
r.Group("/{username}/{reponame}", func() {
|
|
r.Get("/{type:issues|pulls}", h("list-issues-b"))
|
|
r.Group("", func() {
|
|
r.Get("/{type:issues|pulls}/{index}", h("view-issue"))
|
|
}, stopMark())
|
|
r.Group("/issues/{index}", func() {
|
|
r.Post("/update", h("update-issue"))
|
|
})
|
|
})
|
|
|
|
m := NewRouter()
|
|
m.NotFound(h("not-found:/v1"))
|
|
r.Mount("/v1", m)
|
|
m.Group("/repos", func() {
|
|
m.Group("/{username}/{reponame}", func() {
|
|
m.Group("/branches", func() {
|
|
m.Get("", h())
|
|
m.Post("", h())
|
|
m.Group("/{name}", func() {
|
|
m.Get("", h())
|
|
m.Patch("", h())
|
|
m.Delete("", h())
|
|
})
|
|
m.PathGroup("/*", func(g *RouterPathGroup) {
|
|
g.MatchPattern("GET", g.PatternRegexp(`/<dir:*>/<file:[a-z]{1,2}>`, stopMark("s2")), stopMark("s3"), h("match-path"))
|
|
}, stopMark("s1"))
|
|
})
|
|
})
|
|
})
|
|
|
|
testRoute := func(t *testing.T, methodPath string, expected resultStruct) {
|
|
t.Run(methodPath, func(t *testing.T) {
|
|
resRecorder.test(t, r, methodPath, expected)
|
|
})
|
|
}
|
|
|
|
t.Run("RootRouter", func(t *testing.T) {
|
|
testRoute(t, "GET /the-user/the-repo/other", resultStruct{
|
|
method: "GET",
|
|
handlerMarks: []string{"not-found:/"},
|
|
routePattern: new(""),
|
|
})
|
|
testRoute(t, "GET /the-user/the-repo/pulls", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"},
|
|
handlerMarks: []string{"list-issues-b"},
|
|
})
|
|
testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
|
|
handlerMarks: []string{"view-issue"},
|
|
routePattern: new("/{username}/{reponame}/{type:issues|pulls}/{index}"),
|
|
})
|
|
testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
|
|
handlerMarks: []string{"hijack"},
|
|
})
|
|
testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{
|
|
method: "POST",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"},
|
|
handlerMarks: []string{"update-issue"},
|
|
})
|
|
})
|
|
|
|
t.Run("Sub Router", func(t *testing.T) {
|
|
testRoute(t, "GET /v1/other", resultStruct{
|
|
method: "GET",
|
|
handlerMarks: []string{"not-found:/v1"},
|
|
})
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
|
|
})
|
|
|
|
testRoute(t, "POST /v1/repos/the-user/the-repo/branches", resultStruct{
|
|
method: "POST",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
|
|
})
|
|
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/master", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
|
|
})
|
|
|
|
testRoute(t, "PATCH /v1/repos/the-user/the-repo/branches/master", resultStruct{
|
|
method: "PATCH",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
|
|
})
|
|
|
|
testRoute(t, "DELETE /v1/repos/the-user/the-repo/branches/master", resultStruct{
|
|
method: "DELETE",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
|
|
})
|
|
})
|
|
|
|
t.Run("MatchPath", func(t *testing.T) {
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
|
|
handlerMarks: []string{"s1", "s2", "s3", "match-path"},
|
|
})
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/d1%2fd2/fn", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"},
|
|
handlerMarks: []string{"s1", "s2", "s3", "match-path"},
|
|
})
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
|
|
handlerMarks: []string{"s1", "not-found:/v1"},
|
|
})
|
|
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
|
|
handlerMarks: []string{"s1"},
|
|
})
|
|
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
|
|
handlerMarks: []string{"s1", "s2"},
|
|
})
|
|
|
|
testRoute(t, "GET /v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s3", resultStruct{
|
|
method: "GET",
|
|
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
|
|
handlerMarks: []string{"s1", "s2", "s3"},
|
|
routePattern: new("/v1/repos/{username}/{reponame}/branches/<dir:*>/<file:[a-z]{1,2}>"),
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestRouteNormalizePath(t *testing.T) {
|
|
type paths struct {
|
|
EscapedPath, RawPath, Path string
|
|
}
|
|
testPath := func(reqPath string, expectedPaths paths) {
|
|
recorder := httptest.NewRecorder()
|
|
recorder.Body = bytes.NewBuffer(nil)
|
|
|
|
actualPaths := paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}
|
|
r := NewRouter()
|
|
r.Get("/*", func(resp http.ResponseWriter, req *http.Request) {
|
|
actualPaths.EscapedPath = req.URL.EscapedPath()
|
|
actualPaths.RawPath = req.URL.RawPath
|
|
actualPaths.Path = req.URL.Path
|
|
})
|
|
|
|
req, err := http.NewRequest(http.MethodGet, reqPath, nil)
|
|
assert.NoError(t, err)
|
|
r.ServeHTTP(recorder, req)
|
|
assert.Equal(t, expectedPaths, actualPaths, "req path = %q", reqPath)
|
|
}
|
|
|
|
// RawPath could be empty if the EscapedPath is the same as escape(Path) and it is already normalized
|
|
testPath("/", paths{EscapedPath: "/", RawPath: "", Path: "/"})
|
|
testPath("//", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
|
|
testPath("/%2f", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"})
|
|
testPath("///a//b/", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"})
|
|
|
|
defer test.MockVariableValue(&setting.UseSubURLPath, true)()
|
|
defer test.MockVariableValue(&setting.AppSubURL, "/sub-path")()
|
|
testPath("/", paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}) // 404
|
|
testPath("/sub-path", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
|
|
testPath("/sub-path/", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
|
|
testPath("/sub-path//a/b///", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"})
|
|
testPath("/sub-path/%2f/", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"})
|
|
// "/v2" is special for OCI container registry, it should always be in the root of the site
|
|
testPath("/v2", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"})
|
|
testPath("/v2/", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"})
|
|
testPath("/v2/%2f", paths{EscapedPath: "/v2/%2f", RawPath: "/v2/%2f", Path: "/v2//"})
|
|
}
|
|
|
|
func TestPreMiddlewareProvider(t *testing.T) {
|
|
resRecorder := &testRecorder{}
|
|
h := resRecorder.handle
|
|
p := resRecorder.provider
|
|
|
|
root := NewRouter()
|
|
root.BeforeRouting(h("before-root"))
|
|
root.AfterRouting(h("root"))
|
|
root.Get("/a/1", h("mid"), types.PreMiddlewareProvider(p("pre-root")), h("end1"))
|
|
|
|
sub := NewRouter()
|
|
sub.BeforeRouting(h("before-sub"))
|
|
sub.AfterRouting(h("sub"))
|
|
sub.Get("/2", h("mid"), types.PreMiddlewareProvider(p("pre-sub")), h("end2"))
|
|
sub.NotFound(h("not-found"))
|
|
|
|
root.Mount("/a", sub)
|
|
|
|
resRecorder.test(t, root, "GET /a/1", testResult{
|
|
method: "GET",
|
|
handlerMarks: []string{"before-root", "pre-root", "root", "mid", "end1"},
|
|
})
|
|
resRecorder.test(t, root, "GET /a/2", testResult{
|
|
method: "GET",
|
|
handlerMarks: []string{"before-root", "root", "before-sub", "pre-sub", "sub", "mid", "end2"},
|
|
})
|
|
resRecorder.test(t, root, "GET /no-such", testResult{
|
|
method: "GET",
|
|
handlerMarks: []string{"before-root"},
|
|
})
|
|
resRecorder.test(t, root, "GET /a/no-such", testResult{
|
|
method: "GET",
|
|
handlerMarks: []string{"before-root", "root", "before-sub", "sub", "not-found"},
|
|
})
|
|
}
|