diff --git a/app/ai-service/conftest.py b/app/ai-service/conftest.py index e1428362..71a9640f 100644 --- a/app/ai-service/conftest.py +++ b/app/ai-service/conftest.py @@ -44,11 +44,8 @@ def _make_pkg(name: str): if _mod not in sys.modules: sys.modules[_mod] = _make_pkg(_mod) -# proof_of_life raises RuntimeError at import time when cv2 is mocked. -_pol = _make_pkg("proof_of_life") -_pol.ProofOfLifeAnalyzer = MagicMock() -_pol.ProofOfLifeConfig = MagicMock() -sys.modules["proof_of_life"] = _pol +# proof_of_life's __init__ loads cv2 cascade classifiers which work fine +# with the mocked cv2 in conftest — no module-level stub needed. # Patch metrics.check_system_resources so the monitor_requests middleware # doesn't crash when torch (vram) is a MagicMock. diff --git a/app/ai-service/proof_of_life.py b/app/ai-service/proof_of_life.py index 854bf524..ea0837b1 100644 --- a/app/ai-service/proof_of_life.py +++ b/app/ai-service/proof_of_life.py @@ -120,13 +120,16 @@ def analyze( burst_required = bool(burst_images_base64) has_liveness_evidence = ( - checks["blink_detected"] or checks["head_movement_detected"] or not burst_required + checks["blink_detected"] or checks["head_movement_detected"] ) is_real_person = confidence >= threshold and has_liveness_evidence reason = "Face detected and confidence threshold met" if burst_required and not has_liveness_evidence: reason = "No liveness signal detected from burst frames" + elif not burst_required: + is_real_person = False + reason = "Liveness verification requires burst frames" elif confidence < threshold: reason = "Confidence score is below threshold" diff --git a/app/ai-service/tests/test_proof_of_life.py b/app/ai-service/tests/test_proof_of_life.py new file mode 100644 index 00000000..f27c7f44 --- /dev/null +++ b/app/ai-service/tests/test_proof_of_life.py @@ -0,0 +1,101 @@ +"""Tests for proof-of-life liveness gate (Issue #431). + +Verifies that: + - A selfie-only request (no burst frames) returns is_real_person: false. + - Burst-based requests are scored on actual blink/head-movement evidence. +""" + +from unittest.mock import patch, MagicMock + +import numpy as np + +from proof_of_life import ProofOfLifeAnalyzer, ProofOfLifeConfig + + +def _make_analyzer(): + """Build an analyzer with mocked cascade classifiers.""" + cfg = ProofOfLifeConfig(confidence_threshold=0.65) + with patch("cv2.CascadeClassifier") as mock_cls: + mock_instance = MagicMock() + mock_instance.empty.return_value = False + mock_cls.return_value = mock_instance + analyzer = ProofOfLifeAnalyzer(config=cfg) + return analyzer + + +def _fake_decode(image_base64: str) -> np.ndarray: + """Return a synthetic 200x200 BGR image for any base64 input.""" + return np.zeros((200, 200, 3), dtype=np.uint8) + + +class TestSelfieOnlyRefusal: + """Selfie-only requests must always be refused.""" + + def test_selfie_only_returns_false(self): + analyzer = _make_analyzer() + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode), \ + patch.object(analyzer, "_detect_primary_face", return_value=(50, 50, 100, 100)): + result = analyzer.analyze(selfie_image_base64="dGVzdA==") + assert result["is_real_person"] is False + + def test_selfie_only_reason_mentions_liveness(self): + analyzer = _make_analyzer() + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode), \ + patch.object(analyzer, "_detect_primary_face", return_value=(50, 50, 100, 100)): + result = analyzer.analyze(selfie_image_base64="dGVzdA==") + assert "liveness" in result["reason"].lower() + + def test_empty_burst_list_treated_as_selfie_only(self): + analyzer = _make_analyzer() + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode), \ + patch.object(analyzer, "_detect_primary_face", return_value=(50, 50, 100, 100)): + result = analyzer.analyze( + selfie_image_base64="dGVzdA==", + burst_images_base64=[], + ) + assert result["is_real_person"] is False + + +class TestBurstLivenessEvidence: + """When burst frames are provided, liveness is scored on actual signals.""" + + def test_burst_with_blink_and_movement_can_pass(self): + analyzer = _make_analyzer() + analyzer.config.confidence_threshold = 0.10 + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode), \ + patch.object(analyzer, "_detect_primary_face", return_value=(50, 50, 100, 100)), \ + patch.object( + analyzer, + "_analyze_burst_frames", + return_value={ + "blink_detected": True, + "head_movement_detected": True, + "processed_burst_frames": 5, + }, + ): + result = analyzer.analyze( + selfie_image_base64="dGVzdA==", + burst_images_base64=["frame1", "frame2"], + ) + assert result["checks"]["blink_detected"] is True + assert result["checks"]["head_movement_detected"] is True + + def test_burst_without_liveness_fails(self): + analyzer = _make_analyzer() + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode), \ + patch.object(analyzer, "_detect_primary_face", return_value=(50, 50, 100, 100)), \ + patch.object( + analyzer, + "_analyze_burst_frames", + return_value={ + "blink_detected": False, + "head_movement_detected": False, + "processed_burst_frames": 5, + }, + ): + result = analyzer.analyze( + selfie_image_base64="dGVzdA==", + burst_images_base64=["frame1", "frame2"], + ) + assert result["is_real_person"] is False + assert "liveness" in result["reason"].lower()