329 lines
10 KiB
Go
329 lines
10 KiB
Go
package v1
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/genproto/googleapis/api/annotations"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/reflect/protoreflect"
|
|
"google.golang.org/protobuf/reflect/protoregistry"
|
|
)
|
|
|
|
func TestCanonicalGatewayPattern(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
template string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "literal",
|
|
template: "/api/v1/users",
|
|
want: "/api/v1/users",
|
|
},
|
|
{
|
|
name: "bare variable",
|
|
template: "/api/v1/shares/{share_token}/memo",
|
|
want: "/api/v1/shares/{share_token=*}/memo",
|
|
},
|
|
{
|
|
name: "resource name variable",
|
|
template: "/api/v1/{name=users/*}",
|
|
want: "/api/v1/{name=users/*}",
|
|
},
|
|
{
|
|
name: "variable with verb",
|
|
template: "/api/v1/{name=users/*}:getStats",
|
|
want: "/api/v1/{name=users/*}:getStats",
|
|
},
|
|
{
|
|
name: "missing leading slash",
|
|
template: "api/v1/users",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unclosed variable",
|
|
template: "/api/v1/{name",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unexpected closing brace",
|
|
template: "/api/v1/name}",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := canonicalGatewayPattern(test.template)
|
|
if test.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGatewayRouteResolverResolvesKnownRoutes pins the procedures that carry
|
|
// access-control decisions. The request is dispatched through a real ServeMux
|
|
// so the resolver consumes the exact runtime.Pattern seen by middleware.
|
|
func TestGatewayRouteResolverResolvesKnownRoutes(t *testing.T) {
|
|
resolver, err := newGatewayRouteResolver()
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
httpMethod string
|
|
template string
|
|
path string
|
|
procedure string
|
|
}{
|
|
{http.MethodPost, "/api/v1/auth/signin", "/api/v1/auth/signin", "/memos.api.v1.AuthService/SignIn"},
|
|
{http.MethodGet, "/api/v1/instance/profile", "/api/v1/instance/profile", "/memos.api.v1.InstanceService/GetInstanceProfile"},
|
|
{http.MethodPost, "/api/v1/users", "/api/v1/users", "/memos.api.v1.UserService/CreateUser"},
|
|
{http.MethodGet, "/api/v1/users", "/api/v1/users", "/memos.api.v1.UserService/ListUsers"},
|
|
{http.MethodGet, "/api/v1/{name=users/*}", "/api/v1/users/1", "/memos.api.v1.UserService/GetUser"},
|
|
{http.MethodGet, "/api/v1/{parent=users/*}/views", "/api/v1/users/alice/views", "/memos.api.v1.UserService/ListMemoViews"},
|
|
{http.MethodPost, "/api/v1/{parent=users/*}/views", "/api/v1/users/alice/views", "/memos.api.v1.UserService/CreateMemoView"},
|
|
{http.MethodGet, "/api/v1/{name=users/*/views/*}", "/api/v1/users/alice/views/work", "/memos.api.v1.UserService/GetMemoView"},
|
|
{http.MethodPatch, "/api/v1/{memo_view.name=users/*/views/*}", "/api/v1/users/alice/views/work", "/memos.api.v1.UserService/UpdateMemoView"},
|
|
{http.MethodDelete, "/api/v1/{name=users/*/views/*}", "/api/v1/users/alice/views/work", "/memos.api.v1.UserService/DeleteMemoView"},
|
|
{http.MethodGet, "/api/v1/memos", "/api/v1/memos", "/memos.api.v1.MemoService/ListMemos"},
|
|
{http.MethodGet, "/api/v1/{name=memos/*}", "/api/v1/memos/abc", "/memos.api.v1.MemoService/GetMemo"},
|
|
{http.MethodPost, "/api/v1/memos", "/api/v1/memos", "/memos.api.v1.MemoService/CreateMemo"},
|
|
{http.MethodPost, "/api/v1/spaces", "/api/v1/spaces", "/memos.api.v1.SpaceService/CreateSpace"},
|
|
{http.MethodGet, "/api/v1/{name=spaces/*}", "/api/v1/spaces/team", "/memos.api.v1.SpaceService/GetSpace"},
|
|
{http.MethodDelete, "/api/v1/{name=spaces/*}", "/api/v1/spaces/team", "/memos.api.v1.SpaceService/DeleteSpace"},
|
|
{http.MethodGet, "/api/v1/{name=spaces/*/members/*}", "/api/v1/spaces/team/members/alice", "/memos.api.v1.SpaceService/GetSpaceMember"},
|
|
{
|
|
http.MethodGet,
|
|
"/api/v1/shares/{share_token}/memo",
|
|
"/api/v1/shares/token:with-colon/memo",
|
|
"/memos.api.v1.MemoService/GetSharedMemo",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.httpMethod+" "+test.path, func(t *testing.T) {
|
|
procedure, ok := resolveThroughGateway(t, resolver, test.httpMethod, test.template, test.httpMethod, test.path, "")
|
|
require.True(t, ok)
|
|
require.Equal(t, test.procedure, procedure)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGatewayRouteResolverResolvesFormPostFallback(t *testing.T) {
|
|
resolver, err := newGatewayRouteResolver()
|
|
require.NoError(t, err)
|
|
|
|
t.Run("falls back to public GET binding", func(t *testing.T) {
|
|
procedure, ok := resolveThroughGateway(
|
|
t,
|
|
resolver,
|
|
http.MethodGet,
|
|
"/api/v1/instance/profile",
|
|
http.MethodPost,
|
|
"/api/v1/instance/profile",
|
|
"application/x-www-form-urlencoded",
|
|
)
|
|
require.True(t, ok)
|
|
require.Equal(t, "/memos.api.v1.InstanceService/GetInstanceProfile", procedure)
|
|
})
|
|
|
|
t.Run("uses matching POST binding", func(t *testing.T) {
|
|
procedure, ok := resolveThroughGateway(
|
|
t,
|
|
resolver,
|
|
http.MethodPost,
|
|
"/api/v1/memos",
|
|
http.MethodPost,
|
|
"/api/v1/memos",
|
|
"application/x-www-form-urlencoded",
|
|
)
|
|
require.True(t, ok)
|
|
require.Equal(t, "/memos.api.v1.MemoService/CreateMemo", procedure)
|
|
})
|
|
}
|
|
|
|
func TestGatewayRouteResolverRequiresMatchedPattern(t *testing.T) {
|
|
resolver, err := newGatewayRouteResolver()
|
|
require.NoError(t, err)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/memos", nil)
|
|
_, ok := resolver.resolveRequest(request)
|
|
require.False(t, ok)
|
|
}
|
|
|
|
// TestGatewayRouteResolverCoversEveryBinding walks every google.api.http binding
|
|
// in the API package, dispatches a concrete request through grpc-gateway, and
|
|
// requires the matched pattern to map back to the same procedure.
|
|
func TestGatewayRouteResolverCoversEveryBinding(t *testing.T) {
|
|
resolver, err := newGatewayRouteResolver()
|
|
require.NoError(t, err)
|
|
|
|
checked := 0
|
|
protoregistry.GlobalFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool {
|
|
if string(fd.Package()) != apiPackage {
|
|
return true
|
|
}
|
|
services := fd.Services()
|
|
for i := range services.Len() {
|
|
service := services.Get(i)
|
|
methods := service.Methods()
|
|
for j := range methods.Len() {
|
|
method := methods.Get(j)
|
|
rule, ok := proto.GetExtension(method.Options(), annotations.E_Http).(*annotations.HttpRule)
|
|
if !ok || rule == nil {
|
|
continue
|
|
}
|
|
procedure := "/" + string(service.FullName()) + "/" + string(method.Name())
|
|
|
|
for _, binding := range append([]*annotations.HttpRule{rule}, rule.GetAdditionalBindings()...) {
|
|
httpMethod, template := httpRuleMethodAndTemplate(binding)
|
|
if httpMethod == "" || template == "" {
|
|
continue
|
|
}
|
|
|
|
path := synthesizeGatewayPath(template)
|
|
resolved, found := resolveThroughGateway(t, resolver, httpMethod, template, httpMethod, path, "")
|
|
require.True(t, found, "%s %s (from %q) should resolve", httpMethod, path, template)
|
|
require.Equal(t, procedure, resolved,
|
|
"%s %s (from template %q) resolved to the wrong procedure", httpMethod, path, template)
|
|
checked++
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
require.Positive(t, checked, "should have checked at least one binding")
|
|
t.Logf("verified %d HTTP bindings", checked)
|
|
}
|
|
|
|
func resolveThroughGateway(
|
|
t *testing.T,
|
|
resolver *gatewayRouteResolver,
|
|
bindingMethod string,
|
|
template string,
|
|
requestMethod string,
|
|
path string,
|
|
contentType string,
|
|
) (string, bool) {
|
|
t.Helper()
|
|
|
|
var (
|
|
called bool
|
|
procedure string
|
|
resolved bool
|
|
)
|
|
mux := runtime.NewServeMux()
|
|
require.NoError(t, mux.HandlePath(bindingMethod, template, func(_ http.ResponseWriter, request *http.Request, _ map[string]string) {
|
|
called = true
|
|
procedure, resolved = resolver.resolveRequest(request)
|
|
}))
|
|
|
|
request := httptest.NewRequest(requestMethod, path, nil)
|
|
if contentType != "" {
|
|
request.Header.Set("Content-Type", contentType)
|
|
}
|
|
mux.ServeHTTP(httptest.NewRecorder(), request)
|
|
require.True(t, called, "grpc-gateway should match %s %s against %q", requestMethod, path, template)
|
|
return procedure, resolved
|
|
}
|
|
|
|
// synthesizeGatewayPath turns a path template into a concrete path by replacing
|
|
// variables and wildcards with placeholder segments.
|
|
func synthesizeGatewayPath(template string) string {
|
|
path, verb := splitGatewayTemplateVerb(template)
|
|
|
|
segments := splitGatewayPathSegments(path)
|
|
built := make([]string, 0, len(segments))
|
|
for _, segment := range segments {
|
|
switch {
|
|
case segment == "*":
|
|
built = append(built, "one")
|
|
case segment == "**":
|
|
built = append(built, "one", "two")
|
|
case strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}"):
|
|
built = append(built, synthesizeGatewayVariable(segment)...)
|
|
default:
|
|
built = append(built, segment)
|
|
}
|
|
}
|
|
|
|
result := "/" + strings.Join(built, "/")
|
|
if verb != "" {
|
|
result += ":" + verb
|
|
}
|
|
return result
|
|
}
|
|
|
|
func splitGatewayPathSegments(path string) []string {
|
|
trimmed := strings.TrimPrefix(path, "/")
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
|
|
var segments []string
|
|
depth, start := 0, 0
|
|
for index := 0; index < len(trimmed); index++ {
|
|
switch trimmed[index] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
case '/':
|
|
if depth == 0 {
|
|
segments = append(segments, trimmed[start:index])
|
|
start = index + 1
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
return append(segments, trimmed[start:])
|
|
}
|
|
|
|
func splitGatewayTemplateVerb(template string) (string, string) {
|
|
depth := 0
|
|
for index := len(template) - 1; index >= 0; index-- {
|
|
switch template[index] {
|
|
case '}':
|
|
depth++
|
|
case '{':
|
|
depth--
|
|
case ':':
|
|
if depth == 0 {
|
|
return template[:index], template[index+1:]
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
return template, ""
|
|
}
|
|
|
|
func synthesizeGatewayVariable(segment string) []string {
|
|
inner := strings.TrimSuffix(strings.TrimPrefix(segment, "{"), "}")
|
|
_, subtemplate, hasSubtemplate := strings.Cut(inner, "=")
|
|
if !hasSubtemplate || subtemplate == "" || subtemplate == "*" {
|
|
return []string{"one"}
|
|
}
|
|
|
|
var built []string
|
|
for part := range strings.SplitSeq(subtemplate, "/") {
|
|
switch part {
|
|
case "*":
|
|
built = append(built, "one")
|
|
case "**":
|
|
built = append(built, "one", "two")
|
|
default:
|
|
built = append(built, part)
|
|
}
|
|
}
|
|
return built
|
|
}
|