| 1 | package bot |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestFeedLinksFromHTML(t *testing.T) { |
| 12 | base, _ := url.Parse("https://news.example/section/home") |
| 13 | html := `<!doctype html><html><head> |
| 14 | <link rel="alternate" type="application/rss+xml" href="/feeds/all.xml" title="Everything"> |
| 15 | <LINK REL="alternate" TYPE="application/atom+xml" HREF="https://news.example/atom"> |
| 16 | <link type="application/rss+xml" rel='alternate' href='also-relative.rss'> |
| 17 | <link rel="alternate" type="application/rss+xml" href="/feeds/all.xml"> |
| 18 | <link rel="stylesheet" href="/style.css"> |
| 19 | <link rel="alternate" type="text/html" href="/mobile"> |
| 20 | </head><body></body></html>` |
| 21 | feeds := feedLinksFromHTML(html, base) |
| 22 | want := []string{ |
| 23 | "https://news.example/feeds/all.xml", |
| 24 | "https://news.example/atom", |
| 25 | "https://news.example/section/also-relative.rss", // resolved against the page URL |
| 26 | } |
| 27 | if len(feeds) != len(want) { |
| 28 | t.Fatalf("got %v, want %v", feeds, want) |
| 29 | } |
| 30 | for i := range want { |
| 31 | if feeds[i] != want[i] { |
| 32 | t.Errorf("feed %d: got %q, want %q", i, feeds[i], want[i]) |
| 33 | } |
| 34 | } |
| 35 | if got := feedLinksFromHTML("<p>no links here</p>", base); len(got) != 0 { |
| 36 | t.Errorf("linkless page: got %v", got) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestDiscoverFeeds(t *testing.T) { |
| 41 | t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") |
| 42 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 43 | w.Header().Set("Content-Type", "text/html") |
| 44 | w.Write([]byte(`<html><head><link rel="alternate" type="application/rss+xml" href="/rss.xml"></head></html>`)) |
| 45 | })) |
| 46 | defer srv.Close() |
| 47 | |
| 48 | feeds, err := discoverFeeds(srv.URL) |
| 49 | if err != nil { |
| 50 | t.Fatalf("discover: %v", err) |
| 51 | } |
| 52 | if len(feeds) != 1 || feeds[0] != srv.URL+"/rss.xml" { |
| 53 | t.Fatalf("got %v", feeds) |
| 54 | } |
| 55 | |
| 56 | // The tool wrapper formats for a tool_result and surfaces failures as text. |
| 57 | if out := runDiscoverFeeds(map[string]any{"site": srv.URL}); !strings.Contains(out, "/rss.xml") { |
| 58 | t.Fatalf("tool output: %q", out) |
| 59 | } |
| 60 | if out := runDiscoverFeeds(map[string]any{"site": ""}); !strings.Contains(out, "discovery failed") { |
| 61 | t.Fatalf("empty site should fail as text: %q", out) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // Without the test escape hatch, discovery refuses non-https and (via the |
| 66 | // dialer guard) private addresses — the member named a site, not our LAN. |
| 67 | func TestDiscoverFeedsBlocksPrivate(t *testing.T) { |
| 68 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 69 | w.Write([]byte("secret")) |
| 70 | })) |
| 71 | defer srv.Close() |
| 72 | if _, err := discoverFeeds(srv.URL); err == nil { |
| 73 | t.Fatal("loopback http discovery must be refused outside test mode") |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestSameSiteHost(t *testing.T) { |
| 78 | ok := [][2]string{{"bbc.com", "bbc.com"}, {"www.bbc.com", "bbc.com"}, {"bbc.com", "www.bbc.com"}} |
| 79 | for _, p := range ok { |
| 80 | if !sameSiteHost(p[0], p[1]) { |
| 81 | t.Errorf("%s vs %s should be the same site", p[0], p[1]) |
| 82 | } |
| 83 | } |
| 84 | bad := [][2]string{{"bbc.com", "bbc.co.uk"}, {"www.bbc.com", "www.npr.org"}, {"evil-bbc.com", "bbc.com"}} |
| 85 | for _, p := range bad { |
| 86 | if sameSiteHost(p[0], p[1]) { |
| 87 | t.Errorf("%s vs %s should NOT be the same site", p[0], p[1]) |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestHostLabelAndJoinAnd(t *testing.T) { |
| 93 | for in, want := range map[string]string{ |
| 94 | "bbc.com": "bbc.com", |
| 95 | "https://www.bbc.com/": "bbc.com", |
| 96 | "http://npr.org/feeds?x=1": "npr.org", |
| 97 | " theguardian.com/uk ": "theguardian.com", |
| 98 | } { |
| 99 | if got := hostLabel(in); got != want { |
| 100 | t.Errorf("hostLabel(%q) = %q, want %q", in, got, want) |
| 101 | } |
| 102 | } |
| 103 | if got := joinAnd([]string{"a"}); got != "a" { |
| 104 | t.Errorf("joinAnd one: %q", got) |
| 105 | } |
| 106 | if got := joinAnd([]string{"a", "b"}); got != "a and b" { |
| 107 | t.Errorf("joinAnd two: %q", got) |
| 108 | } |
| 109 | if got := joinAnd([]string{"a", "b", "c"}); got != "a, b and c" { |
| 110 | t.Errorf("joinAnd three: %q", got) |
| 111 | } |
| 112 | } |
| 113 | |