package http import ( "context" "sync" "testing" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) // mockAPIKeyStore is a minimal mock for testing the cache layer. type mockAPIKeyStore struct { mu sync.Mutex keys map[string]*store.APIKeyData // hash → key calls int // GetByHash call count touchedID uuid.UUID // last TouchLastUsed ID } func newMockAPIKeyStore() *mockAPIKeyStore { return &mockAPIKeyStore{keys: make(map[string]*store.APIKeyData)} } func (m *mockAPIKeyStore) GetByHash(_ context.Context, hash string) (*store.APIKeyData, error) { m.mu.Lock() m.calls++ m.mu.Unlock() if k, ok := m.keys[hash]; ok { return k, nil } return nil, nil } func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error { m.mu.Lock() m.touchedID = id m.mu.Unlock() return nil } func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil } func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil } func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } func (m *mockAPIKeyStore) getCalls() int { m.mu.Lock() defer m.mu.Unlock() return m.calls } func TestCacheMissFetchesFromStore(t *testing.T) { ms := newMockAPIKeyStore() keyID := uuid.New() ms.keys["hash123"] = &store.APIKeyData{ ID: keyID, Scopes: []string{"operator.read"}, } c := newAPIKeyCache(ms, 5*time.Minute) key, role := c.getOrFetch(context.Background(), "hash123") if key == nil { t.Fatal("expected key, got nil") } if key.ID != keyID { t.Errorf("key ID = %v, want %v", key.ID, keyID) } if role != permissions.RoleViewer { t.Errorf("role = %v, want %v", role, permissions.RoleViewer) } if ms.getCalls() != 1 { t.Errorf("store calls = %d, want 1", ms.getCalls()) } } func TestCacheHitReturnsWithoutStoreCall(t *testing.T) { ms := newMockAPIKeyStore() keyID := uuid.New() ms.keys["hash456"] = &store.APIKeyData{ ID: keyID, Scopes: []string{"operator.admin"}, } c := newAPIKeyCache(ms, 5*time.Minute) // First call: cache miss → store fetch c.getOrFetch(context.Background(), "hash456") // Second call: cache hit → no store fetch key, role := c.getOrFetch(context.Background(), "hash456") if key == nil { t.Fatal("expected key on cache hit, got nil") } if role != permissions.RoleAdmin { t.Errorf("role = %v, want %v", role, permissions.RoleAdmin) } if ms.getCalls() != 1 { t.Errorf("store calls = %d, want 1 (cache hit should not call store)", ms.getCalls()) } } func TestCacheTTLExpiry(t *testing.T) { ms := newMockAPIKeyStore() ms.keys["hash789"] = &store.APIKeyData{ ID: uuid.New(), Scopes: []string{"operator.write"}, } c := newAPIKeyCache(ms, 10*time.Millisecond) // very short TTL // First fetch c.getOrFetch(context.Background(), "hash789") if ms.getCalls() != 1 { t.Fatalf("initial calls = %d, want 1", ms.getCalls()) } // Wait for TTL to expire time.Sleep(20 * time.Millisecond) // Should re-fetch from store key, _ := c.getOrFetch(context.Background(), "hash789") if key == nil { t.Fatal("expected key after TTL expiry") } if ms.getCalls() != 2 { t.Errorf("store calls after TTL = %d, want 2", ms.getCalls()) } } func TestCacheInvalidateAll(t *testing.T) { ms := newMockAPIKeyStore() ms.keys["hashA"] = &store.APIKeyData{ ID: uuid.New(), Scopes: []string{"operator.read"}, } c := newAPIKeyCache(ms, 5*time.Minute) // Populate cache c.getOrFetch(context.Background(), "hashA") if ms.getCalls() != 1 { t.Fatalf("initial calls = %d, want 1", ms.getCalls()) } // Invalidate c.invalidateAll() // Should fetch again from store c.getOrFetch(context.Background(), "hashA") if ms.getCalls() != 2 { t.Errorf("store calls after invalidate = %d, want 2", ms.getCalls()) } } func TestCacheNegativeCache(t *testing.T) { ms := newMockAPIKeyStore() // No keys in store c := newAPIKeyCache(ms, 5*time.Minute) // First lookup: cache miss → store returns nil → negative cache key1, _ := c.getOrFetch(context.Background(), "unknown") if key1 != nil { t.Fatal("expected nil key for unknown hash") } if ms.getCalls() != 1 { t.Fatalf("initial calls = %d, want 1", ms.getCalls()) } // Second lookup: negative cache hit → no store call key2, _ := c.getOrFetch(context.Background(), "unknown") if key2 != nil { t.Fatal("expected nil key on negative cache hit") } if ms.getCalls() != 1 { t.Errorf("store calls after negative cache = %d, want 1", ms.getCalls()) } } func TestCacheConcurrentAccess(t *testing.T) { ms := newMockAPIKeyStore() ms.keys["concurrent"] = &store.APIKeyData{ ID: uuid.New(), Scopes: []string{"operator.admin"}, } c := newAPIKeyCache(ms, 5*time.Minute) var wg sync.WaitGroup for range 50 { wg.Add(1) go func() { defer wg.Done() key, role := c.getOrFetch(context.Background(), "concurrent") if key == nil { t.Error("expected key, got nil") } if role != permissions.RoleAdmin { t.Errorf("role = %v, want admin", role) } }() } wg.Wait() // Store should have been called at least once, but not 50 times // (cache may have been populated after the first call) calls := ms.getCalls() if calls == 0 || calls > 50 { t.Errorf("store calls = %d, want 1..50", calls) } }