Cybersecurity-Projects/PROJECTS/intermediate/security-news-scraper/internal/rank/rank_test.go

107 lines
3.2 KiB
Go

// ©AngelaMos | 2026
// rank_test.go
package rank
import (
"math"
"testing"
"time"
"github.com/CarterPerez-dev/nadezhda/internal/config"
"github.com/CarterPerez-dev/nadezhda/internal/store"
)
func f(v float64) *float64 { return &v }
func TestScoreGoldenOrder(t *testing.T) {
cfg := config.Default().Rank
a := Signals{
AgeHours: 2, MaxCVSS: 9.8, KEV: true, MaxEPSS: 0.97,
ClusterSize: 5, ClusterAgeHours: 6, SourceWeight: 1.0, KeywordMatch: true,
}
b := Signals{
AgeHours: 120, MaxCVSS: 4.3, KEV: false, MaxEPSS: 0.02,
ClusterSize: 1, ClusterAgeHours: 0, SourceWeight: 0.6, KeywordMatch: false,
}
sa, sb := Score(a, cfg), Score(b, cfg)
if !(sa > sb) {
t.Fatalf("expected A (%.4f) to rank strictly above B (%.4f)", sa, sb)
}
if sa < 0.95 {
t.Errorf("A score = %.4f, want ~0.98", sa)
}
if sb > 0.20 {
t.Errorf("B score = %.4f, want ~0.14", sb)
}
}
func TestRecencyDecay(t *testing.T) {
if got := recency(0, 48); math.Abs(got-1.0) > 1e-9 {
t.Errorf("recency(0) = %v, want 1.0", got)
}
if got := recency(48, 48); math.Abs(got-0.5) > 1e-9 {
t.Errorf("recency(one half-life) = %v, want 0.5", got)
}
if got := recency(-5, 48); got != 1.0 {
t.Errorf("negative age should clamp to 1.0, got %v", got)
}
}
func TestVelocity(t *testing.T) {
if v := velocity(1, 0, 0.5); v != 0 {
t.Errorf("size-1 velocity = %v, want 0", v)
}
if v := velocity(6, 12, 0.5); v != 1.0 {
t.Errorf("size-6 in 12h should saturate, got %v", v)
}
if v := velocity(3, 0, 0.5); v != 1.0 {
t.Errorf("burst (age<floor) should saturate, got %v", v)
}
if v := velocity(2, 100, 0.5); math.Abs(v-0.04) > 1e-9 {
t.Errorf("slow cluster velocity = %v, want 0.04", v)
}
}
func TestRankOrdersByScore(t *testing.T) {
now := time.Unix(1_000_000, 0)
hot := store.DigestCluster{
ClusterID: 1, Size: 4, FirstSeen: now.Unix() - 3600, LastSeen: now.Unix() - 60,
Articles: []store.DigestArticle{{SourceName: "a", SourceWeight: 1.0, PublishedAt: now.Unix() - 60}},
CVEs: []store.DigestCVE{{ID: "CVE-2026-1", CVSSScore: f(9.9), EPSS: f(0.95), IsKEV: true}},
}
cold := store.DigestCluster{
ClusterID: 2, Size: 1, FirstSeen: now.Unix() - 500*3600, LastSeen: now.Unix() - 500*3600,
Articles: []store.DigestArticle{{SourceName: "b", SourceWeight: 0.5, PublishedAt: now.Unix() - 500*3600}},
}
scored := Rank([]store.DigestCluster{cold, hot}, config.Default().Rank, nil, now)
if scored[0].Cluster.ClusterID != 1 {
t.Errorf("hot KEV cluster should rank first, got cluster %d", scored[0].Cluster.ClusterID)
}
if !(scored[0].Score > scored[1].Score) {
t.Errorf("scores not descending: %.4f then %.4f", scored[0].Score, scored[1].Score)
}
}
func TestKeywordMatch(t *testing.T) {
c := store.DigestCluster{
Articles: []store.DigestArticle{{Title: "Fortinet FortiOS flaw exploited"}},
CVEs: []store.DigestCVE{{ID: "CVE-2026-1"}},
}
if !matchesWatchlist(c, []string{"fortinet"}) {
t.Error("case-insensitive watchlist term should match title")
}
if !matchesWatchlist(c, []string{"CVE-2026-1"}) {
t.Error("watchlist should match against CVE ids")
}
if matchesWatchlist(c, []string{"cisco"}) {
t.Error("non-matching term should not match")
}
if matchesWatchlist(c, nil) {
t.Error("empty watchlist should not match")
}
}