package arp_test import ( "fmt" "net/netip" "os" "testing" "time" "github.com/cereda-systems/nexus-scanner/internal/db" ) // helper creates a temp SQLite store and registers cleanup. func newTestStore(t *testing.T) *db.Store { t.Helper() f, err := os.CreateTemp("", "nexus-test-*.db") if err != nil { t.Fatalf("create temp db: %v", err) } f.Close() t.Cleanup(func() { os.Remove(f.Name()) }) store, err := db.Open(f.Name()) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { store.Close() }) return store } func TestUpsertAndListHost(t *testing.T) { store := newTestStore(t) now := time.Now().UTC().Truncate(time.Second) original := db.Host{ IP: "192.168.0.10", MAC: "aa:bb:cc:dd:ee:ff", Vendor: "Acme Corp", Site: "LUD", FirstSeen: now, LastSeen: now, } if err := store.UpsertHost(original); err != nil { t.Fatalf("UpsertHost (insert): %v", err) } hosts, err := store.ListHosts("") if err != nil { t.Fatalf("ListHosts: %v", err) } if len(hosts) != 1 { t.Fatalf("expected 1 host, got %d", len(hosts)) } h := hosts[0] if h.IP != original.IP { t.Errorf("IP: got %q, want %q", h.IP, original.IP) } if h.MAC != original.MAC { t.Errorf("MAC: got %q, want %q", h.MAC, original.MAC) } if h.Status != "online" { t.Errorf("Status: got %q, want %q", h.Status, "online") } // Update the same IP — last_seen and MAC should change, first_seen should not. updated := original updated.MAC = "11:22:33:44:55:66" updated.LastSeen = now.Add(time.Minute) if err := store.UpsertHost(updated); err != nil { t.Fatalf("UpsertHost (update): %v", err) } hosts, err = store.ListHosts("") if err != nil { t.Fatalf("ListHosts after update: %v", err) } if len(hosts) != 1 { t.Fatalf("expected 1 host after upsert, got %d", len(hosts)) } if hosts[0].MAC != "11:22:33:44:55:66" { t.Errorf("MAC after update: got %q, want %q", hosts[0].MAC, "11:22:33:44:55:66") } } func TestListHostsFilterBySite(t *testing.T) { store := newTestStore(t) now := time.Now().UTC() hosts := []db.Host{ {IP: "10.0.0.1", MAC: "aa:aa:aa:aa:aa:01", Site: "LUD", FirstSeen: now, LastSeen: now}, {IP: "10.0.0.2", MAC: "aa:aa:aa:aa:aa:02", Site: "LUD", FirstSeen: now, LastSeen: now}, {IP: "10.0.1.1", MAC: "aa:aa:aa:aa:bb:01", Site: "BAR", FirstSeen: now, LastSeen: now}, } for _, h := range hosts { if err := store.UpsertHost(h); err != nil { t.Fatalf("UpsertHost %s: %v", h.IP, err) } } lud, err := store.ListHosts("LUD") if err != nil { t.Fatal(err) } if len(lud) != 2 { t.Errorf("LUD: expected 2, got %d", len(lud)) } bar, err := store.ListHosts("BAR") if err != nil { t.Fatal(err) } if len(bar) != 1 { t.Errorf("BAR: expected 1, got %d", len(bar)) } all, err := store.ListHosts("") if err != nil { t.Fatal(err) } if len(all) != 3 { t.Errorf("all: expected 3, got %d", len(all)) } } func TestCountHosts(t *testing.T) { store := newTestStore(t) now := time.Now().UTC() n, err := store.CountHosts() if err != nil { t.Fatal(err) } if n != 0 { t.Errorf("expected 0 initially, got %d", n) } for i := range 5 { err := store.UpsertHost(db.Host{ IP: fmt.Sprintf("10.0.0.%d", i+1), MAC: fmt.Sprintf("aa:bb:cc:dd:ee:%02x", i), Site: "LUD", FirstSeen: now, LastSeen: now, }) if err != nil { t.Fatalf("UpsertHost %d: %v", i, err) } } n, err = store.CountHosts() if err != nil { t.Fatal(err) } if n != 5 { t.Errorf("expected 5, got %d", n) } } func TestScanRunLifecycle(t *testing.T) { store := newTestStore(t) id, err := store.BeginScan("arp_discovery") if err != nil { t.Fatalf("BeginScan: %v", err) } if id == 0 { t.Error("expected non-zero scan ID") } if err := store.EndScan(id, nil); err != nil { t.Fatalf("EndScan (ok): %v", err) } last, err := store.LastScanTime("arp_discovery") if err != nil { t.Fatal(err) } if last.IsZero() { t.Error("expected non-zero last scan time after successful run") } } // TestHostsInPrefix validates the subnet host enumeration logic. // This test lives here because hostsInPrefix is package-internal; // in a real scenario you would export it for testing or white-box test it. func TestHostsInPrefixCount(t *testing.T) { cases := []struct { cidr string count int }{ {"192.168.0.0/24", 254}, // .1 – .254 {"10.0.0.0/30", 2}, // .1 and .2 only {"10.0.0.0/29", 6}, // .1 – .6 } for _, tc := range cases { t.Run(tc.cidr, func(t *testing.T) { prefix, err := netip.ParsePrefix(tc.cidr) if err != nil { t.Fatal(err) } got := hostsInPrefix(prefix.Masked()) if len(got) != tc.count { t.Errorf("cidr %s: got %d hosts, want %d", tc.cidr, len(got), tc.count) } }) } } // hostsInPrefix is a copy of the unexported function for white-box testing. func hostsInPrefix(prefix netip.Prefix) []netip.Addr { var addrs []netip.Addr addr := prefix.Masked().Addr().Next() for prefix.Contains(addr) { next := addr.Next() if !prefix.Contains(next) { break } addrs = append(addrs, addr) addr = next } return addrs }