""" Unit tests for telemetry module. Tests cover: 1. OFF state: no recording, no disk write 2. ON state: events buffered and saved 3. Buffer limit: oldest events purged 4. TTL: old events auto-purged 5. Invalid category: rejected 6. Invalid label: rejected 7. Unknown pattern ID: mapped to __unknown__ 8. PII detection: rejected 9. Rate limiting: excess events rejected 10. Clear: buffer emptied """ import json import os import tempfile import time import unittest from datetime import datetime, timedelta from unittest.mock import patch, MagicMock # Add parent directory to path for imports import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from telemetry import ( TelemetryStore, TelemetryEvent, RateLimiter, validate_event, contains_pii, get_pattern_allowlist, validate_pattern_label, SCHEMA_VERSION, MAX_EVENTS, MAX_LABEL_LENGTH, EVENT_TTL_DAYS, ) class TestRateLimiter(unittest.TestCase): """Test rate limiter functionality.""" def test_allows_initial_requests(self): """Rate limiter should allow initial burst.""" limiter = RateLimiter(max_per_minute=10) for _ in range(10): self.assertTrue(limiter.allow()) def test_rejects_after_limit(self): """Rate limiter should reject after limit reached.""" limiter = RateLimiter(max_per_minute=5) for _ in range(5): limiter.allow() self.assertFalse(limiter.allow()) def test_refills_over_time(self): """Rate limiter should refill tokens over time.""" limiter = RateLimiter(max_per_minute=60) # Exhaust all tokens for _ in range(60): limiter.allow() self.assertFalse(limiter.allow()) # Simulate time passing (1 second = 1 token) limiter.last_refill -= 2 # 2 seconds ago self.assertTrue(limiter.allow()) class TestValidation(unittest.TestCase): """Test event validation.""" def test_valid_event(self): """Valid event should pass validation.""" is_valid, msg, event = validate_event({ "category": "feature", "action": "tab_switch", "label": "chat", }) self.assertTrue(is_valid) self.assertIsNotNone(event) def test_missing_category(self): """Missing category should fail validation.""" is_valid, msg, _ = validate_event({ "action": "tab_switch", }) self.assertFalse(is_valid) self.assertIn("category", msg.lower()) def test_invalid_category(self): """Invalid category should fail validation.""" is_valid, msg, _ = validate_event({ "category": "invalid_category", "action": "tab_switch", }) self.assertFalse(is_valid) self.assertIn("category", msg.lower()) def test_invalid_action(self): """Invalid action should fail validation.""" is_valid, msg, _ = validate_event({ "category": "feature", "action": "invalid_action", }) self.assertFalse(is_valid) self.assertIn("action", msg.lower()) def test_invalid_label(self): """Invalid label should fail validation.""" is_valid, msg, _ = validate_event({ "category": "feature", "action": "tab_switch", "label": "invalid_label", }) self.assertFalse(is_valid) self.assertIn("label", msg.lower()) def test_label_too_long(self): """Label exceeding max length should fail.""" long_label = "a" * (MAX_LABEL_LENGTH + 1) is_valid, msg, _ = validate_event({ "category": "session", "action": "start", "label": long_label, }) self.assertFalse(is_valid) self.assertIn("long", msg.lower()) def test_value_out_of_range(self): """Value out of range should fail validation.""" is_valid, msg, _ = validate_event({ "category": "feature", "action": "tab_switch", "label": "chat", "value": -1, }) self.assertFalse(is_valid) self.assertIn("range", msg.lower()) class TestPIIDetection(unittest.TestCase): """Test PII pattern detection.""" def test_detects_email(self): """Should detect email addresses.""" self.assertTrue(contains_pii("user@example.com")) def test_detects_windows_path(self): """Should detect Windows user paths.""" self.assertTrue(contains_pii(r"C:\Users\john\file.py")) def test_detects_linux_home(self): """Should detect Linux home paths.""" self.assertTrue(contains_pii("/home/alice/test.py")) def test_detects_macos_home(self): """Should detect macOS home paths.""" self.assertTrue(contains_pii("/Users/bob/documents")) def test_detects_api_key(self): """Should detect API key patterns.""" self.assertTrue(contains_pii("sk-abc123def456ghi789jkl012mno345")) def test_allows_safe_strings(self): """Should allow safe strings.""" self.assertFalse(contains_pii("chat")) self.assertFalse(contains_pii("resolved")) self.assertFalse(contains_pii("cuda_oom")) class TestPatternValidation(unittest.TestCase): """Test pattern ID validation.""" @patch('telemetry.get_pattern_allowlist') def test_valid_pattern_id(self, mock_allowlist): """Valid pattern ID should pass.""" mock_allowlist.return_value = {"cuda_oom", "missing_module"} result = validate_pattern_label("cuda_oom") self.assertEqual(result, "cuda_oom") @patch('telemetry.get_pattern_allowlist') def test_unknown_pattern_id(self, mock_allowlist): """Unknown pattern ID should map to __unknown__.""" mock_allowlist.return_value = {"cuda_oom"} result = validate_pattern_label("unknown_pattern") self.assertEqual(result, "__unknown__") def test_empty_label(self): """Empty label should map to __unknown__.""" result = validate_pattern_label("") self.assertEqual(result, "__unknown__") class TestTelemetryStore(unittest.TestCase): """Test telemetry store functionality.""" def setUp(self): """Create temp file for each test.""" self.temp_dir = tempfile.mkdtemp() self.temp_file = os.path.join(self.temp_dir, "telemetry.json") def tearDown(self): """Clean up temp files.""" if os.path.exists(self.temp_file): os.remove(self.temp_file) if os.path.exists(self.temp_dir): os.rmdir(self.temp_dir) def test_disabled_no_recording(self): """When disabled, no events should be recorded.""" store = TelemetryStore(filepath=self.temp_file, enabled=False) success, msg = store.track({ "category": "feature", "action": "tab_switch", "label": "chat", }) self.assertFalse(success) self.assertIn("disabled", msg.lower()) self.assertFalse(os.path.exists(self.temp_file)) def test_enabled_records_event(self): """When enabled, valid events should be recorded.""" store = TelemetryStore(filepath=self.temp_file, enabled=True) success, msg = store.track({ "category": "feature", "action": "tab_switch", "label": "chat", }) self.assertTrue(success) self.assertTrue(os.path.exists(self.temp_file)) self.assertEqual(len(store), 1) def test_buffer_limit(self): """Buffer should not exceed MAX_EVENTS.""" store = TelemetryStore(filepath=self.temp_file, enabled=True) # Bypass rate limiter for this test store._rate_limiter = RateLimiter(max_per_minute=10000) # Add more than MAX_EVENTS for i in range(MAX_EVENTS + 10): store.track({ "category": "session", "action": "start", }) self.assertEqual(len(store), MAX_EVENTS) def test_clear(self): """Clear should empty the buffer.""" store = TelemetryStore(filepath=self.temp_file, enabled=True) store.track({ "category": "feature", "action": "tab_switch", "label": "chat", }) self.assertEqual(len(store), 1) store.clear() self.assertEqual(len(store), 0) def test_export_json(self): """Export should return valid JSON.""" store = TelemetryStore(filepath=self.temp_file, enabled=True) store.track({ "category": "feature", "action": "tab_switch", "label": "chat", }) json_str = store.export_json() data = json.loads(json_str) self.assertEqual(len(data), 1) self.assertEqual(data[0]["category"], "feature") def test_rate_limiting(self): """Should reject events when rate limited.""" store = TelemetryStore(filepath=self.temp_file, enabled=True) # Set very low rate limit store._rate_limiter = RateLimiter(max_per_minute=2) # First two should succeed success1, _ = store.track({"category": "session", "action": "start"}) success2, _ = store.track({"category": "session", "action": "start"}) # Third should fail success3, msg = store.track({"category": "session", "action": "start"}) self.assertTrue(success1) self.assertTrue(success2) self.assertFalse(success3) self.assertIn("rate", msg.lower()) def test_pii_rejected(self): """Events with PII in label should be rejected.""" store = TelemetryStore(filepath=self.temp_file, enabled=True) # Note: PII check happens in validate_event, but we need a valid category/action # that allows arbitrary labels. Use analysis/pattern_matched with patched allowlist with patch('telemetry.get_pattern_allowlist', return_value={"test@example.com"}): # This should still fail due to PII detection success, msg = store.track({ "category": "session", "action": "start", "label": "test@example.com", # Email in label }) # Session labels are not validated by allowlist, but should catch PII self.assertFalse(success) self.assertIn("pii", msg.lower()) class TestTelemetryPersistence(unittest.TestCase): """Test telemetry file persistence.""" def setUp(self): """Create temp file for each test.""" self.temp_dir = tempfile.mkdtemp() self.temp_file = os.path.join(self.temp_dir, "telemetry.json") def tearDown(self): """Clean up temp files.""" if os.path.exists(self.temp_file): os.remove(self.temp_file) if os.path.exists(self.temp_dir): os.rmdir(self.temp_dir) def test_persistence_across_instances(self): """Events should persist across store instances.""" # First instance store1 = TelemetryStore(filepath=self.temp_file, enabled=True) store1.track({ "category": "feature", "action": "tab_switch", "label": "chat", }) self.assertEqual(len(store1), 1) # Second instance (new object, same file) store2 = TelemetryStore(filepath=self.temp_file, enabled=True) self.assertEqual(len(store2), 1) events = store2.get_buffer() self.assertEqual(events[0]["label"], "chat") if __name__ == "__main__": unittest.main()