milkclient / backend /internal /cache /cache_test.go
AzureAD\AdityaDevarshi
Phase 7: org-scoped in-memory TTL cache + read caching + invalidation + HTTP ETags
affd5ec
Raw
History Blame Contribute Delete
8.63 kB
package cache
import (
"fmt"
"sync"
"testing"
"time"
)
func TestSetGetHit(t *testing.T) {
c := NewMemory(100)
c.Set("settings:5", []byte("hello"), time.Minute)
got, ok := c.Get("settings:5")
if !ok {
t.Fatal("expected hit, got miss")
}
if string(got) != "hello" {
t.Fatalf("expected %q, got %q", "hello", string(got))
}
}
func TestGetMiss(t *testing.T) {
c := NewMemory(100)
if _, ok := c.Get("nope:1"); ok {
t.Fatal("expected miss on absent key")
}
}
func TestTTLExpiry(t *testing.T) {
c := NewMemory(100)
c.Set("rate:5:p=1", []byte("v"), 20*time.Millisecond)
if _, ok := c.Get("rate:5:p=1"); !ok {
t.Fatal("expected hit before expiry")
}
time.Sleep(40 * time.Millisecond)
if _, ok := c.Get("rate:5:p=1"); ok {
t.Fatal("expected miss after TTL expiry")
}
}
func TestZeroTTLNeverExpires(t *testing.T) {
c := NewMemory(100)
c.Set("k", []byte("v"), 0)
time.Sleep(20 * time.Millisecond)
if _, ok := c.Get("k"); !ok {
t.Fatal("expected ttl<=0 entry to persist")
}
}
func TestDelete(t *testing.T) {
c := NewMemory(100)
c.Set("k", []byte("v"), time.Minute)
c.Delete("k")
if _, ok := c.Get("k"); ok {
t.Fatal("expected miss after Delete")
}
// Deleting a missing key must not panic.
c.Delete("missing")
}
func TestDeletePrefix(t *testing.T) {
c := NewMemory(100)
c.Set("products:5:all=false", []byte("a"), time.Minute)
c.Set("products:5:all=true", []byte("b"), time.Minute)
c.Set("products:50:all=false", []byte("c"), time.Minute) // different org, shares text prefix only up to "products:5"
c.Set("customers:5:all=false", []byte("d"), time.Minute)
c.DeletePrefix("products:5:")
if _, ok := c.Get("products:5:all=false"); ok {
t.Fatal("products:5:all=false should be removed")
}
if _, ok := c.Get("products:5:all=true"); ok {
t.Fatal("products:5:all=true should be removed")
}
if _, ok := c.Get("products:50:all=false"); !ok {
t.Fatal("products:50:all=false should remain (different prefix)")
}
if _, ok := c.Get("customers:5:all=false"); !ok {
t.Fatal("customers:5:all=false should remain")
}
}
// TestDeletePrefixNoMatchIsNoop proves DeletePrefix with a prefix that matches
// nothing leaves all entries intact and does not panic.
func TestDeletePrefixNoMatch(t *testing.T) {
c := NewMemory(100)
c.Set("settings:5", []byte("a"), time.Minute)
c.Set("products:5:all=false", []byte("b"), time.Minute)
c.DeletePrefix("customers:5") // matches nothing
if _, ok := c.Get("settings:5"); !ok {
t.Fatal("settings:5 should survive a non-matching DeletePrefix")
}
if _, ok := c.Get("products:5:all=false"); !ok {
t.Fatal("products:5:all=false should survive a non-matching DeletePrefix")
}
// Empty-store DeletePrefix must not panic.
c2 := NewMemory(100)
c2.DeletePrefix("anything:1")
}
// TestCrossOrgNoReadLeak pins the CORE tenancy security invariant: one org can
// never READ another org's cached bytes. Keys for different orgs are distinct
// strings, so a Get for org 5's key never returns org 50's value (and vice
// versa). This is the property that actually protects tenant data; it holds
// independently of any DeletePrefix semantics.
func TestCrossOrgNoReadLeak(t *testing.T) {
c := NewMemory(100)
c.Set("products:5:all=false", []byte("org5-products"), time.Minute)
c.Set("products:50:all=false", []byte("org50-products"), time.Minute)
c.Set("settings:5", []byte("org5-settings"), time.Minute)
c.Set("settings:50", []byte("org50-settings"), time.Minute)
checks := []struct{ key, want string }{
{"products:5:all=false", "org5-products"},
{"products:50:all=false", "org50-products"},
{"settings:5", "org5-settings"},
{"settings:50", "org50-settings"},
}
for _, ch := range checks {
got, ok := c.Get(ch.key)
if !ok || string(got) != ch.want {
t.Fatalf("Get(%q) = %q (ok=%v), want %q — orgs must never read each other's bytes", ch.key, got, ok, ch.want)
}
}
}
// TestDeletePrefixNumericBoundary documents an important, deliberate property of
// the prefix matching the API layer relies on: a bare numeric org prefix is a
// STRING prefix. DeletePrefix("products:5") therefore also matches
// "products:50:...". The API's invalidateProducts uses exactly "products:<org>"
// (no trailing ':'), so this over-invalidates orgs whose id shares a numeric
// prefix (5 vs 50). That is correctness-SAFE — over-invalidation only forces a
// cache miss + reload, never a cross-org data leak (see TestCrossOrgNoReadLeak)
// — but this test makes the behavior explicit so any future "fix" (e.g. adding a
// trailing ':') is a conscious choice, not an accident.
func TestDeletePrefixNumericBoundary(t *testing.T) {
c := NewMemory(100)
c.Set("products:5:all=false", []byte("org5"), time.Minute)
c.Set("products:50:all=false", []byte("org50"), time.Minute)
c.Set("customers:5:all=false", []byte("org5cust"), time.Minute)
c.DeletePrefix("products:5") // bare prefix, as invalidateProducts does
if _, ok := c.Get("products:5:all=false"); ok {
t.Fatal("products:5 should be invalidated")
}
// Documented over-match: org 50's products are ALSO dropped by the bare prefix.
if _, ok := c.Get("products:50:all=false"); ok {
t.Fatal("products:50 is over-matched by bare prefix products:5 (documented, correctness-safe)")
}
// A different entity is never touched.
if got, ok := c.Get("customers:5:all=false"); !ok || string(got) != "org5cust" {
t.Fatalf("customers:5 must be untouched by a products DeletePrefix; got=%q ok=%v", got, ok)
}
// Using the trailing-':' form scopes invalidation to exactly one org.
c2 := NewMemory(100)
c2.Set("products:5:all=false", []byte("org5"), time.Minute)
c2.Set("products:50:all=false", []byte("org50"), time.Minute)
c2.DeletePrefix("products:5:")
if _, ok := c2.Get("products:5:all=false"); ok {
t.Fatal("products:5: should be invalidated")
}
if got, ok := c2.Get("products:50:all=false"); !ok || string(got) != "org50" {
t.Fatalf("products:50 must survive a trailing-':' DeletePrefix(products:5:); got=%q ok=%v", got, ok)
}
}
// TestStoredBytesAreSharedReference documents that Get returns the same slice
// passed to Set (the cache does not copy). Callers store immutable marshaled
// JSON, so this is safe; this test pins the behavior so a future change that
// breaks the contract (or starts copying) is a deliberate decision.
func TestStoredBytesRoundTrip(t *testing.T) {
c := NewMemory(10)
orig := []byte(`{"k":"v"}`)
c.Set("settings:1", orig, time.Minute)
got, ok := c.Get("settings:1")
if !ok {
t.Fatal("expected hit")
}
if string(got) != string(orig) {
t.Fatalf("round-trip mismatch: got %q want %q", got, orig)
}
}
func TestOverwrite(t *testing.T) {
c := NewMemory(100)
c.Set("k", []byte("old"), time.Minute)
c.Set("k", []byte("new"), time.Minute)
got, ok := c.Get("k")
if !ok {
t.Fatal("expected hit")
}
if string(got) != "new" {
t.Fatalf("expected overwrite to %q, got %q", "new", string(got))
}
}
func TestOverwriteRefreshesTTL(t *testing.T) {
c := NewMemory(100)
c.Set("k", []byte("v1"), 20*time.Millisecond)
time.Sleep(10 * time.Millisecond)
c.Set("k", []byte("v2"), time.Minute) // refresh with long TTL
time.Sleep(20 * time.Millisecond)
if _, ok := c.Get("k"); !ok {
t.Fatal("expected entry to survive after TTL refresh")
}
}
func TestEvictionBound(t *testing.T) {
c := NewMemory(3)
c.Set("a", []byte("1"), time.Minute)
c.Set("b", []byte("2"), time.Minute)
c.Set("c", []byte("3"), time.Minute)
c.Set("d", []byte("4"), time.Minute) // should evict oldest "a"
if _, ok := c.Get("a"); ok {
t.Fatal("oldest entry 'a' should have been evicted (FIFO)")
}
for _, k := range []string{"b", "c", "d"} {
if _, ok := c.Get(k); !ok {
t.Fatalf("expected %q to remain after eviction", k)
}
}
}
func TestEvictionAfterDeleteDoesNotOverEvict(t *testing.T) {
c := NewMemory(2)
c.Set("a", []byte("1"), time.Minute)
c.Set("b", []byte("2"), time.Minute)
c.Delete("a")
c.Set("c", []byte("3"), time.Minute) // a was removed; b and c both fit
if _, ok := c.Get("b"); !ok {
t.Fatal("b should remain")
}
if _, ok := c.Get("c"); !ok {
t.Fatal("c should remain")
}
}
func TestConcurrentRace(t *testing.T) {
c := NewMemory(256)
const workers = 16
const iters = 500
var wg sync.WaitGroup
wg.Add(workers)
for w := 0; w < workers; w++ {
go func(w int) {
defer wg.Done()
for i := 0; i < iters; i++ {
key := fmt.Sprintf("k:%d:%d", w, i%32)
switch i % 4 {
case 0:
c.Set(key, []byte("v"), time.Duration(i%10)*time.Millisecond)
case 1:
c.Get(key)
case 2:
c.Delete(key)
case 3:
c.DeletePrefix(fmt.Sprintf("k:%d:", w))
}
}
}(w)
}
wg.Wait()
}