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) } }