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
987 lines
36 KiB
Python
987 lines
36 KiB
Python
"""
|
|
Comprehensive tests for FileIntegrityManager to increase coverage.
|
|
|
|
Tests all public and internal methods with mocked DB sessions and verifiers.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
from pathlib import Path
|
|
from datetime import datetime, UTC
|
|
from contextlib import contextmanager
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_session():
|
|
"""Create a mock DB session with chainable query interface."""
|
|
session = MagicMock()
|
|
return session
|
|
|
|
|
|
@contextmanager
|
|
def _mock_session_cm(session):
|
|
"""Context manager wrapper for mock session."""
|
|
yield session
|
|
|
|
|
|
def _make_verifier(
|
|
should=True,
|
|
file_type="faiss_index",
|
|
algorithm="sha256",
|
|
checksum="abc123",
|
|
allows_mod=False,
|
|
):
|
|
"""Create a mock verifier with configurable behavior."""
|
|
v = MagicMock()
|
|
v.should_verify.return_value = should
|
|
v.get_file_type.return_value = file_type
|
|
v.get_algorithm.return_value = algorithm
|
|
v.calculate_checksum.return_value = checksum
|
|
v.allows_modifications.return_value = allows_mod
|
|
return v
|
|
|
|
|
|
def _make_record(**overrides):
|
|
"""Create a mock FileIntegrityRecord with sensible defaults."""
|
|
defaults = dict(
|
|
id=1,
|
|
file_path="/resolved/path",
|
|
file_type="faiss_index",
|
|
checksum="abc123",
|
|
algorithm="sha256",
|
|
file_size=1024,
|
|
file_mtime=1000.0,
|
|
verify_on_load=True,
|
|
allow_modifications=False,
|
|
total_verifications=5,
|
|
last_verified_at=datetime(2025, 1, 1, tzinfo=UTC),
|
|
last_verification_passed=True,
|
|
consecutive_successes=3,
|
|
consecutive_failures=0,
|
|
created_at=datetime(2025, 1, 1, tzinfo=UTC),
|
|
updated_at=None,
|
|
related_entity_type=None,
|
|
related_entity_id=None,
|
|
)
|
|
defaults.update(overrides)
|
|
record = MagicMock()
|
|
for k, v in defaults.items():
|
|
setattr(record, k, v)
|
|
return record
|
|
|
|
|
|
# Patch path for the session context used inside integrity_manager module
|
|
_SESSION_PATCH = (
|
|
"local_deep_research.security.file_integrity.integrity_manager"
|
|
".get_user_db_session"
|
|
)
|
|
_HAS_CTX_PATCH = (
|
|
"local_deep_research.security.file_integrity.integrity_manager"
|
|
"._has_session_context"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def integrity_manager():
|
|
"""
|
|
Build a FileIntegrityManager with mocked session context.
|
|
|
|
Yields (manager, session). Patcher is automatically stopped on teardown.
|
|
"""
|
|
session = _make_mock_session()
|
|
|
|
with patch(_HAS_CTX_PATCH, True), patch(_SESSION_PATCH) as mock_get_session:
|
|
mock_get_session.return_value = _mock_session_cm(session)
|
|
# cleanup_all_old_failures queries count then returns
|
|
session.query.return_value.count.return_value = 0
|
|
from local_deep_research.security.file_integrity.integrity_manager import (
|
|
FileIntegrityManager,
|
|
)
|
|
|
|
mgr = FileIntegrityManager(username="testuser", password="testpass")
|
|
|
|
# Re-patch for subsequent calls
|
|
patcher = patch(
|
|
_SESSION_PATCH, side_effect=lambda u, p: _mock_session_cm(session)
|
|
)
|
|
patcher.start()
|
|
mgr._test_session = session
|
|
yield mgr, session
|
|
patcher.stop()
|
|
|
|
|
|
def _build_manager(session=None):
|
|
"""
|
|
Build a FileIntegrityManager with mocked session context.
|
|
|
|
Returns (manager, session). Caller MUST call mgr._test_patcher.stop()
|
|
or use the integrity_manager fixture instead.
|
|
|
|
NOTE: Prefer the integrity_manager fixture for new tests.
|
|
This helper is kept for TestInit tests that need custom session setup.
|
|
"""
|
|
if session is None:
|
|
session = _make_mock_session()
|
|
|
|
with patch(_HAS_CTX_PATCH, True), patch(_SESSION_PATCH) as mock_get_session:
|
|
mock_get_session.return_value = _mock_session_cm(session)
|
|
# cleanup_all_old_failures queries count then returns
|
|
session.query.return_value.count.return_value = 0
|
|
from local_deep_research.security.file_integrity.integrity_manager import (
|
|
FileIntegrityManager,
|
|
)
|
|
|
|
mgr = FileIntegrityManager(username="testuser", password="testpass")
|
|
|
|
# Re-patch for subsequent calls
|
|
patcher = patch(
|
|
_SESSION_PATCH, side_effect=lambda u, p: _mock_session_cm(session)
|
|
)
|
|
patcher.start()
|
|
mgr._test_patcher = patcher
|
|
mgr._test_session = session
|
|
return mgr, session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: __init__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInit:
|
|
def test_raises_import_error_when_no_session_context(self):
|
|
with patch(_HAS_CTX_PATCH, False):
|
|
from local_deep_research.security.file_integrity.integrity_manager import (
|
|
FileIntegrityManager,
|
|
)
|
|
|
|
with pytest.raises(ImportError, match="requires Flask"):
|
|
FileIntegrityManager(username="user")
|
|
|
|
def test_successful_init_sets_attributes(self):
|
|
mgr, _session = _build_manager()
|
|
assert mgr.username == "testuser"
|
|
assert mgr.password == "testpass"
|
|
assert mgr.verifiers == []
|
|
mgr._test_patcher.stop()
|
|
|
|
def test_startup_cleanup_logs_when_deleted(self):
|
|
session = _make_mock_session()
|
|
# First call to cleanup_all_old_failures: count returns > MAX
|
|
with (
|
|
patch(_HAS_CTX_PATCH, True),
|
|
patch(_SESSION_PATCH) as mock_get_session,
|
|
):
|
|
mock_get_session.return_value = _mock_session_cm(session)
|
|
# total_failures > MAX_TOTAL_FAILURES to trigger deletion
|
|
session.query.return_value.count.return_value = 10005
|
|
session.query.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
|
MagicMock() for _ in range(5)
|
|
]
|
|
from local_deep_research.security.file_integrity.integrity_manager import (
|
|
FileIntegrityManager,
|
|
)
|
|
|
|
mgr = FileIntegrityManager(username="user")
|
|
mgr._test_patcher = patch(_SESSION_PATCH) # dummy
|
|
# No assertion needed beyond not raising
|
|
|
|
def test_startup_cleanup_handles_exception(self):
|
|
"""If startup cleanup raises, __init__ should still succeed."""
|
|
with (
|
|
patch(_HAS_CTX_PATCH, True),
|
|
patch(_SESSION_PATCH) as mock_get_session,
|
|
):
|
|
mock_get_session.side_effect = RuntimeError("db down")
|
|
from local_deep_research.security.file_integrity.integrity_manager import (
|
|
FileIntegrityManager,
|
|
)
|
|
|
|
# Should not raise thanks to try/except in __init__
|
|
mgr = FileIntegrityManager(username="user")
|
|
assert mgr.username == "user"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _normalize_path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizePath:
|
|
def test_returns_resolved_string(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
p = Path("/some/../some/file.txt")
|
|
result = mgr._normalize_path(p)
|
|
assert result == str(p.resolve())
|
|
assert isinstance(result, str)
|
|
|
|
def test_relative_path_becomes_absolute(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
p = Path("relative/file.txt")
|
|
result = mgr._normalize_path(p)
|
|
assert Path(result).is_absolute()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: register_verifier
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegisterVerifier:
|
|
def test_appends_verifier(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
v1 = _make_verifier()
|
|
v2 = _make_verifier(file_type="pdf")
|
|
mgr.register_verifier(v1)
|
|
mgr.register_verifier(v2)
|
|
assert mgr.verifiers == [v1, v2]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _get_verifier_for_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetVerifierForFile:
|
|
def test_returns_matching_verifier(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
v1 = _make_verifier(should=False)
|
|
v2 = _make_verifier(should=True, file_type="pdf")
|
|
mgr.register_verifier(v1)
|
|
mgr.register_verifier(v2)
|
|
result = mgr._get_verifier_for_file(Path("/some/file.pdf"))
|
|
assert result is v2
|
|
|
|
def test_returns_first_matching(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
v1 = _make_verifier(should=True, file_type="faiss")
|
|
v2 = _make_verifier(should=True, file_type="pdf")
|
|
mgr.register_verifier(v1)
|
|
mgr.register_verifier(v2)
|
|
result = mgr._get_verifier_for_file(Path("/file"))
|
|
assert result is v1
|
|
|
|
def test_returns_none_when_no_match(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
v1 = _make_verifier(should=False)
|
|
mgr.register_verifier(v1)
|
|
result = mgr._get_verifier_for_file(Path("/file"))
|
|
assert result is None
|
|
|
|
def test_returns_none_with_no_verifiers(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
result = mgr._get_verifier_for_file(Path("/file"))
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _needs_verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNeedsVerification:
|
|
def test_file_missing_returns_true(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
record = _make_record()
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = False
|
|
assert mgr._needs_verification(record, path) is True
|
|
|
|
def test_never_verified_returns_true(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
record = _make_record(last_verified_at=None)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
assert mgr._needs_verification(record, path) is True
|
|
|
|
def test_no_stored_mtime_returns_true(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
record = _make_record(file_mtime=None)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_mtime = 1000.0
|
|
assert mgr._needs_verification(record, path) is True
|
|
|
|
def test_mtime_changed_returns_true(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
record = _make_record(file_mtime=1000.0)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_mtime = 2000.0 # clearly different
|
|
assert mgr._needs_verification(record, path) is True
|
|
|
|
def test_mtime_unchanged_returns_false(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
record = _make_record(file_mtime=1000.0)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_mtime = 1000.0
|
|
assert mgr._needs_verification(record, path) is False
|
|
|
|
def test_mtime_tiny_float_difference_returns_false(self, integrity_manager):
|
|
"""Differences within 0.001 threshold should not trigger verification."""
|
|
mgr, _ = integrity_manager
|
|
record = _make_record(file_mtime=1000.0)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_mtime = 1000.0005 # within threshold
|
|
assert mgr._needs_verification(record, path) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _do_verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDoVerification:
|
|
def test_file_missing(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record()
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = False
|
|
passed, reason = mgr._do_verification(record, path, session)
|
|
assert passed is False
|
|
assert reason == "file_missing"
|
|
|
|
def test_no_verifier(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record()
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
# No verifiers registered
|
|
passed, reason = mgr._do_verification(record, path, session)
|
|
assert passed is False
|
|
assert reason == "no_verifier"
|
|
|
|
def test_checksum_match(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="abc123")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(checksum="abc123")
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_mtime = 1234.0
|
|
passed, reason = mgr._do_verification(record, path, session)
|
|
assert passed is True
|
|
assert reason is None
|
|
# mtime should be updated on record
|
|
assert record.file_mtime == 1234.0
|
|
|
|
def test_checksum_mismatch(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="different_checksum")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(checksum="abc123")
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
passed, reason = mgr._do_verification(record, path, session)
|
|
assert passed is False
|
|
assert reason == "checksum_mismatch"
|
|
|
|
def test_checksum_calculation_exception(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier()
|
|
v.calculate_checksum.side_effect = IOError("read error")
|
|
mgr.register_verifier(v)
|
|
record = _make_record()
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
passed, reason = mgr._do_verification(record, path, session)
|
|
assert passed is False
|
|
assert "checksum_calculation_failed" in reason
|
|
assert "read error" in reason
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _update_stats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateStats:
|
|
def test_passed(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(
|
|
total_verifications=5,
|
|
consecutive_successes=2,
|
|
consecutive_failures=1,
|
|
)
|
|
mgr._update_stats(record, True, session)
|
|
assert record.total_verifications == 6
|
|
assert record.consecutive_successes == 3
|
|
assert record.consecutive_failures == 0
|
|
assert record.last_verification_passed is True
|
|
assert record.last_verified_at is not None
|
|
|
|
def test_failed(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(
|
|
total_verifications=10,
|
|
consecutive_successes=5,
|
|
consecutive_failures=0,
|
|
)
|
|
mgr._update_stats(record, False, session)
|
|
assert record.total_verifications == 11
|
|
assert record.consecutive_successes == 0
|
|
assert record.consecutive_failures == 1
|
|
assert record.last_verification_passed is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _log_failure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLogFailure:
|
|
def test_logs_with_existing_file(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="actual_cs")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(id=1, checksum="expected_cs")
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_size = 2048
|
|
# Setup session for cleanup queries
|
|
session.query.return_value.filter_by.return_value.count.return_value = 0
|
|
|
|
mgr._log_failure(record, path, "checksum_mismatch", session)
|
|
|
|
session.add.assert_called_once()
|
|
failure_obj = session.add.call_args[0][0]
|
|
assert failure_obj.expected_checksum == "expected_cs"
|
|
assert failure_obj.actual_checksum == "actual_cs"
|
|
assert failure_obj.file_size == 2048
|
|
assert failure_obj.failure_reason == "checksum_mismatch"
|
|
|
|
def test_logs_with_missing_file(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=1, checksum="expected_cs")
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = False
|
|
session.query.return_value.filter_by.return_value.count.return_value = 0
|
|
|
|
mgr._log_failure(record, path, "file_missing", session)
|
|
|
|
session.add.assert_called_once()
|
|
failure_obj = session.add.call_args[0][0]
|
|
assert failure_obj.actual_checksum is None
|
|
assert failure_obj.file_size is None
|
|
assert failure_obj.failure_reason == "file_missing"
|
|
|
|
def test_logs_with_verifier_exception(self, integrity_manager):
|
|
"""If verifier.calculate_checksum raises, actual_checksum stays None."""
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier()
|
|
v.calculate_checksum.side_effect = IOError("cannot read")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(id=1)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
session.query.return_value.filter_by.return_value.count.return_value = 0
|
|
|
|
mgr._log_failure(record, path, "error", session)
|
|
|
|
failure_obj = session.add.call_args[0][0]
|
|
assert failure_obj.actual_checksum is None
|
|
|
|
def test_triggers_global_cleanup_on_id_mod_100(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=200) # 200 % 100 == 0
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = False
|
|
# Per-file cleanup: count under limit
|
|
session.query.return_value.filter_by.return_value.count.return_value = 0
|
|
# Global cleanup: count under threshold
|
|
session.query.return_value.count.return_value = 5000
|
|
|
|
mgr._log_failure(record, path, "test", session)
|
|
|
|
# count() was called for both per-file and global checks
|
|
assert (
|
|
session.query.return_value.count.called
|
|
or session.query.return_value.filter_by.return_value.count.called
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _cleanup_old_failures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCleanupOldFailures:
|
|
def test_no_cleanup_when_under_limit(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=1)
|
|
session.query.return_value.filter_by.return_value.count.return_value = (
|
|
50
|
|
)
|
|
mgr._cleanup_old_failures(record, session)
|
|
# Should not attempt to delete
|
|
session.delete.assert_not_called()
|
|
|
|
def test_cleanup_when_over_limit(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=1)
|
|
session.query.return_value.filter_by.return_value.count.return_value = (
|
|
110
|
|
)
|
|
old_failures = [MagicMock() for _ in range(10)]
|
|
session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
|
|
|
|
mgr._cleanup_old_failures(record, session)
|
|
|
|
assert session.delete.call_count == 10
|
|
|
|
def test_cleanup_at_exactly_limit(self, integrity_manager):
|
|
"""Exactly at MAX_FAILURES_PER_FILE should not trigger cleanup."""
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=1)
|
|
session.query.return_value.filter_by.return_value.count.return_value = (
|
|
100
|
|
)
|
|
mgr._cleanup_old_failures(record, session)
|
|
session.delete.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _check_global_cleanup_needed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckGlobalCleanupNeeded:
|
|
def test_no_cleanup_under_threshold(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
# threshold = 10000 * 1.2 = 12000
|
|
session.query.return_value.count.return_value = 11000
|
|
mgr._check_global_cleanup_needed(session)
|
|
session.delete.assert_not_called()
|
|
|
|
def test_cleanup_over_threshold(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 13000 # > 12000
|
|
old_failures = [MagicMock() for _ in range(3000)] # 13000 - 10000
|
|
session.query.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
|
|
mgr._check_global_cleanup_needed(session)
|
|
assert session.delete.call_count == 3000
|
|
|
|
def test_exactly_at_threshold_no_cleanup(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 12000 # == threshold
|
|
mgr._check_global_cleanup_needed(session)
|
|
session.delete.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: record_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRecordFile:
|
|
def test_new_file(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(
|
|
checksum="newcs", file_type="faiss_index", algorithm="sha256"
|
|
)
|
|
mgr.register_verifier(v)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/new_file")
|
|
path.stat.return_value.st_size = 4096
|
|
path.stat.return_value.st_mtime = 2000.0
|
|
# No existing record
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
None
|
|
)
|
|
|
|
mgr.record_file(
|
|
path, related_entity_type="rag_index", related_entity_id=42
|
|
)
|
|
|
|
session.add.assert_called_once()
|
|
session.commit.assert_called_once()
|
|
session.refresh.assert_called_once()
|
|
|
|
def test_existing_file_updates(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="updatedcs")
|
|
mgr.register_verifier(v)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/existing")
|
|
path.stat.return_value.st_size = 8192
|
|
path.stat.return_value.st_mtime = 3000.0
|
|
existing_record = _make_record()
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
existing_record
|
|
)
|
|
|
|
mgr.record_file(path)
|
|
|
|
assert existing_record.checksum == "updatedcs"
|
|
assert existing_record.file_size == 8192
|
|
assert existing_record.file_mtime == 3000.0
|
|
session.commit.assert_called_once()
|
|
# session.add should NOT be called for updates
|
|
session.add.assert_not_called()
|
|
|
|
def test_file_not_found(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = False
|
|
with pytest.raises(FileNotFoundError, match="File not found"):
|
|
mgr.record_file(path)
|
|
|
|
def test_no_verifier(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
# No verifiers registered
|
|
with pytest.raises(ValueError, match="No verifier registered"):
|
|
mgr.record_file(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: verify_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVerifyFile:
|
|
def test_no_record_rejects_file(self, integrity_manager):
|
|
# Reject-unknown: a file with no integrity record must fail closed
|
|
# rather than being auto-registered (trust-on-first-use gap). The
|
|
# security property is that record_file is NOT called on this path.
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="cs")
|
|
mgr.register_verifier(v)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/path")
|
|
path.stat.return_value.st_size = 100
|
|
path.stat.return_value.st_mtime = 500.0
|
|
# No record found -> must be rejected, not auto-created
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
None
|
|
)
|
|
|
|
with patch.object(mgr, "record_file") as mock_record:
|
|
passed, reason = mgr.verify_file(path)
|
|
|
|
assert passed is False
|
|
assert reason == "no_integrity_record"
|
|
mock_record.assert_not_called()
|
|
|
|
def test_no_record_rejects_even_without_verifier(self, integrity_manager):
|
|
# Rejection happens before any verifier lookup, so a missing record
|
|
# is rejected identically whether or not a verifier is registered.
|
|
mgr, session = integrity_manager
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/path")
|
|
# No record found, no verifiers registered
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
None
|
|
)
|
|
|
|
with patch.object(mgr, "record_file") as mock_record:
|
|
passed, reason = mgr.verify_file(path)
|
|
|
|
assert passed is False
|
|
assert reason == "no_integrity_record"
|
|
mock_record.assert_not_called()
|
|
|
|
def test_skips_if_unchanged(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(file_mtime=1000.0)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/path")
|
|
path.stat.return_value.st_mtime = 1000.0 # unchanged
|
|
|
|
passed, reason = mgr.verify_file(path)
|
|
|
|
assert passed is True
|
|
assert reason is None
|
|
|
|
def test_verify_passes(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="abc123")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(
|
|
checksum="abc123",
|
|
file_mtime=1000.0,
|
|
total_verifications=5,
|
|
consecutive_successes=2,
|
|
consecutive_failures=0,
|
|
)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/path")
|
|
path.stat.return_value.st_mtime = (
|
|
2000.0 # changed, triggers verification
|
|
)
|
|
|
|
passed, reason = mgr.verify_file(path, force=True)
|
|
|
|
assert passed is True
|
|
assert reason is None
|
|
session.commit.assert_called()
|
|
|
|
def test_verify_fails(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="wrong")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(
|
|
id=1,
|
|
checksum="abc123",
|
|
file_mtime=1000.0,
|
|
total_verifications=5,
|
|
consecutive_successes=2,
|
|
consecutive_failures=0,
|
|
)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
# Per-file cleanup count
|
|
session.query.return_value.filter_by.return_value.count.return_value = 0
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/path")
|
|
path.stat.return_value.st_mtime = 2000.0
|
|
path.stat.return_value.st_size = 1024
|
|
|
|
passed, reason = mgr.verify_file(path, force=True)
|
|
|
|
assert passed is False
|
|
assert reason == "checksum_mismatch"
|
|
session.commit.assert_called()
|
|
|
|
def test_force_overrides_skip(self, integrity_manager):
|
|
"""force=True should verify even when mtime unchanged."""
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="abc123")
|
|
mgr.register_verifier(v)
|
|
record = _make_record(checksum="abc123", file_mtime=1000.0)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.resolve.return_value = Path("/resolved/path")
|
|
path.stat.return_value.st_mtime = 1000.0 # unchanged
|
|
|
|
passed, reason = mgr.verify_file(path, force=True)
|
|
|
|
assert passed is True
|
|
# Should have called _do_verification (verifier was used)
|
|
v.calculate_checksum.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: update_checksum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateChecksum:
|
|
def test_success(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="newcs")
|
|
mgr.register_verifier(v)
|
|
record = _make_record()
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_size = 5000
|
|
path.stat.return_value.st_mtime = 9000.0
|
|
|
|
mgr.update_checksum(path)
|
|
|
|
assert record.checksum == "newcs"
|
|
assert record.file_size == 5000
|
|
assert record.file_mtime == 9000.0
|
|
session.commit.assert_called_once()
|
|
|
|
def test_file_not_found(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = False
|
|
with pytest.raises(FileNotFoundError):
|
|
mgr.update_checksum(path)
|
|
|
|
def test_no_verifier(self, integrity_manager):
|
|
mgr, _ = integrity_manager
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
with pytest.raises(ValueError, match="No verifier registered"):
|
|
mgr.update_checksum(path)
|
|
|
|
def test_no_record(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
v = _make_verifier(checksum="cs")
|
|
mgr.register_verifier(v)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
None
|
|
)
|
|
path = MagicMock(spec=Path)
|
|
path.exists.return_value = True
|
|
path.stat.return_value.st_size = 100
|
|
path.stat.return_value.st_mtime = 100.0
|
|
with pytest.raises(ValueError, match="No integrity record exists"):
|
|
mgr.update_checksum(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: get_file_stats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetFileStats:
|
|
def test_with_record(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(
|
|
total_verifications=10,
|
|
last_verified_at=datetime(2025, 6, 1, tzinfo=UTC),
|
|
last_verification_passed=True,
|
|
consecutive_successes=5,
|
|
consecutive_failures=0,
|
|
file_type="faiss_index",
|
|
created_at=datetime(2025, 1, 1, tzinfo=UTC),
|
|
)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
|
|
result = mgr.get_file_stats(Path("/some/file"))
|
|
|
|
assert result is not None
|
|
assert result["total_verifications"] == 10
|
|
assert result["consecutive_successes"] == 5
|
|
assert result["consecutive_failures"] == 0
|
|
assert result["file_type"] == "faiss_index"
|
|
assert result["last_verification_passed"] is True
|
|
|
|
def test_without_record(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
None
|
|
)
|
|
|
|
result = mgr.get_file_stats(Path("/nonexistent"))
|
|
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: get_failure_history
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetFailureHistory:
|
|
def test_with_records(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=1)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
failures = [MagicMock(), MagicMock()]
|
|
session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = failures
|
|
|
|
result = mgr.get_failure_history(Path("/some/file"), limit=50)
|
|
|
|
assert len(result) == 2
|
|
# Each failure should be expunged from session
|
|
assert session.expunge.call_count == 2
|
|
|
|
def test_without_record(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
None
|
|
)
|
|
|
|
result = mgr.get_failure_history(Path("/no/record"))
|
|
|
|
assert result == []
|
|
|
|
def test_default_limit(self, integrity_manager):
|
|
"""Default limit should be 100."""
|
|
mgr, session = integrity_manager
|
|
record = _make_record(id=1)
|
|
session.query.return_value.filter_by.return_value.first.return_value = (
|
|
record
|
|
)
|
|
session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = []
|
|
|
|
mgr.get_failure_history(Path("/file"))
|
|
|
|
# Verify limit was called via the chain
|
|
session.query.return_value.filter_by.return_value.order_by.return_value.limit.assert_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: cleanup_all_old_failures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCleanupAllOldFailures:
|
|
def test_under_limit(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 5000 # under 10000
|
|
|
|
result = mgr.cleanup_all_old_failures()
|
|
|
|
assert result == 0
|
|
session.delete.assert_not_called()
|
|
|
|
def test_over_limit(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 10500
|
|
old_failures = [MagicMock() for _ in range(500)]
|
|
session.query.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
|
|
|
|
result = mgr.cleanup_all_old_failures()
|
|
|
|
assert result == 500
|
|
assert session.delete.call_count == 500
|
|
session.commit.assert_called()
|
|
|
|
def test_exactly_at_limit(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 10000
|
|
|
|
result = mgr.cleanup_all_old_failures()
|
|
|
|
assert result == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: get_total_failure_count
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetTotalFailureCount:
|
|
def test_returns_count(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 42
|
|
|
|
result = mgr.get_total_failure_count()
|
|
|
|
assert result == 42
|
|
|
|
def test_returns_zero(self, integrity_manager):
|
|
mgr, session = integrity_manager
|
|
session.query.return_value.count.return_value = 0
|
|
|
|
result = mgr.get_total_failure_count()
|
|
|
|
assert result == 0
|