""" Extended tests for credential store base class. Tests cover: - TTL expiration behavior - Concurrent access patterns - Thread safety - Edge cases and error conditions - Memory management - Multiple credentials handling """ import time import threading from datetime import timedelta import pytest from freezegun import freeze_time from local_deep_research.database.credential_store_base import ( CredentialStoreBase, ) class ConcreteCredentialStore(CredentialStoreBase): """Concrete implementation for testing.""" def store(self, key: str, username: str, password: str): self._store_credentials( key, {"username": username, "password": password} ) def retrieve(self, key: str, remove: bool = False): return self._retrieve_credentials(key, remove=remove) @pytest.fixture def store(): """Create a credential store with 1 hour TTL.""" return ConcreteCredentialStore(ttl_seconds=3600) @pytest.fixture def short_ttl_store(): """Create a credential store with very short TTL.""" return ConcreteCredentialStore(ttl_seconds=1) class TestCredentialStoreInitialization: """Tests for credential store initialization.""" def test_store_initializes_with_ttl(self): """Store should initialize with given TTL.""" store = ConcreteCredentialStore(ttl_seconds=7200) assert store.ttl == 7200 def test_store_initializes_empty(self): """Store should start empty.""" store = ConcreteCredentialStore(ttl_seconds=3600) assert len(store._store) == 0 def test_store_has_lock(self): """Store should have a threading lock.""" store = ConcreteCredentialStore(ttl_seconds=3600) assert hasattr(store, "_lock") def test_zero_ttl_store(self): """Store with zero TTL should immediately expire entries.""" store = ConcreteCredentialStore(ttl_seconds=0) with freeze_time("2024-01-01 00:00:00") as frozen: store.store("key1", "user", "pass") # Any forward progress past expires_at causes expiration frozen.tick(timedelta(microseconds=1)) assert store.retrieve("key1") is None class TestCredentialStorage: """Tests for credential storage operations.""" def test_store_single_credential(self, store): """Should store a single credential.""" store.store("key1", "user1", "pass1") result = store.retrieve("key1") assert result == ("user1", "pass1") def test_store_multiple_credentials(self, store): """Should store multiple credentials.""" store.store("key1", "user1", "pass1") store.store("key2", "user2", "pass2") store.store("key3", "user3", "pass3") assert store.retrieve("key1") == ("user1", "pass1") assert store.retrieve("key2") == ("user2", "pass2") assert store.retrieve("key3") == ("user3", "pass3") def test_store_overwrites_existing(self, store): """Storing with same key should overwrite.""" store.store("key1", "user1", "pass1") store.store("key1", "user2", "pass2") result = store.retrieve("key1") assert result == ("user2", "pass2") def test_store_with_empty_username(self, store): """Should handle empty username.""" store.store("key1", "", "pass1") result = store.retrieve("key1") assert result == ("", "pass1") def test_store_with_empty_password(self, store): """Should handle empty password.""" store.store("key1", "user1", "") result = store.retrieve("key1") assert result == ("user1", "") def test_store_with_unicode_credentials(self, store): """Should handle unicode credentials.""" store.store("key1", "用户名", "密码") result = store.retrieve("key1") assert result == ("用户名", "密码") def test_store_with_special_characters(self, store): """Should handle special characters.""" store.store("key1", "user@domain.com", "p@ss!word#123$") result = store.retrieve("key1") assert result == ("user@domain.com", "p@ss!word#123$") class TestCredentialRetrieval: """Tests for credential retrieval operations.""" def test_retrieve_nonexistent_key(self, store): """Should return None for nonexistent key.""" assert store.retrieve("nonexistent") is None def test_retrieve_without_remove(self, store): """Retrieve without remove should preserve entry.""" store.store("key1", "user1", "pass1") store.retrieve("key1", remove=False) # Should still be retrievable assert store.retrieve("key1") == ("user1", "pass1") def test_retrieve_with_remove(self, store): """Retrieve with remove should delete entry.""" store.store("key1", "user1", "pass1") result = store.retrieve("key1", remove=True) assert result == ("user1", "pass1") # Should be gone now assert store.retrieve("key1") is None def test_retrieve_multiple_times(self, store): """Should be able to retrieve multiple times without remove.""" store.store("key1", "user1", "pass1") for _ in range(10): result = store.retrieve("key1") assert result == ("user1", "pass1") class TestTTLExpiration: """Tests for TTL expiration behavior.""" def test_entry_expires_after_ttl(self, short_ttl_store): """Entry should expire after TTL. This test deliberately uses a real ``time.sleep`` instead of ``freeze_time`` so that the rest of the freezegun-based suite is validated against the actual ``time.time()`` clock at least once. Don't migrate this one without keeping another integration anchor. """ short_ttl_store.store("key1", "user1", "pass1") time.sleep(1.5) # allow: unmarked-sleep # Wait for TTL + buffer assert short_ttl_store.retrieve("key1") is None def test_entry_valid_before_ttl(self, short_ttl_store): """Entry should be valid before TTL expires.""" with freeze_time("2024-01-01 00:00:00") as frozen: short_ttl_store.store("key1", "user1", "pass1") frozen.tick(timedelta(milliseconds=500)) # Half of 1s TTL assert short_ttl_store.retrieve("key1") == ("user1", "pass1") def test_each_entry_has_own_ttl(self): """Each entry should have its own expiration time.""" store = ConcreteCredentialStore(ttl_seconds=2) with freeze_time("2024-01-01 00:00:00") as frozen: store.store("key1", "user1", "pass1") frozen.tick(timedelta(seconds=1)) store.store("key2", "user2", "pass2") # Added 1s later frozen.tick(timedelta(milliseconds=1500)) # key1 should be expired (2.5s old) # key2 should still be valid (1.5s old) assert store.retrieve("key1") is None assert store.retrieve("key2") == ("user2", "pass2") def test_overwrite_resets_ttl(self): """Overwriting an entry should reset its TTL.""" store = ConcreteCredentialStore(ttl_seconds=1) with freeze_time("2024-01-01 00:00:00") as frozen: store.store("key1", "user1", "pass1") frozen.tick(timedelta(milliseconds=700)) store.store("key1", "user1", "pass1") # Reset TTL frozen.tick(timedelta(milliseconds=700)) # Should still be valid (0.7s since reset, TTL is 1s) assert store.retrieve("key1") == ("user1", "pass1") class TestCleanupExpired: """Tests for cleanup of expired entries.""" def test_cleanup_removes_expired(self): """Cleanup should remove expired entries.""" store = ConcreteCredentialStore(ttl_seconds=1) with freeze_time("2024-01-01 00:00:00") as frozen: store.store("key1", "user1", "pass1") store.store("key2", "user2", "pass2") frozen.tick(timedelta(milliseconds=1500)) # Trigger cleanup by storing new entry store.store("key3", "user3", "pass3") # Old entries should be cleaned up assert store.retrieve("key1") is None assert store.retrieve("key2") is None assert store.retrieve("key3") == ("user3", "pass3") def test_cleanup_preserves_valid_entries(self, store): """Cleanup should preserve non-expired entries.""" store.store("key1", "user1", "pass1") store.store("key2", "user2", "pass2") # Trigger cleanup store._cleanup_expired() # All should still be valid (TTL is 1 hour) assert store.retrieve("key1") == ("user1", "pass1") assert store.retrieve("key2") == ("user2", "pass2") class TestClearEntry: """Tests for clear_entry method.""" def test_clear_existing_entry(self, store): """Should clear an existing entry.""" store.store("key1", "user1", "pass1") store.clear_entry("key1") assert store.retrieve("key1") is None def test_clear_nonexistent_entry(self, store): """Should handle clearing nonexistent entry.""" # Should not raise store.clear_entry("nonexistent") def test_clear_does_not_affect_other_entries(self, store): """Clearing one entry should not affect others.""" store.store("key1", "user1", "pass1") store.store("key2", "user2", "pass2") store.clear_entry("key1") assert store.retrieve("key1") is None assert store.retrieve("key2") == ("user2", "pass2") class TestThreadSafety: """Tests for thread safety.""" def test_concurrent_stores(self, store): """Concurrent stores should be thread-safe.""" results = {"errors": []} def store_entry(key, username, password): try: store.store(key, username, password) except Exception as e: results["errors"].append(str(e)) threads = [ threading.Thread( target=store_entry, args=(f"key{i}", f"user{i}", f"pass{i}") ) for i in range(100) ] for t in threads: t.start() for t in threads: t.join() assert len(results["errors"]) == 0 def test_concurrent_retrieves(self, store): """Concurrent retrieves should be thread-safe.""" store.store("shared_key", "user", "pass") results = [] lock = threading.Lock() def retrieve_entry(): result = store.retrieve("shared_key") with lock: results.append(result) threads = [threading.Thread(target=retrieve_entry) for _ in range(100)] for t in threads: t.start() for t in threads: t.join() assert all(r == ("user", "pass") for r in results) def test_concurrent_store_and_retrieve(self, store): """Concurrent stores and retrieves should be thread-safe.""" results = {"errors": [], "retrievals": []} lock = threading.Lock() def store_entry(): try: store.store("key1", "user", "pass") except Exception as e: with lock: results["errors"].append(str(e)) def retrieve_entry(): try: result = store.retrieve("key1") with lock: results["retrievals"].append(result) except Exception as e: with lock: results["errors"].append(str(e)) # First store, then concurrent operations store.store("key1", "user", "pass") threads = [] for i in range(50): threads.append(threading.Thread(target=store_entry)) threads.append(threading.Thread(target=retrieve_entry)) for t in threads: t.start() for t in threads: t.join() assert len(results["errors"]) == 0 def test_concurrent_clear_and_retrieve(self, store): """Concurrent clears and retrieves should be thread-safe.""" errors = [] lock = threading.Lock() def clear_and_retrieve(): try: store.store("key", "user", "pass") store.clear_entry("key") store.retrieve("key") except Exception as e: with lock: errors.append(str(e)) threads = [ threading.Thread(target=clear_and_retrieve) for _ in range(50) ] for t in threads: t.start() for t in threads: t.join() assert len(errors) == 0 class TestMemoryManagement: """Tests for memory management.""" def test_many_entries_stored(self, store): """Should handle many entries.""" for i in range(1000): store.store(f"key{i}", f"user{i}", f"pass{i}") # Spot check some entries assert store.retrieve("key0") == ("user0", "pass0") assert store.retrieve("key500") == ("user500", "pass500") assert store.retrieve("key999") == ("user999", "pass999") def test_entries_cleaned_up_over_time(self): """Old entries should be cleaned up.""" store = ConcreteCredentialStore(ttl_seconds=1) with freeze_time("2024-01-01 00:00:00") as frozen: # Add many entries for i in range(100): store.store(f"key{i}", f"user{i}", f"pass{i}") frozen.tick(timedelta(milliseconds=1500)) # Add new entry to trigger cleanup store.store("new_key", "new_user", "new_pass") # Old entries should be gone assert store.retrieve("key0") is None assert store.retrieve("new_key") == ("new_user", "new_pass") class TestEdgeCases: """Tests for edge cases.""" def test_very_long_key(self, store): """Should handle very long keys.""" long_key = "k" * 10000 store.store(long_key, "user", "pass") assert store.retrieve(long_key) == ("user", "pass") def test_very_long_credentials(self, store): """Should handle very long credentials.""" long_username = "u" * 10000 long_password = "p" * 10000 store.store("key1", long_username, long_password) assert store.retrieve("key1") == (long_username, long_password) def test_empty_key(self, store): """Should handle empty key.""" store.store("", "user", "pass") assert store.retrieve("") == ("user", "pass") def test_key_with_null_bytes(self, store): """Should handle keys with null bytes.""" key = "key\x00with\x00nulls" store.store(key, "user", "pass") assert store.retrieve(key) == ("user", "pass") def test_credentials_with_newlines(self, store): """Should handle credentials with newlines.""" store.store("key1", "user\nwith\nnewlines", "pass\nwith\nnewlines") assert store.retrieve("key1") == ( "user\nwith\nnewlines", "pass\nwith\nnewlines", ) def test_whitespace_key(self, store): """Should handle whitespace-only key.""" store.store(" ", "user", "pass") assert store.retrieve(" ") == ("user", "pass") assert store.retrieve("") is None # Different key class TestAbstractMethods: """Tests for abstract method enforcement.""" def test_cannot_instantiate_base_class(self): """Should not be able to instantiate abstract base class.""" with pytest.raises(TypeError): CredentialStoreBase(ttl_seconds=3600) def test_must_implement_store(self): """Subclass must implement store method.""" class IncompleteStore(CredentialStoreBase): def retrieve(self, key): pass with pytest.raises(TypeError): IncompleteStore(ttl_seconds=3600) def test_must_implement_retrieve(self): """Subclass must implement retrieve method.""" class IncompleteStore(CredentialStoreBase): def store(self, key, username, password): pass with pytest.raises(TypeError): IncompleteStore(ttl_seconds=3600)