main_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. package main
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "encoding/xml"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "os"
  10. "path/filepath"
  11. "regexp"
  12. "strings"
  13. "testing"
  14. )
  15. func newTestServer(t *testing.T, files map[string]string) (*httptest.Server, string) {
  16. t.Helper()
  17. dir := t.TempDir()
  18. for name, src := range files {
  19. writeFile(t, dir, name, src)
  20. }
  21. a, err := newApp(dir, "https://posts.example.com/")
  22. if err != nil {
  23. t.Fatal(err)
  24. }
  25. srv := httptest.NewServer(a.routes())
  26. t.Cleanup(srv.Close)
  27. return srv, dir
  28. }
  29. func writeFile(t *testing.T, dir, name, src string) {
  30. t.Helper()
  31. if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil {
  32. t.Fatal(err)
  33. }
  34. }
  35. func get(t *testing.T, srv *httptest.Server, path string) (int, http.Header, string) {
  36. t.Helper()
  37. resp, err := http.Get(srv.URL + path)
  38. if err != nil {
  39. t.Fatal(err)
  40. }
  41. defer resp.Body.Close()
  42. body, err := io.ReadAll(resp.Body)
  43. if err != nil {
  44. t.Fatal(err)
  45. }
  46. return resp.StatusCode, resp.Header, string(body)
  47. }
  48. var sample = map[string]string{
  49. "2026-01-01-oldest.md": "# Oldest\n",
  50. "2026-02-01-middle.md": "---\ntitle: Middle\nsummary: The middle one.\n---\nMiddle body\n",
  51. "2026-03-01-newest.md": "# Newest\n",
  52. "2026-04-01-draft.md": "---\ndraft: true\n---\n# Secret draft\n",
  53. "Bad Name.md": "# Broken\n",
  54. "notes.txt": "not a post",
  55. }
  56. func TestIndex(t *testing.T) {
  57. srv, _ := newTestServer(t, sample)
  58. status, h, body := get(t, srv, "/")
  59. if status != 200 {
  60. t.Fatalf("status = %d", status)
  61. }
  62. if h.Get("Content-Security-Policy") == "" || h.Get("X-Content-Type-Options") != "nosniff" {
  63. t.Errorf("missing security headers: %v", h)
  64. }
  65. n, m, o := strings.Index(body, "Newest"), strings.Index(body, "Middle"), strings.Index(body, "Oldest")
  66. if n < 0 || m < 0 || o < 0 || !(n < m && m < o) {
  67. t.Errorf("posts missing or out of order:\n%s", body)
  68. }
  69. if !strings.Contains(body, "The middle one.") {
  70. t.Error("summary not shown")
  71. }
  72. for _, hidden := range []string{"Secret draft", "Broken"} {
  73. if strings.Contains(body, hidden) {
  74. t.Errorf("%q should not be listed", hidden)
  75. }
  76. }
  77. }
  78. func TestEmptyIndex(t *testing.T) {
  79. srv, _ := newTestServer(t, nil)
  80. if status, _, body := get(t, srv, "/"); status != 200 || !strings.Contains(body, "No posts yet") {
  81. t.Errorf("status = %d, body:\n%s", status, body)
  82. }
  83. }
  84. func TestPostPage(t *testing.T) {
  85. srv, _ := newTestServer(t, sample)
  86. status, _, body := get(t, srv, "/posts/middle")
  87. if status != 200 {
  88. t.Fatalf("status = %d", status)
  89. }
  90. for _, want := range []string{
  91. "<title>Middle · posts.example.com</title>",
  92. "<p>Middle body</p>",
  93. `<a class="older" href="/posts/oldest">← Oldest</a>`,
  94. `<a class="newer" href="/posts/newest">Newest →</a>`,
  95. } {
  96. if !strings.Contains(body, want) {
  97. t.Errorf("missing %q in:\n%s", want, body)
  98. }
  99. }
  100. // Previous (older) on the left, next (newer) on the right.
  101. if strings.Index(body, `class="older"`) > strings.Index(body, `class="newer"`) {
  102. t.Error("older link should come before the newer link")
  103. }
  104. _, _, newest := get(t, srv, "/posts/newest")
  105. if strings.Contains(newest, `class="newer"`) {
  106. t.Error("newest post should have no newer link")
  107. }
  108. _, _, oldest := get(t, srv, "/posts/oldest")
  109. if strings.Contains(oldest, `class="older"`) {
  110. t.Error("oldest post should have no older link")
  111. }
  112. }
  113. func TestNotFound(t *testing.T) {
  114. srv, _ := newTestServer(t, sample)
  115. for _, path := range []string{
  116. "/posts/nope",
  117. "/posts/draft",
  118. "/posts/2026-01-01-oldest",
  119. "/posts/..%2f..%2fetc%2fpasswd",
  120. "/posts/",
  121. "/static/",
  122. "/static/missing.css",
  123. "/whatever",
  124. } {
  125. status, _, body := get(t, srv, path)
  126. if status != 404 {
  127. t.Errorf("%s: status = %d, want 404", path, status)
  128. }
  129. if path != "/static/missing.css" && !strings.Contains(body, "<h1>404</h1>") {
  130. t.Errorf("%s: expected styled 404 page", path)
  131. }
  132. }
  133. }
  134. func TestStatic(t *testing.T) {
  135. srv, _ := newTestServer(t, nil)
  136. status, h, body := get(t, srv, "/static/style.css")
  137. if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "text/css") || !strings.Contains(body, ":root") {
  138. t.Errorf("status = %d, type = %q", status, h.Get("Content-Type"))
  139. }
  140. // gofmt indents with tabs; browsers default to 8 columns, which pushes
  141. // quoted Go code past the edge of the column.
  142. if !strings.Contains(body, "tab-size: 4") {
  143. t.Error("stylesheet should set tab-size: 4 for code blocks")
  144. }
  145. }
  146. func TestStylesheetCacheBusting(t *testing.T) {
  147. srv, _ := newTestServer(t, nil)
  148. _, _, page := get(t, srv, "/")
  149. m := regexp.MustCompile(`<link rel="stylesheet" href="(/static/style\.css\?v=([0-9a-f]{10}))">`).FindStringSubmatch(page)
  150. if m == nil {
  151. t.Fatalf("no hashed stylesheet link in:\n%s", page)
  152. }
  153. b, err := staticFS.ReadFile("static/style.css")
  154. if err != nil {
  155. t.Fatal(err)
  156. }
  157. sum := sha256.Sum256(b)
  158. if want := hex.EncodeToString(sum[:])[:10]; m[2] != want {
  159. t.Errorf("hash = %s, want %s", m[2], want)
  160. }
  161. for path, want := range map[string]string{
  162. m[1]: "public, max-age=31536000, immutable",
  163. "/static/style.css": "public, max-age=3600",
  164. "/static/style.css?v=0000000000": "public, max-age=3600",
  165. } {
  166. status, h, _ := get(t, srv, path)
  167. if status != 200 || h.Get("Cache-Control") != want {
  168. t.Errorf("%s: status = %d, Cache-Control = %q, want %q", path, status, h.Get("Cache-Control"), want)
  169. }
  170. }
  171. }
  172. func TestFeed(t *testing.T) {
  173. srv, _ := newTestServer(t, sample)
  174. status, h, body := get(t, srv, "/feed.xml")
  175. if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "application/rss+xml") {
  176. t.Fatalf("status = %d, type = %q", status, h.Get("Content-Type"))
  177. }
  178. var feed rss
  179. if err := xml.Unmarshal([]byte(body), &feed); err != nil {
  180. t.Fatalf("invalid XML: %v\n%s", err, body)
  181. }
  182. items := feed.Channel.Items
  183. if len(items) != 3 {
  184. t.Fatalf("got %d items, want 3", len(items))
  185. }
  186. if items[0].Link != "https://posts.example.com/posts/newest" {
  187. t.Errorf("first link = %q", items[0].Link)
  188. }
  189. if items[1].Description != "The middle one." {
  190. t.Errorf("description = %q", items[1].Description)
  191. }
  192. }
  193. func TestPinnedPosts(t *testing.T) {
  194. srv, _ := newTestServer(t, map[string]string{
  195. "2026-01-01-old-pinned.md": "---\npinned: true\n---\n# Old pinned\n",
  196. "2026-02-01-plain-a.md": "---\npinned: false\n---\n# Plain A\n",
  197. "2026-03-01-new-pinned.md": "---\npinned: yes\n---\n# Bad value\n",
  198. "2026-04-01-mid-pinned.md": "---\npinned: true\n---\n# Mid pinned\n",
  199. "2026-05-01-plain-b.md": "# Plain B\n",
  200. })
  201. // Index: pinned posts first (newest first), then the rest by date.
  202. // The invalid "pinned: yes" post is skipped, like a bad draft value.
  203. _, _, index := get(t, srv, "/")
  204. order := []string{"Mid pinned", "Old pinned", "Plain B", "Plain A"}
  205. last := -1
  206. for _, title := range order {
  207. i := strings.Index(index, ">"+title+"<")
  208. if i < 0 || i < last {
  209. t.Fatalf("index order should be %v:\n%s", order, index)
  210. }
  211. last = i
  212. }
  213. if strings.Contains(index, "Bad value") {
  214. t.Error("post with invalid pinned value should be skipped")
  215. }
  216. if n := strings.Count(index, `<span class="pinned">Pinned</span>`); n != 2 {
  217. t.Errorf("want 2 Pinned labels, got %d", n)
  218. }
  219. // Newer/older links and the feed keep plain date order.
  220. _, _, post := get(t, srv, "/posts/old-pinned")
  221. if !strings.Contains(post, `<a class="newer" href="/posts/plain-a">`) || strings.Contains(post, `class="older"`) {
  222. t.Errorf("old-pinned should link newer to plain-a and have no older link:\n%s", post)
  223. }
  224. _, _, feed := get(t, srv, "/feed.xml")
  225. if b, m := strings.Index(feed, "/posts/plain-b<"), strings.Index(feed, "/posts/mid-pinned<"); b < 0 || m < 0 || b > m {
  226. t.Error("feed should stay newest first, ignoring pins")
  227. }
  228. }
  229. func TestAuthor(t *testing.T) {
  230. srv, dir := newTestServer(t, map[string]string{
  231. "2026-01-01-anon.md": "# Anonymous\n",
  232. "2026-01-02-signed.md": "---\nauthor: Ann & co\n---\n# Signed\n",
  233. })
  234. check := func(defaultAuthor string) {
  235. t.Helper()
  236. _, _, index := get(t, srv, "/")
  237. for _, want := range []string{"</time> · by Ann &amp; co</span>", "</time> · by " + defaultAuthor + "</span>"} {
  238. if !strings.Contains(index, want) {
  239. t.Errorf("index: missing %q:\n%s", want, index)
  240. }
  241. }
  242. if _, _, post := get(t, srv, "/posts/signed"); !strings.Contains(post, "</time> · by Ann &amp; co</span>") {
  243. t.Errorf("signed post: author missing:\n%s", post)
  244. }
  245. if _, _, anon := get(t, srv, "/posts/anon"); !strings.Contains(anon, "</time> · by "+defaultAuthor+"</span>") {
  246. t.Errorf("anonymous post: want default author %q:\n%s", defaultAuthor, anon)
  247. }
  248. _, _, feed := get(t, srv, "/feed.xml")
  249. for _, want := range []string{
  250. `xmlns:dc="http://purl.org/dc/elements/1.1/"`,
  251. "<dc:creator>Ann &amp; co</dc:creator>",
  252. "<dc:creator>" + defaultAuthor + "</dc:creator>",
  253. } {
  254. if !strings.Contains(feed, want) {
  255. t.Errorf("feed: missing %q in:\n%s", want, feed)
  256. }
  257. }
  258. }
  259. check("Claude")
  260. writeFile(t, dir, "site.json", `{"defaultAuthor": "Site Owner"}`)
  261. check("Site Owner")
  262. }
  263. func TestHeaderAndFooter(t *testing.T) {
  264. srv, dir := newTestServer(t, sample)
  265. for _, path := range []string{"/", "/posts/middle", "/about", "/nope"} {
  266. _, _, body := get(t, srv, path)
  267. for _, want := range []string{
  268. `<a class="home" href="/">Home</a>`,
  269. `<nav><a href="/about">About</a></nav>`,
  270. "<footer class=\"site\">\n <a href=\"/feed.xml\">RSS</a>\n</footer>",
  271. } {
  272. if !strings.Contains(body, want) {
  273. t.Errorf("%s: missing %q", path, want)
  274. }
  275. }
  276. }
  277. writeFile(t, dir, "site.json", `{"homeText": "All <posts>"}`)
  278. if _, _, body := get(t, srv, "/"); !strings.Contains(body, `<a class="home" href="/">All &lt;posts&gt;</a>`) {
  279. t.Errorf("configured home text not used (or not escaped):\n%s", body)
  280. }
  281. if status, _, _ := get(t, srv, "/posts/site"); status != 404 {
  282. t.Error("site.json should not be treated as a post")
  283. }
  284. }
  285. func TestAboutCommitLink(t *testing.T) {
  286. const full = "d2a387e186c8caf21a35349b8c3d60f68fb87b1f"
  287. about := func(t *testing.T, c commitInfo, siteJSON string) string {
  288. t.Helper()
  289. dir := t.TempDir()
  290. if siteJSON != "" {
  291. writeFile(t, dir, "site.json", siteJSON)
  292. }
  293. a, err := newApp(dir, "https://posts.example.com")
  294. if err != nil {
  295. t.Fatal(err)
  296. }
  297. a.commit = c // go test binaries carry no VCS stamp; set one
  298. srv := httptest.NewServer(a.routes())
  299. t.Cleanup(srv.Close)
  300. _, _, body := get(t, srv, "/about")
  301. return body
  302. }
  303. t.Run("clean build links the full hash", func(t *testing.T) {
  304. body := about(t, commitInfo{Full: full, Short: full[:7]}, "")
  305. want := `<span class="muted">(<a href="https://git.monoexp.com/bajdev/posts/commit/` + full + `">d2a387e</a>)</span>`
  306. if !strings.Contains(body, want) {
  307. t.Errorf("missing %s in:\n%s", want, body)
  308. }
  309. })
  310. t.Run("repoURL from site.json", func(t *testing.T) {
  311. body := about(t, commitInfo{Full: full, Short: full[:7]}, `{"repoURL": "https://example.org/me/fork/"}`)
  312. if !strings.Contains(body, `href="https://example.org/me/fork/commit/`+full+`"`) {
  313. t.Errorf("site.json repoURL not used:\n%s", body)
  314. }
  315. })
  316. t.Run("dirty build is not linked", func(t *testing.T) {
  317. body := about(t, commitInfo{Full: full, Short: full[:7], Dirty: true}, "")
  318. if !strings.Contains(body, `(d2a387e-dirty)`) || strings.Contains(body, "/commit/") {
  319. t.Errorf("dirty build should show -dirty with no link:\n%s", body)
  320. }
  321. })
  322. t.Run("no commit shows version only", func(t *testing.T) {
  323. body := about(t, commitInfo{}, "")
  324. if !strings.Contains(body, "<dd>"+version+"</dd>") {
  325. t.Errorf("want bare version:\n%s", body)
  326. }
  327. })
  328. }
  329. func TestAboutPage(t *testing.T) {
  330. srv, _ := newTestServer(t, sample)
  331. status, _, body := get(t, srv, "/about")
  332. if status != 200 {
  333. t.Fatalf("status = %d", status)
  334. }
  335. for _, want := range []string{
  336. "<title>About · posts.example.com</title>",
  337. "<dt>Version</dt>\n <dd>" + version,
  338. "<dt>Posts</dt>\n <dd>3</dd>",
  339. `<dd><a href="/posts/newest">Newest</a>`,
  340. `<h2 id="the-header-block">The header block</h2>`,
  341. `<h2 id="what-markdown-works">What Markdown works</h2>`,
  342. } {
  343. if !strings.Contains(body, want) {
  344. t.Errorf("missing %q in:\n%s", want, body)
  345. }
  346. }
  347. empty, _ := newTestServer(t, nil)
  348. _, _, body = get(t, empty, "/about")
  349. if !strings.Contains(body, "<dd>0</dd>") || strings.Contains(body, "Latest post") {
  350. t.Errorf("empty site: expected 0 posts and no latest post:\n%s", body)
  351. }
  352. }
  353. func TestReloadsWhenContentChanges(t *testing.T) {
  354. srv, dir := newTestServer(t, map[string]string{"2026-01-01-one.md": "# One\n"})
  355. if _, _, body := get(t, srv, "/"); strings.Contains(body, "Two") {
  356. t.Fatal("unexpected post")
  357. }
  358. writeFile(t, dir, "2026-01-02-two.md", "# Two\n")
  359. if status, _, _ := get(t, srv, "/posts/two"); status != 200 {
  360. t.Errorf("new post not picked up: status = %d", status)
  361. }
  362. if err := os.Remove(filepath.Join(dir, "2026-01-01-one.md")); err != nil {
  363. t.Fatal(err)
  364. }
  365. if status, _, _ := get(t, srv, "/posts/one"); status != 404 {
  366. t.Errorf("removed post still served: status = %d", status)
  367. }
  368. }