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) } }