Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions app/ai-service/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion app/ai-service/proof_of_life.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
98 changes: 98 additions & 0 deletions app/ai-service/tests/test_proof_of_life.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""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):
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):
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):
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()
Loading