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


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 }