Recently Written · git

sbm-webpublish

Publish an sbm bookmark file as a list of links on a web site

git clone https://github.com/equwal/sbm-webpublish

Log | Files | Refs


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/">&lt;A&gt;</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/">&lt;A&gt;</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 }