package database import ( "context" "database/sql" "errors" "fmt" "net/netip" "sync" "testing" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/logger" "github.com/google/uuid" ) // tempStoreMemory is the PRAGMA temp_store value meaning "hold temp B-trees in // memory"; the DSN change must leave temp_store below this so they spill to disk. const tempStoreMemory = 2 // autoVacuumIncremental is the PRAGMA auto_vacuum value meaning "incremental". const autoVacuumIncremental = 2 // vacuumTestRoutes is how many routes the vacuum test writes and then deletes, // enough to leave many free pages in the file. const vacuumTestRoutes = 2000 // heldConnections is how many pooled connections the pragma test holds open at // once so each is a distinct SQLite connection that parsed the DSN. const heldConnections = 5 // testPeerIP is the peer every route in the IP lookup test is learned from. const testPeerIP = "192.0.2.254" // Parameters for the checkpoint-contention regression test. const ( // contentionIterations is how many batch writes race the checkpoint loop. contentionIterations = 400 // contendedASNCount is the small set of ASNs the batches reuse, so most // batches update existing rows and exercise the read-before-write path. contendedASNCount = 16 // asnSecondBand offsets a second ASN per batch so each batch writes more // than one row. asnSecondBand = 100 ) // TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both // families and checks that a lookup returns the most specific one covering the // address, ErrNoRoute when none covers it, and the next less specific prefix // once the only route of the most specific one is withdrawn. func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) { t.Parallel() cfg := &config.Config{StateDir: t.TempDir()} db, err := New(cfg, logger.New()) if err != nil { t.Fatalf("failed to create database: %v", err) } defer func() { _ = db.Close() }() // Nested live prefixes, each originated by its own AS. origins := map[string]int{ "10.0.0.0/8": 64500, "10.1.0.0/16": 64501, "10.1.2.0/24": 64502, "2001:db8::/32": 64500, "2001:db8:1::/48": 64501, "2001:db8:1:2::/64": 64502, } writeOriginRoutes(t, db, origins) // lookup checks that ip resolves to the live prefix want, or to ErrNoRoute // when want is empty. lookup := func(ip, want string) { t.Helper() info, err := db.GetIPInfo(ip) if want == "" { if !errors.Is(err, ErrNoRoute) { t.Errorf("GetIPInfo(%s) = %+v, %v; want ErrNoRoute", ip, info, err) } return } if err != nil { t.Errorf("GetIPInfo(%s): %v", ip, err) return } if info.Netblock != want || info.MaskLength != netip.MustParsePrefix(want).Bits() || info.ASN != origins[want] { t.Errorf("GetIPInfo(%s) = %s (mask %d) AS%d, want %s AS%d", ip, info.Netblock, info.MaskLength, info.ASN, want, origins[want]) } } lookup("10.1.2.3", "10.1.2.0/24") lookup("::ffff:10.1.2.3", "10.1.2.0/24") lookup("10.1.3.4", "10.1.0.0/16") lookup("10.2.0.1", "10.0.0.0/8") lookup("192.0.2.1", "") lookup("2001:db8:1:2::3", "2001:db8:1:2::/64") lookup("2001:db8:1:3::4", "2001:db8:1::/48") lookup("2001:db8:2::1", "2001:db8::/32") lookup("2001:db9::1", "") err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{ { Prefix: "10.1.2.0/24", OriginASN: 64502, PeerIP: testPeerIP, IPVersion: ipVersionV4, }, { Prefix: "2001:db8:1:2::/64", OriginASN: 64502, PeerIP: testPeerIP, IPVersion: ipVersionV6, }, }) if err != nil { t.Fatalf("DeleteLiveRouteBatch: %v", err) } lookup("10.1.2.3", "10.1.0.0/16") lookup("2001:db8:1:2::3", "2001:db8:1::/48") } // TestGetIPInfoIgnoresShortRoutes checks that the IP lookup does not report a // default route, or a route shorter than /8 for IPv4 or /16 for IPv6, as the // route for an address, and that a route of exactly /8 or /16, or longer, still // is. The prefixes are written out so that a wrong limit fails the test. func TestGetIPInfoIgnoresShortRoutes(t *testing.T) { t.Parallel() cfg := &config.Config{StateDir: t.TempDir()} db, err := New(cfg, logger.New()) if err != nil { t.Fatalf("failed to create database: %v", err) } defer func() { _ = db.Close() }() // announce writes one live route for prefix, originated by asn. announce := func(prefix string, asn int) { t.Helper() writeOriginRoutes(t, db, map[string]int{prefix: asn}) } // lookup checks that ip resolves to the live prefix netblock from asn. lookup := func(ip, netblock string, asn int) { t.Helper() info, err := db.GetIPInfo(ip) if err != nil { t.Errorf("GetIPInfo(%s): %v", ip, err) return } if info.Netblock != netblock || info.ASN != asn { t.Errorf("GetIPInfo(%s) = %s AS%d, want %s AS%d", ip, info.Netblock, info.ASN, netblock, asn) } } // Default and very short routes, and routes one bit shorter than each limit. announce("2000::/3", 64500) announce("0.0.0.0/0", 64500) announce("2000::/15", 64500) announce("192.0.0.0/7", 64500) for _, ip := range []string{"2001:db8::1", "192.0.2.1"} { info, err := db.GetIPInfo(ip) if !errors.Is(err, ErrNoRoute) { t.Errorf("GetIPInfo(%s) with only short routes = %+v, %v; "+ "want ErrNoRoute", ip, info, err) } } // Routes of exactly the minimum length are used. announce("2001::/16", 64501) announce("192.0.0.0/8", 64501) lookup("2001:db8::1", "2001::/16", 64501) lookup("192.0.2.1", "192.0.0.0/8", 64501) announce("2001:db8::/32", 64502) lookup("2001:db8::1", "2001:db8::/32", 64502) } // writeOriginRoutes writes one live route from testPeerIP for each prefix in // origins, originated by the AS it maps to. func writeOriginRoutes(t *testing.T, db *Database, origins map[string]int) { t.Helper() ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) routes := make([]*LiveRoute, 0, len(origins)) for prefix, asn := range origins { routes = append(routes, &LiveRoute{ ID: uuid.New(), Prefix: prefix, MaskLength: netip.MustParsePrefix(prefix).Bits(), IPVersion: detectIPVersion(prefix), OriginASN: asn, PeerIP: testPeerIP, ASPath: []int{asn}, NextHop: testPeerIP, LastUpdated: ts, }) } err := db.UpsertLiveRouteBatch(routes) if err != nil { t.Fatalf("UpsertLiveRouteBatch: %v", err) } } // TestConnectionPoolPragmas holds several pooled connections open at once and // checks each one carries the per-connection settings from the DSN, plus the // process-wide hard heap limit, and sees the new file with auto_vacuum // incremental. func TestConnectionPoolPragmas(t *testing.T) { t.Parallel() cfg := &config.Config{StateDir: t.TempDir()} db, err := New(cfg, logger.New()) if err != nil { t.Fatalf("failed to create database: %v", err) } defer func() { _ = db.Close() }() ctx := context.Background() // Hold distinct connections open simultaneously so the pool must open a new // one (each parsing the DSN) rather than hand back the same connection. conns := make([]*sql.Conn, 0, heldConnections) defer func() { for _, c := range conns { _ = c.Close() } }() for i := range heldConnections { c, err := db.db.Conn(ctx) if err != nil { t.Fatalf("failed to open connection %d: %v", i, err) } conns = append(conns, c) } for i, c := range conns { cacheSize := readPragma(ctx, t, c, i, "cache_size") if cacheSize != sqliteCacheSizeKiB { t.Errorf("conn %d: cache_size = %d, want %d", i, cacheSize, sqliteCacheSizeKiB) } busyTimeout := readPragma(ctx, t, c, i, "busy_timeout") if busyTimeout != sqliteBusyTimeoutMs { t.Errorf("conn %d: busy_timeout = %d, want %d", i, busyTimeout, sqliteBusyTimeoutMs) } tempStore := readPragma(ctx, t, c, i, "temp_store") if tempStore == tempStoreMemory { t.Errorf("conn %d: temp_store = %d, want anything but %d (MEMORY)", i, tempStore, tempStoreMemory) } hardHeapLimit := readPragma(ctx, t, c, i, "hard_heap_limit") if hardHeapLimit != sqliteHardHeapLimitBytes { t.Errorf("conn %d: hard_heap_limit = %d, want %d", i, hardHeapLimit, sqliteHardHeapLimitBytes) } autoVacuum := readPragma(ctx, t, c, i, "auto_vacuum") if autoVacuum != autoVacuumIncremental { t.Errorf("conn %d: auto_vacuum = %d, want %d (incremental)", i, autoVacuum, autoVacuumIncremental) } } } // readPragma reads the integer value of PRAGMA name on connection i, c. func readPragma( ctx context.Context, t *testing.T, c *sql.Conn, i int, name string, ) int64 { t.Helper() var value int64 err := c.QueryRowContext(ctx, "PRAGMA "+name).Scan(&value) if err != nil { t.Fatalf("conn %d: failed to read %s: %v", i, name, err) } return value } // TestVacuumReturnsFreePages checks that after routes are deleted from a new // database, one Vacuum call returns every page they used. The deletes leave // fewer free pages than the 1000 Vacuum frees per call, so none may remain. // With auto_vacuum off (issue https://git.eeqj.de/sneak/routewatch/issues/43) // the free pages stayed in the file, and with the PRAGMA run by ExecContext // Vacuum freed only one page per call. func TestVacuumReturnsFreePages(t *testing.T) { t.Parallel() cfg := &config.Config{StateDir: t.TempDir()} db, err := New(cfg, logger.New()) if err != nil { t.Fatalf("failed to create database: %v", err) } defer func() { _ = db.Close() }() ctx := context.Background() ts := time.Now().UTC() const asn = 64500 routes := make([]*LiveRoute, 0, vacuumTestRoutes) deletions := make([]LiveRouteDeletion, 0, vacuumTestRoutes) for i := range vacuumTestRoutes { route := mkV4Route(t, fmt.Sprintf("10.%d.%d.0/24", i/256, i%256), asn, ts) routes = append(routes, route) deletions = append(deletions, LiveRouteDeletion{ Prefix: route.Prefix, OriginASN: asn, PeerIP: route.PeerIP, IPVersion: ipVersionV4, }) } err = db.UpsertLiveRouteBatch(routes) if err != nil { t.Fatalf("failed to write routes: %v", err) } err = db.DeleteLiveRouteBatch(deletions) if err != nil { t.Fatalf("failed to delete routes: %v", err) } var before int err = db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&before) if err != nil { t.Fatalf("failed to read freelist_count: %v", err) } if before == 0 { t.Fatalf("no free pages after deleting %d routes", vacuumTestRoutes) } err = db.Vacuum(ctx) if err != nil { t.Fatalf("Vacuum failed: %v", err) } var after int err = db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&after) if err != nil { t.Fatalf("failed to read freelist_count: %v", err) } if after != 0 { t.Errorf("free pages after Vacuum = %d of %d, want 0", after, before) } } // TestBatchWriteDuringCheckpoint reproduces issue #25. A batch write reads // (SELECT) before it writes (INSERT/UPDATE). Under the default deferred locking // the transaction begins as a reader and, when the maintainer's WAL checkpoint // holds the write lock, its upgrade to writer fails immediately with "database // is locked" without honouring busy_timeout, dropping the batch. With // _txlock=immediate the transaction takes the write lock at BEGIN and waits, so // no batch is dropped. The checkpoint runs without the Database mutex, exactly // as the background maintainer does in production. func TestBatchWriteDuringCheckpoint(t *testing.T) { t.Parallel() cfg := &config.Config{StateDir: t.TempDir()} db, err := New(cfg, logger.New()) if err != nil { t.Fatalf("failed to create database: %v", err) } defer func() { _ = db.Close() }() ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() for { select { case <-ctx.Done(): return default: _ = db.Checkpoint(ctx) // errors are the checkpoint's own to absorb } } }() ts := time.Now().UTC() for i := range contentionIterations { asns := map[int]time.Time{ i % contendedASNCount: ts, (i % contendedASNCount) + asnSecondBand: ts, } err := db.GetOrCreateASNBatch(asns) if err != nil { cancel() wg.Wait() t.Fatalf("batch write failed under checkpoint contention: %v", err) } } cancel() wg.Wait() }