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 <b>", "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 & 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 <b>", "No time"}) || !strings.Contains(page, "from <b>Beta & 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 + in a link. A browser reads it back. 338 for _, want := range []string{ 339 `<a href="/feed/follow?name=Alpha&url=https%3A%2F%2Fa.org%2Frss.xml">Share</a>`, 340 `<a href="/feed/follow?name=Beta+%26+Co&url=https%3A%2F%2Fb.org%2Ffeed">Share</a>`, 341 `<a href="/feed/follow?name=Alpha&name=Beta+%26+Co&url=https%3A%2F%2Fa.org%2Frss.xml&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 }