feedjob_test.go (17478 bytes)
1 package main 2 3 import ( 4 "errors" 5 "fmt" 6 "net" 7 "net/url" 8 "os" 9 "path/filepath" 10 "strings" 11 "testing" 12 "time" 13 ) 14 15 // fakeTools are feed tools that need no sfeed: "sfeed_update" writes the 16 // items of a table, and "sfeed_web" answers from a table. 17 type fakeTools struct { 18 items map[string]string // feed URL: the sfeed lines of the feed 19 pages map[string][]string // page URL: the "URL<tab>type" lines that sfeed_web finds 20 updates []string // the sfeedrc of each update 21 crawled []string // the pages given to discover 22 plain int // calls of plain 23 now time.Time 24 } 25 26 func (f *fakeTools) tools() feedTools { 27 return feedTools{ 28 update: func(rc string) error { 29 f.updates = append(f.updates, rc) 30 b, err := os.ReadFile(rc) 31 if err != nil { 32 return err 33 } 34 dir := "" 35 for _, l := range lines(string(b)) { 36 if v, ok := strings.CutPrefix(l, "sfeedpath='"); ok { 37 dir = strings.TrimSuffix(v, "'") 38 } 39 if v, ok := strings.CutPrefix(l, "\tfeed '"); ok { 40 file, u, _ := strings.Cut(strings.TrimSuffix(v, "'"), "' '") 41 if err := os.WriteFile(filepath.Join(dir, file), []byte(f.items[u]), 0o600); err != nil { 42 return err 43 } 44 } 45 } 46 return nil 47 }, 48 plain: func(files []string) (string, error) { 49 f.plain++ 50 var b strings.Builder 51 for _, path := range files { 52 text, err := os.ReadFile(path) 53 if err != nil { 54 return "", err 55 } 56 b.WriteString(filepath.Base(path) + ":\n") 57 for _, l := range lines(string(text)) { 58 c := strings.Split(l, "\t") 59 b.WriteString(" " + c[1] + " " + c[2] + "\n") 60 } 61 } 62 return b.String(), nil 63 }, 64 discover: func(page string) ([]string, error) { 65 f.crawled = append(f.crawled, page) 66 return f.pages[page], nil 67 }, 68 lookup: func(host string) ([]net.IP, error) { 69 switch host { 70 case "internal.test": 71 return []net.IP{net.ParseIP("10.66.0.4")}, nil 72 case "nowhere.test": 73 return nil, errors.New("no such host") 74 } 75 return []net.IP{net.ParseIP("93.184.216.34")}, nil 76 }, 77 now: func() time.Time { return f.now }, 78 } 79 } 80 81 // fileOf gives the text of a file of the feed directory of the account, or 82 // "" when the file is missing. 83 func (e *harness) feedFile(a *Account, name string) string { 84 b, err := os.ReadFile(filepath.Join(e.s.store.feedDir(a.ID), name)) 85 if err != nil { 86 return "" 87 } 88 return string(b) 89 } 90 91 const oneItem = "1700000000\tOne\thttps://a.org/1\t\t\tid1\t\t\t\n" 92 93 func TestJobFetchesTheFeeds(t *testing.T) { 94 e := newEnv(t, false) 95 c, tok, _ := e.account("") 96 a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\nhttp://internal.test/rss\tInside\nhttps://nowhere.test/x\tGone\nhttps://10.0.0.7/rss\tPrivate\n") 97 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: time.Unix(1_800_000_000, 0)} 98 e.s.feeds = f.tools() 99 e.s.runFeeds() 100 rc := e.feedFile(a, "sfeedrc") 101 itemsDir := filepath.Join(e.s.store.feedDir(a.ID), "items") 102 // The conditional fetch (-z) is only for an item file with items in it. 103 for _, want := range []string{"sfeedpath='" + itemsDir + "'\n", "\tfeed 'Alpha' 'https://a.org/rss.xml'\n", 104 "\tif [ -s \"$3\" ]; then\n\t\tcurl --proto '=http,https'", ` -z "$3" "$2" 2>/dev/null` + "\n\telse\n\t\tcurl "} { 105 if !strings.Contains(rc, want) { 106 t.Errorf("the sfeedrc lacks %q:\n%s", want, rc) 107 } 108 } 109 // A host on a private network, an unknown host and an address on a 110 // private network stay out of the sfeedrc. 111 for _, bad := range []string{"internal.test", "nowhere.test", "10.0.0.7"} { 112 if strings.Contains(rc, bad) { 113 t.Errorf("the sfeedrc has %q", bad) 114 } 115 } 116 if len(f.updates) != 1 || f.updates[0] != filepath.Join(e.s.store.feedDir(a.ID), "sfeedrc") { 117 t.Errorf("updates: %q", f.updates) 118 } 119 if got := e.feedFile(a, filepath.Join("items", "Alpha")); got != oneItem { 120 t.Errorf("items of Alpha: %q", got) 121 } 122 if page := e.body(c, "/feed"); !strings.Contains(page, `<a href="https://a.org/1">One</a>`) { 123 t.Error("the page does not show the item") 124 } 125 // The items of a feed that the account left go. 126 e.setFeeds(tok, "") 127 e.s.runFeeds() 128 if e.feedFile(a, filepath.Join("items", "Alpha")) != "" || e.feedFile(a, "sfeedrc") != "" || len(f.updates) != 1 { 129 t.Error("the items or the sfeedrc of the left feed stay") 130 } 131 } 132 133 func TestJobLeavesAccountsWithoutFeedsAlone(t *testing.T) { 134 e := newEnv(t, false) 135 _, tok, _ := e.account("") 136 a := e.s.store.byTok(tok) 137 f := &fakeTools{now: time.Now()} 138 e.s.feeds = f.tools() 139 e.s.runFeeds() 140 if _, err := os.Stat(e.s.store.feedDir(a.ID)); !os.IsNotExist(err) { 141 t.Errorf("the job made a feed directory: %v", err) 142 } 143 } 144 145 func TestJobSendsADailyDigest(t *testing.T) { 146 e := newEnv(t, false) 147 c, tok, _ := e.account("") 148 e.withMail() 149 a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") 150 start := time.Unix(1_800_000_000, 0) 151 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: start} 152 e.s.feeds = f.tools() 153 154 // The email is on by default. The first run notes the items and sends 155 // nothing. 156 e.s.runFeeds() 157 if e.sent() != 0 || e.feedFile(a, "mailed") != "Alpha\tid1\n" || strings.TrimSpace(e.feedFile(a, "digest")) != "1800000000" { 158 t.Fatalf("first run: %d emails, mailed %q, digest %q", e.sent(), e.feedFile(a, "mailed"), e.feedFile(a, "digest")) 159 } 160 f.items["https://a.org/rss.xml"] = "1700003600\tTwo\thttps://a.org/2\t\t\tid2\t\t\t\n" + oneItem 161 f.now = start.Add(time.Hour) 162 e.s.runFeeds() 163 if e.sent() != 0 { 164 t.Fatal("a digest went out within a day") 165 } 166 f.now = start.Add(25 * time.Hour) 167 e.s.runFeeds() 168 if e.sent() != 1 { 169 t.Fatalf("%d emails after a day", e.sent()) 170 } 171 mail := e.mails[0] 172 for _, want := range []string{"me@example.org\nsbm feed: 1 new item\n", "Alpha:\n Two https://a.org/2\n", "Your feed: " + e.s.site + "/feed\n", "https://subread.space/\n"} { 173 if !strings.Contains(mail, want) { 174 t.Errorf("the digest lacks %q:\n%s", want, mail) 175 } 176 } 177 if strings.Contains(mail, "One") { 178 t.Error("the digest has the old item") 179 } 180 if e.feedFile(a, "mailed") != "Alpha\tid1\nAlpha\tid2\n" || strings.TrimSpace(e.feedFile(a, "digest")) != fmt.Sprint(f.now.Unix()) { 181 t.Errorf("after the digest: mailed %q, digest %q", e.feedFile(a, "mailed"), e.feedFile(a, "digest")) 182 } 183 f.now = start.Add(50 * time.Hour) 184 e.s.runFeeds() 185 if e.sent() != 1 { 186 t.Error("a digest went out without new items") 187 } 188 f.items["https://a.org/rss.xml"] = "1700007200\tThree\thttps://a.org/3\t\t\tid3\t\t\t\n" + f.items["https://a.org/rss.xml"] 189 f.now = start.Add(51 * time.Hour) 190 e.s.runFeeds() 191 if e.sent() != 2 || !strings.Contains(e.mails[1], "sbm feed: 1 new item\n") || !strings.Contains(e.mails[1], "Three") { 192 t.Errorf("second digest: %d emails, %q", e.sent(), e.mails[len(e.mails)-1]) 193 } 194 // The opt-out forgets the digests, so that a later opt-in starts fresh. 195 e.send(c, "/feed/settings", url.Values{}) 196 e.s.runFeeds() 197 if e.feedFile(a, "mailed") != "" || e.feedFile(a, "digest") != "" || e.feedFile(a, "known") != "" { 198 t.Error("the opt-out kept the digest files") 199 } 200 f.items["https://a.org/rss.xml"] = "1700010800\tFour\thttps://a.org/4\t\t\tid4\t\t\t\n" + f.items["https://a.org/rss.xml"] 201 f.now = start.Add(80 * time.Hour) 202 e.s.runFeeds() 203 if e.sent() != 2 { 204 t.Error("a digest went out after the opt-out") 205 } 206 // The opt-in starts fresh: the first run notes the items only. 207 e.send(c, "/feed/settings", url.Values{"mail": {"on"}}) 208 e.s.runFeeds() 209 if e.sent() != 2 || e.feedFile(a, "mailed") == "" { 210 t.Errorf("after the opt-in: %d emails, mailed %q", e.sent(), e.feedFile(a, "mailed")) 211 } 212 } 213 214 // A digest that does not go out is sent again with the next run. 215 func TestJobKeepsTheItemsOfAFailedDigest(t *testing.T) { 216 e := newEnv(t, false) 217 _, tok, _ := e.account("") 218 e.withMail() 219 a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") 220 start := time.Unix(1_800_000_000, 0) 221 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": ""}, now: start} 222 e.s.feeds = f.tools() 223 e.s.runFeeds() 224 f.items["https://a.org/rss.xml"] = oneItem 225 f.now = start.Add(25 * time.Hour) 226 e.s.mail = func(string, string, string) error { return errors.New("no SMTP server") } 227 e.s.runFeeds() 228 if e.feedFile(a, "mailed") != "" || strings.TrimSpace(e.feedFile(a, "digest")) != "1800000000" { 229 t.Errorf("a failed digest changed the files: mailed %q, digest %q", e.feedFile(a, "mailed"), e.feedFile(a, "digest")) 230 } 231 e.withMail() 232 e.s.runFeeds() 233 if e.sent() != 1 || !strings.Contains(e.mails[0], "One") { 234 t.Errorf("the next run: %d emails", e.sent()) 235 } 236 } 237 238 // A feed that the account follows after the first digest run does not 239 // bring its old items into the digest: the first run that finds items of 240 // the feed notes them. The items that come after that go out. 241 func TestJobKeepsTheOldItemsOfANewFeedOutOfTheDigest(t *testing.T) { 242 e := newEnv(t, false) 243 c, tok, _ := e.account("") 244 e.withMail() 245 a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") 246 start := time.Unix(1_800_000_000, 0) 247 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: start} 248 e.s.feeds = f.tools() 249 e.s.runFeeds() 250 251 // The first fetch of Beta gives its old items. The first fetch of Gamma 252 // gives nothing, as a fetch that failed. 253 old := "1600000000\tOld 1\thttps://b.org/1\t\t\tb1\t\t\t\n1600000001\tOld 2\thttps://b.org/2\t\t\tb2\t\t\t\n" 254 f.items["https://b.org/rss.xml"] = old 255 f.items["https://c.org/rss.xml"] = "" 256 e.send(c, "/feed/add", url.Values{"url": {"https://b.org/rss.xml"}, "name": {"Beta"}}) 257 e.send(c, "/feed/add", url.Values{"url": {"https://c.org/rss.xml"}, "name": {"Gamma"}}) 258 f.now = start.Add(time.Hour) 259 e.s.runFeeds() 260 // Now the fetch of Gamma works, and Beta has a new item. 261 f.items["https://c.org/rss.xml"] = "1600000002\tOld 3\thttps://c.org/3\t\t\tc3\t\t\t\n" 262 f.items["https://b.org/rss.xml"] = "1800003600\tNew\thttps://b.org/new\t\t\tbnew\t\t\t\n" + old 263 f.now = start.Add(2 * time.Hour) 264 e.s.runFeeds() 265 f.now = start.Add(25 * time.Hour) 266 e.s.runFeeds() 267 if e.sent() != 1 { 268 t.Fatalf("%d emails after a day", e.sent()) 269 } 270 mail := e.mails[0] 271 if !strings.Contains(mail, "sbm feed: 1 new item\n") || !strings.Contains(mail, "New") { 272 t.Errorf("the digest lacks the new item:\n%s", mail) 273 } 274 for _, item := range []string{"Old 1", "Old 2", "Old 3"} { 275 if strings.Contains(mail, item) { 276 t.Errorf("the digest has the old item %q:\n%s", item, mail) 277 } 278 } 279 if got := e.feedFile(a, "known"); got != "https://a.org/rss.xml\tAlpha\nhttps://b.org/rss.xml\tBeta\nhttps://c.org/rss.xml\tGamma\n" { 280 t.Errorf("known: %q", got) 281 } 282 283 // A feed that the account left and follows again is a new feed: the 284 // item that came while the account did not follow it is old. 285 e.send(c, "/feed/delete", url.Values{"url": {"https://b.org/rss.xml"}}) 286 f.now = start.Add(26 * time.Hour) 287 e.s.runFeeds() 288 f.items["https://b.org/rss.xml"] = "1800090000\tWhile away\thttps://b.org/away\t\t\tbaway\t\t\t\n" + f.items["https://b.org/rss.xml"] 289 e.send(c, "/feed/add", url.Values{"url": {"https://b.org/rss.xml"}, "name": {"Beta"}}) 290 f.now = start.Add(27 * time.Hour) 291 e.s.runFeeds() 292 // Under another name, the items of the feed get other keys. So the 293 // feed is new, also without a run between the two changes. 294 e.send(c, "/feed/delete", url.Values{"url": {"https://b.org/rss.xml"}}) 295 e.send(c, "/feed/add", url.Values{"url": {"https://b.org/rss.xml"}, "name": {"Bee"}}) 296 f.now = start.Add(28 * time.Hour) 297 e.s.runFeeds() 298 f.now = start.Add(50 * time.Hour) 299 e.s.runFeeds() 300 if e.sent() != 1 { 301 t.Errorf("old items of the feed that came back went out:\n%s", e.mails[len(e.mails)-1]) 302 } 303 if got := e.feedFile(a, "known"); got != "https://a.org/rss.xml\tAlpha\nhttps://c.org/rss.xml\tGamma\nhttps://b.org/rss.xml\tBee\n" { 304 t.Errorf("known at the end: %q", got) 305 } 306 } 307 308 // A feed whose host does not resolve in one run stays known. So its new 309 // items still go out. 310 func TestJobKeepsAFeedKnownWhenItsHostFailsOnce(t *testing.T) { 311 e := newEnv(t, false) 312 _, tok, _ := e.account("") 313 e.withMail() 314 e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") 315 start := time.Unix(1_800_000_000, 0) 316 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: start} 317 e.s.feeds = f.tools() 318 e.s.runFeeds() 319 e.s.feeds.lookup = func(string) ([]net.IP, error) { return nil, errors.New("no such host") } 320 f.now = start.Add(time.Hour) 321 e.s.runFeeds() 322 e.s.feeds = f.tools() 323 f.items["https://a.org/rss.xml"] = "1700003600\tTwo\thttps://a.org/2\t\t\tid2\t\t\t\n" + oneItem 324 f.now = start.Add(25 * time.Hour) 325 e.s.runFeeds() 326 if e.sent() != 1 || !strings.Contains(e.mails[0], "Two") { 327 t.Errorf("the new item did not go out: %d emails", e.sent()) 328 } 329 } 330 331 // After an update from a version without the file known, the digest knows 332 // all feeds that the account follows. So no item that waits for the next 333 // digest is lost. 334 func TestJobKnowsTheFeedsOfAnOlderDigest(t *testing.T) { 335 e := newEnv(t, false) 336 _, tok, _ := e.account("") 337 e.withMail() 338 a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") 339 start := time.Unix(1_800_000_000, 0) 340 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: start} 341 e.s.feeds = f.tools() 342 e.s.runFeeds() 343 if err := os.Remove(filepath.Join(e.s.store.feedDir(a.ID), "known")); err != nil && !os.IsNotExist(err) { 344 t.Fatal(err) 345 } 346 f.items["https://a.org/rss.xml"] = "1700003600\tTwo\thttps://a.org/2\t\t\tid2\t\t\t\n" + oneItem 347 f.now = start.Add(25 * time.Hour) 348 e.s.runFeeds() 349 if e.sent() != 1 || !strings.Contains(e.mails[0], "Two") { 350 t.Errorf("the update lost the new item: %d emails", e.sent()) 351 } 352 if got := e.feedFile(a, "known"); got != "https://a.org/rss.xml\tAlpha\n" { 353 t.Errorf("known: %q", got) 354 } 355 } 356 357 func TestJobMailsOnlyConfirmedAccounts(t *testing.T) { 358 e := newEnv(t, false) 359 e.withMail() 360 e.signup("me@example.org") 361 tok := e.token("me@example.org") 362 a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") 363 f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: time.Now()} 364 e.s.feeds = f.tools() 365 e.s.runFeeds() 366 // The one email is the email check of the sign-up. 367 if e.sent() != 1 || e.feedFile(a, "mailed") != "" { 368 t.Errorf("an unconfirmed account got a digest: %d emails, mailed %q", e.sent(), e.feedFile(a, "mailed")) 369 } 370 if e.feedFile(a, filepath.Join("items", "Alpha")) != oneItem { 371 t.Error("the feed of an unconfirmed account is not fetched") 372 } 373 } 374 375 func TestJobExploresTheBookmarks(t *testing.T) { 376 e := newEnv(t, false) 377 c, tok, _ := e.account("https://old.org\tOld\t\nhttps://blog.org\tBlog\t\nhttp://internal.test/\tInside\t\njavascript:x\tBad\t\n") 378 a := e.s.store.byTok(tok) 379 f := &fakeTools{now: time.Now(), pages: map[string][]string{ 380 "https://blog.org": {"https://blog.org/feed.xml\tapplication/rss+xml", "https://blog.org/it's\tapplication/atom+xml"}, 381 }} 382 e.s.feeds = f.tools() 383 e.s.runFeeds() 384 if len(f.crawled) != 0 || e.feedFile(a, "explore") != "" { 385 t.Fatal("the job looked at the bookmarks without the opt-in") 386 } 387 e.send(c, "/feed/settings", url.Values{"explore": {"on"}}) 388 e.s.runFeeds() 389 // Newest first. The page on a private network is not fetched. 390 if want := []string{"https://blog.org", "https://old.org"}; strings.Join(f.crawled, " ") != strings.Join(want, " ") { 391 t.Errorf("crawled %q, want %q", f.crawled, want) 392 } 393 want := "http://internal.test/\t-\t\nhttps://blog.org\thttps://blog.org/feed.xml\tapplication/rss+xml\nhttps://old.org\t-\t\n" 394 if got := e.feedFile(a, "explore"); got != want { 395 t.Errorf("explore:\n%q\nwant\n%q", got, want) 396 } 397 page := e.body(c, "/feed") 398 for _, want := range []string{"looked at 3 of your 3 bookmarked pages", `value="https://blog.org/feed.xml"`, `value="Blog"`, "on https://blog.org"} { 399 if !strings.Contains(page, want) { 400 t.Errorf("the page lacks %q", want) 401 } 402 } 403 // The next run looks at no page twice. 404 e.s.runFeeds() 405 if len(f.crawled) != 2 { 406 t.Errorf("crawled %q", f.crawled) 407 } 408 // A feed that the account follows is no suggestion any more. 409 e.send(c, "/feed/add", url.Values{"url": {"https://blog.org/feed.xml"}, "name": {"Blog"}}) 410 _, explore, _ := strings.Cut(e.body(c, "/feed"), "<h2>Explore</h2>") 411 if strings.Contains(explore, "feed.xml") || !strings.Contains(explore, "No new feed to follow yet.") { 412 t.Errorf("the page still suggests the followed feed:\n%s", explore) 413 } 414 } 415 416 func TestJobExploresTwentyPagesInOneRun(t *testing.T) { 417 e := newEnv(t, false) 418 var b strings.Builder 419 for i := range crawlPerRun + 5 { 420 fmt.Fprintf(&b, "https://p%d.org\tP%d\t\n", i, i) 421 } 422 c, tok, _ := e.account(b.String()) 423 f := &fakeTools{now: time.Now()} 424 e.s.feeds = f.tools() 425 e.send(c, "/feed/settings", url.Values{"explore": {"on"}}) 426 e.s.runFeeds() 427 if len(f.crawled) != crawlPerRun || f.crawled[0] != "https://p24.org" { 428 t.Errorf("first run: %d pages, first %q", len(f.crawled), f.crawled[0]) 429 } 430 if !strings.Contains(e.body(c, "/feed"), "looked at 20 of your 25 bookmarked pages") { 431 t.Error("the page does not show the count") 432 } 433 e.s.runFeeds() 434 if len(f.crawled) != crawlPerRun+5 { 435 t.Errorf("second run: %d pages", len(f.crawled)) 436 } 437 _ = tok 438 } 439 440 func TestPublicIP(t *testing.T) { 441 for ip, want := range map[string]bool{ 442 "93.184.216.34": true, "2606:2800:220:1:248:1893:25c8:1946": true, 443 "127.0.0.1": false, "10.66.0.4": false, "192.168.1.1": false, "172.16.0.9": false, 444 "169.254.169.254": false, "100.64.0.1": false, "0.0.0.0": false, "::1": false, "fd00::1": false, "fe80::1": false, 445 } { 446 if got := publicIP(net.ParseIP(ip)); got != want { 447 t.Errorf("publicIP(%s) = %v, want %v", ip, got, want) 448 } 449 } 450 }