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

", ``, ``, } { if !strings.Contains(body, want) { t.Errorf("missing %q in:\n%s", want, body) } } _, _, 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")) } } 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 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 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) } }