signpost.Registry.Pattern now takes an item override only when it starts with a single "/" and holds no surrounding whitespace or control characters (signpost.ValidItemPattern); a javascript: URL or a protocol-relative //host falls through to the next layer.
363 lines
15 KiB
Go
363 lines
15 KiB
Go
package signpost_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
)
|
|
|
|
// TestRegistryDuplicateKey pins D-01: two plugins registering the same menu
|
|
// item type key fail Activate, the error names the key and both plugin IDs in
|
|
// activation order, and nothing is published on the app.
|
|
func TestRegistryDuplicateKey(t *testing.T) {
|
|
app := smokeApp(t, "")
|
|
err := signpost.Activate(app, []typesPlugin{
|
|
{id: "acme.blog", types: []pact.MenuItemType{
|
|
{Key: "acme-page", DefaultPattern: "/:slug", Resolve: echo},
|
|
{Key: "acme-post", DefaultPattern: "/blog/:slug", Resolve: echo},
|
|
}},
|
|
{id: "acme.news", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/news/:slug", Resolve: echo}}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Activate accepted a duplicate menu item type key")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.HasPrefix(msg, "signpost: ") || !strings.Contains(msg, `"acme-post"`) {
|
|
t.Fatalf("error = %q, want the signpost prefix and the key", msg)
|
|
}
|
|
first, second := strings.Index(msg, "acme.blog"), strings.Index(msg, "acme.news")
|
|
if first < 0 || second < 0 || first > second {
|
|
t.Fatalf("error = %q, want both plugin IDs, first registrant first", msg)
|
|
}
|
|
if _, ok := app.Lookup[*signpost.Registry](); ok {
|
|
t.Fatal("a registry was published after a failed Activate")
|
|
}
|
|
|
|
// The same key twice inside one plugin is a duplicate too.
|
|
err = signpost.Activate(smokeApp(t, ""), []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
|
|
{Key: "acme-post", DefaultPattern: "/a", Resolve: echo},
|
|
{Key: "acme-post", DefaultPattern: "/b", Resolve: echo},
|
|
}}})
|
|
if err == nil || !strings.Contains(err.Error(), "acme-post") {
|
|
t.Fatalf("duplicate inside one plugin: error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRegistryActivate pins every boot refusal of Activate and what a
|
|
// successful activation publishes.
|
|
func TestRegistryActivate(t *testing.T) {
|
|
ok := func(key, pattern string) pact.MenuItemType {
|
|
return pact.MenuItemType{Key: key, DefaultPattern: pattern, Resolve: echo}
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
host string
|
|
types []pact.MenuItemType
|
|
want []string
|
|
}{
|
|
{"empty key", "", []pact.MenuItemType{ok("", "/a")}, []string{"acme.blog", "empty key"}},
|
|
{"blank key", "", []pact.MenuItemType{ok(" ", "/a")}, []string{"empty key"}},
|
|
{"nil Resolve", "", []pact.MenuItemType{{Key: "acme-a", DefaultPattern: "/a"}}, []string{`"acme-a"`, "acme.blog", "no Resolve"}},
|
|
{"empty DefaultPattern", "", []pact.MenuItemType{ok("acme-a", "")}, []string{`"acme-a"`, "DefaultPattern"}},
|
|
{"relative DefaultPattern", "", []pact.MenuItemType{ok("acme-a", "blog/:slug")}, []string{`"acme-a"`, `"blog/:slug"`}},
|
|
{"PatternFrom names an unregistered type", "", []pact.MenuItemType{
|
|
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-missing", Resolve: echo},
|
|
}, []string{`"acme-posts"`, `"acme-missing"`, "no plugin registers"}},
|
|
{"PatternFrom names the type itself", "", []pact.MenuItemType{
|
|
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-posts", Resolve: echo},
|
|
}, []string{`"acme-posts"`, "names itself"}},
|
|
{"PatternFrom names a type with its own PatternFrom", "", []pact.MenuItemType{
|
|
ok("acme-post", "/a"),
|
|
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-post", Resolve: echo},
|
|
{Key: "acme-all", DefaultPattern: "/a", PatternFrom: "acme-posts", Resolve: echo},
|
|
}, []string{`"acme-all"`, `"acme-posts"`, "one level only"}},
|
|
{"host override of an unknown type", "patterns:\n acme-missing: /x\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns", `"acme-missing"`}},
|
|
{"relative host override", "patterns:\n acme-post: news/:slug\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns.acme-post", `"news/:slug"`}},
|
|
{"host patterns not a map", "patterns:\n - /x\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns", "map"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
app := smokeApp(t, tc.host)
|
|
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: tc.types}})
|
|
if err == nil {
|
|
t.Fatal("Activate accepted the types")
|
|
}
|
|
if !strings.HasPrefix(err.Error(), "signpost: ") {
|
|
t.Fatalf("error %q lacks the signpost prefix", err)
|
|
}
|
|
for _, part := range tc.want {
|
|
if !strings.Contains(err.Error(), part) {
|
|
t.Fatalf("error %q does not contain %q", err, part)
|
|
}
|
|
}
|
|
if _, published := app.Lookup[*signpost.Registry](); published {
|
|
t.Fatal("a registry was published after a refusal")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("nil app", func(t *testing.T) {
|
|
if err := signpost.Activate[typesPlugin](nil, nil); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
|
|
t.Fatalf("nil app error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("publishes types in plugin then declaration order", func(t *testing.T) {
|
|
app := smokeApp(t, "")
|
|
err := signpost.Activate(app, []typesPlugin{
|
|
{id: "acme.blog", types: []pact.MenuItemType{ok("acme-b", "/b"), ok("acme-a", "/a")}},
|
|
{id: "acme.none"},
|
|
{id: "acme.pages", types: []pact.MenuItemType{ok("acme-c", "/c")}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg, found := app.Lookup[*signpost.Registry]()
|
|
if !found {
|
|
t.Fatal("no registry published")
|
|
}
|
|
var keys []string
|
|
for _, typ := range reg.Types() {
|
|
keys = append(keys, typ.Key)
|
|
}
|
|
if strings.Join(keys, ",") != "acme-b,acme-a,acme-c" {
|
|
t.Fatalf("types = %v", keys)
|
|
}
|
|
types := reg.Types()
|
|
types[0].Key = "changed"
|
|
if again := reg.Types(); again[0].Key != "acme-b" {
|
|
t.Fatal("Types returned the registry's own slice")
|
|
}
|
|
if typ, ok := reg.Type("acme-c"); !ok || typ.DefaultPattern != "/c" {
|
|
t.Fatalf("Type(acme-c) = %+v, %v", typ, ok)
|
|
}
|
|
if _, ok := reg.Type("acme-missing"); ok {
|
|
t.Fatal("Type found an unregistered key")
|
|
}
|
|
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{ok("acme-x", "/x")}}}); err == nil {
|
|
t.Fatal("a second Activate on the same app was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("a plugin without menu item types publishes an empty registry", func(t *testing.T) {
|
|
app := smokeApp(t, "")
|
|
if err := signpost.Activate(app, []typesPlugin{{id: "acme.none"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg, found := app.Lookup[*signpost.Registry]()
|
|
if !found || len(reg.Types()) != 0 {
|
|
t.Fatalf("registry = %v, %v", reg, found)
|
|
}
|
|
})
|
|
|
|
t.Run("a nil registry answers empty", func(t *testing.T) {
|
|
var reg *signpost.Registry
|
|
if len(reg.Types()) != 0 || reg.Pattern("acme-post", "") != "" || reg.Pattern("acme-post", "/x") != "/x" {
|
|
t.Fatal("nil registry answered")
|
|
}
|
|
if _, ok := reg.Type("acme-post"); ok {
|
|
t.Fatal("nil registry found a type")
|
|
}
|
|
if _, err := reg.Resolve(context.Background(), "acme-post", "", "", false, ""); err == nil {
|
|
t.Fatal("nil registry resolved")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRegistryHostOverrideUnknown pins the host override checks: an override
|
|
// of an unregistered key fails the boot with the key named, while an absent
|
|
// menu_items file or an empty patterns map is no override at all.
|
|
func TestRegistryHostOverrideUnknown(t *testing.T) {
|
|
err := signpost.Activate(smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-typo: /x/:slug\n"), []typesPlugin{{id: "acme.blog", types: acmeTypes()}})
|
|
if err == nil || !strings.Contains(err.Error(), `"acme-typo"`) || !strings.Contains(err.Error(), "no plugin registers") {
|
|
t.Fatalf("unknown override error = %v", err)
|
|
}
|
|
for name, cfg := range map[string]string{"no file": "", "empty patterns": "patterns: {}\n", "other key only": "other: 1\n"} {
|
|
app := smokeApp(t, cfg)
|
|
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
if got := reg.Pattern("acme-post", ""); got != "/blog/:slug" {
|
|
t.Fatalf("%s: Pattern = %q, want the default", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRegistryPatternLayers pins D-02 over the record type acme-post and the
|
|
// list type acme-posts (PatternFrom acme-post): for the record type, the item
|
|
// override beats the host override, which beats the default; for the list
|
|
// type, the item override beats the list key's host override, which beats the
|
|
// record type's host override, which beats the list's DefaultPattern.
|
|
func TestRegistryPatternLayers(t *testing.T) {
|
|
types := func() []pact.MenuItemType {
|
|
return []pact.MenuItemType{
|
|
{Key: "acme-post", DefaultPattern: "/post-default/:slug", Resolve: echo},
|
|
{Key: "acme-posts", DefaultPattern: "/posts-default/:slug", PatternFrom: "acme-post", Resolve: echo},
|
|
}
|
|
}
|
|
const (
|
|
item = "/item/:slug"
|
|
listHost = "/list-host/:slug"
|
|
recHost = "/record-host/:slug"
|
|
)
|
|
for _, tc := range []struct {
|
|
item, listHost, recHost string
|
|
wantList, wantRecord string
|
|
}{
|
|
{"", "", "", "/posts-default/:slug", "/post-default/:slug"},
|
|
{"", "", recHost, recHost, recHost},
|
|
{"", listHost, "", listHost, "/post-default/:slug"},
|
|
{"", listHost, recHost, listHost, recHost},
|
|
{item, "", "", item, item},
|
|
{item, "", recHost, item, item},
|
|
{item, listHost, "", item, item},
|
|
{item, listHost, recHost, item, item},
|
|
{" ", "", recHost, recHost, recHost},
|
|
// An override that is not a path is ignored (CR-01).
|
|
{"javascript://%0aalert(1)%0a//x", "", recHost, recHost, recHost},
|
|
{"//evil.test/:slug", "", "", "/posts-default/:slug", "/post-default/:slug"},
|
|
{`/\evil.test/:slug`, listHost, "", listHost, "/post-default/:slug"},
|
|
} {
|
|
name := fmt.Sprintf("item=%q list=%q record=%q", tc.item, tc.listHost, tc.recHost)
|
|
t.Run(name, func(t *testing.T) {
|
|
cfg := ""
|
|
if tc.listHost != "" || tc.recHost != "" {
|
|
cfg = "patterns:\n"
|
|
if tc.listHost != "" {
|
|
cfg += " acme-posts: " + tc.listHost + "\n"
|
|
}
|
|
if tc.recHost != "" {
|
|
cfg += " acme-post: " + tc.recHost + "\n"
|
|
}
|
|
}
|
|
app := smokeApp(t, cfg)
|
|
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: types()}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
if got := reg.Pattern("acme-posts", tc.item); got != tc.wantList {
|
|
t.Fatalf("list Pattern = %q, want %q", got, tc.wantList)
|
|
}
|
|
if got := reg.Pattern("acme-post", tc.item); got != tc.wantRecord {
|
|
t.Fatalf("record Pattern = %q, want %q", got, tc.wantRecord)
|
|
}
|
|
// Resolve hands the type exactly the layered pattern.
|
|
resolved, err := reg.Resolve(context.Background(), "acme-posts", "ref-1", tc.item, true, "pl")
|
|
if err != nil || resolved.URL != tc.wantList {
|
|
t.Fatalf("Resolve = %+v, %v", resolved, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Resolve passes reference, nesting and locale", func(t *testing.T) {
|
|
var got pact.MenuItemResolve
|
|
capture := func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
got = in
|
|
return &pact.ResolvedMenuItem{URL: in.Pattern}, nil
|
|
}
|
|
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n")
|
|
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
|
|
{Key: "acme-post", DefaultPattern: "/blog/:slug", Resolve: capture},
|
|
}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
var noCtx context.Context // a nil context is accepted and replaced
|
|
if _, err := reg.Resolve(noCtx, "acme-post", "7", "", true, "pl"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := pact.MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/news/:slug", Nesting: true, UnprefixedLocale: "pl"}
|
|
if got != want {
|
|
t.Fatalf("Resolve input = %+v, want %+v", got, want)
|
|
}
|
|
if _, err := reg.Resolve(context.Background(), "acme-missing", "", "", false, ""); err == nil || !strings.Contains(err.Error(), `"acme-missing"`) {
|
|
t.Fatalf("unknown type error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidItemPattern pins the item override rule: a path starting with a
|
|
// single "/", without surrounding whitespace or control characters.
|
|
func TestValidItemPattern(t *testing.T) {
|
|
for p, want := range map[string]bool{
|
|
"/blog/:slug": true,
|
|
"/": true,
|
|
"": false,
|
|
"blog/:slug": false,
|
|
"//evil.test/:slug": false,
|
|
`/\evil.test`: false,
|
|
"/\t/evil.test": false,
|
|
" /blog/:slug": false,
|
|
"/blog/:slug ": false,
|
|
"javascript://%0aalert(1)%0a//x": false,
|
|
"https://example.test/:slug": false,
|
|
} {
|
|
if got := signpost.ValidItemPattern(p); got != want {
|
|
t.Errorf("ValidItemPattern(%q) = %v, want %v", p, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReferenceOptions pins the one admin binding: a reference tree is
|
|
// flattened depth first with Depth the nesting level, a type without
|
|
// References or an unknown type answers Applies false with no options, and a
|
|
// References error is returned with the type named.
|
|
func TestReferenceOptions(t *testing.T) {
|
|
tree := []pact.MenuItemReference{
|
|
{Value: "1", Label: "Root", Items: []pact.MenuItemReference{
|
|
{Value: "2", Label: "Child", Items: []pact.MenuItemReference{{Value: "3", Label: "Grandchild"}}},
|
|
{Value: "4", Label: "Second child"},
|
|
}},
|
|
{Value: "5", Label: "acme.blog::lang.other"},
|
|
}
|
|
app := smokeApp(t, "")
|
|
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
|
|
{Key: "acme-tree", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return tree, nil }},
|
|
{Key: "acme-empty", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return nil, nil }},
|
|
{Key: "acme-broken", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return nil, errors.New("db down") }},
|
|
{Key: "acme-none", DefaultPattern: "/:slug", Resolve: echo},
|
|
}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
|
|
set, err := signpost.ReferenceOptions(context.Background(), reg, "acme-tree")
|
|
if err != nil || !set.Applies {
|
|
t.Fatalf("tree = %+v, %v", set, err)
|
|
}
|
|
want := []pact.Option{
|
|
{Value: "1", Label: "Root", Depth: 0},
|
|
{Value: "2", Label: "Child", Depth: 1},
|
|
{Value: "3", Label: "Grandchild", Depth: 2},
|
|
{Value: "4", Label: "Second child", Depth: 1},
|
|
{Value: "5", Label: "acme.blog::lang.other", Depth: 0},
|
|
}
|
|
if fmt.Sprint(set.Options) != fmt.Sprint(want) {
|
|
t.Fatalf("options = %+v, want %+v", set.Options, want)
|
|
}
|
|
|
|
empty, err := signpost.ReferenceOptions(context.Background(), reg, "acme-empty")
|
|
if err != nil || !empty.Applies || empty.Options == nil || len(empty.Options) != 0 {
|
|
t.Fatalf("type with no references yet = %+v, %v (applies, empty non-nil options)", empty, err)
|
|
}
|
|
for _, key := range []string{"acme-none", "acme-missing", ""} {
|
|
none, err := signpost.ReferenceOptions(context.Background(), reg, key)
|
|
if err != nil || none.Applies || none.Options == nil || len(none.Options) != 0 {
|
|
t.Fatalf("%q = %+v, %v (applies false, empty non-nil options)", key, none, err)
|
|
}
|
|
}
|
|
if none, err := signpost.ReferenceOptions(context.Background(), nil, "acme-tree"); err != nil || none.Applies {
|
|
t.Fatalf("nil registry = %+v, %v", none, err)
|
|
}
|
|
if _, err := signpost.ReferenceOptions(context.Background(), reg, "acme-broken"); err == nil || !strings.Contains(err.Error(), `"acme-broken"`) || !strings.Contains(err.Error(), "db down") {
|
|
t.Fatalf("references error = %v", err)
|
|
}
|
|
}
|