Found by serving the include evidence: `tools.md` beside a bundle's index was
itself scanned as a bundle, so a file meant only to be included took a URL of its
own, appeared in its section's listing, and turned the including bundle into a
one-member series. The real binary showed the phantom series nav; no test would
have, because every fixture happened to name its partials differently.
The rule mirrors the one directories already have, `_index` excepted since that
names its directory. `{{< include file="_tools.md" >}}` is now the shape to write.
206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
package content
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
func TestSplitNameDerivesKeyAndLang(t *testing.T) {
|
|
cases := []struct {
|
|
name, key, lang string
|
|
ok bool
|
|
}{
|
|
{name: "pages/about.md", key: "pages/about", lang: "en", ok: true},
|
|
{name: "pages/about.en.md", key: "pages/about", lang: "en", ok: true},
|
|
{name: "pages/about.bn.md", key: "pages/about", lang: "bn", ok: true},
|
|
{name: "posts/hello/index.md", key: "posts/hello", lang: "en", ok: true},
|
|
{name: "posts/hello/index.bn.md", key: "posts/hello", lang: "bn", ok: true},
|
|
{name: "comics/monsoon/_index.md", key: "comics/monsoon", lang: "en", ok: true},
|
|
{name: "posts/my.post.md", key: "posts/my.post", lang: "en", ok: true},
|
|
{name: "posts/notes.txt", ok: false},
|
|
}
|
|
for _, c := range cases {
|
|
key, lang, ok := splitName(c.name)
|
|
if ok != c.ok {
|
|
t.Errorf("%s: ok = %v, want %v", c.name, ok, c.ok)
|
|
continue
|
|
}
|
|
if ok && (key != c.key || lang != c.lang) {
|
|
t.Errorf("%s: got %q/%q, want %q/%q", c.name, key, lang, c.key, c.lang)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseSplitsFrontmatterAndKeepsUnknownKeys(t *testing.T) {
|
|
b, err := Parse("posts/hello.md", []byte("---\ntitle: Hello\nmood: cheerful\ntags: [a, b]\n---\n\nBody text.\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Title != "Hello" {
|
|
t.Errorf("title = %q", b.Title)
|
|
}
|
|
if got := string(b.Body); got != "Body text.\n" {
|
|
t.Errorf("body = %q", got)
|
|
}
|
|
if b.Extra["mood"] != "cheerful" {
|
|
t.Error("an unnamed frontmatter key did not land in Extra")
|
|
}
|
|
if len(b.Tags) != 2 || b.Tags[0] != "a" {
|
|
t.Errorf("tags = %v, want [a b] lifted into the named field", b.Tags)
|
|
}
|
|
if _, leaked := b.Extra["tags"]; leaked {
|
|
t.Error("tags should be lifted out of Extra, not duplicated")
|
|
}
|
|
if _, ok := b.Extra["title"]; ok {
|
|
t.Error("title should be lifted out of Extra, not duplicated")
|
|
}
|
|
}
|
|
|
|
func TestParseWithoutFrontmatterIsAllBody(t *testing.T) {
|
|
b, err := Parse("pages/now.md", []byte("Just prose.\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Title != "" || string(b.Body) != "Just prose.\n" {
|
|
t.Errorf("got title %q body %q", b.Title, b.Body)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsBrokenFrontmatter(t *testing.T) {
|
|
if _, err := Parse("posts/bad.md", []byte("---\ntitle: [unclosed\n---\nbody\n")); err == nil {
|
|
t.Fatal("want an error for unparseable YAML")
|
|
}
|
|
}
|
|
|
|
func TestParseMissingTitleIsLegal(t *testing.T) {
|
|
b, err := Parse("status/note.md", []byte("---\ndate: 2026-07-30\n---\nhi\n"))
|
|
if err != nil {
|
|
t.Fatalf("a titleless bundle must parse: %v", err)
|
|
}
|
|
if b.Title != "" {
|
|
t.Errorf("title = %q, want empty", b.Title)
|
|
}
|
|
}
|
|
|
|
func TestScanSkipsBadBundlesAndUnderscoreDirs(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
|
|
"content/posts/hello/index.md": {Data: []byte("---\ntitle: Hello\n---\ny\n")},
|
|
"content/posts/broken.md": {Data: []byte("---\ntitle: [oops\n---\nz\n")},
|
|
"content/_drafts/secret.md": {Data: []byte("---\ntitle: Secret\n---\nq\n")},
|
|
"content/pages/notes.txt": {Data: []byte("not markdown")},
|
|
}
|
|
got, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys := map[string]bool{}
|
|
for _, b := range got {
|
|
keys[b.Key] = true
|
|
}
|
|
if len(got) != 2 || !keys["pages/about"] || !keys["posts/hello"] {
|
|
t.Fatalf("got %d bundles %v, want pages/about and posts/hello only", len(got), keys)
|
|
}
|
|
}
|
|
|
|
func TestScanDropsAmbiguousVariants(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: A\n---\n")},
|
|
"content/pages/about.en.md": {Data: []byte("---\ntitle: B\n---\n")},
|
|
"content/pages/now.md": {Data: []byte("---\ntitle: Now\n---\n")},
|
|
}
|
|
got, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Key != "pages/now" {
|
|
t.Fatalf("got %+v, want only pages/now: two spellings of one variant are ambiguous", got)
|
|
}
|
|
}
|
|
|
|
func TestNormaliseFoldsDecomposedBengali(t *testing.T) {
|
|
decomposed := "\u0995\u09c7\u09be" // ka + vowel sign e + vowel sign aa
|
|
composed := "\u0995\u09cb" // ka + vowel sign o
|
|
if decomposed == composed {
|
|
t.Skip("inputs are already identical; nothing to prove")
|
|
}
|
|
if Normalise(decomposed) != composed {
|
|
t.Errorf("NFC(%q) = %q, want %q", decomposed, Normalise(decomposed), composed)
|
|
}
|
|
}
|
|
|
|
// TestOpenSiteRefusesSymlinkEscape is the path-traversal guard's evidence: os.DirFS would happily
|
|
// follow this symlink, os.Root does not (ADR-0031).
|
|
func TestOpenSiteRefusesSymlinkEscape(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
site := filepath.Join(tmp, "site")
|
|
if err := os.MkdirAll(filepath.Join(site, "content"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret := filepath.Join(tmp, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("private"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("../secret.txt", filepath.Join(site, "escape.txt")); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
|
|
fsys, err := OpenSite(site)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, err := fs.ReadFile(fsys, "escape.txt"); err == nil {
|
|
t.Fatalf("read outside the site root succeeded with %q", data)
|
|
}
|
|
if _, err := fs.ReadFile(fsys, "../secret.txt"); err == nil {
|
|
t.Fatal("traversal with .. succeeded")
|
|
}
|
|
}
|
|
|
|
func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Long Monsoon": "long-monsoon",
|
|
"WATERCOLOUR": "watercolour",
|
|
"জলরঙ": "জলরঙ",
|
|
" spaced out ": "spaced-out",
|
|
}
|
|
for in, want := range cases {
|
|
if got := TagSlug(in); got != want {
|
|
t.Errorf("TagSlug(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnderscoreFileIsAPartialNotABundle(t *testing.T) {
|
|
// A file meant only to be included must not also be a bundle: it would take a URL, show up in its
|
|
// section's listing, and make its bundle look like a one-member series (content-model.md).
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about/index.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
|
|
"content/pages/about/_tools.md": {Data: []byte("A fragment.\n")},
|
|
"content/pages/about/_notes.bn.md": {Data: []byte("একটি অংশ।\n")},
|
|
"content/pages/_index.md": {Data: []byte("---\ntitle: Pages\n---\n")},
|
|
}
|
|
bundles, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var keys []string
|
|
for _, b := range bundles {
|
|
keys = append(keys, b.Key)
|
|
}
|
|
sort.Strings(keys)
|
|
want := []string{"pages", "pages/about"}
|
|
if len(keys) != len(want) {
|
|
t.Fatalf("scanned %v, want %v — _index still names its directory", keys, want)
|
|
}
|
|
for i := range want {
|
|
if keys[i] != want[i] {
|
|
t.Fatalf("scanned %v, want %v", keys, want)
|
|
}
|
|
}
|
|
}
|