7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
475 lines
16 KiB
Python
475 lines
16 KiB
Python
"""
|
|
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)
|