Files
learningcircuit--local-deep…/tests/security/test_file_integrity_manager_coverage.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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