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:" // (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() }