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>
220 lines
8.2 KiB
Python
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')
|