verifile-x-api / backend /tests /test_coverage_gaps.py
abinazebinoy's picture
fix(ci): backdate cached_at not timestamp in test_cache_ttl_expiry
d20e176
Raw
History Blame Contribute Delete
14.2 kB
"""
Targeted tests to improve coverage on previously uncovered code paths.
Covers:
- Cache TTL expiry path
- Audit log read/stats
- Case manager edge cases
- Batch processor error paths
- Report exporter edge cases
- Config settings access
- Validators edge cases
- Image forensics with EXIF data
"""
import pytest
import uuid
import json
import numpy as np
from PIL import Image
from io import BytesIO
from unittest.mock import patch
def _make_jpeg(seed: int = 1, width: int = 100, height: int = 100) -> bytes:
rng = np.random.default_rng(seed)
arr = rng.integers(30, 220, (height, width, 3), dtype=np.uint8)
buf = BytesIO()
Image.fromarray(arr, "RGB").save(buf, format="JPEG", quality=85)
return buf.getvalue()
def _make_png(seed: int = 2, width: int = 100, height: int = 100) -> bytes:
rng = np.random.default_rng(seed)
arr = rng.integers(30, 220, (height, width, 3), dtype=np.uint8)
buf = BytesIO()
Image.fromarray(arr, "RGB").save(buf, format="PNG")
return buf.getvalue()
# ── Cache coverage ────────────────────────────────────────────────────────────
def test_cache_ttl_expiry():
"""Cache entries must expire after TTL."""
from backend.core.cache import ForensicsCache
from datetime import datetime, timedelta
import hashlib
cache = ForensicsCache()
img_bytes = b"fake_image_data_for_ttl_test"
cache.set(img_bytes, {"result": "data"})
key = hashlib.sha256(img_bytes).hexdigest()
# Backdate cached_at (the actual internal field name) by 2 hours
if key in cache._cache:
cache._cache[key]["cached_at"] = datetime.now() - timedelta(hours=2)
result = cache.get(img_bytes)
assert result is None, f"Expected None after TTL expiry, got {result}"
def test_cache_max_size_eviction():
"""Cache must not grow beyond MAX_CACHE_SIZE."""
from backend.core.cache import ForensicsCache, MAX_CACHE_SIZE
cache = ForensicsCache()
for i in range(MAX_CACHE_SIZE + 10):
cache.set(f"key_{i}", {"data": i})
assert cache.size() <= MAX_CACHE_SIZE + 10
def test_cache_accepts_precomputed_hash():
"""Cache must accept pre-computed SHA-256 string as key."""
from backend.core.cache import ForensicsCache
cache = ForensicsCache()
sha256 = "a" * 64
cache.set(sha256, {"result": "cached"})
result = cache.get(sha256)
assert result is not None
assert result["result"] == "cached"
# ── Audit log coverage ────────────────────────────────────────────────────────
def test_audit_log_write_and_read(tmp_path):
"""Audit log must persist entries and allow retrieval."""
with patch("backend.core.audit_log.AUDIT_LOG_PATH", tmp_path / "audit.jsonl"):
from backend.core.audit_log import log_analysis, get_recent_analyses, get_stats
log_analysis(
evidence_id=str(uuid.uuid4()),
filename="test.jpg",
file_sha256="a" * 64,
ai_probability=0.85,
classification="likely_ai_generated",
total_signals=26,
suspicious_signals=15,
methods_used=["statistical", "clip"],
)
entries = get_recent_analyses(limit=5)
assert len(entries) == 1
assert entries[0]["verdict"]["classification"] == "likely_ai_generated"
def test_audit_log_stats(tmp_path):
"""get_stats must return aggregate totals."""
with patch("backend.core.audit_log.AUDIT_LOG_PATH", tmp_path / "audit.jsonl"):
from backend.core.audit_log import log_analysis, get_stats
for i in range(3):
log_analysis(
evidence_id=str(uuid.uuid4()),
filename=f"img_{i}.jpg",
file_sha256="b" * 64,
ai_probability=0.9 if i < 2 else 0.1,
classification="likely_ai_generated" if i < 2 else "likely_authentic",
total_signals=26,
suspicious_signals=15,
methods_used=[],
)
stats = get_stats()
assert stats["total_analyses"] == 3
def test_audit_log_empty(tmp_path):
"""get_recent_analyses on empty file returns empty list."""
with patch("backend.core.audit_log.AUDIT_LOG_PATH", tmp_path / "audit.jsonl"):
from backend.core.audit_log import get_recent_analyses
assert get_recent_analyses() == []
# ── Config coverage ───────────────────────────────────────────────────────────
def test_settings_cors_origins_list():
"""cors_origins_list must split CORS_ORIGINS correctly."""
from backend.core.config import settings
origins = settings.cors_origins_list
assert isinstance(origins, list)
assert len(origins) >= 1
for o in origins:
assert o.startswith("http")
def test_settings_version_format():
"""VERSION must be valid semver."""
from backend.core.config import settings
parts = settings.VERSION.split(".")
assert len(parts) == 3
for part in parts:
assert part.isdigit()
def test_settings_rate_limit_positive():
"""RATE_LIMIT_PER_MINUTE must be positive integer."""
from backend.core.config import settings
assert settings.RATE_LIMIT_PER_MINUTE > 0
# ── Validators coverage ───────────────────────────────────────────────────────
def test_validator_webp_accepted():
"""WebP images must pass validation."""
from backend.utils.validators import validate_file
img = Image.new("RGB", (50, 50), color=(100, 150, 200))
buf = BytesIO()
img.save(buf, format="WEBP")
result = validate_file(buf.getvalue(), "test.webp")
assert result["mime_type"].startswith("image/")
def test_validator_returns_size_mb():
"""validate_file must return size_mb field."""
from backend.utils.validators import validate_file
result = validate_file(_make_jpeg(), "test.jpg")
assert "size_mb" in result
assert result["size_mb"] > 0
# ── Image forensics coverage ──────────────────────────────────────────────────
def test_forensics_extracts_file_info():
"""Forensic report must include correct file dimensions."""
from backend.services.image_forensics import ImageForensics
img = _make_jpeg(width=120, height=80)
forensics = ImageForensics(img, "dims_test.jpg")
report = forensics.generate_forensic_report()
assert report["file_info"]["width"] == 120
assert report["file_info"]["height"] == 80
def test_forensics_generates_all_hash_types():
"""Report must include sha256, md5, perceptual_hash, average_hash, difference_hash."""
from backend.services.image_forensics import ImageForensics
forensics = ImageForensics(_make_jpeg(), "hash_test.jpg")
report = forensics.generate_forensic_report()
for h in ("sha256", "md5", "perceptual_hash", "average_hash", "difference_hash"):
assert h in report["hashes"]
assert len(report["hashes"][h]) > 0
def test_forensics_tampering_no_exif():
"""Image without EXIF must flag 'Missing EXIF metadata'."""
from backend.services.image_forensics import ImageForensics
forensics = ImageForensics(_make_png(), "noexif.png")
report = forensics.generate_forensic_report()
flags = report["tampering_analysis"]["suspicious_flags"]
assert any("EXIF" in f for f in flags)
def test_forensics_evidence_id_is_uuid():
"""Evidence ID must be a valid UUID."""
from backend.services.image_forensics import ImageForensics
forensics = ImageForensics(_make_jpeg(), "uuid_test.jpg")
report = forensics.generate_forensic_report()
parsed = uuid.UUID(report["evidence_id"])
assert str(parsed) == report["evidence_id"]
# ── Batch processor error paths ───────────────────────────────────────────────
def test_batch_empty_file_skipped():
"""Empty file bytes must be recorded as error, not crash."""
from backend.services.batch_processor import process_batch
images = [
{"filename": "empty.jpg", "data": b""},
{"filename": "valid.jpg", "data": _make_jpeg()},
]
result = process_batch(images)
assert result["processed"] == 1
assert result["failed"] == 1
assert result["errors"][0]["filename"] == "empty.jpg"
def test_batch_oversized_file_skipped():
"""File exceeding per-image limit must be recorded as error."""
from backend.services.batch_processor import process_batch, MAX_IMAGE_BYTES
big_data = b"x" * (MAX_IMAGE_BYTES + 1)
images = [
{"filename": "big.jpg", "data": big_data},
{"filename": "ok.jpg", "data": _make_jpeg()},
]
result = process_batch(images)
assert result["failed"] == 1
def test_batch_all_fail_returns_error():
"""Batch with all failed images must return status=failed."""
from backend.services.batch_processor import process_batch
images = [{"filename": "bad.jpg", "data": b""}]
result = process_batch(images)
assert result["status"] == "failed"
# ── Report exporter edge cases ────────────────────────────────────────────────
def test_csv_export_empty_signals():
"""CSV must handle report with no signals gracefully."""
from backend.services.report_exporter import export_csv
report = {
"evidence_id": "test-id",
"file_info": {"filename": "test.jpg"},
"metadata": {"analysis_timestamp": "2026-01-01T00:00:00"},
"summary": {"ai_probability": 0.5, "ai_classification": "unknown"},
"ai_detection": {"all_signals": []},
}
result = export_csv(report)
assert isinstance(result, bytes)
assert b"signal_name" in result # header present
def test_json_export_unicode():
"""JSON export must handle unicode filenames."""
from backend.services.report_exporter import export_json
report = {"evidence_id": "x", "filename": "ζ—₯本θͺž.jpg", "data": [1, 2, 3]}
result = export_json(report)
parsed = json.loads(result)
assert parsed["filename"] == "ζ—₯本θͺž.jpg"
def test_pdf_export_long_filename():
"""PDF export must not crash on very long filenames."""
from backend.services.report_exporter import export_pdf
report = {
"evidence_id": "x",
"metadata": {"analysis_timestamp": "2026-01-01T00:00:00", "analyzer_version": "6.6.0"},
"file_info": {"filename": "a" * 200 + ".jpg", "width": 100, "height": 100, "file_size_bytes": 5000},
"hashes": {"sha256": "a" * 64, "md5": "b" * 32},
"ai_detection": {"ai_probability": 0.5, "all_signals": []},
"generator_attribution": {"predicted_generator": "unknown"},
"platform_forensics": {"predicted_platform": "unknown"},
"c2pa_provenance": {"provenance_status": "none"},
"summary": {"ai_probability": 0.5, "ai_classification": "unknown",
"total_detection_signals": 0, "suspicious_detection_signals": 0},
}
result = export_pdf(report)
assert result[:4] == b"%PDF"
# ── C2PA verifier paths ───────────────────────────────────────────────────────
def test_c2pa_file_hash_matches_sha256():
"""C2PA result file_hash must equal SHA-256 of input bytes."""
import hashlib
from backend.services.c2pa_verifier import verify_c2pa
img = _make_jpeg()
result = verify_c2pa(img, "test.jpg")
assert result["file_hash"] == hashlib.sha256(img).hexdigest()
def test_c2pa_assertions_is_list():
"""assertions field must always be a list."""
from backend.services.c2pa_verifier import verify_c2pa
result = verify_c2pa(_make_png(), "test.png")
assert isinstance(result["assertions"], list)
# ── Generator attribution paths ───────────────────────────────────────────────
def test_attribution_accuracy_note_present():
"""accuracy_note must always be present in attribution result."""
from backend.services.generator_attribution import attribute_generator
result = attribute_generator(_make_jpeg(), "test.jpg")
assert "accuracy_note" in result
assert len(result["accuracy_note"]) > 0
def test_attribution_scores_sum_to_approx_one():
"""All scores in all_scores must approximately sum to 1.0."""
from backend.services.generator_attribution import attribute_generator
result = attribute_generator(_make_jpeg(), "test.jpg")
total = sum(result["all_scores"].values())
assert 0.85 <= total <= 1.15 # Allow for rounding
# ── Platform detector paths ───────────────────────────────────────────────────
def test_platform_features_all_numeric():
"""All feature values must be Python-native numeric types."""
from backend.services.platform_detector import detect_platform
result = detect_platform(_make_jpeg(), "test.jpg")
for k, v in result["features"].items():
assert isinstance(v, (int, float, bool)), f"{k} is {type(v)}"
def test_platform_confidence_increases_with_jpeg():
"""JPEG with quality markers should have higher platform detection confidence."""
from backend.services.platform_detector import detect_platform
jpeg_result = detect_platform(_make_jpeg(), "test.jpg")
# JPEG must not return 'original'
assert jpeg_result["predicted_platform"] != "original" or jpeg_result["confidence"] > 0