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


feed_test.go (22758 bytes)

1 package main
2 
3 import (
4 	"fmt"
5 	"net/http"
6 	"net/url"
7 	"os"
8 	"path/filepath"
9 	"regexp"
10 	"slices"
11 	"strings"
12 	"testing"
13 	"time"
14 
15 	"pgregory.net/rapid"
16 )
17 
18 // setFeeds writes text as feeds.txt of the account of the token.
19 func (e *harness) setFeeds(tok, text string) *Account {
20 	a := e.s.store.byTok(tok)
21 	if err := e.s.store.changeFeeds(a, func(string) (string, error) { return text, nil }); err != nil {
22 		e.t.Fatal(err)
23 	}
24 	return a
25 }
26 
27 // setItems writes an sfeed file for the feed with the file name.
28 func (e *harness) setItems(a *Account, file, text string) {
29 	dir := filepath.Join(e.s.store.feedDir(a.ID), "items")
30 	if err := os.MkdirAll(dir, 0o700); err != nil {
31 		e.t.Fatal(err)
32 	}
33 	if err := os.WriteFile(filepath.Join(dir, file), []byte(text), 0o600); err != nil {
34 		e.t.Fatal(err)
35 	}
36 }
37 
38 // itemTitles gives the titles of the items on the feed page, without the
39 // lists of feeds and suggestions under them.
40 func itemTitles(page string) []string {
41 	head, _, _ := strings.Cut(page, "<h2>")
42 	return titles(head)
43 }
44 
45 // feedsOf gives feeds.txt as the API gives it.
46 func (e *harness) feedsOf(tok string) string {
47 	code, body := e.api("GET", tok, "/api/feeds", nil)
48 	if code != http.StatusOK {
49 		e.t.Fatalf("GET /api/feeds: %d %s", code, body)
50 	}
51 	return body
52 }
53 
54 func TestFeedPagesNeedSignIn(t *testing.T) {
55 	e := newEnv(t, false)
56 	c := e.browser()
57 	if code, to, _ := e.get(c, "/feed"); code != http.StatusSeeOther || to != "/login" {
58 		t.Errorf("GET /feed: %d to %q", code, to)
59 	}
60 	for _, path := range []string{"/feed/add", "/feed/delete", "/feed/settings", "/feed/follow"} {
61 		if code, to, _ := e.send(c, path, url.Values{"url": {"https://a.org/rss.xml"}}); code != http.StatusSeeOther || to != "/login" {
62 			t.Errorf("POST %s: %d to %q", path, code, to)
63 		}
64 	}
65 	for _, path := range []string{"/api/feed", "/api/feeds"} {
66 		if code, _ := e.api("GET", "wrong", path, nil); code != http.StatusUnauthorized {
67 			t.Errorf("GET %s with a bad token: %d", path, code)
68 		}
69 	}
70 }
71 
72 func TestFeedWaitsForTheEmailCheck(t *testing.T) {
73 	e := newEnv(t, false)
74 	e.withMail()
75 	c := e.signup("me@example.org")
76 	tok := e.token("me@example.org")
77 	if code, to, _ := e.get(c, "/feed"); code != http.StatusSeeOther || to != "/account" {
78 		t.Errorf("feed before the check: %d to %q", code, to)
79 	}
80 	if code, _ := e.api("GET", tok, "/api/feed", nil); code != http.StatusForbidden {
81 		t.Errorf("API before the check: %d", code)
82 	}
83 	e.open(e.link())
84 	if code, _, _ := e.get(c, "/feed"); code != http.StatusOK {
85 		t.Errorf("feed after the check: %d", code)
86 	}
87 	if code, _ := e.api("GET", tok, "/api/feed", nil); code != http.StatusOK {
88 		t.Errorf("API after the check: %d", code)
89 	}
90 }
91 
92 func TestFollowAndUnfollow(t *testing.T) {
93 	e := newEnv(t, false)
94 	c, tok, _ := e.account("")
95 	code, to, _ := e.send(c, "/feed/add", url.Values{"url": {" https://a.org/rss.xml "}, "name": {" A\tfeed "}})
96 	if code != http.StatusSeeOther || to != "/feed?done=added" {
97 		t.Fatalf("follow: %d to %q", code, to)
98 	}
99 	if got := e.feedsOf(tok); got != "https://a.org/rss.xml\tA feed\n" {
100 		t.Fatalf("feeds.txt: %q", got)
101 	}
102 	if !strings.Contains(e.body(c, to), "You follow the feed.") {
103 		t.Error("the page does not tell that the feed is followed")
104 	}
105 	code, _, page := e.send(c, "/feed/add", url.Values{"url": {"http://www.A.org/rss.xml/"}, "name": {"again"}})
106 	if code != http.StatusConflict || !strings.Contains(page, "You follow this feed already.") || !strings.Contains(page, "<details open>") || !strings.Contains(page, `value="again"`) {
107 		t.Errorf("follow the same feed: %d\n%s", code, page)
108 	}
109 	// Without a name, the host is the name.
110 	e.send(c, "/feed/add", url.Values{"url": {"https://b.org/atom"}})
111 	want := "https://a.org/rss.xml\tA feed\nhttps://b.org/atom\tb.org\n"
112 	if got := e.feedsOf(tok); got != want {
113 		t.Fatalf("feeds.txt: %q, want %q", got, want)
114 	}
115 	for _, bad := range []string{"javascript:alert(1)", "https://a.org/it's", "https://a.org/x y", "ftp://a.org/f", "a.org/rss", "", "https://" + strings.Repeat("a", 2100)} {
116 		if code, _, _ := e.send(c, "/feed/add", url.Values{"url": {bad}}); code != http.StatusBadRequest {
117 			t.Errorf("follow %q: %d", bad, code)
118 		}
119 	}
120 	if got := e.feedsOf(tok); got != want {
121 		t.Fatalf("a refusal changed feeds.txt: %q", got)
122 	}
123 	if code, to, _ := e.send(c, "/feed/delete", url.Values{"url": {"https://A.org/rss.xml/"}}); code != http.StatusSeeOther || to != "/feed?done=deleted" {
124 		t.Fatalf("unfollow: %d to %q", code, to)
125 	}
126 	if got := e.feedsOf(tok); got != "https://b.org/atom\tb.org\n" {
127 		t.Fatalf("feeds.txt after the unfollow: %q", got)
128 	}
129 }
130 
131 // A new account follows the default feeds of the server. It can unfollow
132 // them, and they do not come back.
133 func TestDefaultFeeds(t *testing.T) {
134 	e := newEnv(t, false)
135 	e.s.store.defaultFeeds = "https://recentlywritten.com/rss.xml\trecentlywritten.com\n"
136 	c, tok, _ := e.account("")
137 	a := e.s.store.byTok(tok)
138 	if got := e.feedsOf(tok); got != e.s.store.defaultFeeds {
139 		t.Fatalf("feeds of a new account: %q", got)
140 	}
141 	if !strings.Contains(e.body(c, "/feed"), `<a href="/feed?feed=recentlywritten.com">recentlywritten.com</a>`) {
142 		t.Error("the page does not show the default feed")
143 	}
144 	// The job fetches the default feed of an account that never opened the page.
145 	f := &fakeTools{items: map[string]string{"https://recentlywritten.com/rss.xml": oneItem}, now: time.Now()}
146 	e.s.feeds = f.tools()
147 	e.s.runFeeds()
148 	if !strings.Contains(e.feedFile(a, "sfeedrc"), "'https://recentlywritten.com/rss.xml'") {
149 		t.Error("the job does not fetch the default feed")
150 	}
151 	e.send(c, "/feed/add", url.Values{"url": {"https://a.org/rss.xml"}, "name": {"A"}})
152 	if got := e.feedsOf(tok); got != e.s.store.defaultFeeds+"https://a.org/rss.xml\tA\n" {
153 		t.Fatalf("after a follow: %q", got)
154 	}
155 	e.send(c, "/feed/delete", url.Values{"url": {"https://recentlywritten.com/rss.xml"}})
156 	if got := e.feedsOf(tok); got != "https://a.org/rss.xml\tA\n" {
157 		t.Fatalf("after the unfollow of the default feed: %q", got)
158 	}
159 	e.send(c, "/feed/delete", url.Values{"url": {"https://a.org/rss.xml"}})
160 	if got := e.feedsOf(tok); got != "" {
161 		t.Fatalf("after the unfollow of all feeds, the default is back: %q", got)
162 	}
163 	e.s.runFeeds()
164 	if got := e.feedsOf(tok); got != "" {
165 		t.Fatalf("the job brought the default feed back: %q", got)
166 	}
167 }
168 
169 func TestDefaultFeedsSetting(t *testing.T) {
170 	got, err := defaultFeeds(" https://recentlywritten.com/rss.xml\nhttp://a.org/feed ")
171 	if err != nil || got != "https://recentlywritten.com/rss.xml\trecentlywritten.com\nhttp://a.org/feed\ta.org\n" {
172 		t.Errorf("defaultFeeds = %q, %v", got, err)
173 	}
174 	if got, err := defaultFeeds(""); err != nil || got != "" {
175 		t.Errorf("empty setting: %q, %v", got, err)
176 	}
177 	for _, bad := range []string{"ftp://a.org/feed", "https://a.org/it's", "a.org/feed"} {
178 		if _, err := defaultFeeds(bad); err == nil {
179 			t.Errorf("defaultFeeds(%q) gave no error", bad)
180 		}
181 	}
182 }
183 
184 func TestFeedLimit(t *testing.T) {
185 	e := newEnv(t, false)
186 	c, tok, _ := e.account("")
187 	var b strings.Builder
188 	for i := range maxFeeds {
189 		fmt.Fprintf(&b, "https://f%d.org/rss\tF%d\n", i, i)
190 	}
191 	e.setFeeds(tok, b.String())
192 	if code, _, page := e.send(c, "/feed/add", url.Values{"url": {"https://one-more.org/rss"}}); code != http.StatusBadRequest || !strings.Contains(page, "200 feeds already") {
193 		t.Errorf("feed 201: %d", code)
194 	}
195 }
196 
197 func TestFeedTimeline(t *testing.T) {
198 	e := newEnv(t, false)
199 	c, tok, _ := e.account("")
200 	a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\nhttps://b.org/feed\tBeta & Co\n")
201 	e.setItems(a, "Alpha", "1700000000\tOld \\t tab\thttps://a.org/1\t\t\tid1\tAnn\t\t\n1700003600\tNewer\thttps://a.org/2\t\t\t\t\t\t\n")
202 	e.setItems(a, "Beta-Co", "1700001800\tScript <b>\tjavascript:alert(1)\t\t\tid3\t\t\t\n\tNo time\thttps://b.org/4\t\t\tid4\t\t\t\n")
203 	page := e.body(c, "/feed")
204 	if got := itemTitles(page); !slices.Equal(got, []string{"Newer", "Script &lt;b&gt;", "Old \t tab", "No time"}) {
205 		t.Fatalf("titles: %q\n%s", got, page)
206 	}
207 	for _, want := range []string{`<a href="https://a.org/2">Newer</a>`, "Beta &amp; Co", " · Ann", "2023-11-14 23:13", "4 items."} {
208 		if !strings.Contains(page, want) {
209 			t.Errorf("the page lacks %q", want)
210 		}
211 	}
212 	for _, bad := range []string{`href="javascript:`, `target="_blank"`} {
213 		if strings.Contains(page, bad) {
214 			t.Errorf("the page has %q", bad)
215 		}
216 	}
217 	page = e.body(c, "/feed?feed=Beta-Co")
218 	if got := itemTitles(page); !slices.Equal(got, []string{"Script &lt;b&gt;", "No time"}) || !strings.Contains(page, "from <b>Beta &amp; Co</b>") {
219 		t.Errorf("one feed: %q", got)
220 	}
221 	if page := e.body(c, "/feed?feed=nothing"); !strings.Contains(page, "0 items.") {
222 		t.Error("an unknown feed shows items")
223 	}
224 
225 	if code, to, _ := e.send(c, "/feed/settings", url.Values{"newtab": {"on"}}); code != http.StatusSeeOther || to != "/feed?done=settings" {
226 		t.Fatalf("settings: %d to %q", code, to)
227 	}
228 	if page := e.body(c, "/feed"); !strings.Contains(page, `<a href="https://a.org/2" target="_blank" rel="noopener">Newer</a>`) {
229 		t.Error("links do not open in a new tab")
230 	}
231 	if !e.s.store.view(a).Feed.NewTab {
232 		t.Error("the setting is not kept")
233 	}
234 	e.send(c, "/feed/settings", url.Values{})
235 	if page := e.body(c, "/feed"); strings.Contains(page, `target="_blank"`) || e.s.store.view(a).Feed.NewTab {
236 		t.Error("links still open in a new tab")
237 	}
238 
239 	code, body := e.api("GET", tok, "/api/feed", nil)
240 	want := "1700003600\tNewer\thttps://a.org/2\t\t\t\t\t\t\n" +
241 		"1700001800\tScript <b>\tjavascript:alert(1)\t\t\tid3\t\t\t\n" +
242 		"1700000000\tOld \\t tab\thttps://a.org/1\t\t\tid1\tAnn\t\t\n" +
243 		"\tNo time\thttps://b.org/4\t\t\tid4\t\t\t\n"
244 	if code != http.StatusOK || body != want {
245 		t.Errorf("GET /api/feed: %d\n%q\nwant\n%q", code, body, want)
246 	}
247 }
248 
249 func TestFeedPages(t *testing.T) {
250 	e := newEnv(t, false)
251 	c, tok, _ := e.account("")
252 	a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n")
253 	var b strings.Builder
254 	for i := range itemsOnPage + 5 {
255 		fmt.Fprintf(&b, "%d\tn%d\thttps://a.org/%d\t\t\t\t\t\t\n", 1700000000+i, i, i)
256 	}
257 	e.setItems(a, "Alpha", b.String())
258 	page := e.body(c, "/feed")
259 	if got := itemTitles(page); len(got) != itemsOnPage || got[0] != "n104" || !strings.Contains(page, `<a href="/feed?start=100">Older items</a>`) {
260 		t.Errorf("page 1: %d titles, first %q", len(got), got[0])
261 	}
262 	page = e.body(c, "/feed?start=100&feed=Alpha")
263 	if got := itemTitles(page); !slices.Equal(got, []string{"n4", "n3", "n2", "n1", "n0"}) || strings.Contains(page, "Older items") {
264 		t.Errorf("page 2: %q", got)
265 	}
266 }
267 
268 // The daily email is on by default. The page offers the box only when the
269 // server sends email, and a save without the box keeps the choice.
270 func TestFeedMailSettingNeedsAnSMTPServer(t *testing.T) {
271 	e := newEnv(t, false)
272 	c, tok, _ := e.account("")
273 	a := e.s.store.byTok(tok)
274 	if strings.Contains(e.body(c, "/feed"), `name="mail"`) {
275 		t.Error("the page offers the digest without an SMTP server")
276 	}
277 	e.send(c, "/feed/settings", url.Values{"explore": {"on"}})
278 	if f := e.s.store.view(a).Feed; f.NoMail || !f.Explore {
279 		t.Errorf("settings without an SMTP server: %+v", f)
280 	}
281 	e.withMail()
282 	if !strings.Contains(e.body(c, "/feed"), `<input type="checkbox" class="check" name="mail" checked>`) {
283 		t.Error("the page does not offer the digest, on")
284 	}
285 	e.send(c, "/feed/settings", url.Values{})
286 	if f := e.s.store.view(a).Feed; !f.NoMail || f.Explore {
287 		t.Errorf("settings with the box cleared: %+v", f)
288 	}
289 	if !strings.Contains(e.body(c, "/feed"), `<input type="checkbox" class="check" name="mail">`) {
290 		t.Error("the page shows the digest on")
291 	}
292 	e.send(c, "/feed/settings", url.Values{"mail": {"on"}})
293 	if f := e.s.store.view(a).Feed; f.NoMail {
294 		t.Errorf("settings with the box ticked: %+v", f)
295 	}
296 }
297 
298 func TestOtherSiteCannotChangeFeeds(t *testing.T) {
299 	e := newEnv(t, false)
300 	c, tok, _ := e.account("")
301 	e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n")
302 	for _, path := range []string{"/feed/add", "/feed/delete", "/feed/settings"} {
303 		req, _ := http.NewRequest("POST", e.web.URL+path, strings.NewReader("url=https%3A%2F%2Fa.org%2Frss.xml&newtab=on"))
304 		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
305 		req.Header.Set("Origin", "https://evil.example")
306 		resp, err := c.Do(req)
307 		if err != nil {
308 			t.Fatal(err)
309 		}
310 		resp.Body.Close()
311 		if resp.StatusCode != http.StatusForbidden {
312 			t.Errorf("POST %s from another site: %s", path, resp.Status)
313 		}
314 	}
315 	if got := e.feedsOf(tok); got != "https://a.org/rss.xml\tAlpha\n" {
316 		t.Errorf("another site changed the feeds: %q", got)
317 	}
318 }
319 
320 func TestDeleteAccountRemovesTheFeeds(t *testing.T) {
321 	e := newEnv(t, false)
322 	c, tok, _ := e.account("")
323 	a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n")
324 	if resp := e.post(c, "/account/delete", url.Values{"password": {"password1"}}); resp.StatusCode != http.StatusSeeOther {
325 		t.Fatalf("delete: %s", resp.Status)
326 	}
327 	if _, err := os.Stat(e.s.store.feedDir(a.ID)); !os.IsNotExist(err) {
328 		t.Errorf("the feeds of the account are still there: %v", err)
329 	}
330 }
331 
332 func TestShareLinks(t *testing.T) {
333 	e := newEnv(t, false)
334 	c, tok, _ := e.account("")
335 	e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\nhttps://b.org/feed\tBeta & Co\n")
336 	page := e.body(c, "/feed")
337 	// html/template writes + as &#43; in a link. A browser reads it back.
338 	for _, want := range []string{
339 		`<a href="/feed/follow?name=Alpha&amp;url=https%3A%2F%2Fa.org%2Frss.xml">Share</a>`,
340 		`<a href="/feed/follow?name=Beta&#43;%26&#43;Co&amp;url=https%3A%2F%2Fb.org%2Ffeed">Share</a>`,
341 		`<a href="/feed/follow?name=Alpha&amp;name=Beta&#43;%26&#43;Co&amp;url=https%3A%2F%2Fa.org%2Frss.xml&amp;url=https%3A%2F%2Fb.org%2Ffeed">Share your list</a>`,
342 	} {
343 		if !strings.Contains(page, want) {
344 			t.Errorf("the page lacks %q", want)
345 		}
346 	}
347 	// The link of the list has at most maxShare feeds.
348 	var many []Feed
349 	for i := range maxShare + 10 {
350 		many = append(many, Feed{URL: fmt.Sprintf("https://f%d.org/rss", i), Name: "F"})
351 	}
352 	if got := strings.Count(shareLink(many), "url="); got != maxShare {
353 		t.Errorf("the link of %d feeds has %d feeds", len(many), got)
354 	}
355 }
356 
357 // A visitor who opens a share link is asked to sign in or to sign up, and
358 // comes back to the link. An account gets a button that follows the feeds.
359 func TestShareLinkFollowsAfterSignUp(t *testing.T) {
360 	e := newEnv(t, false)
361 	link := "/feed/follow?name=Alpha&name=Bad&url=https%3A%2F%2Fa.org%2Frss.xml&url=javascript%3Ax"
362 	c := e.browser()
363 	code, _, page := e.get(c, "/feed/follow")
364 	if code != http.StatusOK || !strings.Contains(page, "This link has no feed in it.") {
365 		t.Errorf("a link without feeds: %d\n%s", code, page)
366 	}
367 	code, _, page = e.get(c, link)
368 	for _, want := range []string{"Feeds shared with you", `<a href="/login">Sign in</a>`, `<a href="/signup">create an account</a>`,
369 		"Alpha<br><small>https://a.org/rss.xml</small>", "javascript:x<br><small class=\"error\">not a feed address</small>"} {
370 		if !strings.Contains(page, want) {
371 			t.Errorf("the share page lacks %q", want)
372 		}
373 	}
374 	if code != http.StatusOK || strings.Contains(page, "<form") || strings.Contains(page, `href="/bookmarks"`) {
375 		t.Errorf("the share page of a visitor: %d, has a form or the nav", code)
376 	}
377 	form := url.Values{"email": {"me@example.org"}, "password": {"password1"}}
378 	if code, to, _ := e.send(c, "/signup", form); code != http.StatusSeeOther || to != link {
379 		t.Fatalf("sign up from the share page: %d to %q", code, to)
380 	}
381 	code, _, page = e.get(c, link)
382 	for _, want := range []string{"Follow these feeds?", `<button>Follow 1 feed</button>`,
383 		`<input type="hidden" name="url" value="https://a.org/rss.xml"><input type="hidden" name="name" value="Alpha">`} {
384 		if !strings.Contains(page, want) {
385 			t.Errorf("the follow page lacks %q", want)
386 		}
387 	}
388 	if code != http.StatusOK || strings.Contains(page, `value="javascript:x"`) {
389 		t.Errorf("the follow page: %d, or it sends the bad address", code)
390 	}
391 	follow := url.Values{"url": {"https://a.org/rss.xml", "javascript:x"}, "name": {"Alpha", "Bad"}}
392 	code, to, _ := e.send(c, "/feed/follow", follow)
393 	if code != http.StatusSeeOther || to != "/feed?done=followed&added=1&known=0" {
394 		t.Fatalf("follow: %d to %q", code, to)
395 	}
396 	if got := e.feedsOf(e.token("me@example.org")); got != "https://a.org/rss.xml\tAlpha\n" {
397 		t.Fatalf("feeds.txt: %q", got)
398 	}
399 	if !strings.Contains(e.body(c, to), "You follow 1 new feeds. 0 were in your list already.") {
400 		t.Error("the feed page does not tell what happened")
401 	}
402 	// The same link again: nothing new to follow.
403 	if page := e.body(c, link); !strings.Contains(page, "you follow this feed already") || strings.Contains(page, "<form") {
404 		t.Errorf("the follow page of a known feed:\n%s", page)
405 	}
406 	if _, to, _ := e.send(c, "/feed/follow", follow); to != "/feed?done=followed&added=0&known=1" {
407 		t.Errorf("follow again: to %q", to)
408 	}
409 	// The visit as an account used the cookie up: a sign-in goes to the
410 	// bookmarks, as usual.
411 	e.post(c, "/logout", nil)
412 	if _, to, _ := e.send(c, "/login", form); to != "/bookmarks" {
413 		t.Errorf("sign in after the share page: to %q", to)
414 	}
415 }
416 
417 func TestShareLinkWaitsForTheEmailCheck(t *testing.T) {
418 	e := newEnv(t, false)
419 	e.withMail()
420 	link := "/feed/follow?name=Alpha&url=https%3A%2F%2Fa.org%2Frss.xml"
421 	c := e.browser()
422 	e.get(c, link)
423 	if _, to, _ := e.send(c, "/signup", url.Values{"email": {"me@example.org"}, "password": {"password1"}}); to != link {
424 		t.Fatalf("sign up: to %q", to)
425 	}
426 	if code, to, _ := e.get(c, link); code != http.StatusSeeOther || to != "/account" {
427 		t.Fatalf("the link before the check: %d to %q", code, to)
428 	}
429 	// The link of the email, opened in the same browser, goes back to the
430 	// share page.
431 	resp, err := c.Get(e.link())
432 	if err != nil {
433 		t.Fatal(err)
434 	}
435 	resp.Body.Close()
436 	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != link {
437 		t.Fatalf("the email check: %d to %q", resp.StatusCode, resp.Header.Get("Location"))
438 	}
439 	if code, _, page := e.get(c, link); code != http.StatusOK || !strings.Contains(page, `<button>Follow 1 feed</button>`) {
440 		t.Errorf("the link after the check: %d", code)
441 	}
442 	// In another browser, the email check shows the page as before.
443 	e.post(c, "/account/verify", nil)
444 	if code := e.open(e.link()); code != http.StatusBadRequest {
445 		t.Errorf("a used code: %d", code)
446 	}
447 }
448 
449 // The next cookie sends the browser only to a share page.
450 func TestNextCookieOnlyGoesToShareLinks(t *testing.T) {
451 	e := newEnv(t, false)
452 	e.signup("me@example.org")
453 	c := e.browser()
454 	u, _ := url.Parse(e.web.URL)
455 	c.Jar.SetCookies(u, []*http.Cookie{{Name: nextCookie, Value: "/account/delete"}})
456 	if _, to, _ := e.send(c, "/login", url.Values{"email": {"me@example.org"}, "password": {"password1"}}); to != "/bookmarks" {
457 		t.Errorf("sign in with a cookie for another page: to %q", to)
458 	}
459 }
460 
461 // feedURLs draws strings that are often feed URLs and often not.
462 var feedURLs = rapid.Custom(func(t *rapid.T) string {
463 	head := rapid.SampledFrom([]string{"https://a.org/", "http://b.org", "HTTPS://c.org/x", "https://", "ftp://c.org/", "javascript:", "", " https://d.org/"}).Draw(t, "head")
464 	tail := rapid.StringOf(rapid.SampledFrom([]rune("abcz09:/.-_?=&%#~'\" \t\n\\日é\x01"))).Draw(t, "tail")
465 	return head + tail
466 })
467 
468 // A URL that feedURL takes has only printable ASCII without single quotes,
469 // so that it holds between single quotes in the sfeedrc. Each such http or
470 // https URL with a host is taken.
471 func TestFeedURLIsShellSafe(t *testing.T) {
472 	rapid.Check(t, func(t *rapid.T) {
473 		raw := feedURLs.Draw(t, "url")
474 		u := strings.TrimSpace(raw)
475 		safe := u != "" && len(u) <= maxFeedURL
476 		for i := 0; i < len(u); i++ {
477 			if c := u[i]; c <= ' ' || c > '~' || c == '\'' {
478 				safe = false
479 			}
480 		}
481 		p, err := url.Parse(u)
482 		want := safe && err == nil && p.Host != "" && (p.Scheme == "http" || p.Scheme == "https")
483 		got, err := feedURL(raw)
484 		if (err == nil) != want {
485 			t.Fatalf("feedURL(%q) = %q, %v; want ok %v", raw, got, err, want)
486 		}
487 		if err != nil {
488 			return
489 		}
490 		if got != u {
491 			t.Fatalf("feedURL(%q) = %q, want %q", raw, got, u)
492 		}
493 		rc := sfeedrc("/data/items", []Feed{{URL: got, Name: "x", File: "x"}})
494 		line := "\tfeed 'x' '" + got + "'\n"
495 		if !strings.Contains(rc, line) || strings.Count(line, "'") != 4 {
496 			t.Fatalf("sfeedrc for %q:\n%s", got, rc)
497 		}
498 	})
499 }
500 
501 var fileNames = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]*$`)
502 
503 // Each line of feeds.txt parses back, and each feed gets a safe file name
504 // that no other feed of the file has.
505 func TestFeedsParseBackWithUniqueFiles(t *testing.T) {
506 	rapid.Check(t, func(t *rapid.T) {
507 		var b strings.Builder
508 		var want []Feed
509 		for i := range rapid.IntRange(0, 6).Draw(t, "feeds") {
510 			u := fmt.Sprintf("https://f%d.org/rss", i)
511 			name := feedName(rapid.StringOf(rapid.SampledFrom([]rune("aA0 -._/\t\n日'"))).Draw(t, "name"), u)
512 			if strings.ContainsAny(name, "\t\n") || len([]rune(name)) > maxFeedName {
513 				t.Fatalf("feedName gave %q", name)
514 			}
515 			b.WriteString(u + "\t" + name + "\n")
516 			want = append(want, Feed{URL: u, Name: name})
517 		}
518 		got := parseFeeds(b.String())
519 		if len(got) != len(want) {
520 			t.Fatalf("%d feeds parsed, want %d", len(got), len(want))
521 		}
522 		files := map[string]bool{}
523 		for i, f := range got {
524 			if f.URL != want[i].URL || f.Name != want[i].Name {
525 				t.Fatalf("feed %d is %+v, want %+v", i, f, want[i])
526 			}
527 			if !fileNames.MatchString(f.File) || files[f.File] || len(f.File) > maxFileName+4 {
528 				t.Fatalf("feed %d has the file name %q; files %v", i, f.File, files)
529 			}
530 			files[f.File] = true
531 		}
532 	})
533 }
534 
535 // escape is the escaping of the sfeed format, as a reference.
536 func escape(s string) string {
537 	return strings.NewReplacer(`\`, `\\`, "\t", `\t`, "\n", `\n`).Replace(s)
538 }
539 
540 func TestUnescapeUndoesEscape(t *testing.T) {
541 	rapid.Check(t, func(t *rapid.T) {
542 		s := rapid.StringOf(rapid.SampledFrom([]rune("ab\\tn\t\n日"))).Draw(t, "text")
543 		if got := unescape(escape(s)); got != s {
544 			t.Fatalf("unescape(%q) = %q, want %q", escape(s), got, s)
545 		}
546 	})
547 }
548 
549 func TestParseItems(t *testing.T) {
550 	f := Feed{URL: "https://a.org/rss", Name: "A", File: "A"}
551 	items := parseItems("1700000000\tT\\ta\\\\b\\n\thttps://a.org/1\tbody\thtml\tid1\tAnn\t\tcat\n"+
552 		"x\t\thttps://a.org/2\t\t\t\t\t\n"+
553 		"short\n"+
554 		"1\t\t\t\t\t\t\t\t\n", f)
555 	if len(items) != 3 {
556 		t.Fatalf("%d items", len(items))
557 	}
558 	a, b, c := items[0], items[1], items[2]
559 	if a.Title != "T\ta\\b\n" || a.Link != "https://a.org/1" || a.Author != "Ann" || a.Key != "A\tid1" || a.Time.Unix() != 1700000000 || a.Feed != "A" {
560 		t.Errorf("item 1: %+v", a)
561 	}
562 	// Without a time, an id or a title: the time is zero, the link stands in
563 	// for the id, and the title comes from the link.
564 	if !b.Time.IsZero() || b.Key != "A\thttps://a.org/2" || b.Title != "https://a.org/2" {
565 		t.Errorf("item 2: %+v", b)
566 	}
567 	if c.Title != "(no title)" || c.Key != "A\t" || c.Link != "" {
568 		t.Errorf("item 3: %+v", c)
569 	}
570 }