package bot import ( "net/http" "net/http/httptest" "strings" "testing" ) func TestCheckFetchURL(t *testing.T) { allow := []string{"https://www.rte.ie/feeds/", "https://rss.nytimes.com/"} if err := checkFetchURL("https://www.rte.ie/feeds/rss/news.xml", allow); err != nil { t.Errorf("allowlisted url rejected: %v", err) } if err := checkFetchURL("https://evil.example.com/feeds/", allow); err == nil { t.Error("off-list url accepted") } if err := checkFetchURL("http://www.rte.ie/feeds/rss/news.xml", allow); err == nil { t.Error("plain http accepted outside test mode") } // Prefix means prefix: a lookalike host that merely CONTAINS the prefix // elsewhere must fail. if err := checkFetchURL("https://phish.example/https://www.rte.ie/feeds/", allow); err == nil { t.Error("embedded-prefix url accepted") } } // TestCheckFetchURLWWW pins the apex↔www tolerance: a homepage-fallback allow // entry survives the site's own redirect to (or from) www — the live failure // that broke "switch to the Irish Times" (irishtimes.com 301s to www). func TestCheckFetchURLWWW(t *testing.T) { cases := []struct { raw string allow string ok bool }{ {"https://www.irishtimes.com/", "https://irishtimes.com", true}, {"https://irishtimes.com/news", "https://www.irishtimes.com", true}, {"https://www.irishtimes.com/news?page=2", "https://irishtimes.com", true}, // www tolerance is not subdomain tolerance. {"https://api.irishtimes.com/", "https://irishtimes.com", false}, {"https://wwwx.irishtimes.com/", "https://irishtimes.com", false}, } for _, c := range cases { err := checkFetchURL(c.raw, []string{c.allow}) if (err == nil) != c.ok { t.Errorf("checkFetchURL(%q, allow %q) = %v, want ok=%v", c.raw, c.allow, err, c.ok) } } } // TestAllowPrefixMatchBoundary pins the boundary rule: a bare-host entry must // not match a LONGER host that merely starts with the same bytes — the model // picks fetch URLs, so this edge is load-bearing. func TestAllowPrefixMatchBoundary(t *testing.T) { cases := []struct { raw string entry string ok bool }{ {"https://irishtimes.com", "https://irishtimes.com", true}, {"https://irishtimes.com/", "https://irishtimes.com", true}, {"https://irishtimes.com/news", "https://irishtimes.com", true}, {"https://irishtimes.com?utm=x", "https://irishtimes.com", true}, {"https://irishtimes.com.evil.com/", "https://irishtimes.com", false}, {"https://irishtimes.community/", "https://irishtimes.com", false}, {"https://irishtimes.com:8443/", "https://irishtimes.com", false}, // Entries ending in a slash or already inside a query keep plain // prefix semantics. {"https://www.rte.ie/feeds/rss/news.xml", "https://www.rte.ie/feeds/", true}, {"https://a.com/feed?index=/news/&limit=100", "https://a.com/feed?index=/news/", true}, {"https://a.com/feedsecret", "https://a.com/feeds", false}, } for _, c := range cases { if got := allowPrefixMatch(c.raw, c.entry); got != c.ok { t.Errorf("allowPrefixMatch(%q, %q) = %v, want %v", c.raw, c.entry, got, c.ok) } } } // TestFetchBlocksPrivateAddresses pins the SSRF guard: without the test-only // escape hatch, a loopback URL — even one on the allowlist — must not connect. func TestFetchBlocksPrivateAddresses(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("secret")) })) defer srv.Close() // The dialer's Control hook must reject the loopback address (scheme check // is bypassed by allowlisting the URL itself — the layers are independent). if _, err := fetchURL(srv.URL+"/feed", []string{srv.URL}); err == nil { t.Fatal("loopback fetch succeeded without LCHAT_BOT_ALLOW_PRIVATE_FETCH") } t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") body, err := fetchURL(srv.URL+"/feed", []string{srv.URL}) if err != nil { t.Fatalf("test-mode loopback fetch: %v", err) } if body != "secret" { t.Fatalf("got %q", body) } } func TestValidateTaskSpec(t *testing.T) { ok := &TaskSpec{Prompt: "summarise", Tools: []string{"fetch"}, Fetch: &FetchSpec{Allow: []string{"https://rss.nytimes.com/"}}} if err := validateTaskSpec(0, ok); err != nil { t.Errorf("valid spec rejected: %v", err) } bad := []struct { name string spec *TaskSpec want string }{ {"nil", nil, "needs a prompt"}, {"no prompt", &TaskSpec{}, "needs a prompt"}, {"fetch without allowlist", &TaskSpec{Prompt: "x", Tools: []string{"fetch"}}, "non-empty allowlist"}, {"unknown tool", &TaskSpec{Prompt: "x", Tools: []string{"shell"}}, "unknown task tool"}, {"http allow entry", &TaskSpec{Prompt: "x", Tools: []string{"fetch"}, Fetch: &FetchSpec{Allow: []string{"http://a.example/"}}}, "https:// prefixes"}, {"fetch config, no tool", &TaskSpec{Prompt: "x", Fetch: &FetchSpec{Allow: []string{"https://a.example/"}}}, "without the fetch tool"}, {"notice too long", &TaskSpec{Prompt: "x", Post: &PostSpec{Notice: &NoticeSpec{Title: strings.Repeat("t", 200)}}}, "notice too long"}, {"budget out of range", &TaskSpec{Prompt: "x", MaxToolCalls: 99}, "out of range"}, } for _, tc := range bad { err := validateTaskSpec(0, tc.spec) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: want error containing %q, got %v", tc.name, tc.want, err) } } }