package main import ( "context" "net" "strconv" "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" ) func TestWireNetScanOffUnlessEnabled(t *testing.T) { for name, cfg := range map[string]*config.Config{ "no block": {}, "written but dark": {NetScan: &config.NetScanConfig{ Subnets: []string{"192.168.1.0/24"}, }}, "enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}}, "enabled but public": {NetScan: &config.NetScanConfig{ Subnets: []string{"8.8.8.0/24"}, Enabled: true, }}, "enabled but far too wide": {NetScan: &config.NetScanConfig{ Subnets: []string{"10.0.0.0/8"}, Enabled: true, }}, } { t.Run(name, func(t *testing.T) { if w := wireNetScan(cfg, nil); w != nil { t.Fatal("the scanner must not wire for this config") } }) } var w *netWiring if _, ok := w.scanSummary(context.Background()); ok { t.Fatal("a nil wiring must not claim a query") } ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ Subnets: []string{"192.168.1.0/24"}, Enabled: true, }}, nil) if ok == nil { t.Fatal("a valid enabled block should wire") } } // A loopback /32 with nothing listening on the scanned port: the summary must // come back honest rather than inventing a host. This also exercises the real // dialer end to end without touching anything outside this box. func TestScanSummaryOnAnEmptyRange(t *testing.T) { w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ // Port 1 on loopback: nothing listens and the connection is refused // immediately, so the scan is fast and touches only this machine. Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true, }}, nil) if w == nil { t.Fatal("wireNetScan returned nil") } out, claimed := w.scanSummary(context.Background()) if !claimed { t.Fatal("the summary did not claim the turn") } if out == "" { t.Fatal("empty summary") } // Persona: feminine self-reference, informal address, no pet names. low := strings.ToLower(out) for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} { if strings.Contains(low, bad) { t.Errorf("persona violation %q in %q", bad, out) } } } func TestIsNetworkQuery(t *testing.T) { yes := []string{ "какие устройства в сети?", "кто в сети?", "просканируй сеть", "покажи устройства в локальной сети", "сколько машин в сети", } no := []string{ "", "интернет не работает", "сеть какая-то медленная", "я в сети инстаграма", "что включено дома?", "напомни оплатить интернет", } for _, u := range yes { if !isNetworkQuery(u) { t.Errorf("isNetworkQuery(%q) = false, want true", u) } } for _, u := range no { if isNetworkQuery(u) { t.Errorf("isNetworkQuery(%q) = true, want false", u) } } } // notingAPI counts the notes a scan writes, and remembers the last one. type notingAPI struct { ipc.CoreAPI n int last string } func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) { a.n++ a.last = text return int64(a.n), nil } // The spoken answer must not be a list of IP addresses. It goes to piper as // well as to /chat, and six dotted quads read out as a digit stream is not an // answer anybody can use. The addresses belong in the written record. func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() _, portStr, _ := net.SplitHostPort(ln.Addr().String()) port, _ := strconv.Atoi(portStr) api := ¬ingAPI{} w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{ Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true, }}, api) if w == nil { t.Fatal("wireNetScan returned nil") } out, claimed := w.scanSummary(context.Background()) if !claimed { t.Fatal("the summary did not claim the turn") } if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) { t.Errorf("the spoken reply reads addresses out loud: %q", out) } if !strings.Contains(out, "нашла 1 устройство") { t.Errorf("reply = %q, want a count", out) } if api.n != 1 { t.Fatalf("wrote %d notes, want 1", api.n) } if !strings.Contains(api.last, "127.0.0.1") { t.Errorf("the written record has no addresses: %q", api.last) } // A follow-up question inside the TTL reuses the answer: two questions in // a row must not be two sweeps of the LAN. if _, _ = w.scanSummary(context.Background()); api.n != 1 { t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n) } } // An unconfigured scanner names the gap instead of declining the turn. // // Falling through sent "какие устройства в сети?" to the search leg, which // answered with a paragraph about routers in general — and put a question about // his own LAN on an upstream engine, which the personal boundary exists to // prevent (Vikunja #479). func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) { h := &reactiveHandler{} reply, ok := h.queryNetwork(context.Background(), &queryTurn{ dec: router.Decision{Utterance: "какие устройства в сети?"}, }) if !ok { t.Fatal("an unconfigured scanner let the question fall through to search") } if !phraser.IsQ(phraser.QueryNetOff, nil, reply) { t.Errorf("got %q, want the gap named", reply) } }