Files
ctrld/internal/firewall/allowlist_test.go
T
2026-07-08 15:31:54 +07:00

462 lines
11 KiB
Go

package firewall
import (
"context"
"net/netip"
"sync"
"testing"
"time"
)
func TestAddAndContains(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// IP should not be allowed before adding.
if al.Contains(ip) {
t.Fatal("expected IP to not be in allowlist before Add")
}
al.Add(ip, "example.com", 5*time.Minute)
if !al.Contains(ip) {
t.Fatal("expected IP to be in allowlist after Add")
}
}
func TestContainsExpired(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// Add with a TTL that's already past.
al.Add(ip, "example.com", -1*time.Second)
if al.Contains(ip) {
t.Fatal("expected expired IP to not be allowed")
}
}
func TestAddExtendsExpiry(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// Add with short TTL.
al.Add(ip, "example.com", 1*time.Second)
// Extend with longer TTL.
al.Add(ip, "cdn.example.com", 1*time.Hour)
// Should still be valid.
if !al.Contains(ip) {
t.Fatal("expected IP to be allowed after expiry extension")
}
// Check domain provenance was tracked.
existing, ok := al.ips.Load(ip)
if !ok {
t.Fatal("expected IP entry to exist")
}
e := existing.(*entry)
e.mu.Lock()
domainCount := len(e.domains)
e.mu.Unlock()
if domainCount != 2 {
t.Fatalf("expected 2 domains, got %d", domainCount)
}
}
func TestRemove(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
al.Add(ip, "example.com", 5*time.Minute)
al.Remove(ip)
if al.Contains(ip) {
t.Fatal("expected IP to not be in allowlist after Remove")
}
}
func TestPermanent(t *testing.T) {
al := New()
ip := netip.MustParseAddr("127.0.0.1")
al.AddPermanent(ip)
if !al.Contains(ip) {
t.Fatal("expected permanent IP to be allowed")
}
// Permanent entries survive Flush.
al.Flush()
if !al.Contains(ip) {
t.Fatal("expected permanent IP to survive Flush")
}
}
func TestPermanentPrefix(t *testing.T) {
al := New()
prefix := netip.MustParsePrefix("10.0.0.0/8")
al.AddPermanentPrefix(prefix)
if !al.Contains(netip.MustParseAddr("10.1.2.3")) {
t.Fatal("expected IP in permanent prefix to be allowed")
}
if !al.Contains(netip.MustParseAddr("10.255.255.255")) {
t.Fatal("expected IP at end of permanent prefix to be allowed")
}
if al.Contains(netip.MustParseAddr("11.0.0.1")) {
t.Fatal("expected IP outside permanent prefix to not be allowed")
}
}
func TestFlush(t *testing.T) {
al := New()
al.AddPermanent(netip.MustParseAddr("127.0.0.1"))
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", 5*time.Minute)
al.Add(netip.MustParseAddr("151.101.1.69"), "reddit.com", 5*time.Minute)
al.Flush()
// Dynamic entries should be gone.
if al.Contains(netip.MustParseAddr("93.184.216.34")) {
t.Fatal("expected dynamic IP to be removed after Flush")
}
if al.Contains(netip.MustParseAddr("151.101.1.69")) {
t.Fatal("expected dynamic IP to be removed after Flush")
}
// Permanent should survive.
if !al.Contains(netip.MustParseAddr("127.0.0.1")) {
t.Fatal("expected permanent IP to survive Flush")
}
// Domain reverse map should be cleared.
stats := al.Stats()
if stats.TrackedDomains != 0 {
t.Fatalf("expected 0 tracked domains after Flush, got %d", stats.TrackedDomains)
}
}
func TestFlushDomain(t *testing.T) {
al := New()
// IP shared between two domains.
sharedIP := netip.MustParseAddr("93.184.216.34")
exclusiveIP := netip.MustParseAddr("93.184.216.35")
al.Add(sharedIP, "example.com", 5*time.Minute)
al.Add(sharedIP, "cdn.example.com", 5*time.Minute)
al.Add(exclusiveIP, "example.com", 5*time.Minute)
// Flush only example.com.
al.FlushDomain("example.com")
// Shared IP should remain because cdn.example.com still references it.
if !al.Contains(sharedIP) {
t.Fatal("expected shared IP to remain (still referenced by cdn.example.com)")
}
// Exclusive IP should be removed (only referenced by example.com).
if al.Contains(exclusiveIP) {
t.Fatal("expected exclusive IP to be removed after FlushDomain")
}
}
func TestFlushFallsBackToOnChange(t *testing.T) {
al := New()
ip1 := netip.MustParseAddr("93.184.216.34")
ip2 := netip.MustParseAddr("151.101.1.69")
var removed []netip.Addr
al.SetOnChange(func(ip netip.Addr, added bool) {
if !added {
removed = append(removed, ip)
}
})
al.Add(ip1, "example.com", 5*time.Minute)
al.Add(ip2, "reddit.com", 5*time.Minute)
al.Flush()
if len(removed) != 2 {
t.Fatalf("expected 2 removed IP callbacks, got %d", len(removed))
}
}
func TestFlushDomainThenAddKeepsPlatformInSync(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
platform := make(map[netip.Addr]bool)
al.SetOnChange(func(ip netip.Addr, added bool) {
platform[ip] = added
})
al.SetOnBatchChange(func(added, removed []netip.Addr) {
for _, ip := range added {
platform[ip] = true
}
for _, ip := range removed {
platform[ip] = false
}
})
al.Add(ip, "example.com", 5*time.Minute)
al.FlushDomain("example.com")
al.Add(ip, "example.com", 5*time.Minute)
if !al.Contains(ip) {
t.Fatal("expected IP to be allowed after re-add")
}
if !platform[ip] {
t.Fatal("expected platform callback state to match allowlist after flush/re-add")
}
}
func TestConcurrentAddFlushDomainAndReap(t *testing.T) {
al := New()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
al.StartReaper(ctx)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 200; j++ {
ip := netip.AddrFrom4([4]byte{10, byte(n), byte(j / 255), byte(j % 255)})
al.Add(ip, "example.com", time.Minute)
al.Contains(ip)
}
}(i)
}
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
al.FlushDomain("example.com")
al.reap()
}
}()
}
wg.Wait()
}
func TestIPv4MappedIPv6(t *testing.T) {
al := New()
// Add as IPv4.
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", 5*time.Minute)
// Look up as IPv4-mapped IPv6 — should still match due to Unmap().
mapped := netip.AddrFrom16(netip.MustParseAddr("93.184.216.34").As16())
if !al.Contains(mapped) {
t.Fatal("expected IPv4-mapped IPv6 lookup to match IPv4 entry")
}
}
func TestIPv6(t *testing.T) {
al := New()
ip := netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")
al.Add(ip, "example.com", 5*time.Minute)
if !al.Contains(ip) {
t.Fatal("expected IPv6 address to be in allowlist")
}
}
func TestReaper(t *testing.T) {
al := New()
ip := netip.MustParseAddr("93.184.216.34")
// Add with very short TTL that's already expired.
al.Add(ip, "example.com", -1*time.Second)
// Manually trigger reaper.
al.reap()
// Should have been cleaned up.
_, ok := al.ips.Load(ip)
if ok {
t.Fatal("expected reaper to remove expired entry from ips map")
}
}
func TestReaperFallsBackToOnChange(t *testing.T) {
al := New()
var removed []netip.Addr
al.SetOnChange(func(ip netip.Addr, added bool) {
if !added {
removed = append(removed, ip)
}
})
al.Add(netip.MustParseAddr("1.1.1.1"), "one.com", -1*time.Second)
al.Add(netip.MustParseAddr("2.2.2.2"), "two.com", -1*time.Second)
al.reap()
if len(removed) != 2 {
t.Fatalf("expected 2 removed IP callbacks, got %d", len(removed))
}
}
func TestReaperBatchCallback(t *testing.T) {
al := New()
var removedIPs []netip.Addr
al.SetOnBatchChange(func(added, removed []netip.Addr) {
removedIPs = append(removedIPs, removed...)
})
al.Add(netip.MustParseAddr("1.1.1.1"), "one.com", -1*time.Second)
al.Add(netip.MustParseAddr("2.2.2.2"), "two.com", -1*time.Second)
al.Add(netip.MustParseAddr("3.3.3.3"), "three.com", 1*time.Hour) // not expired
al.reap()
if len(removedIPs) != 2 {
t.Fatalf("expected 2 removed IPs in batch callback, got %d", len(removedIPs))
}
}
func TestStatsExcludesExpiredAllowedIPs(t *testing.T) {
al := New()
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", -1*time.Second)
al.Add(netip.MustParseAddr("151.101.1.69"), "reddit.com", 5*time.Minute)
stats := al.Stats()
if stats.AllowedIPs != 1 {
t.Fatalf("expected 1 non-expired allowed IP, got %d", stats.AllowedIPs)
}
}
func TestStats(t *testing.T) {
al := New()
al.AddPermanent(netip.MustParseAddr("127.0.0.1"))
al.AddPermanentPrefix(netip.MustParsePrefix("10.0.0.0/8"))
al.Add(netip.MustParseAddr("93.184.216.34"), "example.com", 5*time.Minute)
al.Add(netip.MustParseAddr("151.101.1.69"), "reddit.com", 5*time.Minute)
// Generate some hits/misses.
al.Contains(netip.MustParseAddr("93.184.216.34")) // hit
al.Contains(netip.MustParseAddr("127.0.0.1")) // hit (permanent)
al.Contains(netip.MustParseAddr("8.8.8.8")) // miss
stats := al.Stats()
if stats.AllowedIPs != 2 {
t.Fatalf("expected 2 allowed IPs, got %d", stats.AllowedIPs)
}
// Permanent count includes both the individual IP and the prefix.
if stats.PermanentIPs != 2 {
t.Fatalf("expected 2 permanent entries, got %d", stats.PermanentIPs)
}
if stats.TrackedDomains != 2 {
t.Fatalf("expected 2 tracked domains, got %d", stats.TrackedDomains)
}
if stats.TotalHits != 2 {
t.Fatalf("expected 2 total hits, got %d", stats.TotalHits)
}
if stats.TotalMisses != 1 {
t.Fatalf("expected 1 total miss, got %d", stats.TotalMisses)
}
}
func TestConcurrentAccess(t *testing.T) {
al := New()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
al.StartReaper(ctx)
al.AddPermanent(netip.MustParseAddr("127.0.0.1"))
var wg sync.WaitGroup
// Concurrent writers.
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 100; j++ {
ip := netip.AddrFrom4([4]byte{10, 0, byte(n), byte(j)})
al.Add(ip, "test.com", 1*time.Minute)
}
}(i)
}
// Concurrent readers.
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 100; j++ {
ip := netip.AddrFrom4([4]byte{10, 0, byte(n), byte(j)})
al.Contains(ip)
}
}(i)
}
// Concurrent flush.
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
al.Flush()
}()
wg.Wait()
}
func TestStartReaperWithContext(t *testing.T) {
// This test verifies the reaper goroutine starts and stops cleanly.
// We test actual reaping behavior via TestReaper (calls reap() directly).
al := New()
ctx, cancel := context.WithCancel(context.Background())
al.StartReaper(ctx)
cancel() // Should not panic or leak.
}
func TestOnChangeCallback(t *testing.T) {
al := New()
var added []netip.Addr
var removed []netip.Addr
al.SetOnChange(func(ip netip.Addr, isAdded bool) {
if isAdded {
added = append(added, ip)
} else {
removed = append(removed, ip)
}
})
ip := netip.MustParseAddr("93.184.216.34")
al.Add(ip, "example.com", 5*time.Minute)
al.Remove(ip)
if len(added) != 1 {
t.Fatalf("expected 1 added callback, got %d", len(added))
}
if len(removed) != 1 {
t.Fatalf("expected 1 removed callback, got %d", len(removed))
}
}
func TestInvalidIP(t *testing.T) {
al := New()
// Should not panic on invalid IPs.
var invalid netip.Addr
al.Add(invalid, "test.com", time.Minute)
if al.Contains(invalid) {
t.Fatal("expected invalid IP to not be in allowlist")
}
al.AddPermanent(invalid)
}