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> (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, "<script>alert(2)</script>") { 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 & 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 & 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 }