"""Tests for security/file_integrity/integrity_manager.py.""" import pytest import tempfile from pathlib import Path from unittest.mock import Mock, patch, MagicMock class TestFileIntegrityManagerConstants: """Tests for FileIntegrityManager constants.""" def test_max_failures_per_file_is_reasonable(self): """Test that MAX_FAILURES_PER_FILE is set to a reasonable value.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) assert FileIntegrityManager.MAX_FAILURES_PER_FILE == 100 assert FileIntegrityManager.MAX_FAILURES_PER_FILE > 0 def test_max_total_failures_is_reasonable(self): """Test that MAX_TOTAL_FAILURES is set to a reasonable value.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) assert FileIntegrityManager.MAX_TOTAL_FAILURES == 10000 assert FileIntegrityManager.MAX_TOTAL_FAILURES > 0 class TestFileIntegrityManagerInit: """Tests for FileIntegrityManager initialization.""" def test_init_raises_without_session_context(self): """Test that init raises ImportError when session context unavailable.""" from local_deep_research.security.file_integrity import ( integrity_manager, ) # Save original state original_has_context = integrity_manager._has_session_context try: # Simulate missing session context integrity_manager._has_session_context = False with pytest.raises(ImportError, match="requires Flask"): integrity_manager.FileIntegrityManager("testuser", "testpass") finally: # Restore original state integrity_manager._has_session_context = original_has_context def test_init_stores_username_and_password(self): """Test that init stores username and password.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("myuser", "mypass") assert manager.username == "myuser" assert manager.password == "mypass" def test_init_creates_empty_verifiers_list(self): """Test that init creates an empty verifiers list.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") assert manager.verifiers == [] class TestNormalizePath: """Tests for _normalize_path method.""" def test_normalize_path_returns_string(self): """Test that _normalize_path returns a string.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") result = manager._normalize_path(Path("/tmp/test.txt")) assert isinstance(result, str) def test_normalize_path_resolves_to_absolute(self): """Test that _normalize_path resolves to absolute path.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") # Create actual temp file to test resolution with tempfile.NamedTemporaryFile(delete=False) as f: file_path = Path(f.name) try: result = manager._normalize_path(file_path) assert result.startswith("/") finally: file_path.unlink() class TestRegisterVerifier: """Tests for register_verifier method.""" def test_register_verifier_adds_to_list(self): """Test that register_verifier adds verifier to list.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class TestVerifier(BaseFileVerifier): def should_verify(self, file_path): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") verifier = TestVerifier() manager.register_verifier(verifier) assert verifier in manager.verifiers def test_register_multiple_verifiers(self): """Test that multiple verifiers can be registered.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class Verifier1(BaseFileVerifier): def should_verify(self, file_path): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False class Verifier2(BaseFileVerifier): def should_verify(self, file_path): return True def get_file_type(self): return FileType.FAISS_INDEX def allows_modifications(self): return False with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") manager.register_verifier(Verifier1()) manager.register_verifier(Verifier2()) assert len(manager.verifiers) == 2 class TestGetVerifierForFile: """Tests for _get_verifier_for_file method.""" def test_returns_none_when_no_verifiers(self): """Test that None is returned when no verifiers registered.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") result = manager._get_verifier_for_file(Path("/some/file.txt")) assert result is None def test_returns_matching_verifier(self): """Test that the matching verifier is returned.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class TestVerifier(BaseFileVerifier): def should_verify(self, file_path): return file_path.suffix == ".pdf" def get_file_type(self): return FileType.PDF def allows_modifications(self): return False with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") verifier = TestVerifier() manager.register_verifier(verifier) result = manager._get_verifier_for_file(Path("/test/file.pdf")) assert result is verifier class TestNeedsVerification: """Tests for _needs_verification method.""" def test_returns_true_for_missing_file(self): """Test that True is returned for missing files.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") mock_record = Mock() mock_record.last_verified_at = None result = manager._needs_verification( mock_record, Path("/nonexistent/file.txt") ) assert result is True def test_returns_true_when_never_verified(self): """Test that True is returned when file was never verified.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") mock_record = Mock() mock_record.last_verified_at = None with tempfile.NamedTemporaryFile(delete=False) as f: file_path = Path(f.name) try: result = manager._needs_verification(mock_record, file_path) assert result is True finally: file_path.unlink() class TestUpdateStats: """Tests for _update_stats method.""" def test_increments_total_verifications(self): """Test that total_verifications is incremented.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") mock_record = Mock() mock_record.total_verifications = 5 mock_record.consecutive_successes = 0 mock_record.consecutive_failures = 0 manager._update_stats(mock_record, passed=True, session=mock_ctx) assert mock_record.total_verifications == 6 def test_updates_consecutive_successes_on_pass(self): """Test that consecutive_successes is incremented on pass.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") mock_record = Mock() mock_record.total_verifications = 0 mock_record.consecutive_successes = 3 mock_record.consecutive_failures = 1 manager._update_stats(mock_record, passed=True, session=mock_ctx) assert mock_record.consecutive_successes == 4 assert mock_record.consecutive_failures == 0 def test_updates_consecutive_failures_on_fail(self): """Test that consecutive_failures is incremented on fail.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 manager = FileIntegrityManager("testuser") mock_record = Mock() mock_record.total_verifications = 0 mock_record.consecutive_successes = 5 mock_record.consecutive_failures = 0 manager._update_stats(mock_record, passed=False, session=mock_ctx) assert mock_record.consecutive_failures == 1 assert mock_record.consecutive_successes == 0 class TestVerifyFile: """Tests for verify_file method.""" def test_verify_file_returns_result(self): """Test that verify_file returns a result.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class TestVerifier(BaseFileVerifier): def should_verify(self, file_path): return file_path.suffix == ".pdf" def get_file_type(self): return FileType.PDF def allows_modifications(self): return False with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 mock_ctx.query.return_value.filter.return_value.first.return_value = None manager = FileIntegrityManager("testuser") manager.register_verifier(TestVerifier()) with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: f.write(b"test pdf content") file_path = Path(f.name) try: result = manager.verify_file(file_path) # verify_file should return a verification result assert result is not None except (ImportError, AttributeError) as e: # Expected if optional dependencies missing pytest.skip(f"Skipping due to missing dependency: {e}") except (OSError, IOError) as e: # File system errors are acceptable in test environment pytest.skip(f"Skipping due to file system error: {e}") except TypeError as e: # Mock comparison issues can occur in complex verification flows pytest.skip(f"Skipping due to mock comparison issue: {e}") finally: file_path.unlink() class TestMaxFailuresLimits: """Tests for max failures limits.""" def test_max_failures_per_file_constant(self): """Test MAX_FAILURES_PER_FILE is set.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) assert hasattr(FileIntegrityManager, "MAX_FAILURES_PER_FILE") assert FileIntegrityManager.MAX_FAILURES_PER_FILE > 0 def test_max_total_failures_constant(self): """Test MAX_TOTAL_FAILURES is set.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) assert hasattr(FileIntegrityManager, "MAX_TOTAL_FAILURES") assert FileIntegrityManager.MAX_TOTAL_FAILURES > 0 def test_max_total_failures_greater_than_per_file(self): """Test MAX_TOTAL_FAILURES is greater than MAX_FAILURES_PER_FILE.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) assert ( FileIntegrityManager.MAX_TOTAL_FAILURES >= FileIntegrityManager.MAX_FAILURES_PER_FILE ) # ---------- helper to build a manager without hitting the real DB ---------- def _make_manager(): """Create a FileIntegrityManager with mocked DB session.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_session: mock_ctx = MagicMock() mock_session.return_value.__enter__ = Mock(return_value=mock_ctx) mock_session.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 mgr = FileIntegrityManager("testuser", "testpass") return mgr class TestDoVerification: """Tests for _do_verification (the core tamper-detection method).""" def test_returns_false_when_file_missing(self, tmp_path): """Missing file is detected.""" mgr = _make_manager() record = Mock(checksum="abc123") missing = tmp_path / "no_such_file" passed, reason = mgr._do_verification(record, missing, MagicMock()) assert passed is False assert reason == "file_missing" def test_returns_false_when_no_verifier(self, tmp_path): """No registered verifier → failure.""" mgr = _make_manager() f = tmp_path / "file.xyz" f.write_text("data") record = Mock(checksum="abc") passed, reason = mgr._do_verification(record, f, MagicMock()) assert passed is False assert reason == "no_verifier" def test_returns_false_on_checksum_mismatch(self, tmp_path): """Tampered file detected via checksum mismatch.""" from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class StubVerifier(BaseFileVerifier): def should_verify(self, fp): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False mgr = _make_manager() v = StubVerifier() v.calculate_checksum = Mock(return_value="new_checksum") mgr.register_verifier(v) f = tmp_path / "file.bin" f.write_text("data") record = Mock(checksum="old_checksum") passed, reason = mgr._do_verification(record, f, MagicMock()) assert passed is False assert reason == "checksum_mismatch" def test_returns_true_on_match_and_updates_mtime(self, tmp_path): """Valid file passes and mtime is updated.""" from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class StubVerifier(BaseFileVerifier): def should_verify(self, fp): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False mgr = _make_manager() v = StubVerifier() v.calculate_checksum = Mock(return_value="matching") mgr.register_verifier(v) f = tmp_path / "file.bin" f.write_text("data") record = Mock(checksum="matching", file_mtime=0.0) passed, reason = mgr._do_verification(record, f, MagicMock()) assert passed is True assert reason is None # mtime should have been updated to the file's actual mtime assert record.file_mtime == f.stat().st_mtime def test_checksum_calculation_exception(self, tmp_path): """Exception during checksum calculation is handled.""" from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class StubVerifier(BaseFileVerifier): def should_verify(self, fp): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False mgr = _make_manager() v = StubVerifier() v.calculate_checksum = Mock(side_effect=IOError("disk error")) mgr.register_verifier(v) f = tmp_path / "file.bin" f.write_text("data") record = Mock(checksum="x") passed, reason = mgr._do_verification(record, f, MagicMock()) assert passed is False assert "checksum_calculation_failed" in reason class TestRecordFile: """Tests for record_file (baseline creation / update).""" def test_creates_new_record(self, tmp_path): """New file baseline is created.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class StubVerifier(BaseFileVerifier): def should_verify(self, fp): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False f = tmp_path / "new.bin" f.write_text("hello") with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_gs: mock_ctx = MagicMock() mock_gs.return_value.__enter__ = Mock(return_value=mock_ctx) mock_gs.return_value.__exit__ = Mock(return_value=False) # Init: cleanup returns 0 mock_ctx.query.return_value.count.return_value = 0 mgr = FileIntegrityManager("user", "pass") v = StubVerifier() v.calculate_checksum = Mock(return_value="abc123") mgr.register_verifier(v) # record_file path: no existing record mock_ctx.query.return_value.filter_by.return_value.first.return_value = None mgr.record_file(f) # session.add should have been called with a new record mock_ctx.add.assert_called_once() mock_ctx.commit.assert_called() def test_updates_existing_record(self, tmp_path): """Existing record is updated.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class StubVerifier(BaseFileVerifier): def should_verify(self, fp): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False f = tmp_path / "existing.bin" f.write_text("updated content") with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_gs: mock_ctx = MagicMock() mock_gs.return_value.__enter__ = Mock(return_value=mock_ctx) mock_gs.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = 0 mgr = FileIntegrityManager("user", "pass") v = StubVerifier() v.calculate_checksum = Mock(return_value="newchecksum") mgr.register_verifier(v) # Simulate existing record existing = Mock() existing.checksum = "oldchecksum" mock_ctx.query.return_value.filter_by.return_value.first.return_value = existing mgr.record_file(f) assert existing.checksum == "newchecksum" mock_ctx.commit.assert_called() def test_raises_file_not_found(self, tmp_path): """Missing file raises FileNotFoundError.""" mgr = _make_manager() with pytest.raises(FileNotFoundError): mgr.record_file(tmp_path / "nope.bin") def test_raises_no_verifier(self, tmp_path): """No matching verifier raises ValueError.""" mgr = _make_manager() f = tmp_path / "file.xyz" f.write_text("data") with pytest.raises(ValueError, match="No verifier"): mgr.record_file(f) class TestLogFailure: """Tests for _log_failure (audit trail).""" def test_creates_failure_record(self, tmp_path): """Failure record is added to session.""" from local_deep_research.security.file_integrity.base_verifier import ( BaseFileVerifier, FileType, ) class StubVerifier(BaseFileVerifier): def should_verify(self, fp): return True def get_file_type(self): return FileType.PDF def allows_modifications(self): return False mgr = _make_manager() v = StubVerifier() v.calculate_checksum = Mock(return_value="actual_hash") mgr.register_verifier(v) f = tmp_path / "tampered.bin" f.write_text("bad") mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.count.return_value = 0 record = Mock(id=1, checksum="expected_hash") mgr._log_failure(record, f, "checksum_mismatch", mock_session) mock_session.add.assert_called_once() added_obj = mock_session.add.call_args[0][0] assert added_obj.failure_reason == "checksum_mismatch" assert added_obj.expected_checksum == "expected_hash" assert added_obj.actual_checksum == "actual_hash" class TestCleanupOldFailures: """Tests for _cleanup_old_failures (per-file).""" def test_deletes_excess_failures(self): """Old failures beyond MAX_FAILURES_PER_FILE are deleted.""" mgr = _make_manager() mock_session = MagicMock() # Simulate 105 failures (limit is 100) mock_session.query.return_value.filter_by.return_value.count.return_value = 105 old_failures = [Mock() for _ in range(5)] mock_session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures record = Mock(id=1) mgr._cleanup_old_failures(record, mock_session) assert mock_session.delete.call_count == 5 class TestCleanupAllOldFailures: """Tests for cleanup_all_old_failures (global).""" def test_under_limit_returns_zero(self): """No cleanup when under limit.""" from local_deep_research.security.file_integrity.integrity_manager import ( FileIntegrityManager, ) with patch( "local_deep_research.security.file_integrity.integrity_manager.get_user_db_session" ) as mock_gs: mock_ctx = MagicMock() mock_gs.return_value.__enter__ = Mock(return_value=mock_ctx) mock_gs.return_value.__exit__ = Mock(return_value=False) mock_ctx.query.return_value.count.return_value = ( 50 # well under 10000 ) mgr = FileIntegrityManager("user", "pass") result = mgr.cleanup_all_old_failures() assert result == 0