Files
vibedns/internal/cache/cache_test.go
T
2026-08-16 21:18:45 -05:00

332 lines
9.4 KiB
Go

package cache
import (
"testing"
"time"
"github.com/miekg/dns"
)
func testConfig() Config {
return Config{
Enabled: true, MaxEntries: 1000, MinTTL: 0, MaxTTL: 86400,
NegativeTTL: 300, ServeStale: false, StaleTTL: 0,
}
}
func reply(name string, ttl uint32, ip string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
m = m.SetReply(m)
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: []byte{192, 0, 2, 1},
}}
return m
}
func keyFor(name string) Key {
return Key{Name: dns.Fqdn(name), Type: dns.TypeA, Class: dns.ClassINET}
}
func request(name string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
return m
}
func TestPutAndGet(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
if res := c.Get(k, request("example.com")); res.Hit {
t.Fatal("expected a miss on an empty cache")
}
if ttl := c.Put(k, reply("example.com", 300, "192.0.2.1")); ttl != 300 {
t.Fatalf("stored TTL = %d, want 300", ttl)
}
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit after storing")
}
if len(res.Msg.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(res.Msg.Answer))
}
s := c.Stats()
if s.Hits != 1 || s.Misses != 1 {
t.Errorf("hits/misses = %d/%d, want 1/1", s.Hits, s.Misses)
}
}
func TestTTLIsClamped(t *testing.T) {
tests := []struct {
name string
min, max uint32
ttl uint32
want uint32
}{
{"below minimum is raised", 60, 86400, 5, 60},
{"above maximum is capped", 0, 3600, 100000, 3600},
{"within bounds is unchanged", 60, 3600, 300, 300},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := testConfig()
cfg.MinTTL, cfg.MaxTTL = tc.min, tc.max
c := New(cfg)
if got := c.Put(keyFor("example.com"), reply("example.com", tc.ttl, "192.0.2.1")); got != tc.want {
t.Errorf("stored TTL = %d, want %d", got, tc.want)
}
})
}
}
// TestTTLDecays is the property that matters most: a cached answer must not
// hand out a TTL that stands still, or downstream caches never expire it.
func TestTTLDecays(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 300, "192.0.2.1"))
// Reach in and age the entry rather than sleeping for real.
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-100 * time.Second)
sh.mu.Unlock()
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit while still fresh")
}
got := res.Msg.Answer[0].Header().Ttl
if got > 201 || got < 199 {
t.Errorf("served TTL = %d, want about 200 after 100 seconds", got)
}
}
func TestExpiredEntryIsAMiss(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-30 * time.Second)
sh.mu.Unlock()
if res := c.Get(k, request("example.com")); res.Hit {
t.Error("an expired entry must not be served when stale serving is off")
}
}
func TestServeStale(t *testing.T) {
cfg := testConfig()
cfg.ServeStale = true
cfg.StaleTTL = 3600
c := New(cfg)
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-60 * time.Second)
sh.mu.Unlock()
res := c.Get(k, request("example.com"))
if !res.Hit || !res.Stale {
t.Fatalf("expected a stale hit, got hit=%v stale=%v", res.Hit, res.Stale)
}
if got := res.Msg.Answer[0].Header().Ttl; got == 0 || got > 60 {
t.Errorf("stale TTL = %d, want a short positive value", got)
}
// Past the stale window it must miss.
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-7200 * time.Second)
sh.mu.Unlock()
if res := c.Get(k, request("example.com")); res.Hit {
t.Error("an entry past the stale window must not be served")
}
}
func TestNegativeCachingUsesSOAMinimum(t *testing.T) {
c := New(testConfig())
m := new(dns.Msg)
m.SetQuestion("missing.example.com.", dns.TypeA)
m = m.SetReply(m)
m.Rcode = dns.RcodeNameError
m.Ns = []dns.RR{&dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "hostmaster.example.com.",
Minttl: 120,
}}
k := Key{Name: "missing.example.com.", Type: dns.TypeA, Class: dns.ClassINET}
// RFC 2308: the negative TTL is the lesser of the SOA TTL and its MINIMUM.
if ttl := c.Put(k, m); ttl != 120 {
t.Errorf("negative TTL = %d, want the SOA minimum of 120", ttl)
}
if res := c.Get(k, request("missing.example.com")); !res.Hit {
t.Error("a negative answer should be cached")
}
}
func TestUncacheableResponses(t *testing.T) {
c := New(testConfig())
t.Run("servfail is not cached", func(t *testing.T) {
m := reply("fail.example.com", 300, "192.0.2.1")
m.Rcode = dns.RcodeServerFailure
if ttl := c.Put(keyFor("fail.example.com"), m); ttl != 0 {
t.Errorf("stored a SERVFAIL with TTL %d; transient failures must not be cached", ttl)
}
})
t.Run("truncated is not cached", func(t *testing.T) {
m := reply("trunc.example.com", 300, "192.0.2.1")
m.Truncated = true
if ttl := c.Put(keyFor("trunc.example.com"), m); ttl != 0 {
t.Errorf("stored a truncated response with TTL %d", ttl)
}
})
}
// TestOPTIsNotReplayed guards a subtle correctness bug: the OPT record
// describes one client's transport, not the data, so replaying it to another
// client would advertise the wrong buffer size.
func TestOPTIsNotReplayed(t *testing.T) {
c := New(testConfig())
m := reply("example.com", 300, "192.0.2.1")
m.SetEdns0(4096, true)
k := keyFor("example.com")
c.Put(k, m)
res := c.Get(k, request("example.com"))
if !res.Hit {
t.Fatal("expected a hit")
}
if res.Msg.IsEdns0() != nil {
t.Error("the cached response still carries an OPT record from the original exchange")
}
}
// TestDOBitSeparatesEntries: a DNSSEC answer carries RRSIGs that a non-DO
// client must never receive, so the two must not share a cache entry.
func TestDOBitSeparatesEntries(t *testing.T) {
c := New(testConfig())
plain := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: false}
signed := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: true}
c.Put(plain, reply("example.com", 300, "192.0.2.1"))
if res := c.Get(signed, request("example.com")); res.Hit {
t.Error("a DO query was served from the non-DO cache entry")
}
}
func TestFlushAndDelete(t *testing.T) {
c := New(testConfig())
for _, n := range []string{"a.example.com", "b.example.com", "c.example.com"} {
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
}
if got := c.Stats().Entries; got != 3 {
t.Fatalf("entries = %d, want 3", got)
}
if !c.Delete(keyFor("a.example.com")) {
t.Error("Delete reported the entry was absent")
}
if got := c.Stats().Entries; got != 2 {
t.Errorf("entries after delete = %d, want 2", got)
}
if n := c.Flush(); n != 2 {
t.Errorf("Flush removed %d, want 2", n)
}
if got := c.Stats().Entries; got != 0 {
t.Errorf("entries after flush = %d, want 0", got)
}
}
func TestFlushName(t *testing.T) {
c := New(testConfig())
c.Put(Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
c.Put(Key{Name: "example.com.", Type: dns.TypeAAAA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
c.Put(Key{Name: "other.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("other.com", 300, "192.0.2.1"))
if n := c.FlushName("example.com"); n != 2 {
t.Errorf("FlushName removed %d entries, want 2 (both types)", n)
}
if got := c.Stats().Entries; got != 1 {
t.Errorf("entries remaining = %d, want 1", got)
}
}
func TestDisabledCacheStoresNothing(t *testing.T) {
cfg := testConfig()
cfg.Enabled = false
c := New(cfg)
if ttl := c.Put(keyFor("example.com"), reply("example.com", 300, "192.0.2.1")); ttl != 0 {
t.Error("a disabled cache must not store entries")
}
if res := c.Get(keyFor("example.com"), request("example.com")); res.Hit {
t.Error("a disabled cache must not report hits")
}
}
func TestEvictionRespectsBound(t *testing.T) {
cfg := testConfig()
// One entry per shard; the bound is applied per shard.
cfg.MaxEntries = shardCount
c := New(cfg)
for i := 0; i < shardCount*20; i++ {
name := dns.Fqdn("host" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ".example.com")
c.Put(Key{Name: name, Type: dns.TypeA, Class: dns.ClassINET}, reply(name, 300, "192.0.2.1"))
}
s := c.Stats()
if s.Entries > shardCount {
t.Errorf("entries = %d, want at most %d after eviction", s.Entries, shardCount)
}
if s.Evictions == 0 {
t.Error("expected evictions to be recorded")
}
}
func TestCleanupRemovesExpired(t *testing.T) {
c := New(testConfig())
k := keyFor("example.com")
c.Put(k, reply("example.com", 10, "192.0.2.1"))
sh := c.shardFor(k)
sh.mu.Lock()
sh.entries[k].stored = time.Now().Add(-time.Hour)
sh.mu.Unlock()
if n := c.Cleanup(); n != 1 {
t.Errorf("Cleanup removed %d, want 1", n)
}
}
func TestEntriesBrowsing(t *testing.T) {
c := New(testConfig())
for _, n := range []string{"alpha.example.com", "beta.example.com", "gamma.test"} {
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
}
all, total := c.Entries("", 10, 0)
if total != 3 || len(all) != 3 {
t.Errorf("browse all: got %d of %d, want 3 of 3", len(all), total)
}
filtered, total := c.Entries("example.com", 10, 0)
if total != 2 || len(filtered) != 2 {
t.Errorf("browse filtered: got %d of %d, want 2 of 2", len(filtered), total)
}
}