Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

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

Log | Files | Refs


web_test.go (22428 bytes)

1 package main
2 
3 import (
4 	"bytes"
5 	"encoding/xml"
6 	"io"
7 	"mime/multipart"
8 	"net/http"
9 	"net/url"
10 	"regexp"
11 	"slices"
12 	"strings"
13 	"testing"
14 	"time"
15 )
16 
17 // get gives the status, the Location header and the body of a page.
18 func (e *harness) get(c *http.Client, path string) (int, string, string) {
19 	resp, err := c.Get(e.web.URL + path)
20 	if err != nil {
21 		e.t.Fatal(err)
22 	}
23 	defer resp.Body.Close()
24 	b, _ := io.ReadAll(resp.Body)
25 	return resp.StatusCode, resp.Header.Get("Location"), string(b)
26 }
27 
28 // send posts a form, and gives the status, the Location header and the body.
29 func (e *harness) send(c *http.Client, path string, form url.Values) (int, string, string) {
30 	resp := e.post(c, path, form)
31 	defer resp.Body.Close()
32 	b, _ := io.ReadAll(resp.Body)
33 	return resp.StatusCode, resp.Header.Get("Location"), string(b)
34 }
35 
36 // upload sends a file with the import form, as a browser does.
37 func (e *harness) upload(c *http.Client, content string) (int, string, string) {
38 	var body bytes.Buffer
39 	m := multipart.NewWriter(&body)
40 	f, _ := m.CreateFormFile("file", "bookmarks.html")
41 	io.WriteString(f, content)
42 	m.Close()
43 	req, _ := http.NewRequest("POST", e.web.URL+"/bookmarks/import", &body)
44 	req.Header.Set("Content-Type", m.FormDataContentType())
45 	req.Header.Set("Origin", e.web.URL)
46 	resp, err := c.Do(req)
47 	if err != nil {
48 		e.t.Fatal(err)
49 	}
50 	defer resp.Body.Close()
51 	b, _ := io.ReadAll(resp.Body)
52 	return resp.StatusCode, resp.Header.Get("Location"), string(b)
53 }
54 
55 // account makes an account whose file is text. It gives a signed-in browser,
56 // the token of a device and the version of the file.
57 func (e *harness) account(text string) (*http.Client, string, string) {
58 	c := e.signup("me@example.org")
59 	tok := e.token("me@example.org")
60 	code, _, v := e.sync(tok, "", text)
61 	if code != http.StatusOK {
62 		e.t.Fatalf("first sync: %d", code)
63 	}
64 	return c, tok, v
65 }
66 
67 // fileOf gives the file on the server, as a device without a file gets it.
68 func (e *harness) fileOf(tok string) string {
69 	_, text, _ := e.sync(tok, "", "")
70 	return text
71 }
72 
73 var editLink = regexp.MustCompile(`href="/bookmarks/edit\?v=([0-9a-f]{64})&n=([0-9]+)"`)
74 
75 // editForm gives the fields v and n of the edit link of the bookmark with the
76 // title on the page.
77 func editForm(t *testing.T, page, title string) url.Values {
78 	t.Helper()
79 	for _, li := range strings.Split(page, "<li>") {
80 		if strings.Contains(li, ">"+title+"</a>") {
81 			if m := editLink.FindStringSubmatch(li); m != nil {
82 				return url.Values{"v": {m[1]}, "n": {m[2]}}
83 			}
84 		}
85 	}
86 	t.Fatalf("no edit link for %q on the page:\n%s", title, page)
87 	return nil
88 }
89 
90 var rowTitle = regexp.MustCompile(`<li>(?:<a href="[^"]*"[^>]*>)?([^<]*)`)
91 
92 // titles gives the titles of the bookmarks on the page, in their order.
93 func titles(page string) []string {
94 	var out []string
95 	for _, m := range rowTitle.FindAllStringSubmatch(page, -1) {
96 		out = append(out, m[1])
97 	}
98 	return out
99 }
100 
101 func TestBookmarkPagesNeedSignIn(t *testing.T) {
102 	e := newEnv(t, false)
103 	c := e.browser()
104 	for _, path := range []string{"/bookmarks", "/bookmarks/file", "/bookmarks.txt",
105 		"/bookmarks/edit?v=" + strings.Repeat("0", 64) + "&n=0"} {
106 		if code, to, _ := e.get(c, path); code != http.StatusSeeOther || to != "/login" {
107 			t.Errorf("GET %s: %d to %q", path, code, to)
108 		}
109 	}
110 	for _, path := range []string{"/bookmarks/add", "/bookmarks/edit", "/bookmarks/file", "/bookmarks/import"} {
111 		if code, to, _ := e.send(c, path, url.Values{"url": {"https://a.org"}}); code != http.StatusSeeOther || to != "/login" {
112 			t.Errorf("POST %s: %d to %q", path, code, to)
113 		}
114 	}
115 }
116 
117 func TestSignInOpensTheBookmarks(t *testing.T) {
118 	e := newEnv(t, false)
119 	form := url.Values{"email": {"me@example.org"}, "password": {"password1"}}
120 	if code, to, _ := e.send(e.browser(), "/signup", form); code != http.StatusSeeOther || to != "/account" {
121 		t.Errorf("sign up: %d to %q", code, to)
122 	}
123 	c := e.browser()
124 	if code, to, _ := e.send(c, "/login", form); code != http.StatusSeeOther || to != "/bookmarks" {
125 		t.Errorf("sign in: %d to %q", code, to)
126 	}
127 	if code, _, page := e.get(c, "/bookmarks"); code != http.StatusOK || !strings.Contains(page, "0 of 0 bookmarks.") {
128 		t.Errorf("bookmarks of a new account: %d\n%s", code, page)
129 	}
130 	for _, path := range []string{"/", "/account"} {
131 		if !strings.Contains(e.body(c, path), `<a href="/bookmarks">`) {
132 			t.Errorf("%s has no link to the bookmarks", path)
133 		}
134 	}
135 }
136 
137 func TestWebChangesReachTheDevices(t *testing.T) {
138 	e := newEnv(t, false)
139 	c, tok, _ := e.account("https://a.org\tA\tx\nhttps://b.org\tB\t\n")
140 	if got := titles(e.body(c, "/bookmarks")); !slices.Equal(got, []string{"B", "A"}) {
141 		t.Fatalf("titles: %q", got)
142 	}
143 	code, to, _ := e.send(c, "/bookmarks/add", url.Values{"url": {" https://c.org "}, "desc": {"C\tc"}, "tags": {" y  z "}})
144 	if code != http.StatusSeeOther || to != "/bookmarks?done=added" {
145 		t.Fatalf("add: %d to %q", code, to)
146 	}
147 	if got, want := e.fileOf(tok), "https://a.org\tA\tx\nhttps://b.org\tB\t\nhttps://c.org\tC c\ty z\n"; got != want {
148 		t.Fatalf("after the add: %q, want %q", got, want)
149 	}
150 	if !strings.Contains(e.body(c, to), "Bookmark added.") {
151 		t.Error("the page does not tell that the bookmark is added")
152 	}
153 
154 	form := editForm(t, e.body(c, "/bookmarks"), "A")
155 	page := e.body(c, "/bookmarks/edit?v="+form.Get("v")+"&n="+form.Get("n"))
156 	if !strings.Contains(page, `name="url" value="https://a.org"`) || !strings.Contains(page, `name="tags" value="x"`) {
157 		t.Fatalf("edit page:\n%s", page)
158 	}
159 	form.Set("url", "https://a.org")
160 	form.Set("desc", "A, edited")
161 	form.Set("tags", "x w")
162 	if code, to, _ := e.send(c, "/bookmarks/edit", form); code != http.StatusSeeOther || to != "/bookmarks?done=saved" {
163 		t.Fatalf("save: %d to %q", code, to)
164 	}
165 	form = editForm(t, e.body(c, "/bookmarks"), "B")
166 	form.Set("do", "delete")
167 	if code, to, _ := e.send(c, "/bookmarks/edit", form); code != http.StatusSeeOther || to != "/bookmarks?done=deleted" {
168 		t.Fatalf("delete: %d to %q", code, to)
169 	}
170 	if got, want := e.fileOf(tok), "https://a.org\tA, edited\tx w\nhttps://c.org\tC c\ty z\n"; got != want {
171 		t.Fatalf("after the edit and the delete: %q, want %q", got, want)
172 	}
173 }
174 
175 func TestWebEditsKeepTheChangesOfDevices(t *testing.T) {
176 	e := newEnv(t, false)
177 	c, tok, v := e.account("https://a.org\tA\t\nhttps://b.org\tB\t\n")
178 	form := editForm(t, e.body(c, "/bookmarks"), "A")
179 	// A device adds a bookmark while the page is open.
180 	e.sync(tok, v, "https://a.org\tA\t\nhttps://b.org\tB\t\nhttps://d.org\tD\t\n")
181 	form.Set("do", "delete")
182 	if code, to, _ := e.send(c, "/bookmarks/edit", form); code != http.StatusSeeOther || to != "/bookmarks?done=deleted" {
183 		t.Fatalf("delete: %d to %q", code, to)
184 	}
185 	if got, want := e.fileOf(tok), "https://b.org\tB\t\nhttps://d.org\tD\t\n"; got != want {
186 		t.Fatalf("after the delete: %q, want %q", got, want)
187 	}
188 
189 	// A device edits B while the page is open. The page does not change B.
190 	form = editForm(t, e.body(c, "/bookmarks"), "B")
191 	_, text, v := e.sync(tok, "", "")
192 	e.sync(tok, v, strings.Replace(text, "\tB\t", "\tB on a device\t", 1))
193 	form.Set("url", "https://b.org")
194 	form.Set("desc", "B on the web")
195 	if code, to, _ := e.send(c, "/bookmarks/edit", form); code != http.StatusSeeOther || to != "/bookmarks?done=changed" {
196 		t.Fatalf("save of a bookmark that a device changed: %d to %q", code, to)
197 	}
198 	if got, want := e.fileOf(tok), "https://b.org\tB on a device\t\nhttps://d.org\tD\t\n"; got != want {
199 		t.Fatalf("after the refused save: %q, want %q", got, want)
200 	}
201 }
202 
203 func TestAddAndEditRefusals(t *testing.T) {
204 	e := newEnv(t, false)
205 	c, tok, _ := e.account("http://a.org/\tA\t\nhttps://b.org\tB\t\n")
206 	file := e.fileOf(tok)
207 	for _, tc := range []struct {
208 		form url.Values
209 		code int
210 		text string
211 	}{
212 		{url.Values{"url": {"https://www.A.org"}, "desc": {"typed"}}, http.StatusConflict, "This URL is a bookmark already: A."},
213 		{url.Values{"url": {" "}}, http.StatusBadRequest, "Type the URL of the bookmark."},
214 		{url.Values{"url": {"#x"}}, http.StatusBadRequest, "A URL cannot start with #"},
215 	} {
216 		code, _, page := e.send(c, "/bookmarks/add", tc.form)
217 		if code != tc.code || !strings.Contains(page, tc.text) || !strings.Contains(page, "<details open>") {
218 			t.Errorf("add %v: %d, want %d with %q in an open form", tc.form, code, tc.code, tc.text)
219 		}
220 	}
221 	if _, _, page := e.send(c, "/bookmarks/add", url.Values{"url": {"https://a.org"}, "desc": {"typed"}}); !strings.Contains(page, `value="typed"`) {
222 		t.Error("the add form lost the text that was typed")
223 	}
224 
225 	form := editForm(t, e.body(c, "/bookmarks"), "A")
226 	form.Set("url", "https://b.org/")
227 	if code, _, page := e.send(c, "/bookmarks/edit", form); code != http.StatusConflict || !strings.Contains(page, "Another bookmark has this URL: B.") {
228 		t.Errorf("edit to the URL of B: %d", code)
229 	}
230 	form.Set("url", "")
231 	if code, _, _ := e.send(c, "/bookmarks/edit", form); code != http.StatusBadRequest {
232 		t.Errorf("edit without a URL: %d", code)
233 	}
234 	// A version that is gone, or a line that the version does not have.
235 	if code, to, _ := e.get(c, "/bookmarks/edit?v="+strings.Repeat("0", 64)+"&n=0"); code != http.StatusSeeOther || to != "/bookmarks?done=changed" {
236 		t.Errorf("edit page of an unknown version: %d to %q", code, to)
237 	}
238 	if code, to, _ := e.send(c, "/bookmarks/edit", url.Values{"v": {form.Get("v")}, "n": {"9"}, "url": {"https://x.org"}}); code != http.StatusSeeOther || to != "/bookmarks?done=changed" {
239 		t.Errorf("save of an unknown line: %d to %q", code, to)
240 	}
241 	if got := e.fileOf(tok); got != file {
242 		t.Errorf("a refusal changed the file: %q, want %q", got, file)
243 	}
244 }
245 
246 func TestSearchTagsAndOrders(t *testing.T) {
247 	e := newEnv(t, false)
248 	c, _, _ := e.account("https://b.org\tBee\tx\nhttps://c.org\tAnt\ty\nhttps://a.org\tCat\tz w\n# a comment\nhttps://jisho.org\tJisho: 日本語\tja\n")
249 	for _, tc := range []struct {
250 		path string
251 		want []string
252 	}{
253 		{"/bookmarks", []string{"Jisho: 日本語", "Cat", "Ant", "Bee"}},
254 		{"/bookmarks?sort=url", []string{"Cat", "Bee", "Ant", "Jisho: 日本語"}},
255 		{"/bookmarks?sort=desc", []string{"Ant", "Bee", "Cat", "Jisho: 日本語"}},
256 		{"/bookmarks?sort=tag", []string{"Jisho: 日本語", "Bee", "Ant", "Cat"}},
257 		{"/bookmarks?sort=nonsense", []string{"Jisho: 日本語", "Cat", "Ant", "Bee"}},
258 		{"/bookmarks?q=jsh", []string{"Jisho: 日本語"}},
259 		{"/bookmarks?q=" + url.QueryEscape("日本"), []string{"Jisho: 日本語"}},
260 		{"/bookmarks?tag=z", []string{"Cat"}},
261 		{"/bookmarks?tag=z&q=bee", nil},
262 	} {
263 		if got := titles(e.body(c, tc.path)); !slices.Equal(got, tc.want) {
264 			t.Errorf("%s: %q, want %q", tc.path, got, tc.want)
265 		}
266 	}
267 	page := e.body(c, "/bookmarks?q=jsh")
268 	if !strings.Contains(page, "1 of 4 bookmarks.") || !strings.Contains(page, `<a href="/bookmarks?tag=ja">ja</a>&nbsp;(1)`) {
269 		t.Errorf("the count or the tags are wrong:\n%s", page)
270 	}
271 	// Text that no bookmark matches goes to the web, as in bm.
272 	if page := e.body(c, "/bookmarks?q=posix+sh"); !strings.Contains(page, "No bookmark matches.") || !strings.Contains(page, "https://duckduckgo.com/?q=posix") {
273 		t.Errorf("no web search for text that no bookmark matches:\n%s", page)
274 	}
275 	if page := e.body(c, "/bookmarks?q=example.org/x"); !strings.Contains(page, `Go to <a href="https://example.org/x" class="open">`) {
276 		t.Errorf("no link for an address that no bookmark matches:\n%s", page)
277 	}
278 }
279 
280 func TestBookmarkPagesEscapeTheFile(t *testing.T) {
281 	e := newEnv(t, false)
282 	c, _, _ := e.account("javascript:alert(1)\t<script>alert(2)</script>\t<marquee>\nwww.example.org\tNo scheme\t\n")
283 	page := e.body(c, "/bookmarks")
284 	for _, bad := range []string{`href="javascript:`, "<script>", "<marquee>"} {
285 		if strings.Contains(page, bad) {
286 			t.Errorf("the page has %q", bad)
287 		}
288 	}
289 	if !strings.Contains(page, "&lt;script&gt;alert(2)&lt;/script&gt;") {
290 		t.Error("the page does not show the description as text")
291 	}
292 	if !strings.Contains(page, `<a href="https://www.example.org" class="open">No scheme</a>`) {
293 		t.Error("an address without a scheme does not get https")
294 	}
295 }
296 
297 func TestBookmarksWaitForTheEmailCheck(t *testing.T) {
298 	e := newEnv(t, false)
299 	e.withMail()
300 	c := e.signup("me@example.org")
301 	if code, to, _ := e.get(c, "/bookmarks"); code != http.StatusSeeOther || to != "/account" {
302 		t.Errorf("bookmarks before the check: %d to %q", code, to)
303 	}
304 	if code, to, _ := e.send(c, "/bookmarks/add", url.Values{"url": {"https://a.org"}}); code != http.StatusSeeOther || to != "/account" {
305 		t.Errorf("add before the check: %d to %q", code, to)
306 	}
307 	if strings.Contains(e.body(c, "/account"), `href="/bookmarks"`) {
308 		t.Error("the account page links to the bookmarks before the check")
309 	}
310 	if code := e.open(e.link()); code != http.StatusOK {
311 		t.Fatalf("the link of the email: %d", code)
312 	}
313 	if code, _, _ := e.get(c, "/bookmarks"); code != http.StatusOK {
314 		t.Errorf("bookmarks after the check: %d", code)
315 	}
316 }
317 
318 func TestBookmarksPauseWithSync(t *testing.T) {
319 	e := newEnv(t, true)
320 	c, tok, _ := e.account("https://a.org\tA\t\n")
321 	e.s.store.byTok(tok).Created = time.Now().Add(-31 * 24 * time.Hour)
322 	if code, to, _ := e.get(c, "/bookmarks"); code != http.StatusSeeOther || to != "/account" {
323 		t.Errorf("bookmarks after the trial: %d to %q", code, to)
324 	}
325 	// The download works: the bookmarks belong to their owner.
326 	resp, err := c.Get(e.web.URL + "/bookmarks.txt")
327 	if err != nil {
328 		t.Fatal(err)
329 	}
330 	defer resp.Body.Close()
331 	b, _ := io.ReadAll(resp.Body)
332 	if resp.StatusCode != http.StatusOK || string(b) != "https://a.org\tA\t\n" ||
333 		resp.Header.Get("Content-Disposition") != `attachment; filename="bookmarks.txt"` {
334 		t.Errorf("download after the trial: %s %q %q", resp.Status, b, resp.Header.Get("Content-Disposition"))
335 	}
336 }
337 
338 func TestEditTheWholeFile(t *testing.T) {
339 	e := newEnv(t, false)
340 	c, tok, v := e.account("https://a.org\tA\t\nhttps://b.org\tB\t\n")
341 	page := e.body(c, "/bookmarks/file")
342 	if !strings.Contains(page, `name="base" value="`+v+`"`) || !strings.Contains(page, ">\nhttps://a.org\tA\t\nhttps://b.org\tB\t\n</textarea>") {
343 		t.Fatalf("file page:\n%s", page)
344 	}
345 	// A device adds D while the page is open. The browser sends CRLF, and a
346 	// file without A and with C.
347 	e.sync(tok, v, "https://a.org\tA\t\nhttps://b.org\tB\t\nhttps://d.org\tD\t\n")
348 	code, to, _ := e.send(c, "/bookmarks/file", url.Values{"base": {v}, "text": {"https://b.org\tB\t\r\nhttps://c.org\tC\t\r\n"}})
349 	if code != http.StatusSeeOther || to != "/bookmarks?done=merged" {
350 		t.Fatalf("save: %d to %q", code, to)
351 	}
352 	want := "https://b.org\tB\t\nhttps://d.org\tD\t\nhttps://c.org\tC\t\n"
353 	if got := e.fileOf(tok); got != want {
354 		t.Fatalf("after the save: %q, want %q", got, want)
355 	}
356 	// Without changes of other devices, the file is as the page sent it.
357 	_, _, v = e.sync(tok, "", "")
358 	if code, to, _ := e.send(c, "/bookmarks/file", url.Values{"base": {v}, "text": {want + "https://e.org\tE\t\n"}}); code != http.StatusSeeOther || to != "/bookmarks?done=file" {
359 		t.Errorf("save without other changes: %d to %q", code, to)
360 	}
361 	if code, _, _ := e.send(c, "/bookmarks/file", url.Values{"base": {"../accounts.json"}, "text": {"x"}}); code != http.StatusBadRequest {
362 		t.Errorf("a base that is not a version name: %d", code)
363 	}
364 }
365 
366 // browserExport is a file as the browsers export it.
367 const browserExport = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
368 <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
369 <TITLE>Bookmarks</TITLE>
370 <H1>Bookmarks</H1>
371 <DL><p>
372     <DT><A HREF="https://www.a.org/" ADD_DATE="1" ICON="data:image/png;base64,AAAA">A &amp; more</A>
373     <DT><H3 ADD_DATE="1">Dev Tools</H3>
374     <DL><p>
375         <DT><A HREF="https://go.dev/doc/">Go   docs</A>
376         <DT><H3>Go &amp; Co</H3>
377         <DL><p>
378             <DT><A HREF="https://pkg.go.dev/">Packages</A>
379             <DT><A HREF="https://go.dev/doc">Go docs again</A>
380         </DL><p>
381         <DT><A HREF="javascript:alert(1)">Bookmarklet</A>
382     </DL><p>
383     <DT><A HREF="https://news.example/">News</A>
384 </DL>
385 `
386 
387 func TestImportFiles(t *testing.T) {
388 	e := newEnv(t, false)
389 	c, tok, _ := e.account("http://a.org\tA\t\n")
390 	code, to, _ := e.upload(c, browserExport)
391 	if code != http.StatusSeeOther || to != "/bookmarks?done=imported&added=3&known=2" {
392 		t.Fatalf("import of a browser file: %d to %q", code, to)
393 	}
394 	want := "http://a.org\tA\t\n" +
395 		"https://go.dev/doc/\tGo docs\tdev-tools\n" +
396 		"https://pkg.go.dev/\tPackages\tdev-tools go-co\n" +
397 		"https://news.example/\tNews\t\n"
398 	if got := e.fileOf(tok); got != want {
399 		t.Fatalf("after the import: %q, want %q", got, want)
400 	}
401 	if !strings.Contains(e.body(c, to), "Bookmarks imported: 3. Skipped because they are bookmarks already: 2.") {
402 		t.Error("the page does not tell what the import did")
403 	}
404 	// A file of bookmark lines, as bm --merge takes them.
405 	if code, to, _ := e.upload(c, "https://b.org\tB\tx\n# comment\nhttps://pkg.go.dev\tknown\t\nhttps://c.org\n"); code != http.StatusSeeOther || to != "/bookmarks?done=imported&added=2&known=1" {
406 		t.Fatalf("import of bookmark lines: %d to %q", code, to)
407 	}
408 	if got := e.fileOf(tok); got != want+"https://b.org\tB\tx\nhttps://c.org\t\t\n" {
409 		t.Fatalf("after the import of lines: %q", got)
410 	}
411 	if code, _, page := e.send(c, "/bookmarks/import", url.Values{}); code != http.StatusBadRequest || !strings.Contains(page, "Choose a file") {
412 		t.Errorf("import without a file: %d", code)
413 	}
414 }
415 
416 func TestOtherSiteCannotChangeBookmarks(t *testing.T) {
417 	e := newEnv(t, false)
418 	c, tok, _ := e.account("https://a.org\tA\t\n")
419 	for _, path := range []string{"/bookmarks/add", "/bookmarks/edit", "/bookmarks/file", "/bookmarks/import"} {
420 		req, _ := http.NewRequest("POST", e.web.URL+path, strings.NewReader("url=https%3A%2F%2Fevil.example&text="))
421 		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
422 		req.Header.Set("Origin", "https://evil.example")
423 		resp, err := c.Do(req)
424 		if err != nil {
425 			t.Fatal(err)
426 		}
427 		resp.Body.Close()
428 		if resp.StatusCode != http.StatusForbidden {
429 			t.Errorf("POST %s from another site: %s", path, resp.Status)
430 		}
431 	}
432 	if got := e.fileOf(tok); got != "https://a.org\tA\t\n" {
433 		t.Errorf("another site changed the file: %q", got)
434 	}
435 }
436 
437 func TestSearchFromTheAddressBar(t *testing.T) {
438 	e := newEnv(t, false)
439 	resp, err := http.Get(e.web.URL + "/opensearch.xml")
440 	if err != nil {
441 		t.Fatal(err)
442 	}
443 	b, _ := io.ReadAll(resp.Body)
444 	resp.Body.Close()
445 	var d struct {
446 		ShortName string
447 		URL       struct {
448 			Type     string `xml:"type,attr"`
449 			Template string `xml:"template,attr"`
450 		} `xml:"Url"`
451 	}
452 	if err := xml.Unmarshal(b, &d); err != nil {
453 		t.Fatalf("the description is not XML: %v\n%s", err, b)
454 	}
455 	if resp.Header.Get("Content-Type") != "application/opensearchdescription+xml" || d.ShortName != "sbm" ||
456 		d.URL.Type != "text/html" || d.URL.Template != e.web.URL+"/bookmarks?q={searchTerms}" {
457 		t.Errorf("description: %q %+v", resp.Header.Get("Content-Type"), d)
458 	}
459 	// Each page tells the browser where the description is.
460 	if !strings.Contains(e.body(e.browser(), "/"), `<link rel="search" type="application/opensearchdescription+xml" title="sbm bookmarks" href="/opensearch.xml">`) {
461 		t.Error("the home page does not link to the description")
462 	}
463 	// The bookmarks page tells the address for a browser, and that address
464 	// gives the search.
465 	c, _, _ := e.account("https://a.org\tA\t\nhttps://b.org\tB\t\n")
466 	if !strings.Contains(e.body(c, "/bookmarks"), "<code>"+e.web.URL+"/bookmarks?q=%s</code>") {
467 		t.Error("the bookmarks page does not tell the address for the browser")
468 	}
469 	if got := titles(e.body(c, "/bookmarks?q=b.org")); !slices.Equal(got, []string{"B"}) {
470 		t.Errorf("search from the address bar: %q", got)
471 	}
472 }
473 
474 func TestSearchOnTheHomePage(t *testing.T) {
475 	e := newEnv(t, false)
476 	e.withMail()
477 	if strings.Contains(e.body(e.browser(), "/"), `id="search"`) {
478 		t.Error("the home page shows the search to a visitor who is not signed in")
479 	}
480 	c := e.signup("me@example.org")
481 	if strings.Contains(e.body(c, "/"), `id="search"`) {
482 		t.Error("the home page shows the search before the email check")
483 	}
484 	e.open(e.link())
485 	page := e.body(c, "/")
486 	for _, want := range []string{`<form id="search" method="get" action="/bookmarks">`,
487 		`<div id="results" data-empty="clear"></div>`, `<script src="/search.js" defer></script>`} {
488 		if !strings.Contains(page, want) {
489 			t.Errorf("the home page has no %q", want)
490 		}
491 	}
492 }
493 
494 func TestLiveSearch(t *testing.T) {
495 	e := newEnv(t, false)
496 	c, _, _ := e.account("https://a.org\tA\t\ngemini://b.org\tB\t\n")
497 	resp, err := c.Get(e.web.URL + "/bookmarks")
498 	if err != nil {
499 		t.Fatal(err)
500 	}
501 	b, _ := io.ReadAll(resp.Body)
502 	resp.Body.Close()
503 	// Scripts come only from this server, and fetch only from it.
504 	csp := resp.Header.Get("Content-Security-Policy")
505 	if !strings.Contains(csp, "script-src 'self';") || !strings.Contains(csp, "connect-src 'self';") || strings.Contains(csp, "unsafe-eval") {
506 		t.Errorf("Content-Security-Policy: %q", csp)
507 	}
508 	// The script finds the form, the results, and the links that Enter opens.
509 	// A bookmark without a web address has no such link.
510 	for _, want := range []string{`<form id="search" `, `<div id="results">`, `<a href="https://a.org" class="open">A</a>`,
511 		`<li>B<br>`, `<script src="/search.js" defer></script>`} {
512 		if !strings.Contains(string(b), want) {
513 			t.Errorf("the page has no %q", want)
514 		}
515 	}
516 	resp, err = e.browser().Get(e.web.URL + "/search.js")
517 	if err != nil {
518 		t.Fatal(err)
519 	}
520 	b, _ = io.ReadAll(resp.Body)
521 	resp.Body.Close()
522 	if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/javascript; charset=utf-8" || string(b) != searchJS {
523 		t.Errorf("GET /search.js: %s %q, %d bytes", resp.Status, resp.Header.Get("Content-Type"), len(b))
524 	}
525 }
526 
527 // Most sites do not let a page of another site show them in a frame
528 // (X-Frame-Options or frame-ancestors), YouTube among them. A preview of a
529 // bookmark in a frame stays empty for them, so the bookmarks page has no
530 // frame, and the policy allows none.
531 func TestNoFrames(t *testing.T) {
532 	e := newEnv(t, false)
533 	c, _, _ := e.account("https://www.youtube.com/watch?v=aqz-KE-bpKQ\tA video\t\n")
534 	resp, err := c.Get(e.web.URL + "/bookmarks")
535 	if err != nil {
536 		t.Fatal(err)
537 	}
538 	b, _ := io.ReadAll(resp.Body)
539 	resp.Body.Close()
540 	csp := resp.Header.Get("Content-Security-Policy")
541 	if !strings.Contains(csp, "default-src 'none';") || strings.Contains(csp, "frame-src") || strings.Contains(csp, "child-src") {
542 		t.Errorf("Content-Security-Policy: %q", csp)
543 	}
544 	if strings.Contains(string(b), "<iframe") {
545 		t.Error("the bookmarks page has a frame")
546 	}
547 }