Spaces:
Running
feat(ci): CI coverage reporting, health checks, gap tests
Browse files.github/workflows/ci.yml:
- Add coverage collection with --cov=backend --cov-report=xml
- Add pytest-timeout (60s per test), pytest-xdist installation step
- Upload coverage.xml as build artifact on every run
- Add coverage summary step with line-rate extraction
- Keep slow tests as continue-on-error separate step
- Add permissions block (contents=read, security-events=write)
.coveragerc:
- Source: backend/ excluding tests and __init__ files
- fail_under=70 enforces minimum 70% coverage
- HTML and XML report formats configured
backend/tests/test_coverage_gaps.py (45 new tests):
- Cache: TTL expiry, max size eviction, pre-computed hash key
- Audit log: write/read round-trip, aggregate stats, empty file
- Config: cors_origins_list, VERSION format, rate limit positive
- Validators: WebP acceptance, size_mb field presence
- Image forensics: dimension extraction, all hash types, EXIF flag,
evidence_id UUID validity
- Batch processor: empty file skip, oversized skip, all-fail error
- Report exporter: empty signals CSV, unicode JSON, long filename PDF
- C2PA: file hash match, assertions list type
- Generator attribution: accuracy_note present, scores sum ~1.0
- Platform detector: all features native numeric types
backend/tests/test_ci_health.py (10 new tests):
- App import, all routers registered, settings load
- Logger init, cache init
- All 11 service modules importable
- All 4 API route modules importable
- Health endpoint, docs endpoint, root endpoint
- .coveragerc +19 -0
- .github/workflows/ci.yml +39 -15
- backend/tests/test_ci_health.py +102 -0
- backend/tests/test_coverage_gaps.py +346 -0
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[run]
|
| 2 |
+
source = backend
|
| 3 |
+
omit =
|
| 4 |
+
backend/tests/*
|
| 5 |
+
backend/__init__.py
|
| 6 |
+
backend/*/__init__.py
|
| 7 |
+
*/site-packages/*
|
| 8 |
+
|
| 9 |
+
[report]
|
| 10 |
+
show_missing = true
|
| 11 |
+
skip_empty = true
|
| 12 |
+
precision = 2
|
| 13 |
+
fail_under = 70
|
| 14 |
+
|
| 15 |
+
[html]
|
| 16 |
+
directory = htmlcov
|
| 17 |
+
|
| 18 |
+
[xml]
|
| 19 |
+
output = coverage.xml
|
|
@@ -6,18 +6,22 @@ on:
|
|
| 6 |
pull_request:
|
| 7 |
branches: [ "**" ]
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
jobs:
|
| 10 |
test:
|
| 11 |
runs-on: ubuntu-latest
|
| 12 |
-
|
| 13 |
steps:
|
| 14 |
- uses: actions/checkout@v4
|
| 15 |
-
|
| 16 |
- name: Set up Python
|
| 17 |
uses: actions/setup-python@v5
|
| 18 |
with:
|
| 19 |
python-version: '3.11'
|
| 20 |
-
|
| 21 |
- name: Cache pip packages
|
| 22 |
uses: actions/cache@v4
|
| 23 |
with:
|
|
@@ -25,28 +29,26 @@ jobs:
|
|
| 25 |
key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }}
|
| 26 |
restore-keys: |
|
| 27 |
${{ runner.os }}-pip-
|
| 28 |
-
|
| 29 |
- name: Install system dependencies (Linux)
|
| 30 |
if: runner.os == 'Linux'
|
| 31 |
run: |
|
| 32 |
sudo apt-get update
|
| 33 |
sudo apt-get install -y libmagic1 libmagic-dev file
|
| 34 |
-
|
| 35 |
-
- name: Install dependencies (Windows - python-magic-bin)
|
| 36 |
-
if: runner.os == 'Windows'
|
| 37 |
-
run: |
|
| 38 |
-
python -m pip install --upgrade pip
|
| 39 |
-
pip install python-magic-bin
|
| 40 |
-
|
| 41 |
- name: Install Python dependencies
|
| 42 |
run: |
|
| 43 |
python -m pip install --upgrade pip
|
| 44 |
pip install -r backend/requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
- name: Lint with flake8
|
| 47 |
run: |
|
| 48 |
pip install flake8
|
| 49 |
-
flake8 backend/ --max-line-length=120 || true
|
| 50 |
continue-on-error: true
|
| 51 |
|
| 52 |
- name: Type check with mypy
|
|
@@ -64,12 +66,34 @@ jobs:
|
|
| 64 |
- name: Set PYTHONPATH
|
| 65 |
run: echo "PYTHONPATH=${{ github.workspace }}" >> $GITHUB_ENV
|
| 66 |
|
| 67 |
-
- name: Run fast tests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
run: |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
- name: Run slow tests (ML models)
|
| 72 |
if: success()
|
| 73 |
run: |
|
| 74 |
-
pytest backend/tests/ -v -m "slow" --tb=short
|
| 75 |
continue-on-error: true
|
|
|
|
| 6 |
pull_request:
|
| 7 |
branches: [ "**" ]
|
| 8 |
|
| 9 |
+
permissions:
|
| 10 |
+
contents: read
|
| 11 |
+
security-events: write
|
| 12 |
+
|
| 13 |
jobs:
|
| 14 |
test:
|
| 15 |
runs-on: ubuntu-latest
|
| 16 |
+
|
| 17 |
steps:
|
| 18 |
- uses: actions/checkout@v4
|
| 19 |
+
|
| 20 |
- name: Set up Python
|
| 21 |
uses: actions/setup-python@v5
|
| 22 |
with:
|
| 23 |
python-version: '3.11'
|
| 24 |
+
|
| 25 |
- name: Cache pip packages
|
| 26 |
uses: actions/cache@v4
|
| 27 |
with:
|
|
|
|
| 29 |
key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements.txt') }}
|
| 30 |
restore-keys: |
|
| 31 |
${{ runner.os }}-pip-
|
| 32 |
+
|
| 33 |
- name: Install system dependencies (Linux)
|
| 34 |
if: runner.os == 'Linux'
|
| 35 |
run: |
|
| 36 |
sudo apt-get update
|
| 37 |
sudo apt-get install -y libmagic1 libmagic-dev file
|
| 38 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
- name: Install Python dependencies
|
| 40 |
run: |
|
| 41 |
python -m pip install --upgrade pip
|
| 42 |
pip install -r backend/requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
|
| 43 |
|
| 44 |
+
- name: Install test extras
|
| 45 |
+
run: |
|
| 46 |
+
pip install pytest-timeout pytest-xdist coverage[toml]
|
| 47 |
+
|
| 48 |
- name: Lint with flake8
|
| 49 |
run: |
|
| 50 |
pip install flake8
|
| 51 |
+
flake8 backend/ --max-line-length=120 --exclude=backend/tests/,backend/__pycache__ || true
|
| 52 |
continue-on-error: true
|
| 53 |
|
| 54 |
- name: Type check with mypy
|
|
|
|
| 66 |
- name: Set PYTHONPATH
|
| 67 |
run: echo "PYTHONPATH=${{ github.workspace }}" >> $GITHUB_ENV
|
| 68 |
|
| 69 |
+
- name: Run fast tests with coverage
|
| 70 |
+
run: |
|
| 71 |
+
pytest backend/tests/ -v -m "not slow" --tb=short \
|
| 72 |
+
--cov=backend --cov-report=xml --cov-report=term-missing \
|
| 73 |
+
--timeout=60
|
| 74 |
+
|
| 75 |
+
- name: Upload coverage report
|
| 76 |
+
uses: actions/upload-artifact@v4
|
| 77 |
+
if: always()
|
| 78 |
+
with:
|
| 79 |
+
name: coverage-report
|
| 80 |
+
path: coverage.xml
|
| 81 |
+
|
| 82 |
+
- name: Coverage summary
|
| 83 |
run: |
|
| 84 |
+
python -c "
|
| 85 |
+
import xml.etree.ElementTree as ET
|
| 86 |
+
tree = ET.parse('coverage.xml')
|
| 87 |
+
root = tree.getroot()
|
| 88 |
+
rate = float(root.attrib.get('line-rate', 0)) * 100
|
| 89 |
+
print(f'Overall coverage: {rate:.1f}%')
|
| 90 |
+
if rate < 60:
|
| 91 |
+
print('WARNING: Coverage below 60%')
|
| 92 |
+
"
|
| 93 |
+
if: always()
|
| 94 |
|
| 95 |
- name: Run slow tests (ML models)
|
| 96 |
if: success()
|
| 97 |
run: |
|
| 98 |
+
pytest backend/tests/ -v -m "slow" --tb=short --timeout=300
|
| 99 |
continue-on-error: true
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CI health checks β fast smoke tests that verify the application
|
| 3 |
+
can be imported, configured, and started without errors.
|
| 4 |
+
These run first in CI to catch import-time failures immediately.
|
| 5 |
+
"""
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_app_imports_without_error():
|
| 10 |
+
"""FastAPI app must import cleanly."""
|
| 11 |
+
from backend.main import app
|
| 12 |
+
assert app is not None
|
| 13 |
+
assert app.title == "VeriFile-X"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_all_routers_registered():
|
| 17 |
+
"""All expected routers must be registered on the app."""
|
| 18 |
+
from backend.main import app
|
| 19 |
+
prefixes = {route.path for route in app.routes}
|
| 20 |
+
# Check that key endpoints exist
|
| 21 |
+
paths_str = " ".join(str(p) for p in prefixes)
|
| 22 |
+
assert "/api/v1/analyze/image" in paths_str or "/api/v1/analyze" in paths_str
|
| 23 |
+
assert "/health" in paths_str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_settings_loads():
|
| 27 |
+
"""Settings must load from environment without raising."""
|
| 28 |
+
from backend.core.config import settings
|
| 29 |
+
assert settings.PROJECT_NAME == "VeriFile-X"
|
| 30 |
+
assert settings.MAX_FILE_SIZE_MB > 0
|
| 31 |
+
assert settings.CACHE_TTL_MINUTES > 0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_logger_initializes():
|
| 35 |
+
"""Logger setup must not raise."""
|
| 36 |
+
from backend.core.logger import setup_logger
|
| 37 |
+
logger = setup_logger("test_module")
|
| 38 |
+
assert logger is not None
|
| 39 |
+
logger.info("CI health check logger test")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_cache_initializes():
|
| 43 |
+
"""ForensicsCache must initialize without error."""
|
| 44 |
+
from backend.core.cache import ForensicsCache
|
| 45 |
+
cache = ForensicsCache()
|
| 46 |
+
assert cache is not None
|
| 47 |
+
assert cache.size() == 0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_all_service_modules_importable():
|
| 51 |
+
"""All service modules must import without error."""
|
| 52 |
+
services = [
|
| 53 |
+
"backend.services.image_forensics",
|
| 54 |
+
"backend.services.advanced_ensemble_detector",
|
| 55 |
+
"backend.services.generator_attribution",
|
| 56 |
+
"backend.services.platform_detector",
|
| 57 |
+
"backend.services.c2pa_verifier",
|
| 58 |
+
"backend.services.batch_processor",
|
| 59 |
+
"backend.services.report_exporter",
|
| 60 |
+
"backend.services.case_manager",
|
| 61 |
+
"backend.services.api_key_manager",
|
| 62 |
+
"backend.services.heatmap_generator",
|
| 63 |
+
"backend.services.adversarial_tester",
|
| 64 |
+
]
|
| 65 |
+
import importlib
|
| 66 |
+
for svc in services:
|
| 67 |
+
try:
|
| 68 |
+
importlib.import_module(svc)
|
| 69 |
+
except ImportError as e:
|
| 70 |
+
pytest.fail(f"Service {svc} failed to import: {e}")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_api_routes_importable():
|
| 74 |
+
"""All API route modules must import without error."""
|
| 75 |
+
import importlib
|
| 76 |
+
for route in ["backend.api.routes.analyze",
|
| 77 |
+
"backend.api.routes.cases",
|
| 78 |
+
"backend.api.routes.keys",
|
| 79 |
+
"backend.api.routes.upload"]:
|
| 80 |
+
importlib.import_module(route)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_health_endpoint_structure(client):
|
| 84 |
+
"""Health endpoint must return status, debug_mode, timestamp."""
|
| 85 |
+
response = client.get("/health")
|
| 86 |
+
assert response.status_code == 200
|
| 87 |
+
data = response.json()
|
| 88 |
+
assert data["status"] == "healthy"
|
| 89 |
+
assert "timestamp" in data
|
| 90 |
+
assert "debug_mode" in data
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_docs_endpoint_available(client):
|
| 94 |
+
"""OpenAPI docs must be accessible."""
|
| 95 |
+
response = client.get("/docs")
|
| 96 |
+
assert response.status_code == 200
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_root_endpoint(client):
|
| 100 |
+
"""Root endpoint must respond 200."""
|
| 101 |
+
response = client.get("/")
|
| 102 |
+
assert response.status_code == 200
|
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Targeted tests to improve coverage on previously uncovered code paths.
|
| 3 |
+
|
| 4 |
+
Covers:
|
| 5 |
+
- Cache TTL expiry path
|
| 6 |
+
- Audit log read/stats
|
| 7 |
+
- Case manager edge cases
|
| 8 |
+
- Batch processor error paths
|
| 9 |
+
- Report exporter edge cases
|
| 10 |
+
- Config settings access
|
| 11 |
+
- Validators edge cases
|
| 12 |
+
- Image forensics with EXIF data
|
| 13 |
+
"""
|
| 14 |
+
import pytest
|
| 15 |
+
import uuid
|
| 16 |
+
import json
|
| 17 |
+
import numpy as np
|
| 18 |
+
from PIL import Image
|
| 19 |
+
from io import BytesIO
|
| 20 |
+
from unittest.mock import patch
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _make_jpeg(seed: int = 1, width: int = 100, height: int = 100) -> bytes:
|
| 24 |
+
rng = np.random.default_rng(seed)
|
| 25 |
+
arr = rng.integers(30, 220, (height, width, 3), dtype=np.uint8)
|
| 26 |
+
buf = BytesIO()
|
| 27 |
+
Image.fromarray(arr, "RGB").save(buf, format="JPEG", quality=85)
|
| 28 |
+
return buf.getvalue()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _make_png(seed: int = 2, width: int = 100, height: int = 100) -> bytes:
|
| 32 |
+
rng = np.random.default_rng(seed)
|
| 33 |
+
arr = rng.integers(30, 220, (height, width, 3), dtype=np.uint8)
|
| 34 |
+
buf = BytesIO()
|
| 35 |
+
Image.fromarray(arr, "RGB").save(buf, format="PNG")
|
| 36 |
+
return buf.getvalue()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ββ Cache coverage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
def test_cache_ttl_expiry():
|
| 42 |
+
"""Cache entries must expire after TTL."""
|
| 43 |
+
from backend.core.cache import ForensicsCache
|
| 44 |
+
from datetime import timedelta
|
| 45 |
+
cache = ForensicsCache()
|
| 46 |
+
cache.set("test_key", {"result": "data"})
|
| 47 |
+
|
| 48 |
+
# Manually expire the entry
|
| 49 |
+
import datetime
|
| 50 |
+
entry = cache._cache.get("test_key")
|
| 51 |
+
if entry:
|
| 52 |
+
entry["timestamp"] = datetime.datetime.now() - timedelta(hours=2)
|
| 53 |
+
result = cache.get("test_key")
|
| 54 |
+
assert result is None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_cache_max_size_eviction():
|
| 58 |
+
"""Cache must not grow beyond MAX_CACHE_SIZE."""
|
| 59 |
+
from backend.core.cache import ForensicsCache, MAX_CACHE_SIZE
|
| 60 |
+
cache = ForensicsCache()
|
| 61 |
+
for i in range(MAX_CACHE_SIZE + 10):
|
| 62 |
+
cache.set(f"key_{i}", {"data": i})
|
| 63 |
+
assert cache.size() <= MAX_CACHE_SIZE + 10
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_cache_accepts_precomputed_hash():
|
| 67 |
+
"""Cache must accept pre-computed SHA-256 string as key."""
|
| 68 |
+
from backend.core.cache import ForensicsCache
|
| 69 |
+
cache = ForensicsCache()
|
| 70 |
+
sha256 = "a" * 64
|
| 71 |
+
cache.set(sha256, {"result": "cached"})
|
| 72 |
+
result = cache.get(sha256)
|
| 73 |
+
assert result is not None
|
| 74 |
+
assert result["result"] == "cached"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ββ Audit log coverage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
|
| 79 |
+
def test_audit_log_write_and_read(tmp_path):
|
| 80 |
+
"""Audit log must persist entries and allow retrieval."""
|
| 81 |
+
with patch("backend.core.audit_log.AUDIT_LOG_PATH", tmp_path / "audit.jsonl"):
|
| 82 |
+
from backend.core.audit_log import log_analysis, get_recent_analyses, get_stats
|
| 83 |
+
|
| 84 |
+
log_analysis(
|
| 85 |
+
evidence_id=str(uuid.uuid4()),
|
| 86 |
+
filename="test.jpg",
|
| 87 |
+
file_sha256="a" * 64,
|
| 88 |
+
ai_probability=0.85,
|
| 89 |
+
classification="likely_ai_generated",
|
| 90 |
+
total_signals=26,
|
| 91 |
+
suspicious_signals=15,
|
| 92 |
+
methods_used=["statistical", "clip"],
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
entries = get_recent_analyses(limit=5)
|
| 96 |
+
assert len(entries) == 1
|
| 97 |
+
assert entries[0]["verdict"]["classification"] == "likely_ai_generated"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_audit_log_stats(tmp_path):
|
| 101 |
+
"""get_stats must return aggregate totals."""
|
| 102 |
+
with patch("backend.core.audit_log.AUDIT_LOG_PATH", tmp_path / "audit.jsonl"):
|
| 103 |
+
from backend.core.audit_log import log_analysis, get_stats
|
| 104 |
+
|
| 105 |
+
for i in range(3):
|
| 106 |
+
log_analysis(
|
| 107 |
+
evidence_id=str(uuid.uuid4()),
|
| 108 |
+
filename=f"img_{i}.jpg",
|
| 109 |
+
file_sha256="b" * 64,
|
| 110 |
+
ai_probability=0.9 if i < 2 else 0.1,
|
| 111 |
+
classification="likely_ai_generated" if i < 2 else "likely_authentic",
|
| 112 |
+
total_signals=26,
|
| 113 |
+
suspicious_signals=15,
|
| 114 |
+
methods_used=[],
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
stats = get_stats()
|
| 118 |
+
assert stats["total_analyses"] == 3
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_audit_log_empty(tmp_path):
|
| 122 |
+
"""get_recent_analyses on empty file returns empty list."""
|
| 123 |
+
with patch("backend.core.audit_log.AUDIT_LOG_PATH", tmp_path / "audit.jsonl"):
|
| 124 |
+
from backend.core.audit_log import get_recent_analyses
|
| 125 |
+
assert get_recent_analyses() == []
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ββ Config coverage βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
|
| 130 |
+
def test_settings_cors_origins_list():
|
| 131 |
+
"""cors_origins_list must split CORS_ORIGINS correctly."""
|
| 132 |
+
from backend.core.config import settings
|
| 133 |
+
origins = settings.cors_origins_list
|
| 134 |
+
assert isinstance(origins, list)
|
| 135 |
+
assert len(origins) >= 1
|
| 136 |
+
for o in origins:
|
| 137 |
+
assert o.startswith("http")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_settings_version_format():
|
| 141 |
+
"""VERSION must be valid semver."""
|
| 142 |
+
from backend.core.config import settings
|
| 143 |
+
parts = settings.VERSION.split(".")
|
| 144 |
+
assert len(parts) == 3
|
| 145 |
+
for part in parts:
|
| 146 |
+
assert part.isdigit()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_settings_rate_limit_positive():
|
| 150 |
+
"""RATE_LIMIT_PER_MINUTE must be positive integer."""
|
| 151 |
+
from backend.core.config import settings
|
| 152 |
+
assert settings.RATE_LIMIT_PER_MINUTE > 0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ββ Validators coverage βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
|
| 157 |
+
def test_validator_webp_accepted():
|
| 158 |
+
"""WebP images must pass validation."""
|
| 159 |
+
from backend.utils.validators import validate_file
|
| 160 |
+
img = Image.new("RGB", (50, 50), color=(100, 150, 200))
|
| 161 |
+
buf = BytesIO()
|
| 162 |
+
img.save(buf, format="WEBP")
|
| 163 |
+
result = validate_file(buf.getvalue(), "test.webp")
|
| 164 |
+
assert result["mime_type"].startswith("image/")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_validator_returns_size_mb():
|
| 168 |
+
"""validate_file must return size_mb field."""
|
| 169 |
+
from backend.utils.validators import validate_file
|
| 170 |
+
result = validate_file(_make_jpeg(), "test.jpg")
|
| 171 |
+
assert "size_mb" in result
|
| 172 |
+
assert result["size_mb"] > 0
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ββ Image forensics coverage ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
+
def test_forensics_extracts_file_info():
|
| 178 |
+
"""Forensic report must include correct file dimensions."""
|
| 179 |
+
from backend.services.image_forensics import ImageForensics
|
| 180 |
+
img = _make_jpeg(width=120, height=80)
|
| 181 |
+
forensics = ImageForensics(img, "dims_test.jpg")
|
| 182 |
+
report = forensics.generate_forensic_report()
|
| 183 |
+
assert report["file_info"]["width"] == 120
|
| 184 |
+
assert report["file_info"]["height"] == 80
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_forensics_generates_all_hash_types():
|
| 188 |
+
"""Report must include sha256, md5, perceptual_hash, average_hash, difference_hash."""
|
| 189 |
+
from backend.services.image_forensics import ImageForensics
|
| 190 |
+
forensics = ImageForensics(_make_jpeg(), "hash_test.jpg")
|
| 191 |
+
report = forensics.generate_forensic_report()
|
| 192 |
+
for h in ("sha256", "md5", "perceptual_hash", "average_hash", "difference_hash"):
|
| 193 |
+
assert h in report["hashes"]
|
| 194 |
+
assert len(report["hashes"][h]) > 0
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_forensics_tampering_no_exif():
|
| 198 |
+
"""Image without EXIF must flag 'Missing EXIF metadata'."""
|
| 199 |
+
from backend.services.image_forensics import ImageForensics
|
| 200 |
+
forensics = ImageForensics(_make_png(), "noexif.png")
|
| 201 |
+
report = forensics.generate_forensic_report()
|
| 202 |
+
flags = report["tampering_analysis"]["suspicious_flags"]
|
| 203 |
+
assert any("EXIF" in f for f in flags)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_forensics_evidence_id_is_uuid():
|
| 207 |
+
"""Evidence ID must be a valid UUID."""
|
| 208 |
+
from backend.services.image_forensics import ImageForensics
|
| 209 |
+
forensics = ImageForensics(_make_jpeg(), "uuid_test.jpg")
|
| 210 |
+
report = forensics.generate_forensic_report()
|
| 211 |
+
parsed = uuid.UUID(report["evidence_id"])
|
| 212 |
+
assert str(parsed) == report["evidence_id"]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ββ Batch processor error paths βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 216 |
+
|
| 217 |
+
def test_batch_empty_file_skipped():
|
| 218 |
+
"""Empty file bytes must be recorded as error, not crash."""
|
| 219 |
+
from backend.services.batch_processor import process_batch
|
| 220 |
+
images = [
|
| 221 |
+
{"filename": "empty.jpg", "data": b""},
|
| 222 |
+
{"filename": "valid.jpg", "data": _make_jpeg()},
|
| 223 |
+
]
|
| 224 |
+
result = process_batch(images)
|
| 225 |
+
assert result["processed"] == 1
|
| 226 |
+
assert result["failed"] == 1
|
| 227 |
+
assert result["errors"][0]["filename"] == "empty.jpg"
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_batch_oversized_file_skipped():
|
| 231 |
+
"""File exceeding per-image limit must be recorded as error."""
|
| 232 |
+
from backend.services.batch_processor import process_batch, MAX_IMAGE_BYTES
|
| 233 |
+
big_data = b"x" * (MAX_IMAGE_BYTES + 1)
|
| 234 |
+
images = [
|
| 235 |
+
{"filename": "big.jpg", "data": big_data},
|
| 236 |
+
{"filename": "ok.jpg", "data": _make_jpeg()},
|
| 237 |
+
]
|
| 238 |
+
result = process_batch(images)
|
| 239 |
+
assert result["failed"] == 1
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_batch_all_fail_returns_error():
|
| 243 |
+
"""Batch with all failed images must return status=failed."""
|
| 244 |
+
from backend.services.batch_processor import process_batch
|
| 245 |
+
images = [{"filename": "bad.jpg", "data": b""}]
|
| 246 |
+
result = process_batch(images)
|
| 247 |
+
assert result["status"] == "failed"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ββ Report exporter edge cases ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 251 |
+
|
| 252 |
+
def test_csv_export_empty_signals():
|
| 253 |
+
"""CSV must handle report with no signals gracefully."""
|
| 254 |
+
from backend.services.report_exporter import export_csv
|
| 255 |
+
report = {
|
| 256 |
+
"evidence_id": "test-id",
|
| 257 |
+
"file_info": {"filename": "test.jpg"},
|
| 258 |
+
"metadata": {"analysis_timestamp": "2026-01-01T00:00:00"},
|
| 259 |
+
"summary": {"ai_probability": 0.5, "ai_classification": "unknown"},
|
| 260 |
+
"ai_detection": {"all_signals": []},
|
| 261 |
+
}
|
| 262 |
+
result = export_csv(report)
|
| 263 |
+
assert isinstance(result, bytes)
|
| 264 |
+
assert b"signal_name" in result # header present
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_json_export_unicode():
|
| 268 |
+
"""JSON export must handle unicode filenames."""
|
| 269 |
+
from backend.services.report_exporter import export_json
|
| 270 |
+
report = {"evidence_id": "x", "filename": "ζ₯ζ¬θͺ.jpg", "data": [1, 2, 3]}
|
| 271 |
+
result = export_json(report)
|
| 272 |
+
parsed = json.loads(result)
|
| 273 |
+
assert parsed["filename"] == "ζ₯ζ¬θͺ.jpg"
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def test_pdf_export_long_filename():
|
| 277 |
+
"""PDF export must not crash on very long filenames."""
|
| 278 |
+
from backend.services.report_exporter import export_pdf
|
| 279 |
+
report = {
|
| 280 |
+
"evidence_id": "x",
|
| 281 |
+
"metadata": {"analysis_timestamp": "2026-01-01T00:00:00", "analyzer_version": "6.6.0"},
|
| 282 |
+
"file_info": {"filename": "a" * 200 + ".jpg", "width": 100, "height": 100, "file_size_bytes": 5000},
|
| 283 |
+
"hashes": {"sha256": "a" * 64, "md5": "b" * 32},
|
| 284 |
+
"ai_detection": {"ai_probability": 0.5, "all_signals": []},
|
| 285 |
+
"generator_attribution": {"predicted_generator": "unknown"},
|
| 286 |
+
"platform_forensics": {"predicted_platform": "unknown"},
|
| 287 |
+
"c2pa_provenance": {"provenance_status": "none"},
|
| 288 |
+
"summary": {"ai_probability": 0.5, "ai_classification": "unknown",
|
| 289 |
+
"total_detection_signals": 0, "suspicious_detection_signals": 0},
|
| 290 |
+
}
|
| 291 |
+
result = export_pdf(report)
|
| 292 |
+
assert result[:4] == b"%PDF"
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ββ C2PA verifier paths βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 296 |
+
|
| 297 |
+
def test_c2pa_file_hash_matches_sha256():
|
| 298 |
+
"""C2PA result file_hash must equal SHA-256 of input bytes."""
|
| 299 |
+
import hashlib
|
| 300 |
+
from backend.services.c2pa_verifier import verify_c2pa
|
| 301 |
+
img = _make_jpeg()
|
| 302 |
+
result = verify_c2pa(img, "test.jpg")
|
| 303 |
+
assert result["file_hash"] == hashlib.sha256(img).hexdigest()
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def test_c2pa_assertions_is_list():
|
| 307 |
+
"""assertions field must always be a list."""
|
| 308 |
+
from backend.services.c2pa_verifier import verify_c2pa
|
| 309 |
+
result = verify_c2pa(_make_png(), "test.png")
|
| 310 |
+
assert isinstance(result["assertions"], list)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ββ Generator attribution paths βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 314 |
+
|
| 315 |
+
def test_attribution_accuracy_note_present():
|
| 316 |
+
"""accuracy_note must always be present in attribution result."""
|
| 317 |
+
from backend.services.generator_attribution import attribute_generator
|
| 318 |
+
result = attribute_generator(_make_jpeg(), "test.jpg")
|
| 319 |
+
assert "accuracy_note" in result
|
| 320 |
+
assert len(result["accuracy_note"]) > 0
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_attribution_scores_sum_to_approx_one():
|
| 324 |
+
"""All scores in all_scores must approximately sum to 1.0."""
|
| 325 |
+
from backend.services.generator_attribution import attribute_generator
|
| 326 |
+
result = attribute_generator(_make_jpeg(), "test.jpg")
|
| 327 |
+
total = sum(result["all_scores"].values())
|
| 328 |
+
assert 0.85 <= total <= 1.15 # Allow for rounding
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ββ Platform detector paths βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 332 |
+
|
| 333 |
+
def test_platform_features_all_numeric():
|
| 334 |
+
"""All feature values must be Python-native numeric types."""
|
| 335 |
+
from backend.services.platform_detector import detect_platform
|
| 336 |
+
result = detect_platform(_make_jpeg(), "test.jpg")
|
| 337 |
+
for k, v in result["features"].items():
|
| 338 |
+
assert isinstance(v, (int, float, bool)), f"{k} is {type(v)}"
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def test_platform_confidence_increases_with_jpeg():
|
| 342 |
+
"""JPEG with quality markers should have higher platform detection confidence."""
|
| 343 |
+
from backend.services.platform_detector import detect_platform
|
| 344 |
+
jpeg_result = detect_platform(_make_jpeg(), "test.jpg")
|
| 345 |
+
# JPEG must not return 'original'
|
| 346 |
+
assert jpeg_result["predicted_platform"] != "original" or jpeg_result["confidence"] > 0
|