package main import ( "crypto/sha256" "encoding/hex" "encoding/xml" "io" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" ) func newTestServer(t *testing.T, files map[string]string) (*httptest.Server, string) { t.Helper() dir := t.TempDir() for name, src := range files { writeFile(t, dir, name, src) } a, err := newApp(dir, "https://posts.example.com/") if err != nil { t.Fatal(err) } srv := httptest.NewServer(a.routes()) t.Cleanup(srv.Close) return srv, dir } func writeFile(t *testing.T, dir, name, src string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil { t.Fatal(err) } } func get(t *testing.T, srv *httptest.Server, path string) (int, http.Header, string) { t.Helper() resp, err := http.Get(srv.URL + path) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } return resp.StatusCode, resp.Header, string(body) } var sample = map[string]string{ "2026-01-01-oldest.md": "# Oldest\n", "2026-02-01-middle.md": "---\ntitle: Middle\nsummary: The middle one.\n---\nMiddle body\n", "2026-03-01-newest.md": "# Newest\n", "2026-04-01-draft.md": "---\ndraft: true\n---\n# Secret draft\n", "Bad Name.md": "# Broken\n", "notes.txt": "not a post", } func TestIndex(t *testing.T) { srv, _ := newTestServer(t, sample) status, h, body := get(t, srv, "/") if status != 200 { t.Fatalf("status = %d", status) } if h.Get("Content-Security-Policy") == "" || h.Get("X-Content-Type-Options") != "nosniff" { t.Errorf("missing security headers: %v", h) } n, m, o := strings.Index(body, "Newest"), strings.Index(body, "Middle"), strings.Index(body, "Oldest") if n < 0 || m < 0 || o < 0 || !(n < m && m < o) { t.Errorf("posts missing or out of order:\n%s", body) } if !strings.Contains(body, "The middle one.") { t.Error("summary not shown") } for _, hidden := range []string{"Secret draft", "Broken"} { if strings.Contains(body, hidden) { t.Errorf("%q should not be listed", hidden) } } } func TestEmptyIndex(t *testing.T) { srv, _ := newTestServer(t, nil) if status, _, body := get(t, srv, "/"); status != 200 || !strings.Contains(body, "No posts yet") { t.Errorf("status = %d, body:\n%s", status, body) } } func TestPostPage(t *testing.T) { srv, _ := newTestServer(t, sample) status, _, body := get(t, srv, "/posts/middle") if status != 200 { t.Fatalf("status = %d", status) } for _, want := range []string{ "Middle · posts.example.com", "

Middle body

", `← Oldest`, `Newest →`, } { if !strings.Contains(body, want) { t.Errorf("missing %q in:\n%s", want, body) } } // Previous (older) on the left, next (newer) on the right. if strings.Index(body, `class="older"`) > strings.Index(body, `class="newer"`) { t.Error("older link should come before the newer link") } _, _, newest := get(t, srv, "/posts/newest") if strings.Contains(newest, `class="newer"`) { t.Error("newest post should have no newer link") } _, _, oldest := get(t, srv, "/posts/oldest") if strings.Contains(oldest, `class="older"`) { t.Error("oldest post should have no older link") } } func TestNotFound(t *testing.T) { srv, _ := newTestServer(t, sample) for _, path := range []string{ "/posts/nope", "/posts/draft", "/posts/2026-01-01-oldest", "/posts/..%2f..%2fetc%2fpasswd", "/posts/", "/static/", "/static/missing.css", "/whatever", } { status, _, body := get(t, srv, path) if status != 404 { t.Errorf("%s: status = %d, want 404", path, status) } if path != "/static/missing.css" && !strings.Contains(body, "

404

") { t.Errorf("%s: expected styled 404 page", path) } } } func TestStatic(t *testing.T) { srv, _ := newTestServer(t, nil) status, h, body := get(t, srv, "/static/style.css") if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "text/css") || !strings.Contains(body, ":root") { t.Errorf("status = %d, type = %q", status, h.Get("Content-Type")) } // gofmt indents with tabs; browsers default to 8 columns, which pushes // quoted Go code past the edge of the column. if !strings.Contains(body, "tab-size: 4") { t.Error("stylesheet should set tab-size: 4 for code blocks") } } func TestStylesheetCacheBusting(t *testing.T) { srv, _ := newTestServer(t, nil) _, _, page := get(t, srv, "/") m := regexp.MustCompile(``).FindStringSubmatch(page) if m == nil { t.Fatalf("no hashed stylesheet link in:\n%s", page) } b, err := staticFS.ReadFile("static/style.css") if err != nil { t.Fatal(err) } sum := sha256.Sum256(b) if want := hex.EncodeToString(sum[:])[:10]; m[2] != want { t.Errorf("hash = %s, want %s", m[2], want) } for path, want := range map[string]string{ m[1]: "public, max-age=31536000, immutable", "/static/style.css": "public, max-age=3600", "/static/style.css?v=0000000000": "public, max-age=3600", } { status, h, _ := get(t, srv, path) if status != 200 || h.Get("Cache-Control") != want { t.Errorf("%s: status = %d, Cache-Control = %q, want %q", path, status, h.Get("Cache-Control"), want) } } } func TestFeed(t *testing.T) { srv, _ := newTestServer(t, sample) status, h, body := get(t, srv, "/feed.xml") if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "application/rss+xml") { t.Fatalf("status = %d, type = %q", status, h.Get("Content-Type")) } var feed rss if err := xml.Unmarshal([]byte(body), &feed); err != nil { t.Fatalf("invalid XML: %v\n%s", err, body) } items := feed.Channel.Items if len(items) != 3 { t.Fatalf("got %d items, want 3", len(items)) } if items[0].Link != "https://posts.example.com/posts/newest" { t.Errorf("first link = %q", items[0].Link) } if items[1].Description != "The middle one." { t.Errorf("description = %q", items[1].Description) } } func TestPinnedPosts(t *testing.T) { srv, _ := newTestServer(t, map[string]string{ "2026-01-01-old-pinned.md": "---\npinned: true\n---\n# Old pinned\n", "2026-02-01-plain-a.md": "---\npinned: false\n---\n# Plain A\n", "2026-03-01-new-pinned.md": "---\npinned: yes\n---\n# Bad value\n", "2026-04-01-mid-pinned.md": "---\npinned: true\n---\n# Mid pinned\n", "2026-05-01-plain-b.md": "# Plain B\n", }) // Index: pinned posts first (newest first), then the rest by date. // The invalid "pinned: yes" post is skipped, like a bad draft value. _, _, index := get(t, srv, "/") order := []string{"Mid pinned", "Old pinned", "Plain B", "Plain A"} last := -1 for _, title := range order { i := strings.Index(index, ">"+title+"<") if i < 0 || i < last { t.Fatalf("index order should be %v:\n%s", order, index) } last = i } if strings.Contains(index, "Bad value") { t.Error("post with invalid pinned value should be skipped") } if n := strings.Count(index, `Pinned`); n != 2 { t.Errorf("want 2 Pinned labels, got %d", n) } // Newer/older links and the feed keep plain date order. _, _, post := get(t, srv, "/posts/old-pinned") if !strings.Contains(post, ``) || strings.Contains(post, `class="older"`) { t.Errorf("old-pinned should link newer to plain-a and have no older link:\n%s", post) } _, _, feed := get(t, srv, "/feed.xml") if b, m := strings.Index(feed, "/posts/plain-b<"), strings.Index(feed, "/posts/mid-pinned<"); b < 0 || m < 0 || b > m { t.Error("feed should stay newest first, ignoring pins") } } func TestAuthor(t *testing.T) { srv, dir := newTestServer(t, map[string]string{ "2026-01-01-anon.md": "# Anonymous\n", "2026-01-02-signed.md": "---\nauthor: Ann & co\n---\n# Signed\n", }) check := func(defaultAuthor string) { t.Helper() _, _, index := get(t, srv, "/") for _, want := range []string{" · by Ann & co", " · by " + defaultAuthor + ""} { if !strings.Contains(index, want) { t.Errorf("index: missing %q:\n%s", want, index) } } if _, _, post := get(t, srv, "/posts/signed"); !strings.Contains(post, " · by Ann & co") { t.Errorf("signed post: author missing:\n%s", post) } if _, _, anon := get(t, srv, "/posts/anon"); !strings.Contains(anon, " · by "+defaultAuthor+"") { t.Errorf("anonymous post: want default author %q:\n%s", defaultAuthor, anon) } _, _, feed := get(t, srv, "/feed.xml") for _, want := range []string{ `xmlns:dc="http://purl.org/dc/elements/1.1/"`, "Ann & co", "" + defaultAuthor + "", } { if !strings.Contains(feed, want) { t.Errorf("feed: missing %q in:\n%s", want, feed) } } } check("Claude") writeFile(t, dir, "site.json", `{"defaultAuthor": "Site Owner"}`) check("Site Owner") } func TestHeaderAndFooter(t *testing.T) { srv, dir := newTestServer(t, sample) for _, path := range []string{"/", "/posts/middle", "/about", "/nope"} { _, _, body := get(t, srv, path) for _, want := range []string{ `Home`, ``, "", } { if !strings.Contains(body, want) { t.Errorf("%s: missing %q", path, want) } } } writeFile(t, dir, "site.json", `{"homeText": "All "}`) if _, _, body := get(t, srv, "/"); !strings.Contains(body, `All <posts>`) { t.Errorf("configured home text not used (or not escaped):\n%s", body) } if status, _, _ := get(t, srv, "/posts/site"); status != 404 { t.Error("site.json should not be treated as a post") } } func TestAboutCommitLink(t *testing.T) { const full = "d2a387e186c8caf21a35349b8c3d60f68fb87b1f" about := func(t *testing.T, c commitInfo, siteJSON string) string { t.Helper() dir := t.TempDir() if siteJSON != "" { writeFile(t, dir, "site.json", siteJSON) } a, err := newApp(dir, "https://posts.example.com") if err != nil { t.Fatal(err) } a.commit = c // go test binaries carry no VCS stamp; set one srv := httptest.NewServer(a.routes()) t.Cleanup(srv.Close) _, _, body := get(t, srv, "/about") return body } t.Run("clean build links the full hash", func(t *testing.T) { body := about(t, commitInfo{Full: full, Short: full[:7]}, "") want := `(d2a387e)` if !strings.Contains(body, want) { t.Errorf("missing %s in:\n%s", want, body) } }) t.Run("repoURL from site.json", func(t *testing.T) { body := about(t, commitInfo{Full: full, Short: full[:7]}, `{"repoURL": "https://example.org/me/fork/"}`) if !strings.Contains(body, `href="https://example.org/me/fork/commit/`+full+`"`) { t.Errorf("site.json repoURL not used:\n%s", body) } }) t.Run("dirty build is not linked", func(t *testing.T) { body := about(t, commitInfo{Full: full, Short: full[:7], Dirty: true}, "") if !strings.Contains(body, `(d2a387e-dirty)`) || strings.Contains(body, "/commit/") { t.Errorf("dirty build should show -dirty with no link:\n%s", body) } }) t.Run("no commit shows version only", func(t *testing.T) { body := about(t, commitInfo{}, "") if !strings.Contains(body, "
"+version+"
") { t.Errorf("want bare version:\n%s", body) } }) } func TestAboutPage(t *testing.T) { srv, _ := newTestServer(t, sample) status, _, body := get(t, srv, "/about") if status != 200 { t.Fatalf("status = %d", status) } for _, want := range []string{ "About · posts.example.com", "
Version
\n
" + version, "
Posts
\n
3
", `
Newest`, `

The header block

`, `

What Markdown works

`, } { if !strings.Contains(body, want) { t.Errorf("missing %q in:\n%s", want, body) } } empty, _ := newTestServer(t, nil) _, _, body = get(t, empty, "/about") if !strings.Contains(body, "
0
") || strings.Contains(body, "Latest post") { t.Errorf("empty site: expected 0 posts and no latest post:\n%s", body) } } func TestReloadsWhenContentChanges(t *testing.T) { srv, dir := newTestServer(t, map[string]string{"2026-01-01-one.md": "# One\n"}) if _, _, body := get(t, srv, "/"); strings.Contains(body, "Two") { t.Fatal("unexpected post") } writeFile(t, dir, "2026-01-02-two.md", "# Two\n") if status, _, _ := get(t, srv, "/posts/two"); status != 200 { t.Errorf("new post not picked up: status = %d", status) } if err := os.Remove(filepath.Join(dir, "2026-01-01-one.md")); err != nil { t.Fatal(err) } if status, _, _ := get(t, srv, "/posts/one"); status != 404 { t.Errorf("removed post still served: status = %d", status) } }