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 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 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`,
``,
`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)
}
}