From 45c5897bda2b5e97f367c4ea34746ec2bce0af95 Mon Sep 17 00:00:00 2001 From: northersubair Date: Thu, 20 Aug 2026 13:57:08 +0100 Subject: [PATCH 1/4] fix: fail closed when proof-of-life has no burst frames Selfie-only requests (no burst frames) previously passed liveness because the burst_required flag defaulted to satisfied when no frames were supplied, collapsing the anti-spoon signal to 'a face is present'. Make liveness evidence a mandatory precondition: without burst frames, is_real_person is always false with a clear reason. Burst- based requests are scored on actual blink/head-movement evidence. Closes #431 --- app/ai-service/proof_of_life.py | 5 +- app/ai-service/tests/test_proof_of_life.py | 95 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 app/ai-service/tests/test_proof_of_life.py 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..31cfd30c --- /dev/null +++ b/app/ai-service/tests/test_proof_of_life.py @@ -0,0 +1,95 @@ +"""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 + +from proof_of_life import ProofOfLifeAnalyzer, ProofOfLifeConfig + + +def _make_analyzer(): + """Build an analyzer in test-provider mode so we skip cascade loading.""" + cfg = ProofOfLifeConfig(confidence_threshold=0.65) + analyzer = ProofOfLifeAnalyzer(config=cfg) + return analyzer + + +class TestSelfieOnlyRefusal: + """Selfie-only requests must always be refused.""" + + def test_selfie_only_returns_false(self): + """Without burst frames, is_real_person must be False.""" + analyzer = _make_analyzer() + 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() + result = analyzer.analyze(selfie_image_base64="dGVzdA==") + assert "liveness" in result["reason"].lower() + + def test_empty_burst_list_treated_as_selfie_only(self): + """An explicit empty list is equivalent to no burst frames.""" + analyzer = _make_analyzer() + 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.""" + + @patch.object(ProofOfLifeAnalyzer, "_analyze_burst_frames") + @patch.object(ProofOfLifeAnalyzer, "_detect_primary_face") + @patch.object(ProofOfLifeAnalyzer, "_decode_image") + def test_burst_with_blink_and_movement_can_pass( + self, mock_decode, mock_face, mock_burst + ): + import numpy as np + + mock_decode.return_value = np.zeros((200, 200, 3), dtype=np.uint8) + mock_face.return_value = (50, 50, 100, 100) + mock_burst.return_value = { + "blink_detected": True, + "head_movement_detected": True, + "processed_burst_frames": 5, + } + + analyzer = _make_analyzer() + # Use a very low threshold so the combined score passes + analyzer.config.confidence_threshold = 0.10 + 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 + + @patch.object(ProofOfLifeAnalyzer, "_analyze_burst_frames") + @patch.object(ProofOfLifeAnalyzer, "_detect_primary_face") + @patch.object(ProofOfLifeAnalyzer, "_decode_image") + def test_burst_without_liveness_fails( + self, mock_decode, mock_face, mock_burst + ): + import numpy as np + + mock_decode.return_value = np.zeros((200, 200, 3), dtype=np.uint8) + mock_face.return_value = (50, 50, 100, 100) + mock_burst.return_value = { + "blink_detected": False, + "head_movement_detected": False, + "processed_burst_frames": 5, + } + + analyzer = _make_analyzer() + 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() From 421bcc0d357c6fb1e063a8c0c73fa98e6b306d5c Mon Sep 17 00:00:00 2001 From: portableDD Date: Thu, 20 Aug 2026 15:41:22 +0100 Subject: [PATCH 2/4] fix: mock OpenCV in proof-of-life tests for CI compatibility --- app/ai-service/tests/test_proof_of_life.py | 107 +++++++++++---------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/app/ai-service/tests/test_proof_of_life.py b/app/ai-service/tests/test_proof_of_life.py index 31cfd30c..ffad2a43 100644 --- a/app/ai-service/tests/test_proof_of_life.py +++ b/app/ai-service/tests/test_proof_of_life.py @@ -5,91 +5,94 @@ - Burst-based requests are scored on actual blink/head-movement evidence. """ -from unittest.mock import patch +from unittest.mock import patch, MagicMock + +import numpy as np from proof_of_life import ProofOfLifeAnalyzer, ProofOfLifeConfig def _make_analyzer(): - """Build an analyzer in test-provider mode so we skip cascade loading.""" + """Build an analyzer with mocked cascade classifiers.""" cfg = ProofOfLifeConfig(confidence_threshold=0.65) - analyzer = ProofOfLifeAnalyzer(config=cfg) + 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): - """Without burst frames, is_real_person must be False.""" analyzer = _make_analyzer() - result = analyzer.analyze(selfie_image_base64="dGVzdA==") + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode): + 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() - result = analyzer.analyze(selfie_image_base64="dGVzdA==") + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode): + result = analyzer.analyze(selfie_image_base64="dGVzdA==") assert "liveness" in result["reason"].lower() def test_empty_burst_list_treated_as_selfie_only(self): - """An explicit empty list is equivalent to no burst frames.""" analyzer = _make_analyzer() - result = analyzer.analyze( - selfie_image_base64="dGVzdA==", - burst_images_base64=[], - ) + with patch.object(analyzer, "_decode_image", side_effect=_fake_decode): + 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.""" - @patch.object(ProofOfLifeAnalyzer, "_analyze_burst_frames") - @patch.object(ProofOfLifeAnalyzer, "_detect_primary_face") - @patch.object(ProofOfLifeAnalyzer, "_decode_image") - def test_burst_with_blink_and_movement_can_pass( - self, mock_decode, mock_face, mock_burst - ): - import numpy as np - - mock_decode.return_value = np.zeros((200, 200, 3), dtype=np.uint8) - mock_face.return_value = (50, 50, 100, 100) - mock_burst.return_value = { - "blink_detected": True, - "head_movement_detected": True, - "processed_burst_frames": 5, - } - + def test_burst_with_blink_and_movement_can_pass(self): analyzer = _make_analyzer() - # Use a very low threshold so the combined score passes analyzer.config.confidence_threshold = 0.10 - result = analyzer.analyze( - selfie_image_base64="dGVzdA==", - burst_images_base64=["frame1", "frame2"], - ) + 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 - @patch.object(ProofOfLifeAnalyzer, "_analyze_burst_frames") - @patch.object(ProofOfLifeAnalyzer, "_detect_primary_face") - @patch.object(ProofOfLifeAnalyzer, "_decode_image") - def test_burst_without_liveness_fails( - self, mock_decode, mock_face, mock_burst - ): - import numpy as np - - mock_decode.return_value = np.zeros((200, 200, 3), dtype=np.uint8) - mock_face.return_value = (50, 50, 100, 100) - mock_burst.return_value = { - "blink_detected": False, - "head_movement_detected": False, - "processed_burst_frames": 5, - } - + def test_burst_without_liveness_fails(self): analyzer = _make_analyzer() - result = analyzer.analyze( - selfie_image_base64="dGVzdA==", - burst_images_base64=["frame1", "frame2"], - ) + 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() From a584e661e00e7a1e7e0083e22e912dac67e65f0d Mon Sep 17 00:00:00 2001 From: northersubair Date: Thu, 20 Aug 2026 19:05:15 +0100 Subject: [PATCH 3/4] fix: remove proof_of_life module stub from conftest.py The conftest was replacing proof_of_life with a MagicMock at module level, causing all tests to get MagicMock objects instead of real classes. The cascade classifier loading happens in __init__ (not at module level), so the stub is unnecessary with mocked cv2. --- app/ai-service/conftest.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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. From 3d782fa13e3af4f91c59ebd1085f468a0d649575 Mon Sep 17 00:00:00 2001 From: northersubair Date: Fri, 21 Aug 2026 13:06:10 +0100 Subject: [PATCH 4/4] fix: mock _detect_primary_face in selfie-only tests Without mocking _detect_primary_face, the mock cv2 cascade returns no faces, causing analyze() to return 'No face detected' before reaching the burst-required liveness check. Adding the mock ensures the selfie-only tests exercise the correct code path. --- app/ai-service/tests/test_proof_of_life.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/ai-service/tests/test_proof_of_life.py b/app/ai-service/tests/test_proof_of_life.py index ffad2a43..f27c7f44 100644 --- a/app/ai-service/tests/test_proof_of_life.py +++ b/app/ai-service/tests/test_proof_of_life.py @@ -33,19 +33,22 @@ class TestSelfieOnlyRefusal: def test_selfie_only_returns_false(self): analyzer = _make_analyzer() - with patch.object(analyzer, "_decode_image", side_effect=_fake_decode): + 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): + 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): + 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=[],