diff --git a/app/methods/idaa_novi_verify_methods.py b/app/methods/idaa_novi_verify_methods.py new file mode 100644 index 0000000..d7bd59a --- /dev/null +++ b/app/methods/idaa_novi_verify_methods.py @@ -0,0 +1,154 @@ +import datetime +import json +import requests +from typing import Dict, Optional + +from app.lib_general import log, logger_reset + +IDAA_SITE_ID_RANDOM = '58_gJESdlUh' +_CACHE_TTL = datetime.timedelta(hours=4) + + +# ── Config ──────────────────────────────────────────────────────────────── + +@logger_reset +def _load_idaa_cfg() -> Optional[Dict]: + """Load IDAA site cfg_json. Returns parsed dict or None on failure.""" + from app.methods.site_methods import load_site_obj + site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True) + if not site: + log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM) + return None + cfg = site.get('cfg_json') + if isinstance(cfg, str): + try: + cfg = json.loads(cfg) + except Exception as e: + log.error("Failed to parse IDAA cfg_json: %s", e) + return None + if not isinstance(cfg, dict): + log.error("IDAA cfg_json is not a dict after parsing.") + return None + return cfg + + +def _cache_key(uuid: str) -> str: + return f'idaa:novi_member:{uuid}' + + +# ── Public API ──────────────────────────────────────────────────────────── + +@logger_reset +def verify_novi_member(uuid: str) -> Dict: + """ + Proxy GET /customers/{uuid} to Novi AMS and return normalized member data. + + Returns a dict with one of: + {'status': 200, 'verified': True, 'full_name': '...', 'email': '...'} + {'status': 404, 'reason': '...'} + {'status': 429, 'reason': '...'} + {'status': 503, 'reason': '...'} + + Redis cache key: idaa:novi_member:{uuid}, TTL 4 hours. + Only 200 (verified) results are cached — 404 is never cached. + """ + from app.lib_redis_helpers import redis_client + + cache_key = _cache_key(uuid) + + # ── Cache hit ───────────────────────────────────────────────────────── + cached_raw = redis_client.get(cache_key) + if cached_raw: + try: + cached = json.loads(cached_raw) + log.info("Novi verify cache hit: %s", uuid) + return cached + except Exception: + pass # corrupt cache entry — fall through to Novi + + # ── Load credentials ────────────────────────────────────────────────── + cfg = _load_idaa_cfg() + if not cfg: + return {'status': 503, 'reason': 'IDAA site configuration unavailable.'} + + base_url = cfg.get('novi_api_root_url', '').rstrip('/') + api_key = cfg.get('novi_idaa_api_key', '') + + if not base_url or not api_key: + log.error("novi_api_root_url or novi_idaa_api_key missing from IDAA cfg_json.") + return {'status': 503, 'reason': 'Novi credentials not configured.'} + + headers = {'Authorization': f'Basic {api_key}', 'Accept': 'application/json'} + + # ── Call Novi ───────────────────────────────────────────────────────── + try: + resp = requests.get(f'{base_url}/customers/{uuid}', headers=headers, timeout=10) + except requests.exceptions.ConnectionError as e: + log.error("Novi unreachable: %s", e) + return {'status': 503, 'reason': 'Novi API unreachable.'} + except requests.exceptions.Timeout: + log.error("Novi request timed out for UUID %s", uuid) + return {'status': 503, 'reason': 'Novi API timed out.'} + except Exception as e: + log.exception("Unexpected error calling Novi for UUID %s: %s", uuid, e) + return {'status': 503, 'reason': 'Unexpected error contacting Novi.'} + + if resp.status_code == 429: + log.warning("Novi rate limit hit for UUID %s", uuid) + return {'status': 429, 'reason': 'Novi rate limit exceeded. Try again shortly.'} + + if resp.status_code >= 500: + log.error("Novi server error %s for UUID %s", resp.status_code, uuid) + return {'status': 503, 'reason': f'Novi server error ({resp.status_code}).'} + + if resp.status_code == 404: + log.info("Novi returned 404 for UUID %s", uuid) + return {'status': 404, 'reason': 'Member not found in Novi.'} + + if resp.status_code != 200: + log.error("Unexpected Novi status %s for UUID %s: %s", resp.status_code, uuid, resp.text[:200]) + return {'status': 503, 'reason': f'Unexpected Novi response ({resp.status_code}).'} + + # ── Parse response ──────────────────────────────────────────────────── + try: + data = resp.json() + except Exception: + log.error("Novi returned non-JSON for UUID %s", uuid) + return {'status': 503, 'reason': 'Novi returned an unparseable response.'} + + if not isinstance(data, dict): + log.warning("Novi returned non-dict body for UUID %s", uuid) + return {'status': 404, 'reason': 'Member not found in Novi (empty response).'} + + # Empty-member anti-pattern: Novi 200 with no identity data + email_raw = (data.get('Email') or '').strip() + if not email_raw: + log.info("Novi 200 with no Email for UUID %s — empty-member anti-pattern", uuid) + return {'status': 404, 'reason': 'Member not found in Novi (no identity data).'} + + email = email_raw.replace(' ', '+') + + # Build display name: "FirstName LastName[0]." — fall back to Name field + first = (data.get('FirstName') or '').strip() + last = (data.get('LastName') or '').strip() + if first and last: + full_name = f'{first} {last[0]}.' + elif first: + full_name = first + else: + full_name = (data.get('Name') or '').strip() or 'Member' + + result = { + 'status': 200, + 'verified': True, + 'full_name': full_name, + 'email': email, + } + + # ── Cache verified result ───────────────────────────────────────────── + try: + redis_client.setex(cache_key, _CACHE_TTL, json.dumps(result)) + except Exception as e: + log.warning("Failed to cache Novi verify result for %s: %s", uuid, e) + + return result diff --git a/app/routers/api_v3_actions_idaa.py b/app/routers/api_v3_actions_idaa.py new file mode 100644 index 0000000..83d990d --- /dev/null +++ b/app/routers/api_v3_actions_idaa.py @@ -0,0 +1,41 @@ +import asyncio + +from fastapi import APIRouter, Depends + +from app.lib_general_v3 import AccountContext, get_account_context, DelayParams +from app.models.response_models import Resp_Body_Base, mk_resp +from app.methods.idaa_novi_verify_methods import verify_novi_member + +router = APIRouter() + + +@router.get('/novi_member/{uuid}', response_model=Resp_Body_Base) +async def get_novi_member_verification( + uuid: str, + account: AccountContext = Depends(get_account_context), + delay: DelayParams = Depends(), + ): + """ + Proxy Novi AMS member lookup server-to-server. + Returns verified member identity or an appropriate error code. + """ + if delay.sleep_time_s > 0: + await asyncio.sleep(delay.sleep_time_s) + + result = verify_novi_member(uuid) + status = result.get('status', 503) + + if status == 200: + return mk_resp(data={ + 'verified': result['verified'], + 'full_name': result['full_name'], + 'email': result['email'], + }) + + if status == 404: + return mk_resp(data=False, status_code=404, status_message=result.get('reason', 'Member not found.')) + + if status == 429: + return mk_resp(data=False, status_code=429, status_message=result.get('reason', 'Novi rate limit exceeded.')) + + return mk_resp(data=False, status_code=503, status_message=result.get('reason', 'Novi API unavailable.')) diff --git a/app/routers/registry.py b/app/routers/registry.py index fe73b54..ede8703 100644 --- a/app/routers/registry.py +++ b/app/routers/registry.py @@ -6,7 +6,7 @@ from app.routers import ( event_badge_importing, event_importing, api_v3_actions_email, - api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3, + api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_idaa, api_v3_actions_user, lookup_v3, user, util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe ) @@ -51,6 +51,7 @@ def setup_routers(app: FastAPI): app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)']) app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)']) app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)']) + app.include_router(api_v3_actions_idaa.router, prefix='/v3/action/idaa', tags=['IDAA Actions (V3)']) app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)']) app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)']) # app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index fcdff04..d36bf0d 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -638,6 +638,61 @@ const url = URL.createObjectURL(blob); --- +## 12. IDAA: Server-Side Novi Member Verification + +Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP. + +- **Method:** `GET` +- **Path:** `/v3/action/idaa/novi_member/{uuid}` +- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`) + +### Request + +| Parameter | Location | Required | Description | +|---|---|---|---| +| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) | + +### Response on success (`200 OK`) + +```json +{ + "data": { + "verified": true, + "full_name": "Alice S.", + "email": "alice+member@idaa.org" + } +} +``` + +- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent. +- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`). + +### Error responses + +| Status | Meaning | Frontend action | +|---|---|---| +| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member | +| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry | +| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry | + +### Migration from direct Novi call + +The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping: + +| Direct Novi result | This endpoint returns | Frontend state | +|---|---|---| +| `200` with identity data | `200` | `verified` | +| `200` with no identity data | `404` | `denied` | +| `404` | `404` | `denied` | +| `429` | `429` | `'rate_limited'` | +| Network error / Novi 5xx | `503` | `'api_error'` | + +### Caching + +Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt. + +--- + ## 11. Troubleshooting 403 Forbidden If you receive a 403 on a valid ID: diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 65fdc93..c186fe9 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -39,6 +39,42 @@ - [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token). - [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`. +## 🔌 IDAA: Server-Side Novi Verification (Mini Project) +> **Status: P1–P4 Complete (May 2026).** Endpoint live at `GET /v3/action/idaa/novi_member/{uuid}`. P5 (frontend migration) is the remaining step. +> Rationale and frontend integration notes: `aether_app_sveltekit/documentation/CLIENT__IDAA_and_customized_mods.md` → "Planned: Server-Side Novi Verification" + +**Goal:** Proxy the Novi member-verification call server-to-server (FastAPI → Novi) so members' browser IPs are no longer in the call path. + +- [x] **[P1] New router:** `app/routers/api_v3_actions_idaa.py` + - Route: `GET /v3/action/idaa/novi_member/{uuid}` + - Required auth: `Depends(get_account_context)` — valid API key + any account context (x-account-id, JWT, or bypass). This is the standard V3 gate. + - Reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json` via `_load_idaa_cfg()` (same as Mailman bridge) + - Calls Novi: `GET {novi_api_root_url}/customers/{uuid}` with `Authorization: Basic {api_key}` + - Normalize email: `.replace(' ', '+')` (Novi quirk — see Novi-Mailman bridge notes) + - Build display name: `"{FirstName} {LastName[0]}."` format, fall back to `Name` field + - Returns `{ "verified": true, "full_name": "...", "email": "..." }` on success + - Returns `404` if Novi 200 with no identity data (empty-member anti-pattern) + - Returns `429` if Novi rate limits; `503` if Novi unreachable or 5xx + - Business logic in `app/methods/idaa_novi_verify_methods.py` + +- [x] **[P2] Redis cache:** + - Key: `idaa:novi_member:{uuid}` — TTL 4 hours + - Note: `account_id` dropped from key — Novi credentials are hardcoded to the IDAA site; same UUID always returns the same data regardless of caller, so per-caller scoping wastes Redis space and halves hit rate. + - Cache only verified (200) results — do NOT cache 404 (member may have just joined) + - Uses `redis_client` from `lib_redis_helpers.py` directly + +- [x] **[P3] Register in registry:** Added to `routers/registry.py` at `/v3/action/idaa` tag `IDAA Actions (V3)`. Confirmed live — endpoint appears in `/openapi.json`. + +- [x] **[P4] Tests:** `tests/unit/test_unit_idaa_novi_verify.py` — 9 tests, all passing. + - Mock Novi responses (200/empty-200/404/429/503/unreachable) + - Verify Redis cache is set on 200, hit bypasses Novi call + - Verify email normalization (space → +) + - Verify display name format (5 cases) + +- [ ] **[P5] Coordinate with Frontend Agent** once P1–P3 are done: + - Frontend replaces direct `fetch()` to Novi in `+layout.svelte:verify_novi_uuid()` + - Map response codes: 200 → verified, 404 → denied, 429 → `'rate_limited'`, 503 → `'api_error'` + ## 🛡️ Security & Privacy Baseline (IDAA) - **Status:** **ENFORCED**. - **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change. diff --git a/tests/README.md b/tests/README.md index 8c5f337..b3c1333 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,6 +33,7 @@ These consolidated scripts are the primary verification tool for the V3 API. | `test_e2e_v3_action_novi_mailman.py` | **Novi-Mailman Bridge — Connections**: Verifies Novi AMS and Mailman 3 API credentials are valid (IDAA). Run first before the lists test. | | `test_e2e_v3_action_novi_mailman_lists.py` | **Novi-Mailman Bridge — List Operations**: Full member lifecycle — read roster, subscribe, verify, unsubscribe — against `mm3@idaa.org`, `mm3@dgrzone.com`, `mm3@oneskyit.com`. | | `test_e2e_v3_action_event_exhibit_tracking_export.py` | **Exhibit Leads Export**: Auth/permission guards, CSV column structure, XLSX bytes, and `return_file` mode for the V3 tracking export action. | +| `test_e2e_v3_action_idaa_novi_verify.py` | **IDAA Novi Member Verify**: Auth guard, 200 verified, 404 not-found, 429 rate-limit, 503 unreachable, Redis cache hit, email normalization. (not yet written — add when endpoint is stable) | | `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. | | `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. | | `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. | @@ -81,6 +82,7 @@ Tests exist to be used — run the relevant suite whenever you touch backend cod | User action route changes (sign-in, password, magic link) | `test_e2e_v3_user_action_routes.py` | | File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` | | Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` | +| IDAA Novi member verify changes | `tests/unit/test_unit_idaa_novi_verify.py`, `test_e2e_v3_action_idaa_novi_verify.py` (e2e pending) | | Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.py` | | Any backend change before frontend hand-off | All of the above | @@ -111,6 +113,16 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P ./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py ``` +### Running unit tests with pytest +```bash +./environment/bin/python3 -m pytest tests/unit/ -v +``` + +`pytest` and `pytest-asyncio` are dev-only dependencies (not in `requirements.txt`). After rebuilding the venv (e.g. following an OS Python update), reinstall them: +```bash +./environment/bin/pip install pytest pytest-asyncio +``` + ### Path Requirements Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly. diff --git a/tests/unit/test_unit_idaa_novi_verify.py b/tests/unit/test_unit_idaa_novi_verify.py new file mode 100644 index 0000000..ad95a7b --- /dev/null +++ b/tests/unit/test_unit_idaa_novi_verify.py @@ -0,0 +1,219 @@ +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')