Files
OSIT-AE-API-FastAPI/tests/unit/test_unit_idaa_novi_verify.py
Scott Idem 221854df90 feat(idaa): server-side Novi member verification endpoint
Proxies GET /customers/{uuid} to Novi AMS server-to-server so members'
browser IPs are no longer in the call path, eliminating false "Access
Denied" for users on hotel/conference WiFi, VPNs, and CDN-filtered nets.

- New router: GET /v3/action/idaa/novi_member/{uuid}
- Business logic in app/methods/idaa_novi_verify_methods.py
  - Redis cache (4h TTL, key: idaa:novi_member:{uuid})
  - 404 never cached (recently-joined member anti-pattern)
  - Email space→+ normalization (Novi quirk)
  - Display name: "FirstName L." format with Name field fallback
- Registered in registry.py under /v3/action/idaa tag
- 9 unit tests covering all response paths (200/404/429/503/unreachable,
  cache hit, email normalization, display name format)
- Frontend guide (Section 12) and tests/README updated with full spec
  and migration table for frontend hand-off

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:35:01 -04:00

220 lines
8.2 KiB
Python

import sys
import os
import json
from unittest.mock import MagicMock, patch
# Add project root to path
sys.path.append(os.getcwd())
# Mock low-level deps BEFORE importing the target module.
# logger_reset must be a passthrough — if it stays a MagicMock the decorator
# replaces the decorated function with a MagicMock and tests get garbage results.
mock_lib_general = MagicMock()
mock_lib_general.logger_reset = lambda f: f
sys.modules['app.config'] = MagicMock()
sys.modules['app.lib_general'] = mock_lib_general
sys.modules['app.db_sql'] = MagicMock()
sys.modules['app.lib_redis_helpers'] = MagicMock()
from app.methods import idaa_novi_verify_methods as m
# ── Helpers ───────────────────────────────────────────────────────────────
def _make_cfg():
return {
'novi_api_root_url': 'https://www.idaa.org/api',
'novi_idaa_api_key': 'dGVzdGtleQ==',
}
def _novi_resp(email='alice@idaa.org', first='Alice', last='Smith', name=None):
d = {'Email': email, 'FirstName': first, 'LastName': last}
if name is not None:
d['Name'] = name
return d
def _set_redis(cached_value=None):
"""Set redis_client on the already-imported module's imported name."""
r = MagicMock()
r.get.return_value = cached_value
sys.modules['app.lib_redis_helpers'].redis_client = r
return r
# ── Cache hit bypasses Novi ───────────────────────────────────────────────
def test_cache_hit_bypasses_novi():
print('--- test_cache_hit_bypasses_novi ---')
cached = json.dumps({'status': 200, 'verified': True, 'full_name': 'Bob J.', 'email': 'bob@idaa.org'})
redis_mock = _set_redis(cached_value=cached)
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get') as mock_get:
result = m.verify_novi_member('some-uuid')
print('Result:', result)
assert result['status'] == 200
assert result['full_name'] == 'Bob J.'
mock_get.assert_not_called() # Novi was never contacted
print('PASS')
# ── Verified 200 ──────────────────────────────────────────────────────────
def test_verified_member_200():
print('--- test_verified_member_200 ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = _novi_resp()
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
print('Result:', result)
assert result['status'] == 200
assert result['verified'] is True
assert result['full_name'] == 'Alice S.'
assert result['email'] == 'alice@idaa.org'
redis_mock.setex.assert_called_once() # verified result cached
print('PASS')
# ── Email normalization: space → + ────────────────────────────────────────
def test_email_space_normalization():
print('--- test_email_space_normalization ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = _novi_resp(email='alice member@idaa.org')
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
print('Result:', result)
assert result['status'] == 200
assert result['email'] == 'alice+member@idaa.org'
print('PASS')
# ── Display name format ───────────────────────────────────────────────────
def test_display_name_format():
print('--- test_display_name_format ---')
cases = [
(_novi_resp(first='Alice', last='Smith'), 'Alice S.'),
(_novi_resp(first='Alice', last=''), 'Alice'),
(_novi_resp(first='', last='Smith', name='Dr. Alice'), 'Dr. Alice'),
(_novi_resp(first='', last='', name='Dr. Alice'), 'Dr. Alice'),
(_novi_resp(first='', last='', name=''), 'Member'),
]
for novi_data, expected_name in cases:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = novi_data
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
assert result['status'] == 200
assert result['full_name'] == expected_name, \
f"Expected '{expected_name}', got '{result['full_name']}' for input {novi_data}"
print('All display name cases PASS')
# ── Empty-member anti-pattern: Novi 200, no Email ─────────────────────────
def test_empty_member_returns_404():
print('--- test_empty_member_returns_404 ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {} # Novi 200 with no identity data
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('ghost-uuid')
print('Result:', result)
assert result['status'] == 404
redis_mock.setex.assert_not_called() # 404 must NOT be cached
print('PASS')
# ── Novi 404 ──────────────────────────────────────────────────────────────
def test_novi_404_returns_404():
print('--- test_novi_404_returns_404 ---')
mock_resp = MagicMock()
mock_resp.status_code = 404
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('missing-uuid')
print('Result:', result)
assert result['status'] == 404
redis_mock.setex.assert_not_called()
print('PASS')
# ── Novi 429 ──────────────────────────────────────────────────────────────
def test_novi_429_returns_429():
print('--- test_novi_429_returns_429 ---')
mock_resp = MagicMock()
mock_resp.status_code = 429
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 429
redis_mock.setex.assert_not_called()
print('PASS')
# ── Novi 5xx → 503 ────────────────────────────────────────────────────────
def test_novi_5xx_returns_503():
print('--- test_novi_5xx_returns_503 ---')
mock_resp = MagicMock()
mock_resp.status_code = 502
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 503
print('PASS')
# ── Novi unreachable → 503 ────────────────────────────────────────────────
def test_novi_unreachable_returns_503():
print('--- test_novi_unreachable_returns_503 ---')
import requests as req_lib
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', side_effect=req_lib.exceptions.ConnectionError('refused')):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 503
print('PASS')