server_test.go (14188 bytes)
1 package main 2 3 import ( 4 "bytes" 5 "encoding/xml" 6 "mime/multipart" 7 "net/http" 8 "net/http/httptest" 9 "net/url" 10 "os" 11 "path/filepath" 12 "reflect" 13 "runtime" 14 "slices" 15 "strings" 16 "testing" 17 "time" 18 19 "pgregory.net/rapid" 20 ) 21 22 // day is the time of the clock of the test server. 23 var day = time.Date(2026, 9, 25, 8, 0, 0, 0, time.UTC) 24 25 const dayLine = "# 2026-09-25T08:00:00Z\n" 26 27 func newServer(t *testing.T) (*server, http.Handler) { 28 s := &server{ 29 links: &store{path: filepath.Join(t.TempDir(), "links.sbm")}, 30 password: "pw", 31 now: func() time.Time { return day }, 32 } 33 return s, s.routes() 34 } 35 36 func do(h http.Handler, r *http.Request, auth bool) *httptest.ResponseRecorder { 37 if auth { 38 r.SetBasicAuth("me", "pw") 39 } 40 w := httptest.NewRecorder() 41 h.ServeHTTP(w, r) 42 return w 43 } 44 45 func get(h http.Handler, path string) *httptest.ResponseRecorder { 46 return do(h, httptest.NewRequest("GET", path, nil), false) 47 } 48 49 func form(path string, v url.Values) *http.Request { 50 r := httptest.NewRequest("POST", path, strings.NewReader(v.Encode())) 51 r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 52 return r 53 } 54 55 // fataler is *testing.T or *rapid.T. 56 type fataler interface { 57 Fatalf(format string, args ...any) 58 } 59 60 func write(t fataler, s *server, text string) { 61 if err := os.WriteFile(s.links.path, []byte(text), 0o644); err != nil { 62 t.Fatalf("%v", err) 63 } 64 } 65 66 func feedOf(t fataler, w *httptest.ResponseRecorder) atomFeed { 67 var f atomFeed 68 if err := xml.Unmarshal(w.Body.Bytes(), &f); err != nil { 69 t.Fatalf("%v in %s", err, w.Body) 70 } 71 return f 72 } 73 74 func TestAdminNeedsPassword(t *testing.T) { 75 _, h := newServer(t) 76 for _, r := range []*http.Request{ 77 httptest.NewRequest("GET", "/links/admin", nil), 78 form("/links/admin/add", url.Values{"url": {"https://a.com"}}), 79 form("/links/admin/feed", url.Values{"url": {"https://a.com"}, "feed": {"https://a.com/rss"}}), 80 } { 81 if w := do(h, r, false); w.Code != http.StatusUnauthorized { 82 t.Errorf("%s %s: code %d", r.Method, r.URL, w.Code) 83 } 84 } 85 r := httptest.NewRequest("GET", "/links/admin", nil) 86 r.SetBasicAuth("me", "wrong") 87 if w := do(h, r, false); w.Code != http.StatusUnauthorized { 88 t.Errorf("wrong password: code %d", w.Code) 89 } 90 } 91 92 func TestAddAndDelete(t *testing.T) { 93 s, h := newServer(t) 94 if body := get(h, "/links/links.txt").Body.String(); body != "" { 95 t.Fatalf("empty store gave %q", body) 96 } 97 do(h, form("/links/admin/add", url.Values{"url": {"https://a.com"}, "desc": {"A"}, "tags": {"x y"}, "feed": {"https://a.com/rss"}}), true) 98 w := do(h, form("/links/admin/add", url.Values{"url": {"b.com"}}), true) 99 if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/links/admin?m=Added." { 100 t.Fatalf("add: %d %q", w.Code, w.Header().Get("Location")) 101 } 102 w = do(h, form("/links/admin/add", url.Values{"url": {"http://www.a.com/"}}), true) 103 if !strings.Contains(w.Header().Get("Location"), "already") { 104 t.Fatalf("duplicate add: %q", w.Header().Get("Location")) 105 } 106 if body := get(h, "/links/links.txt").Body.String(); body != "https://a.com\tA\tx y\thttps://a.com/rss\nhttps://b.com\t\t\n" { 107 t.Fatalf("links.txt: %q", body) 108 } 109 do(h, form("/links/admin/delete", url.Values{"url": {"https://b.com"}}), true) 110 // The date line of the deleted link stays. bm ignores it. 111 b, _ := os.ReadFile(s.links.path) 112 if string(b) != dayLine+"https://a.com\tA\tx y\thttps://a.com/rss\n"+dayLine { 113 t.Fatalf("file: %q", b) 114 } 115 } 116 117 func location(w *httptest.ResponseRecorder) string { 118 m, _ := url.ParseQuery(strings.TrimPrefix(w.Header().Get("Location"), "/links/admin?")) 119 return m.Get("m") 120 } 121 122 func TestAddGivesTheFeedToALinkWithout(t *testing.T) { 123 s, h := newServer(t) 124 do(h, form("/links/admin/add", url.Values{"url": {"https://a.com"}, "desc": {"A"}}), true) 125 w := do(h, form("/links/admin/add", url.Values{"url": {"a.com/"}, "desc": {"other"}, "feed": {"https://a.com/rss"}}), true) 126 if m := location(w); m != "That URL is a link already. It got the feed." { 127 t.Fatalf("message %q", m) 128 } 129 // One date line only: no link was added the second time. 130 if b, _ := os.ReadFile(s.links.path); string(b) != dayLine+"https://a.com\tA\t\thttps://a.com/rss\n" { 131 t.Fatalf("file: %q", b) 132 } 133 } 134 135 func TestAdminPageShowsFeeds(t *testing.T) { 136 s, h := newServer(t) 137 write(t, s, "https://github.com/a/b\tB\tx\thttps://github.com/a/b/releases.atom\nhttps://c.com\tC\t\n") 138 body := do(h, httptest.NewRequest("GET", "/links/admin", nil), true).Body.String() 139 for _, want := range []string{ 140 `<input type="url" name="feed" placeholder="Feed (RSS or Atom) of the site, if it has one">`, 141 `<span class="host">github.com/a/b</span>`, 142 `<input type="url" name="feed" value="https://github.com/a/b/releases.atom" placeholder="Feed (RSS or Atom)">`, 143 `<input type="url" name="feed" value="" placeholder="Feed (RSS or Atom)">`, 144 } { 145 if !strings.Contains(body, want) { 146 t.Errorf("admin page has no %s", want) 147 } 148 } 149 } 150 151 func TestEditFeed(t *testing.T) { 152 s, h := newServer(t) 153 write(t, s, dayLine+"https://a.com\tA\tx\nhttps://b.com\tB\t\n") 154 for _, c := range []struct{ feed, message, file string }{ 155 {"https://a.com/rss", "Saved the feed.", dayLine + "https://a.com\tA\tx\thttps://a.com/rss\nhttps://b.com\tB\t\n"}, 156 {"javascript:alert(1)", "Give an http or https address for the feed.", dayLine + "https://a.com\tA\tx\thttps://a.com/rss\nhttps://b.com\tB\t\n"}, 157 {"", "Saved the feed.", dayLine + "https://a.com\tA\tx\nhttps://b.com\tB\t\n"}, 158 } { 159 w := do(h, form("/links/admin/feed", url.Values{"url": {"https://a.com"}, "feed": {c.feed}}), true) 160 b, _ := os.ReadFile(s.links.path) 161 if m := location(w); m != c.message || string(b) != c.file { 162 t.Errorf("feed %q: message %q, file %q", c.feed, m, b) 163 } 164 } 165 } 166 167 func TestOutputsHideUnsafeLinks(t *testing.T) { 168 s, h := newServer(t) 169 write(t, s, "javascript:alert(1)\tx\t\ndata:text/html,x\ty\t\nhttps://a.com\tA\t\nhttps://b.com\tB\t\tjavascript:alert(2)\n") 170 for _, path := range []string{"/links/links.txt", "/links/atom.xml", "/links/list.html"} { 171 body := get(h, path).Body.String() 172 if strings.Contains(body, "javascript") || strings.Contains(body, "data:") || !strings.Contains(body, "https://a.com") || !strings.Contains(body, "https://b.com") { 173 t.Errorf("%s: %s", path, body) 174 } 175 } 176 } 177 178 func TestAtom(t *testing.T) { 179 s, h := newServer(t) 180 write(t, s, "https://old.com\tOld\t\n# 2026-01-02T03:04:05Z\nhttps://a.com\tA & B\tx y\n# a comment\nb.com\t\t\n") 181 r := httptest.NewRequest("GET", "/links/atom.xml", nil) 182 r.Host = "example.org" 183 r.Header.Set("X-Forwarded-Proto", "https") 184 w := do(h, r, false) 185 if ct := w.Header().Get("Content-Type"); ct != "application/atom+xml; charset=utf-8" { 186 t.Errorf("Content-Type %q", ct) 187 } 188 f := feedOf(t, w) 189 self := "https://example.org/links/atom.xml" 190 if f.ID != self || f.Link != (atomLink{"self", self}) || f.Title != "example.org links" || f.Author != "example.org" { 191 t.Errorf("feed: %+v", f) 192 } 193 st, err := os.Stat(s.links.path) 194 if err != nil { 195 t.Fatal(err) 196 } 197 mod := atomTime(st.ModTime()) 198 if f.Updated != mod { 199 t.Errorf("feed updated %q, want the time of the file %q", f.Updated, mod) 200 } 201 date := "2026-01-02T03:04:05Z" 202 want := []atomEntry{ 203 {Title: "https://b.com", Link: atomLink{Href: "https://b.com"}, ID: "https://b.com", Updated: date}, 204 {Title: "A & B", Link: atomLink{Href: "https://a.com"}, ID: "https://a.com", Updated: date, 205 Categories: []atomCategory{{"x"}, {"y"}}}, 206 {Title: "Old", Link: atomLink{Href: "https://old.com"}, ID: "https://old.com", Updated: mod}, 207 } 208 if !reflect.DeepEqual(f.Entries, want) { 209 t.Errorf("entries:\n%+v\nwant:\n%+v", f.Entries, want) 210 } 211 } 212 213 // genPublicRow makes a file line with the characters that XML and HTML must 214 // escape, and with links that the outputs must leave out. 215 func genPublicRow(t *rapid.T) string { 216 u := rapid.SampledFrom([]string{"https://a.com/", "http://b.org/?x=1&y=<2>", "c.net/", "javascript:alert(1)//", "ftp://d.com/"}).Draw(t, "url") 217 path := rapid.StringMatching(`[a-z]{0,3}`).Draw(t, "path") 218 desc := rapid.StringMatching(`[a-z<>&"' \]\t]{0,10}`).Draw(t, "desc") 219 tags := rapid.StringMatching(`[a-z<>&" ]{0,8}`).Draw(t, "tags") 220 feed := rapid.SampledFrom([]string{"", "https://a.com/feed", "http://b.org/rss?x=<1>&y", "c.net/atom.xml", "javascript:alert(2)"}).Draw(t, "feed") 221 return line(u+path, desc, tags, feed) 222 } 223 224 // TestOutputsRoundTrip: links.txt parses back to the published links, and the 225 // feed gives each published link, newest first, with its time and tags. 226 func TestOutputsRoundTrip(t *testing.T) { 227 s, h := newServer(t) 228 rapid.Check(t, func(t *rapid.T) { 229 text := "" 230 for range rapid.IntRange(0, 3).Draw(t, "batches") { 231 rows := rapid.SliceOf(rapid.Custom(genPublicRow)).Draw(t, "rows") 232 when := time.Unix(rapid.Int64Range(0, 4102444800).Draw(t, "when"), 0) 233 text, _, _, _ = addDated(text, rows, when) 234 } 235 write(t, s, text) 236 want := published(text) 237 238 got := parse(get(h, "/links/links.txt").Body.String()) 239 if len(got) != len(want) { 240 t.Fatalf("links.txt has %d links, want %d", len(got), len(want)) 241 } 242 for i := range want { 243 if got[i].URL != want[i].URL || got[i].Desc != want[i].Desc || !slices.Equal(got[i].Tags, want[i].Tags) || got[i].Feed != want[i].Feed { 244 t.Fatalf("links.txt link %d is %+v, want %+v", i, got[i], want[i]) 245 } 246 } 247 248 f := feedOf(t, get(h, "/links/atom.xml")) 249 if len(f.Entries) != len(want) { 250 t.Fatalf("feed has %d entries, want %d", len(f.Entries), len(want)) 251 } 252 for i, e := range f.Entries { 253 l := want[len(want)-1-i] 254 var tags []string 255 for _, c := range e.Categories { 256 tags = append(tags, c.Term) 257 } 258 if e.Title != l.Title() || e.Link.Href != l.URL || e.ID != l.URL || e.Updated != atomTime(l.Added) || !slices.Equal(tags, l.Tags) { 259 t.Fatalf("entry %d is %+v, want %+v", i, e, l) 260 } 261 } 262 }) 263 } 264 265 func TestAtomReplacesCharactersThatXMLCannotHold(t *testing.T) { 266 s, h := newServer(t) 267 write(t, s, "https://a.com\tbell \x07 and ]]>\t\n") 268 if f := feedOf(t, get(h, "/links/atom.xml")); f.Entries[0].Title != "bell � and ]]>" { 269 t.Fatalf("title %q", f.Entries[0].Title) 270 } 271 } 272 273 func TestNotModified(t *testing.T) { 274 s, h := newServer(t) 275 write(t, s, "https://a.com\tA\t\n") 276 for _, path := range []string{"/links/links.txt", "/links/atom.xml", "/links/list.html"} { 277 lm := get(h, path).Header().Get("Last-Modified") 278 r := httptest.NewRequest("GET", path, nil) 279 r.Header.Set("If-Modified-Since", lm) 280 if w := do(h, r, false); lm == "" || w.Code != http.StatusNotModified { 281 t.Errorf("%s: Last-Modified %q, code %d", path, lm, w.Code) 282 } 283 } 284 } 285 286 func TestList(t *testing.T) { 287 s, h := newServer(t) 288 if body := get(h, "/links/list.html").Body.String(); body != "<p>No links yet.</p>\n" { 289 t.Fatalf("empty list: %q", body) 290 } 291 write(t, s, "https://a.com/x/\t<A>\tx y\thttps://a.com/rss\nhttps://b.com\tB\t\nhttps://c.com\tC\ty y\n") 292 want := `<p><a href="#x">x</a> <a href="#y">y</a> <a href="#untagged">untagged</a> </p> 293 <h2 id="x">x</h2> 294 <ul> 295 <li><a href="https://a.com/x/"><A></a> <small>a.com/x</small> <a class="feed" href="https://a.com/rss">rss</a></li> 296 </ul> 297 <h2 id="y">y</h2> 298 <ul> 299 <li><a href="https://c.com">C</a> <small>c.com</small></li> 300 <li><a href="https://a.com/x/"><A></a> <small>a.com/x</small> <a class="feed" href="https://a.com/rss">rss</a></li> 301 </ul> 302 <h2 id="untagged">untagged</h2> 303 <ul> 304 <li><a href="https://b.com">B</a> <small>b.com</small></li> 305 </ul> 306 ` 307 if body := get(h, "/links/list.html").Body.String(); body != want { 308 t.Fatalf("list:\n%s\nwant:\n%s", body, want) 309 } 310 } 311 312 func TestImport(t *testing.T) { 313 s, h := newServer(t) 314 write(t, s, "https://a.com\tA\t\n") 315 var body bytes.Buffer 316 mw := multipart.NewWriter(&body) 317 f, _ := mw.CreateFormFile("file", "bookmarks.sbm") 318 f.Write([]byte("https://a.com/\tagain\t\thttps://a.com/rss\nhttps://b.com\tB\tt\n")) 319 mw.Close() 320 r := httptest.NewRequest("POST", "/links/admin/import", &body) 321 r.Header.Set("Content-Type", mw.FormDataContentType()) 322 w := do(h, r, true) 323 if m := location(w); m != "Imported 1 links. Skipped 1 duplicates. Added 1 feeds to links that had none." { 324 t.Fatalf("import: %d %q", w.Code, m) 325 } 326 // The link a.com keeps its description, and gets the feed. 327 b, _ := os.ReadFile(s.links.path) 328 if string(b) != "https://a.com\tA\t\thttps://a.com/rss\n"+dayLine+"https://b.com\tB\tt\n" { 329 t.Fatalf("file: %q", b) 330 } 331 } 332 333 func TestFeedCommand(t *testing.T) { 334 s, _ := newServer(t) 335 write(t, s, "https://a.com\tA\tx\thttps://github.com/a/commits.atom\n") 336 for _, c := range []struct{ u, feed, message, file string }{ 337 // A feed that the link has already changes. 338 {"a.com/", "https://a.com/Japanese.xml", "Saved the feed.", "https://a.com\tA\tx\thttps://a.com/Japanese.xml\n"}, 339 {"https://b.com", "https://b.com/rss", "No link has that URL.", "https://a.com\tA\tx\thttps://a.com/Japanese.xml\n"}, 340 {"https://a.com", "file:///x", "Give an http or https address for the feed.", "https://a.com\tA\tx\thttps://a.com/Japanese.xml\n"}, 341 } { 342 m, err := saveFeed(s.links, c.u, c.feed) 343 b, _ := os.ReadFile(s.links.path) 344 if err != nil || m != c.message || string(b) != c.file { 345 t.Errorf("feed %s %s: %q %v, file %q", c.u, c.feed, m, err, b) 346 } 347 } 348 } 349 350 func TestMergeCommand(t *testing.T) { 351 s, _ := newServer(t) 352 write(t, s, "https://a.com\tA\t\n") 353 m, err := mergeFrom(s.links, strings.NewReader("https://b.com\tB\tt\thttps://b.com/rss\nhttps://a.com\tagain\t\n"), day) 354 if err != nil || m != "Imported 1 links. Skipped 1 duplicates." { 355 t.Fatalf("%q %v", m, err) 356 } 357 b, _ := os.ReadFile(s.links.path) 358 if string(b) != "https://a.com\tA\t\n"+dayLine+"https://b.com\tB\tt\thttps://b.com/rss\n" { 359 t.Fatalf("file: %q", b) 360 } 361 // The service must be able to read a file that root wrote. 362 if st, err := os.Stat(s.links.path); runtime.GOOS != "windows" && (err != nil || st.Mode().Perm() != 0o644) { 363 t.Fatalf("mode %v %v", st.Mode(), err) 364 } 365 } 366 367 func TestCrossOriginPostRefused(t *testing.T) { 368 s, h := newServer(t) 369 r := form("/links/admin/add", url.Values{"url": {"https://evil.com"}}) 370 r.Header.Set("Sec-Fetch-Site", "cross-site") 371 if w := do(h, r, true); w.Code != http.StatusForbidden { 372 t.Fatalf("code %d", w.Code) 373 } 374 if _, err := os.Stat(s.links.path); err == nil { 375 t.Fatal("cross-site POST wrote the file") 376 } 377 }