Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases — no bundle can collide, since a key always sits under a section. robots.txt at the site root is served verbatim, because a site that ships one has said something deliberate; otherwise the engine emits the minimum that is true and points at the sitemap. The sitemap lists every bundle in every language it exists in, since each variant is separately reachable, with lastmod only where a bundle has a date. Every URL comes from content.URL like every other path the engine emits, so a sitemap cannot disagree with what is actually served. Both need a declared base. Without one the sitemap answers 404 rather than listing paths no crawler can resolve, and robots omits the Sitemap line rather than writing a relative one. write() was setting text/html for every caller, and headers only go out with the first byte — so a handler setting its own type would have had it silently replaced, which is how a sitemap gets served as a web page. It now splits into write and writeAs, and the tests assert the content types rather than only the bodies.
266 lines
10 KiB
Go
266 lines
10 KiB
Go
package shortcodes
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/yuin/goldmark"
|
|
|
|
"khosra/internal/content"
|
|
"khosra/internal/render"
|
|
)
|
|
|
|
func TestParseAcceptsOnlyAWholeLineCall(t *testing.T) {
|
|
name, args, ok := parse(` {{< figure src="a.jpg" alt="A cat" >}} `)
|
|
if !ok || name != "figure" {
|
|
t.Fatalf("parse gave %q %v ok=%v", name, args, ok)
|
|
}
|
|
if args["src"] != "a.jpg" || args["alt"] != "A cat" {
|
|
t.Errorf("args = %v", args)
|
|
}
|
|
if _, _, ok := parse(`{{< figure src="a.jpg" >}} and then prose`); ok {
|
|
t.Error("a call must be the whole line, so trailing prose is not a call")
|
|
}
|
|
for _, line := range []string{
|
|
"plain prose",
|
|
"{{< figure", // unterminated
|
|
`{{< src="a.jpg" >}}`, // no name
|
|
`{{< figure src=a.jpg >}}`, // unquoted value
|
|
`{{< figure src="unclosed >}}`, // unbalanced quote
|
|
"{{<>}}", // empty
|
|
} {
|
|
if _, _, ok := parse(line); ok {
|
|
t.Errorf("parse accepted %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// wired builds a real Renderer wired to this extension, the way cmd does.
|
|
func wired(t *testing.T, siteFS fstest.MapFS) *render.Renderer {
|
|
t.Helper()
|
|
var fsys fstest.MapFS
|
|
if siteFS != nil {
|
|
fsys = siteFS
|
|
}
|
|
r, err := render.New(fsys, content.Settings{}, func(p render.Partial) []goldmark.Extender {
|
|
return []goldmark.Extender{New(p)}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func body(t *testing.T, r *render.Renderer, markdown string) string {
|
|
t.Helper()
|
|
b, err := content.Parse("posts/x.md", []byte("---\ntitle: X\n---\n"+markdown))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Bundle(b, "en", nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func TestFigureRendersThroughTheThemeFragment(t *testing.T) {
|
|
got := body(t, wired(t, nil), "Before.\n\n{{< figure src=\"cat.jpg\" alt=\"A cat\" caption=\"Sleeping\" >}}\n\nAfter.\n")
|
|
for _, want := range []string{
|
|
"<figure>", `<img src="cat.jpg" alt="A cat">`, "<figcaption>Sleeping</figcaption>", "</figure>",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "<p><figure>") || strings.Contains(got, "{{<") {
|
|
t.Errorf("a call on its own line is a block, not paragraph text:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAnAuthorsArgumentCannotBecomeMarkup(t *testing.T) {
|
|
// The security property of ADR-0036, on a call that really parses: output is a template's, so hostile
|
|
// argument text arrives as escaped data in whichever context it lands in.
|
|
got := body(t, wired(t, nil), `{{< figure src="ok.jpg" alt="<script>alert(1)</script>" >}}`+"\n")
|
|
if strings.Contains(got, "<script>") {
|
|
t.Fatalf("an argument became markup:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "<script>") {
|
|
t.Errorf("the hostile alt text should survive as escaped text:\n%s", got)
|
|
}
|
|
|
|
// A javascript: URL in an attribute the template uses as a URL is html/template's job, and getting it
|
|
// for free is the reason a fragment renders this rather than the feature (ADR-0036).
|
|
got = body(t, wired(t, nil), `{{< figure src="javascript:alert(1)" alt="x" >}}`+"\n")
|
|
if strings.Contains(got, "javascript:alert(1)") {
|
|
t.Errorf("a javascript: URL should not survive into src:\n%s", got)
|
|
}
|
|
|
|
// A quote cannot even be expressed in an argument, so attribute breakout fails at the syntax before it
|
|
// reaches escaping: the call is not a call, and the line stays prose.
|
|
got = body(t, wired(t, nil), `{{< figure src="x.jpg\" onerror=\"alert(1)" >}}`+"\n\n<script>alert(2)</script>\n")
|
|
if strings.Contains(got, "onerror") && !strings.Contains(got, """) {
|
|
t.Errorf("a malformed call must stay escaped text, not markup:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "raw HTML omitted") {
|
|
t.Errorf("authored raw HTML must still be dropped:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownShortcodeDegradesToNothing(t *testing.T) {
|
|
got := body(t, wired(t, nil), "{{< nosuchthing key=\"v\" >}}\n\nStill here.\n")
|
|
if !strings.Contains(got, "Still here.") {
|
|
t.Errorf("the rest of the page must survive:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "nosuchthing") {
|
|
t.Errorf("a missing fragment renders nothing, not its own name:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// galleryFS is a directory bundle with pictures, a non-picture, and a subdirectory that is not one.
|
|
func galleryFS() fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"content/art/monsoon/index.md": {Data: []byte("---\ntitle: Monsoon\n---\n{{< gallery >}}\n")},
|
|
"content/art/monsoon/20-second.jpg": {Data: []byte("x")},
|
|
"content/art/monsoon/10-first.PNG": {Data: []byte("x")},
|
|
"content/art/monsoon/30-third.webp": {Data: []byte("x")},
|
|
"content/art/monsoon/notes.md": {Data: []byte("not a picture")},
|
|
"content/art/monsoon/sketches/a.jpg": {Data: []byte("x")},
|
|
"content/art/elsewhere.jpg": {Data: []byte("x")},
|
|
}
|
|
}
|
|
|
|
// bundle renders the named bundle out of fsys, the way the server does.
|
|
func bundle(t *testing.T, fsys fstest.MapFS, name string) string {
|
|
t.Helper()
|
|
bundles, err := content.Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
site := content.NewSite(bundles)
|
|
b, served, ok := site.Lookup(name, "en")
|
|
if !ok {
|
|
t.Fatalf("no bundle %q", name)
|
|
}
|
|
out, err := wired(t, fsys).Bundle(b, served, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func TestGalleryListsThePicturesBesideItsBundle(t *testing.T) {
|
|
got := bundle(t, galleryFS(), "art/monsoon")
|
|
first := strings.Index(got, "10-first.PNG")
|
|
second := strings.Index(got, "20-second.jpg")
|
|
third := strings.Index(got, "30-third.webp")
|
|
if first < 0 || second < first || third < second {
|
|
t.Errorf("pictures should list in filename order, case-insensitively recognised:\n%s", got)
|
|
}
|
|
for _, absent := range []string{"notes.md", "sketches", "elsewhere.jpg"} {
|
|
if strings.Contains(got, absent) {
|
|
t.Errorf("a gallery is pictures beside the bundle only, but %q appeared:\n%s", absent, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGalleryWithoutASiteRootRendersNothing(t *testing.T) {
|
|
// wired(t, nil) has no files, which is how a unit test or a bare renderer is built. Gathering nothing
|
|
// must not become a broken page.
|
|
got := body(t, wired(t, nil), "{{< gallery >}}\n\nStill here.\n")
|
|
if !strings.Contains(got, "Still here.") {
|
|
t.Errorf("the page must survive a gallery with nothing to show:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "<div class=\"gallery\">") {
|
|
t.Errorf("an empty gallery should render nothing at all:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestIncludeRendersTheFileBesideTheBundle(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about/index.md": {Data: []byte("---\ntitle: About\n---\nFirst.\n\n{{< include file=\"more.md\" >}}\n\nLast.\n")},
|
|
"content/pages/about/more.md": {Data: []byte("## Included\n\nWith *emphasis* and a [link](/posts/).\n")},
|
|
}
|
|
got := bundle(t, fsys, "pages/about")
|
|
// Converted as Markdown, not pasted as text: the heading, emphasis and link prove it.
|
|
for _, want := range []string{"<h2>Included</h2>", "<em>emphasis</em>", `href="/posts/"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q — an include is Markdown, not a string:\n%s", want, got)
|
|
}
|
|
}
|
|
first, included, last := strings.Index(got, "First."), strings.Index(got, "Included"), strings.Index(got, "Last.")
|
|
if first < 0 || included < first || last < included {
|
|
t.Errorf("included content belongs where the call was:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAnIncludedFileCannotItselfInclude(t *testing.T) {
|
|
// One level, by design (ADR-0038). A file including itself is the case that would otherwise recurse
|
|
// until the stack gave out — a crash caused by content, which ADR-0029 forbids.
|
|
fsys := fstest.MapFS{
|
|
"content/pages/loop/index.md": {Data: []byte("---\ntitle: Loop\n---\nBefore.\n\n{{< include file=\"self.md\" >}}\n\nAfter.\n")},
|
|
"content/pages/loop/self.md": {Data: []byte("Round.\n\n{{< include file=\"self.md\" >}}\n")},
|
|
}
|
|
got := bundle(t, fsys, "pages/loop")
|
|
for _, want := range []string{"Before.", "Round.", "After."} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q — one level must still render:\n%s", want, got)
|
|
}
|
|
}
|
|
if n := strings.Count(got, "Round."); n != 1 {
|
|
t.Errorf("expanded %d times, want exactly one level", n)
|
|
}
|
|
if strings.Contains(got, "{{<") {
|
|
t.Errorf("the ignored nested call renders nothing, it is not printed:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAGalleryInsideAnIncludedFileStillResolves(t *testing.T) {
|
|
// The nested parse carries the same Origin, which is what makes this work.
|
|
fsys := fstest.MapFS{
|
|
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n{{< include file=\"body.md\" >}}\n")},
|
|
"content/art/set/body.md": {Data: []byte("Studies:\n\n{{< gallery >}}\n")},
|
|
"content/art/set/one.jpg": {Data: []byte("x")},
|
|
"content/art/set/two.png": {Data: []byte("x")},
|
|
}
|
|
got := bundle(t, fsys, "art/set")
|
|
if !strings.Contains(got, "one.jpg") || !strings.Contains(got, "two.png") {
|
|
t.Errorf("a gallery inside an include should resolve against the same bundle:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestIncludeCannotEscapeTheSiteRootAndDegradesOnMisses(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
// `..` is refused outright: path.Join would collapse it to a real path inside the site root, which
|
|
// would let an include publish a template or a dotfile that is not content (ADR-0038).
|
|
"content/pages/a/index.md": {Data: []byte("---\ntitle: A\n---\n{{< include file=\"../../../etc/passwd\" >}}\n\nSurvived.\n")},
|
|
"content/pages/d/index.md": {Data: []byte("---\ntitle: D\n---\n{{< include file=\"../../../secret.md\" >}}\n\nSurvived.\n")},
|
|
"secret.md": {Data: []byte("NOT CONTENT\n")},
|
|
"content/pages/b/index.md": {Data: []byte("---\ntitle: B\n---\n{{< include file=\"nothing.md\" >}}\n\nSurvived.\n")},
|
|
"content/pages/c/index.md": {Data: []byte("---\ntitle: C\n---\n{{< include >}}\n\nSurvived.\n")},
|
|
}
|
|
for _, key := range []string{"pages/a", "pages/b", "pages/c", "pages/d"} {
|
|
got := bundle(t, fsys, key)
|
|
if !strings.Contains(got, "Survived.") {
|
|
t.Errorf("%s: the page must survive a bad include:\n%s", key, got)
|
|
}
|
|
if strings.Contains(got, "root:") || strings.Contains(got, "passwd") || strings.Contains(got, "NOT CONTENT") {
|
|
t.Fatalf("%s: an include read something it must not:\n%s", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestASiteRedefinesOneFragment(t *testing.T) {
|
|
site := fstest.MapFS{
|
|
"templates/shortcodes.html": {Data: []byte(`{{define "figure"}}<div class="mine">{{.Args.src}}</div>{{end}}`)},
|
|
}
|
|
got := body(t, wired(t, site), "{{< figure src=\"cat.jpg\" >}}\n")
|
|
if !strings.Contains(got, `<div class="mine">cat.jpg</div>`) {
|
|
t.Errorf("the site's fragment should win:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "<figure>") {
|
|
t.Error("the embedded fragment should have been replaced, not appended")
|
|
}
|
|
}
|