cb3641e7bb
internal/rss parses RSS 2.0 and Atom, and polls each configured feed on its own interval; internal/webfetch is the one door either of them uses to touch the network. The poller writes items as notes with source "rss:<feed>" and nothing else: the answer path reads them back when he asks "что нового в лентах?", and nothing is announced on arrival. A feed that dispatched would be a nag, which is why the plan's breaking-news rule was left out rather than built. webfetch is where the limits live, as code rather than a paragraph: http(s) only, an allowlist (the configured feeds' hosts) and a denylist, a 2 MiB body cap, a 3-redirect cap, one request per host per second, and a refusal to connect to any private address — checked in the dialer's Control hook so it holds for every resolved address and every redirect hop, not just for a literal IP. Off unless configured: no "feeds" block, no poller, no outbound request. How far a feed was read is a config fact (rss:latest:<name>), so a restart does not re-note yesterday's headlines.
178 lines
6.2 KiB
Go
178 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/rss"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
// buildFeedHandler — a handler with the given feed notes already stored. No
|
|
// embedder: the feed source answers from recent notes by source, which is what
|
|
// makes it work for notes written before an embedder existed.
|
|
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Now()
|
|
for i, n := range notes {
|
|
ts := now.Add(time.Duration(i) * time.Minute)
|
|
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
|
t.Fatalf("WriteNote: %v", err)
|
|
}
|
|
}
|
|
return &reactiveHandler{
|
|
api: ipc.NewStoreAPI(st),
|
|
replier: voice.NewStubReplier(),
|
|
phraser: phraser.NewStub(),
|
|
now: func() time.Time { return now },
|
|
feedsOn: feedsOn,
|
|
embedder: nil,
|
|
}
|
|
}
|
|
|
|
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
|
t.Helper()
|
|
return h.queryFeeds(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
|
})
|
|
}
|
|
|
|
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
|
h := buildFeedHandler(t, true,
|
|
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
|
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
|
)
|
|
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
|
if !ok {
|
|
t.Fatal("the feed source did not claim the question")
|
|
}
|
|
if !strings.Contains(reply, "уязвимость") {
|
|
t.Errorf("reply = %q, want the headline", reply)
|
|
}
|
|
if strings.Contains(reply, "он сам сказал") {
|
|
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
|
}
|
|
// She reads the headline, not the summary and not the URL.
|
|
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
|
t.Errorf("reply = %q, want the title line only", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryFeedsByCategory(t *testing.T) {
|
|
h := buildFeedHandler(t, true,
|
|
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
|
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
|
)
|
|
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
|
if !ok {
|
|
t.Fatal("not claimed")
|
|
}
|
|
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
|
}
|
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
|
if !strings.Contains(reply, "ничего") {
|
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
|
}
|
|
}
|
|
|
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
|
// answered by the model inventing a bulletin.
|
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
|
off := buildFeedHandler(t, false)
|
|
reply, ok := askFeeds(t, off, "что нового?")
|
|
if !ok || !strings.Contains(reply, "не настроены") {
|
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
|
}
|
|
on := buildFeedHandler(t, true)
|
|
reply, ok = askFeeds(t, on, "что нового?")
|
|
if !ok || !strings.Contains(reply, "ничего нового") {
|
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
|
}
|
|
}
|
|
|
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
|
h := buildFeedHandler(t, true)
|
|
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
|
t.Fatalf("claimed an unrelated question with %q", reply)
|
|
}
|
|
}
|
|
|
|
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
|
// fact round-trip is worth a test of its own.
|
|
func TestFactMarksRoundTrip(t *testing.T) {
|
|
st := newTestStore(t)
|
|
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
|
ctx := context.Background()
|
|
|
|
at, err := m.LastMark(ctx, "habr")
|
|
if err != nil || !at.IsZero() {
|
|
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
|
}
|
|
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
|
if err := m.SetMark(ctx, "habr", want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := m.LastMark(ctx, "habr")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Equal(want) {
|
|
t.Fatalf("mark = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
|
// worker ⇒ no outbound request is possible.
|
|
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
|
t.Fatal("a config with no feeds block wired a feed worker")
|
|
}
|
|
// An empty sources list is normalised to "off" by config.Load; the worker
|
|
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
|
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
|
t.Fatal("an empty sources list wired a feed worker")
|
|
}
|
|
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
|
{Name: "habr", URL: "https://example.org/rss"},
|
|
}}}
|
|
w := newFeedWorker(api, nil, cfg)
|
|
if w == nil {
|
|
t.Fatal("a configured feed did not wire a worker")
|
|
}
|
|
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
|
t.Fatalf("feeds = %+v", got)
|
|
}
|
|
}
|
|
|
|
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
|
// nothing else — the crawler's SSRF guards are only worth as much as the
|
|
// allowlist handed to them.
|
|
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
|
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
|
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
|
}}}
|
|
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
|
if w == nil {
|
|
t.Fatal("no worker")
|
|
}
|
|
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
|
// itself must fail rather than be read.
|
|
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
|
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
|
}, time.Now())
|
|
if err == nil {
|
|
t.Fatal("the poller fetched a private address")
|
|
}
|
|
}
|