from datetime import UTC, datetime, timedelta from uuid import uuid4 from app.geo import GeoLocation from app.schemas import AccessAttempted from app.scoring import HeuristicScorer, risk_band def _evt( *, guest_id=None, fingerprint=None, ip=None, user_agent="Mozilla/5.0", occurred_at=None, ): return AccessAttempted( event_id=uuid4(), guest_id=guest_id or uuid4(), token_id=uuid4(), access_log_id=uuid4(), fingerprint=fingerprint, ip_address=ip, user_agent=user_agent, occurred_at=occurred_at or datetime.now(UTC), ) def test_first_access_with_full_signals_is_low_risk(): scorer = HeuristicScorer() evt = _evt( fingerprint={"ua": "Chrome", "platform": "macOS"}, ip="203.0.113.7", ) res = scorer.score(evt) assert res.score <= 30 assert risk_band(res.score) == "low" def test_fingerprint_change_drives_score_up(): scorer = HeuristicScorer() guest = uuid4() first = _evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="203.0.113.7") scorer.score(first) second = _evt(guest_id=guest, fingerprint={"ua": "Safari"}, ip="203.0.113.7") res = scorer.score(second) assert res.score >= 40 assert any("fingerprint" in r for r in res.reasons) def test_ip_change_and_fingerprint_change_classify_high_or_block(): scorer = HeuristicScorer() guest = uuid4() scorer.score(_evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="203.0.113.7")) suspicious = _evt( guest_id=guest, fingerprint={"ua": "Curl/8"}, ip="198.51.100.42", user_agent=None, ) res = scorer.score(suspicious) assert res.score >= 60 assert risk_band(res.score) in {"high", "block"} def test_missing_fingerprint_and_user_agent_flagged(): scorer = HeuristicScorer() res = scorer.score(_evt(fingerprint=None, ip="203.0.113.1", user_agent=None)) assert "no device fingerprint provided" in res.reasons assert "missing user agent" in res.reasons def test_score_clamped_to_0_100(): scorer = HeuristicScorer() # 10 successive accesses with no fingerprint, no UA, changing IPs guest = uuid4() for i in range(12): res = scorer.score(_evt(guest_id=guest, fingerprint=None, ip=f"10.0.{i}.1", user_agent=None)) assert 0 <= res.score <= 100 # --- Tier 2 Block G: geo_jump --- _LAGOS = GeoLocation(country="NG", city="Lagos", lat=6.5244, lon=3.3792) _PARIS = GeoLocation(country="FR", city="Paris", lat=48.8566, lon=2.3522) def test_geo_jump_fires_on_implausible_travel(): """Two accesses 5,000+ km apart within 12 minutes is the textbook forwarded-link case the spec targets.""" scorer = HeuristicScorer() guest = uuid4() t0 = datetime.now(UTC) first = _evt( guest_id=guest, fingerprint={"ua": "Chrome"}, ip="102.89.0.1", occurred_at=t0, ) scorer.score(first, geo=_LAGOS) twelve_mins_later = _evt( guest_id=guest, fingerprint={"ua": "Chrome"}, ip="80.10.20.30", occurred_at=t0 + timedelta(minutes=12), ) res = scorer.score(twelve_mins_later, geo=_PARIS) assert any("Lagos" in r and "Paris" in r for r in res.reasons), res.reasons # geo_jump (100 × 0.40) + ip_change (80 × 0.20) = 56 weighted; the # single-signal cap (geo_jump + ip_change both ≥ 70, so 2 signals) # does NOT trigger and the score stays > the cap-at-55 floor. assert res.score >= 55, res.score def test_geo_jump_does_not_fire_for_local_movement(): """A train-trip distance shouldn't escalate.""" scorer = HeuristicScorer() guest = uuid4() t0 = datetime.now(UTC) london = GeoLocation(country="GB", city="London", lat=51.5074, lon=-0.1278) brighton = GeoLocation(country="GB", city="Brighton", lat=50.8225, lon=-0.1372) scorer.score( _evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="80.0.0.1", occurred_at=t0), geo=london, ) res = scorer.score( _evt( guest_id=guest, fingerprint={"ua": "Chrome"}, ip="80.0.0.1", occurred_at=t0 + timedelta(hours=2), ), geo=brighton, ) assert not any("Lagos" in r or "minutes" in r.lower() for r in res.reasons) def test_geo_jump_carries_geo_back_on_result(): scorer = HeuristicScorer() res = scorer.score(_evt(fingerprint={"ua": "Chrome"}, ip="102.89.0.1"), geo=_LAGOS) assert res.geo is _LAGOS