""" Service Worker Integration Tests Tests core functionality: cache management, TTL expiry, API key hashing, timeout behavior, and URL validation. Run with: pytest tests/test_service_worker.py -v """ import asyncio import hashlib import json import time import pytest # Mock service worker helpers (simulate JS crypto and cache behavior) class MockCache: """Simulates browser Cache API.""" def __init__(self): self.store = {} self.metadata = {} async def put(self, url, response): self.store[url] = response self.metadata[url] = time.time() # Use time.time() for timestamps async def match(self, url): return self.store.get(url) async def delete(self, url): if url in self.store: del self.store[url] del self.metadata[url] async def keys(self): return list(self.store.keys()) # Service Worker helper functions (Python equivalent of JS code) async def hash_api_key(api_key: str = None) -> str: """Hash API key using SHA-256.""" if not api_key: api_key = "no_api_key" return hashlib.sha256(api_key.encode()).hexdigest() def validate_notification_url(url: str, origin: str = "https://example.com") -> str: """Validate notification URL; prevent open redirects.""" if not url: return "/" try: if url.startswith(origin): return url if url.startswith("/"): return url except Exception: pass return "/" def is_cache_entry_expired( timestamp: float, ttl_ms: int = 7 * 24 * 60 * 60 * 1000 ) -> bool: """Check if cache entry has exceeded TTL.""" # timestamp is in seconds, convert to ms for comparison now_ms = time.time() * 1000 entry_ms = timestamp * 1000 return (now_ms - entry_ms) > ttl_ms # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def mock_cache(): """Provide a mock cache store.""" return MockCache() @pytest.fixture def cache_config(): """Cache configuration parameters.""" return { "NETWORK_TIMEOUT_MS": 10000, "CACHE_TTL_MS": 7 * 24 * 60 * 60 * 1000, "CURRENT_CACHE": "lnbits-test-", } # ============================================================================ # Tests: Core Functionality # ============================================================================ @pytest.mark.anyio async def test_hash_api_key(): """API keys should be hashed, not stored plaintext.""" api_key = "test-secret-key-12345" hash1 = await hash_api_key(api_key) hash2 = await hash_api_key(api_key) # Consistent hash assert hash1 == hash2 assert len(hash1) == 64 # SHA-256 hex digest # Not plaintext assert api_key not in hash1 @pytest.mark.anyio async def test_hash_different_keys(): """Different API keys should produce different hashes.""" hash1 = await hash_api_key("key1") hash2 = await hash_api_key("key2") assert hash1 != hash2 @pytest.mark.anyio async def test_cache_put_and_match(mock_cache): """Cache should store and retrieve responses.""" url = "https://example.com/api/data" response = {"status": 200, "body": "test"} await mock_cache.put(url, response) result = await mock_cache.match(url) assert result == response @pytest.mark.anyio async def test_cache_metadata_tracking(mock_cache): """Cache should track timestamps for TTL expiry.""" url = "https://example.com/api/data" response = {"status": 200} await mock_cache.put(url, response) assert url in mock_cache.metadata assert mock_cache.metadata[url] > 0 # ============================================================================ # Tests: TTL and Expiry (Security & Correctness) # ============================================================================ @pytest.mark.anyio async def test_cache_entry_not_expired_fresh(cache_config): """Fresh cache entries should not be expired.""" now_ms = time.time() * 1000 ttl = cache_config["CACHE_TTL_MS"] # Entry created just now expired = is_cache_entry_expired(now_ms / 1000, ttl) assert not expired @pytest.mark.anyio async def test_cache_entry_expired_old(cache_config): """Cache entries exceeding TTL should be expired.""" now_ms = time.time() * 1000 ttl = cache_config["CACHE_TTL_MS"] # Entry created 8 days ago (exceeds 7-day TTL) old_timestamp = (now_ms - ttl - 1000) / 1000 expired = is_cache_entry_expired(old_timestamp, ttl) assert expired # ============================================================================ # Tests: URL Validation (Security: CWE-601 Open Redirect) # ============================================================================ @pytest.mark.anyio async def test_validate_same_origin_url(): """Same-origin URLs should be allowed.""" url = "/wallet?id=123" result = validate_notification_url(url) assert result == url @pytest.mark.anyio async def test_validate_same_origin_absolute(): """Absolute same-origin URLs should be allowed.""" url = "https://example.com/wallet" result = validate_notification_url(url, origin="https://example.com") assert result == url @pytest.mark.anyio async def test_prevent_open_redirect_external(): """External URLs should be rejected; redirect to safe default.""" malicious_url = "https://evil.com/phish" result = validate_notification_url(malicious_url, origin="https://example.com") assert result == "/" @pytest.mark.anyio async def test_prevent_open_redirect_protocol_switch(): """Protocol switches should be prevented.""" malicious_url = "javascript:alert('xss')" result = validate_notification_url(malicious_url) assert result == "/" @pytest.mark.anyio async def test_validate_none_url(): """Missing or null URLs should fallback to safe default.""" assert validate_notification_url(None) == "/" assert validate_notification_url("") == "/" # ============================================================================ # Tests: Network Timeout (Performance & UX) # ============================================================================ @pytest.mark.anyio async def test_network_timeout_enforced(): """Fetch requests should timeout after configured delay.""" timeout_ms = 100 async def slow_fetch(): await asyncio.sleep(timeout_ms / 1000 + 0.5) return {"status": 200} with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(slow_fetch(), timeout=timeout_ms / 1000) @pytest.mark.anyio async def test_timeout_fallback_to_cache(mock_cache): """On timeout, should fallback to cached response.""" url = "https://example.com/api/data" cached_response = {"status": 200, "cached": True} # Pre-populate cache await mock_cache.put(url, cached_response) result = await mock_cache.match(url) assert result == cached_response # ============================================================================ # Tests: Worker Lifecycle (Deployment Update) # ============================================================================ @pytest.mark.anyio async def test_cache_versioning(): """Cache should use version prefix to isolate deployments.""" version1 = "lnbits-v1.0-" version2 = "lnbits-v1.1-" api_key_hash = await hash_api_key("key1") cache_key_v1 = version1 + api_key_hash cache_key_v2 = version2 + api_key_hash assert cache_key_v1 != cache_key_v2 assert cache_key_v1.startswith("lnbits-v1.0-") assert cache_key_v2.startswith("lnbits-v1.1-") @pytest.mark.anyio async def test_old_caches_should_be_cleaned(mock_cache): """Old cache versions should be deleted on activation.""" old_cache = "lnbits-old-version-" current_cache = "lnbits-current-" # Simulate old and new caches await mock_cache.put(f"{old_cache}key1", {"data": "old"}) await mock_cache.put(f"{current_cache}key1", {"data": "new"}) # Cleanup: delete old await mock_cache.delete(f"{old_cache}key1") old_result = await mock_cache.match(f"{old_cache}key1") current_result = await mock_cache.match(f"{current_cache}key1") assert old_result is None assert current_result == {"data": "new"} # ============================================================================ # Tests: Edge Cases & Error Handling # ============================================================================ @pytest.mark.anyio async def test_malformed_push_notification(): """Malformed push data should not crash handler.""" malformed_data = "not-json" with pytest.raises(json.JSONDecodeError): json.loads(malformed_data) @pytest.mark.anyio async def test_missing_api_key_defaults(): """Missing API key should hash as 'no_api_key'.""" hash_with_key = await hash_api_key("actual-key") hash_without_key = await hash_api_key(None) assert hash_with_key != hash_without_key @pytest.mark.anyio async def test_cache_entry_collision_prevention(): """Different API keys should use separate cache stores.""" key1 = "user-api-key-1" key2 = "user-api-key-2" hash1 = await hash_api_key(key1) hash2 = await hash_api_key(key2) cache1 = f"lnbits-{hash1}" cache2 = f"lnbits-{hash2}" assert cache1 != cache2