package security import ( "math" "testing" "time" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" "github.com/fraktal/mev-beta/internal/logger" ) func TestNewAnomalyDetector(t *testing.T) { logger := logger.New("info", "text", "") // Test with default config ad := NewAnomalyDetector(logger, nil) assert.NotNil(t, ad) assert.NotNil(t, ad.config) assert.Equal(t, 2.5, ad.config.ZScoreThreshold) // Test with custom config customConfig := &AnomalyConfig{ ZScoreThreshold: 3.0, VolumeThreshold: 4.0, BaselineWindow: 12 * time.Hour, EnableVolumeDetection: false, } ad2 := NewAnomalyDetector(logger, customConfig) assert.NotNil(t, ad2) assert.Equal(t, 3.0, ad2.config.ZScoreThreshold) assert.Equal(t, 4.0, ad2.config.VolumeThreshold) assert.Equal(t, 12*time.Hour, ad2.config.BaselineWindow) assert.False(t, ad2.config.EnableVolumeDetection) } func TestAnomalyDetectorStartStop(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Test start err := ad.Start() assert.NoError(t, err) assert.True(t, ad.running) // Test start when already running err = ad.Start() assert.NoError(t, err) // Test stop err = ad.Stop() assert.NoError(t, err) assert.False(t, ad.running) // Test stop when already stopped err = ad.Stop() assert.NoError(t, err) } func TestRecordMetric(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Record some normal values metricName := "test_metric" values := []float64{10.0, 12.0, 11.0, 13.0, 9.0, 14.0, 10.5, 11.5} for _, value := range values { ad.RecordMetric(metricName, value) } // Check pattern was created ad.mu.RLock() pattern, exists := ad.patterns[metricName] ad.mu.RUnlock() assert.True(t, exists) assert.NotNil(t, pattern) assert.Equal(t, metricName, pattern.MetricName) assert.Equal(t, len(values), len(pattern.Observations)) assert.Greater(t, pattern.Mean, 0.0) assert.Greater(t, pattern.StandardDev, 0.0) } func TestRecordTransaction(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Create test transaction record := &TransactionRecord{ Hash: common.HexToHash("0x123"), From: common.HexToAddress("0xabc"), To: &common.Address{}, Value: 1.5, GasPrice: 20.0, GasUsed: 21000, Timestamp: time.Now(), BlockNumber: 12345, Success: true, } ad.RecordTransaction(record) // Check transaction was recorded ad.mu.RLock() assert.Equal(t, 1, len(ad.transactionLog)) assert.Equal(t, record.Hash, ad.transactionLog[0].Hash) assert.Greater(t, ad.transactionLog[0].AnomalyScore, 0.0) ad.mu.RUnlock() } func TestPatternStatistics(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Create pattern with known values pattern := &PatternBaseline{ MetricName: "test", Observations: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Percentiles: make(map[int]float64), SeasonalPatterns: make(map[string]float64), } ad.updatePatternStatistics(pattern) // Check statistics assert.Equal(t, 5.5, pattern.Mean) assert.Equal(t, 1.0, pattern.Min) assert.Equal(t, 10.0, pattern.Max) assert.Greater(t, pattern.StandardDev, 0.0) assert.Greater(t, pattern.Variance, 0.0) // Check percentiles assert.NotEmpty(t, pattern.Percentiles) assert.Contains(t, pattern.Percentiles, 50) assert.Contains(t, pattern.Percentiles, 95) } func TestZScoreCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) pattern := &PatternBaseline{ Mean: 10.0, StandardDev: 2.0, } testCases := []struct { value float64 expected float64 }{ {10.0, 0.0}, // At mean {12.0, 1.0}, // 1 std dev above {8.0, -1.0}, // 1 std dev below {16.0, 3.0}, // 3 std devs above {4.0, -3.0}, // 3 std devs below } for _, tc := range testCases { zScore := ad.calculateZScore(tc.value, pattern) assert.Equal(t, tc.expected, zScore, "Z-score for value %.1f", tc.value) } // Test with zero standard deviation pattern.StandardDev = 0 zScore := ad.calculateZScore(15.0, pattern) assert.Equal(t, 0.0, zScore) } func TestAnomalyDetection(t *testing.T) { logger := logger.New("info", "text", "") config := &AnomalyConfig{ ZScoreThreshold: 2.0, VolumeThreshold: 2.0, EnableVolumeDetection: true, EnableBehavioralAD: true, EnablePatternDetection: true, } ad := NewAnomalyDetector(logger, config) // Build baseline with normal values normalValues := []float64{100, 105, 95, 110, 90, 115, 85, 120, 80, 125} for _, value := range normalValues { ad.RecordMetric("transaction_value", value) } // Record anomalous value anomalousValue := 500.0 // Way above normal ad.RecordMetric("transaction_value", anomalousValue) // Check if alert was generated select { case alert := <-ad.GetAlerts(): assert.NotNil(t, alert) assert.Equal(t, AnomalyTypeStatistical, alert.Type) assert.Equal(t, "transaction_value", alert.MetricName) assert.Equal(t, anomalousValue, alert.ObservedValue) assert.Greater(t, alert.Score, 2.0) case <-time.After(100 * time.Millisecond): t.Error("Expected anomaly alert but none received") } } func TestVolumeAnomalyDetection(t *testing.T) { logger := logger.New("info", "text", "") config := &AnomalyConfig{ VolumeThreshold: 2.0, EnableVolumeDetection: true, } ad := NewAnomalyDetector(logger, config) // Build baseline for i := 0; i < 20; i++ { record := &TransactionRecord{ Hash: common.HexToHash("0x" + string(rune(i))), From: common.HexToAddress("0x123"), Value: 1.0, // Normal value GasPrice: 20.0, Timestamp: time.Now(), } ad.RecordTransaction(record) } // Record anomalous transaction anomalousRecord := &TransactionRecord{ Hash: common.HexToHash("0xanomaly"), From: common.HexToAddress("0x456"), Value: 50.0, // Much higher than normal GasPrice: 20.0, Timestamp: time.Now(), } ad.RecordTransaction(anomalousRecord) // Check for alert select { case alert := <-ad.GetAlerts(): assert.NotNil(t, alert) assert.Equal(t, AnomalyTypeVolume, alert.Type) assert.Equal(t, 50.0, alert.ObservedValue) case <-time.After(100 * time.Millisecond): // Volume detection might not trigger with insufficient baseline // This is acceptable behavior } } func TestBehavioralAnomalyDetection(t *testing.T) { logger := logger.New("info", "text", "") config := &AnomalyConfig{ EnableBehavioralAD: true, } ad := NewAnomalyDetector(logger, config) sender := common.HexToAddress("0x123") // Record normal transactions from sender for i := 0; i < 10; i++ { record := &TransactionRecord{ Hash: common.HexToHash("0x" + string(rune(i))), From: sender, Value: 1.0, GasPrice: 20.0, // Normal gas price Timestamp: time.Now(), } ad.RecordTransaction(record) } // Record anomalous gas price transaction anomalousRecord := &TransactionRecord{ Hash: common.HexToHash("0xanomaly"), From: sender, Value: 1.0, GasPrice: 200.0, // 10x higher gas price Timestamp: time.Now(), } ad.RecordTransaction(anomalousRecord) // Check for alert select { case alert := <-ad.GetAlerts(): assert.NotNil(t, alert) assert.Equal(t, AnomalyTypeBehavioral, alert.Type) assert.Equal(t, sender.Hex(), alert.Source) case <-time.After(100 * time.Millisecond): // Behavioral detection might not trigger immediately // This is acceptable behavior } } func TestSeverityCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) testCases := []struct { zScore float64 expected AnomalySeverity }{ {1.5, AnomalySeverityLow}, {2.5, AnomalySeverityMedium}, {3.5, AnomalySeverityHigh}, {4.5, AnomalySeverityCritical}, } for _, tc := range testCases { severity := ad.calculateSeverity(tc.zScore) assert.Equal(t, tc.expected, severity, "Severity for Z-score %.1f", tc.zScore) } } func TestConfidenceCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Test with different Z-scores and sample sizes testCases := []struct { zScore float64 sampleSize int minConf float64 maxConf float64 }{ {2.0, 10, 0.0, 1.0}, {5.0, 100, 0.5, 1.0}, {1.0, 200, 0.0, 1.0}, } for _, tc := range testCases { confidence := ad.calculateConfidence(tc.zScore, tc.sampleSize) assert.GreaterOrEqual(t, confidence, tc.minConf) assert.LessOrEqual(t, confidence, tc.maxConf) } } func TestTrendCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Test increasing trend increasing := []float64{1, 2, 3, 4, 5} trend := ad.calculateTrend(increasing) assert.Greater(t, trend, 0.0) // Test decreasing trend decreasing := []float64{5, 4, 3, 2, 1} trend = ad.calculateTrend(decreasing) assert.Less(t, trend, 0.0) // Test stable trend stable := []float64{5, 5, 5, 5, 5} trend = ad.calculateTrend(stable) assert.Equal(t, 0.0, trend) // Test edge cases empty := []float64{} trend = ad.calculateTrend(empty) assert.Equal(t, 0.0, trend) single := []float64{5} trend = ad.calculateTrend(single) assert.Equal(t, 0.0, trend) } func TestAnomalyReport(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Add some data ad.RecordMetric("test_metric1", 10.0) ad.RecordMetric("test_metric2", 20.0) record := &TransactionRecord{ Hash: common.HexToHash("0x123"), From: common.HexToAddress("0xabc"), Value: 1.0, Timestamp: time.Now(), } ad.RecordTransaction(record) // Generate report report := ad.GetAnomalyReport() assert.NotNil(t, report) assert.Greater(t, report.PatternsTracked, 0) assert.Greater(t, report.TransactionsAnalyzed, 0) assert.NotNil(t, report.PatternSummaries) assert.NotNil(t, report.SystemHealth) assert.NotZero(t, report.Timestamp) } func TestPatternSummaries(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Create patterns with different trends ad.RecordMetric("increasing", 1.0) ad.RecordMetric("increasing", 2.0) ad.RecordMetric("increasing", 3.0) ad.RecordMetric("increasing", 4.0) ad.RecordMetric("increasing", 5.0) ad.RecordMetric("stable", 10.0) ad.RecordMetric("stable", 10.0) ad.RecordMetric("stable", 10.0) summaries := ad.getPatternSummaries() assert.NotEmpty(t, summaries) for name, summary := range summaries { assert.NotEmpty(t, summary.MetricName) assert.Equal(t, name, summary.MetricName) assert.GreaterOrEqual(t, summary.SampleCount, int64(0)) assert.Contains(t, []string{"INCREASING", "DECREASING", "STABLE"}, summary.Trend) } } func TestSystemHealth(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) health := ad.calculateSystemHealth() assert.NotNil(t, health) assert.GreaterOrEqual(t, health.AlertChannelSize, 0) assert.GreaterOrEqual(t, health.ProcessingLatency, 0.0) assert.GreaterOrEqual(t, health.MemoryUsage, int64(0)) assert.GreaterOrEqual(t, health.ErrorRate, 0.0) assert.Contains(t, []string{"HEALTHY", "WARNING", "DEGRADED", "CRITICAL"}, health.OverallHealth) } func TestTransactionHistoryLimit(t *testing.T) { logger := logger.New("info", "text", "") config := &AnomalyConfig{ MaxTransactionHistory: 5, // Small limit for testing } ad := NewAnomalyDetector(logger, config) // Add more transactions than the limit for i := 0; i < 10; i++ { record := &TransactionRecord{ Hash: common.HexToHash("0x" + string(rune(i))), From: common.HexToAddress("0x123"), Value: float64(i), Timestamp: time.Now(), } ad.RecordTransaction(record) } // Check that history is limited ad.mu.RLock() assert.LessOrEqual(t, len(ad.transactionLog), config.MaxTransactionHistory) ad.mu.RUnlock() } func TestPatternHistoryLimit(t *testing.T) { logger := logger.New("info", "text", "") config := &AnomalyConfig{ MaxPatternHistory: 3, // Small limit for testing } ad := NewAnomalyDetector(logger, config) metricName := "test_metric" // Add more observations than the limit for i := 0; i < 10; i++ { ad.RecordMetric(metricName, float64(i)) } // Check that pattern history is limited ad.mu.RLock() pattern := ad.patterns[metricName] assert.LessOrEqual(t, len(pattern.Observations), config.MaxPatternHistory) ad.mu.RUnlock() } func TestTimeAnomalyScore(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Test business hours (should be normal) businessTime := time.Date(2023, 1, 1, 14, 0, 0, 0, time.UTC) // 2 PM score := ad.calculateTimeAnomalyScore(businessTime) assert.Equal(t, 0.0, score) // Test late night (should be suspicious) nightTime := time.Date(2023, 1, 1, 2, 0, 0, 0, time.UTC) // 2 AM score = ad.calculateTimeAnomalyScore(nightTime) assert.Greater(t, score, 0.5) // Test evening (should be medium suspicion) eveningTime := time.Date(2023, 1, 1, 20, 0, 0, 0, time.UTC) // 8 PM score = ad.calculateTimeAnomalyScore(eveningTime) assert.Greater(t, score, 0.0) assert.Less(t, score, 0.5) } func TestSenderFrequencyCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) sender := common.HexToAddress("0x123") now := time.Now() // Add recent transactions for i := 0; i < 5; i++ { record := &TransactionRecord{ Hash: common.HexToHash("0x" + string(rune(i))), From: sender, Value: 1.0, Timestamp: now.Add(-time.Duration(i) * time.Minute), } ad.RecordTransaction(record) } // Add old transaction (should not count) oldRecord := &TransactionRecord{ Hash: common.HexToHash("0xold"), From: sender, Value: 1.0, Timestamp: now.Add(-2 * time.Hour), } ad.RecordTransaction(oldRecord) frequency := ad.calculateSenderFrequency(sender) assert.Equal(t, 5.0, frequency) // Should only count recent transactions } func TestAverageGasPriceCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) transactions := []*TransactionRecord{ {GasPrice: 10.0}, {GasPrice: 20.0}, {GasPrice: 30.0}, } avgGasPrice := ad.calculateAverageGasPrice(transactions) assert.Equal(t, 20.0, avgGasPrice) // Test empty slice emptyAvg := ad.calculateAverageGasPrice([]*TransactionRecord{}) assert.Equal(t, 0.0, emptyAvg) } func TestMeanAndStdDevCalculation(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) values := []float64{1, 2, 3, 4, 5} mean := ad.calculateMean(values) assert.Equal(t, 3.0, mean) stdDev := ad.calculateStdDev(values, mean) expectedStdDev := math.Sqrt(2.0) // For this specific sequence assert.InDelta(t, expectedStdDev, stdDev, 0.001) // Test empty slice emptyMean := ad.calculateMean([]float64{}) assert.Equal(t, 0.0, emptyMean) emptyStdDev := ad.calculateStdDev([]float64{}, 0.0) assert.Equal(t, 0.0, emptyStdDev) } func TestAlertGeneration(t *testing.T) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) // Test alert ID generation id1 := ad.generateAlertID() id2 := ad.generateAlertID() assert.NotEqual(t, id1, id2) assert.Contains(t, id1, "anomaly_") // Test description generation pattern := &PatternBaseline{ Mean: 10.0, } desc := ad.generateAnomalyDescription("test_metric", 15.0, pattern, 2.5) assert.Contains(t, desc, "test_metric") assert.Contains(t, desc, "15.00") assert.Contains(t, desc, "10.00") assert.Contains(t, desc, "2.5") // Test recommendations generation recommendations := ad.generateRecommendations("transaction_value", 3.5) assert.NotEmpty(t, recommendations) assert.Contains(t, recommendations[0], "investigation") } func BenchmarkRecordTransaction(b *testing.B) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) record := &TransactionRecord{ Hash: common.HexToHash("0x123"), From: common.HexToAddress("0xabc"), Value: 1.0, GasPrice: 20.0, Timestamp: time.Now(), } b.ResetTimer() for i := 0; i < b.N; i++ { ad.RecordTransaction(record) } } func BenchmarkRecordMetric(b *testing.B) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) b.ResetTimer() for i := 0; i < b.N; i++ { ad.RecordMetric("test_metric", float64(i)) } } func BenchmarkCalculateZScore(b *testing.B) { logger := logger.New("info", "text", "") ad := NewAnomalyDetector(logger, nil) pattern := &PatternBaseline{ Mean: 10.0, StandardDev: 2.0, } b.ResetTimer() for i := 0; i < b.N; i++ { ad.calculateZScore(float64(i), pattern) } }