An alias is a promise that an old URL keeps working, so it answers 301 to the canonical one rather than serving the same content twice (ADR-0008). Frontmatter takes a scalar or a list and tolerates surrounding slashes, because authors write both. Ambiguity is dropped, not resolved: an alias naming a real bundle, or claimed by two bundles, is logged and ignored so the real bundle keeps its URL. Aliases compose with language prefixes for free, since the resolver splits the language before the key is looked up. The redirect still fires only for an alias that exists, so a nonexistent path cannot be probed by 301 — the property prompt 3 established. Evidence: /pages/bio/ and /about/ both 301 to /pages/about/, /bn/pages/bio/ 301s to /bn/pages/about/, and /pages/nothing/ is 404.
272 lines
8.7 KiB
Go
272 lines
8.7 KiB
Go
package content
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"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\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 _, ok := b.Extra["tags"]; !ok {
|
|
t.Error("tags did not land in Extra")
|
|
}
|
|
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 TestSiteLookupFindsDefaultVariant(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")},
|
|
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")},
|
|
}
|
|
bundles, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
site := NewSite(bundles)
|
|
if site.Len() != 2 {
|
|
t.Fatalf("indexed %d bundles, want 2", site.Len())
|
|
}
|
|
b, served, ok := site.Lookup("pages/about", "en")
|
|
if !ok || b.Title != "About" || served != "en" {
|
|
t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok)
|
|
}
|
|
if _, _, ok := site.Lookup("pages/missing", "en"); ok {
|
|
t.Error("Lookup invented a bundle")
|
|
}
|
|
}
|
|
|
|
func TestLookupFallsBackThroughLanguages(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")},
|
|
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")},
|
|
"content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")},
|
|
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")},
|
|
}
|
|
bundles, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
site := NewSite(bundles)
|
|
cases := []struct{ key, want, served string }{
|
|
{"pages/about", "পরিচিতি", "bn"}, // the requested language exists
|
|
{"posts/plain", "Plain", "en"}, // falls back to the default
|
|
{"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404
|
|
}
|
|
for _, c := range cases {
|
|
b, served, ok := site.Lookup(c.key, "bn")
|
|
if !ok || b.Title != c.want || served != c.served {
|
|
t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served)
|
|
}
|
|
}
|
|
if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" {
|
|
t.Errorf("Variants = %v, want [bn en]", got)
|
|
}
|
|
if !site.HasLang("bn") || site.HasLang("fr") {
|
|
t.Error("HasLang misreported")
|
|
}
|
|
}
|
|
|
|
func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) {
|
|
cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"}
|
|
for lang, want := range cases {
|
|
if got := URL("pages/about", lang); got != want {
|
|
t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAliasesAreParsedAndIndexed(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [/posts/old-name/, posts/older]\n---\n")},
|
|
"content/pages/single.md": {Data: []byte("---\ntitle: Single\naliases: pages/one\n---\n")},
|
|
}
|
|
bundles, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
site := NewSite(bundles)
|
|
for alias, want := range map[string]string{
|
|
"posts/old-name": "posts/new-name",
|
|
"posts/older": "posts/new-name",
|
|
"pages/one": "pages/single",
|
|
} {
|
|
got, ok := site.Alias(alias)
|
|
if !ok || got != want {
|
|
t.Errorf("Alias(%q) = %q %v, want %q", alias, got, ok, want)
|
|
}
|
|
}
|
|
for _, b := range bundles {
|
|
if _, leaked := b.Extra["aliases"]; leaked {
|
|
t.Error("aliases should be lifted out of Extra, not duplicated")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAmbiguousAliasesAreDropped(t *testing.T) {
|
|
fsys := fstest.MapFS{
|
|
"content/pages/real.md": {Data: []byte("---\ntitle: Real\n---\n")},
|
|
"content/pages/a.md": {Data: []byte("---\ntitle: A\naliases: [pages/real, pages/shared]\n---\n")},
|
|
"content/pages/b.md": {Data: []byte("---\ntitle: B\naliases: [pages/shared]\n---\n")},
|
|
}
|
|
site := NewSite(mustScan(t, fsys))
|
|
if _, ok := site.Alias("pages/real"); ok {
|
|
t.Error("an alias naming a real bundle must be dropped, not shadow it")
|
|
}
|
|
if _, ok := site.Alias("pages/shared"); ok {
|
|
t.Error("an alias claimed by two bundles must be dropped, not picked arbitrarily")
|
|
}
|
|
if _, _, ok := site.Lookup("pages/real", "en"); !ok {
|
|
t.Error("the real bundle must keep its URL")
|
|
}
|
|
}
|
|
|
|
func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle {
|
|
t.Helper()
|
|
b, err := Scan(fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|