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