231 lines
5.0 KiB
Go
231 lines
5.0 KiB
Go
|
|
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
|
|||
|
|
}
|