Spaces:
Runtime error
Runtime error
AzureAD\AdityaDevarshi
Phase 7: org-scoped in-memory TTL cache + read caching + invalidation + HTTP ETags
affd5ec | 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() | |
| } | |