Files
routewatch/internal/routewatch/app_integration_test.go
T
clawbot b0cd884019 Skip live network feed test under -short for deterministic make check (closes #2)
TestRouteWatchLiveFeed streams the live RIPE RIS feed for a few seconds, so
the default test run depended on the network and tripped the race detector.
It now skips under testing.Short(), and script/test passes -short in both
go test lines, so make check runs offline and gives the same result every
time. The README says how to run the live test on demand. No non-test code
changed.

The race itself is not fixed: the test reads the peeringHandler field of
RouteWatch while Run is still setting it. Nothing outside the test reads
that field concurrently.

Model: opus-4-8 (implementation, review); fable-5-1 (summary)
2026-09-21 09:05:33 +02:00

501 lines
12 KiB
Go

package routewatch
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"github.com/google/uuid"
)
// mockStore is a mock implementation of database.Store for testing
type mockStore struct {
mu sync.Mutex
// Counters for tracking calls
ASNCount int
PrefixCount int
PeeringCount int
RouteCount int
WithdrawalCount int
// Track unique items
ASNs map[int]*database.ASN
Prefixes map[string]*database.Prefix
Peerings map[string]bool // key is "from_to"
Routes map[string]bool // key is "prefix_origin_peer"
// Track IP versions
IPv4Prefixes int
IPv6Prefixes int
}
// newMockStore creates a new mock store
func newMockStore() *mockStore {
return &mockStore{
ASNs: make(map[int]*database.ASN),
Prefixes: make(map[string]*database.Prefix),
Peerings: make(map[string]bool),
Routes: make(map[string]bool),
}
}
// GetOrCreateASN mock implementation
func (m *mockStore) GetOrCreateASN(number int, timestamp time.Time) (*database.ASN, error) {
m.mu.Lock()
defer m.mu.Unlock()
if asn, exists := m.ASNs[number]; exists {
asn.LastSeen = timestamp
return asn, nil
}
asn := &database.ASN{
ASN: number,
FirstSeen: timestamp,
LastSeen: timestamp,
}
m.ASNs[number] = asn
m.ASNCount++
return asn, nil
}
// UpdatePrefixesBatch mock implementation
func (m *mockStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
for prefix, timestamp := range prefixes {
if p, exists := m.Prefixes[prefix]; exists {
p.LastSeen = timestamp
} else {
const (
ipVersionV4 = 4
ipVersionV6 = 6
)
ipVersion := ipVersionV4
if strings.Contains(prefix, ":") {
ipVersion = ipVersionV6
}
m.Prefixes[prefix] = &database.Prefix{
ID: uuid.New(),
Prefix: prefix,
IPVersion: ipVersion,
FirstSeen: timestamp,
LastSeen: timestamp,
}
}
}
return nil
}
// GetOrCreatePrefix mock implementation
func (m *mockStore) GetOrCreatePrefix(prefix string, timestamp time.Time) (*database.Prefix, error) {
m.mu.Lock()
defer m.mu.Unlock()
if p, exists := m.Prefixes[prefix]; exists {
p.LastSeen = timestamp
return p, nil
}
const (
ipVersionV4 = 4
ipVersionV6 = 6
)
ipVersion := ipVersionV4
if strings.Contains(prefix, ":") {
ipVersion = ipVersionV6
}
p := &database.Prefix{
ID: uuid.New(),
Prefix: prefix,
IPVersion: ipVersion,
FirstSeen: timestamp,
LastSeen: timestamp,
}
m.Prefixes[prefix] = p
m.PrefixCount++
if ipVersion == ipVersionV4 {
m.IPv4Prefixes++
} else {
m.IPv6Prefixes++
}
return p, nil
}
// RecordAnnouncement mock implementation
func (m *mockStore) RecordAnnouncement(_ *database.Announcement) error {
// Not tracking announcements in detail for now
return nil
}
// RecordPeering mock implementation
func (m *mockStore) RecordPeering(asA, asB int, _ time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
// Normalize
if asA > asB {
asA, asB = asB, asA
}
key := fmt.Sprintf("%d_%d", asA, asB)
if !m.Peerings[key] {
m.Peerings[key] = true
m.PeeringCount++
}
return nil
}
// UpdatePeer mock implementation
func (m *mockStore) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
// Simple mock - just return nil
return nil
}
// Close mock implementation
func (m *mockStore) Close() error {
return nil
}
// GetStats returns statistics about the mock store
func (m *mockStore) GetStats() (database.Stats, error) {
m.mu.Lock()
defer m.mu.Unlock()
return database.Stats{
ASNs: len(m.ASNs),
Prefixes: len(m.Prefixes),
IPv4Prefixes: m.IPv4Prefixes,
IPv6Prefixes: m.IPv6Prefixes,
Peerings: m.PeeringCount,
Peers: 10, // Mock peer count
}, nil
}
// GetStatsContext returns statistics about the mock store with context support
func (m *mockStore) GetStatsContext(ctx context.Context) (database.Stats, error) {
return m.GetStats()
}
// UpsertLiveRoute mock implementation
func (m *mockStore) UpsertLiveRoute(route *database.LiveRoute) error {
// Simple mock - just return nil
return nil
}
// DeleteLiveRoute mock implementation
func (m *mockStore) DeleteLiveRoute(prefix string, originASN int, peerIP string) error {
// Simple mock - just return nil
return nil
}
// GetPrefixDistribution mock implementation
func (m *mockStore) GetPrefixDistribution() (ipv4 []database.PrefixDistribution, ipv6 []database.PrefixDistribution, err error) {
// Return empty distributions for now
return nil, nil, nil
}
// GetPrefixDistributionContext mock implementation with context support
func (m *mockStore) GetPrefixDistributionContext(ctx context.Context) (ipv4 []database.PrefixDistribution, ipv6 []database.PrefixDistribution, err error) {
return m.GetPrefixDistribution()
}
// GetLiveRouteCounts mock implementation
func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
// Return mock counts
return m.RouteCount / 2, m.RouteCount / 2, nil
}
// GetLiveRouteCountsContext mock implementation with context support
func (m *mockStore) GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error) {
return m.GetLiveRouteCounts()
}
// GetASInfoForIP mock implementation
func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
// Simple mock - return a test AS
now := time.Now()
return &database.ASInfo{
ASN: 15169,
Handle: "GOOGLE",
Description: "Google LLC",
Prefix: "8.8.8.0/24",
LastUpdated: now.Add(-5 * time.Minute),
Age: "5m0s",
}, nil
}
// GetASInfoForIPContext mock implementation with context support
func (m *mockStore) GetASInfoForIPContext(ctx context.Context, ip string) (*database.ASInfo, error) {
return m.GetASInfoForIP(ip)
}
// GetASDetails mock implementation
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if ASN exists
if asnInfo, exists := m.ASNs[asn]; exists {
// Return empty prefixes for now
return asnInfo, []database.LiveRoute{}, nil
}
return nil, nil, database.ErrNoRoute
}
// GetASDetailsContext mock implementation with context support
func (m *mockStore) GetASDetailsContext(ctx context.Context, asn int) (*database.ASN, []database.LiveRoute, error) {
return m.GetASDetails(asn)
}
// GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
}
// GetPrefixDetailsContext mock implementation with context support
func (m *mockStore) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]database.LiveRoute, error) {
return m.GetPrefixDetails(prefix)
}
func (m *mockStore) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]database.LiveRoute, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
}
// GetRandomPrefixesByLengthContext mock implementation with context support
func (m *mockStore) GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]database.LiveRoute, error) {
return m.GetRandomPrefixesByLength(maskLength, ipVersion, limit)
}
// GetASPeers mock implementation
func (m *mockStore) GetASPeers(asn int) ([]database.ASPeer, error) {
// Return empty peers for now
return []database.ASPeer{}, nil
}
// GetASPeersContext mock implementation with context support
func (m *mockStore) GetASPeersContext(ctx context.Context, asn int) ([]database.ASPeer, error) {
return m.GetASPeers(asn)
}
// GetIPInfo mock implementation
func (m *mockStore) GetIPInfo(ip string) (*database.IPInfo, error) {
return m.GetIPInfoContext(context.Background(), ip)
}
// GetIPInfoContext mock implementation with context support
func (m *mockStore) GetIPInfoContext(ctx context.Context, ip string) (*database.IPInfo, error) {
now := time.Now()
return &database.IPInfo{
IP: ip,
Netblock: "8.8.8.0/24",
MaskLength: 24,
IPVersion: 4,
NumPeers: 3,
ASN: 15169,
Handle: "GOOGLE",
Description: "Google LLC",
CountryCode: "US",
FirstSeen: now.Add(-24 * time.Hour),
LastSeen: now,
}, nil
}
// GetNextStaleASN mock implementation
func (m *mockStore) GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error) {
return 0, database.ErrNoStaleASN
}
// UpdateASNWHOIS mock implementation
func (m *mockStore) UpdateASNWHOIS(ctx context.Context, update *database.ASNWHOISUpdate) error {
return nil
}
// GetWHOISStats mock implementation
func (m *mockStore) GetWHOISStats(ctx context.Context, staleThreshold time.Duration) (*database.WHOISStats, error) {
m.mu.Lock()
defer m.mu.Unlock()
return &database.WHOISStats{
TotalASNs: len(m.ASNs),
FreshASNs: 0,
StaleASNs: 0,
NeverFetched: len(m.ASNs),
}, nil
}
// UpsertLiveRouteBatch mock implementation
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, route := range routes {
// Track prefix
if _, exists := m.Prefixes[route.Prefix]; !exists {
m.Prefixes[route.Prefix] = &database.Prefix{
ID: uuid.New(),
Prefix: route.Prefix,
IPVersion: route.IPVersion,
FirstSeen: route.LastUpdated,
LastSeen: route.LastUpdated,
}
m.PrefixCount++
if route.IPVersion == 4 {
m.IPv4Prefixes++
} else {
m.IPv6Prefixes++
}
}
m.RouteCount++
}
return nil
}
// DeleteLiveRouteBatch mock implementation
func (m *mockStore) DeleteLiveRouteBatch(deletions []database.LiveRouteDeletion) error {
// Simple mock - just return nil
return nil
}
// GetOrCreateASNBatch mock implementation
func (m *mockStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
for number, timestamp := range asns {
if _, exists := m.ASNs[number]; !exists {
m.ASNs[number] = &database.ASN{
ASN: number,
FirstSeen: timestamp,
LastSeen: timestamp,
}
m.ASNCount++
}
}
return nil
}
// UpdatePeerBatch mock implementation
func (m *mockStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) error {
// Simple mock - just return nil
return nil
}
// Vacuum mock implementation
func (m *mockStore) Vacuum(ctx context.Context) error {
return nil
}
// Analyze mock implementation
func (m *mockStore) Analyze(ctx context.Context) error {
return nil
}
// Checkpoint mock implementation
func (m *mockStore) Checkpoint(ctx context.Context) error {
return nil
}
// Ping mock implementation
func (m *mockStore) Ping(ctx context.Context) error {
return nil
}
func TestRouteWatchLiveFeed(t *testing.T) {
if testing.Short() {
t.Skip("skipping live RIPE RIS network feed test in short mode; run without -short to include it")
}
// Create mock database
mockDB := newMockStore()
defer mockDB.Close()
logger := logger.New()
// Create metrics tracker
metricsTracker := metrics.New()
// Create streamer
s := streamer.New(logger, metricsTracker)
// Create test config with empty state dir (no snapshot loading)
cfg := &config.Config{
StateDir: "",
MaxRuntime: 5 * time.Second,
EnableBatchedDatabaseWrites: true,
}
// Create server
srv := server.New(mockDB, s, logger)
// Create RouteWatch with 5 second limit
deps := Dependencies{
DB: mockDB,
Streamer: s,
Server: srv,
Logger: logger,
Config: cfg,
}
rw := New(deps)
// Run with context
ctx := context.Background()
go func() {
_ = rw.Run(ctx)
}()
// Wait for the configured duration
time.Sleep(5 * time.Second)
// Force peering processing for test
if rw.peeringHandler != nil {
rw.peeringHandler.ProcessPeeringsNow()
}
// Get statistics
stats, err := mockDB.GetStats()
if err != nil {
t.Fatalf("Failed to get stats: %v", err)
}
if stats.ASNs == 0 {
t.Error("Expected to receive some ASNs from live feed")
}
t.Logf("Received %d unique ASNs in 5 seconds", stats.ASNs)
if stats.Prefixes == 0 {
t.Error("Expected to receive some prefixes from live feed")
}
t.Logf("Received %d unique prefixes (%d IPv4, %d IPv6) in 5 seconds", stats.Prefixes, stats.IPv4Prefixes, stats.IPv6Prefixes)
if stats.Peerings == 0 {
t.Error("Expected to receive some peerings from live feed")
}
t.Logf("Recorded %d AS peering relationships in 5 seconds", stats.Peerings)
}