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>
This commit is contained in:
154
app/methods/idaa_novi_verify_methods.py
Normal file
154
app/methods/idaa_novi_verify_methods.py
Normal file
@@ -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
|
||||||
41
app/routers/api_v3_actions_idaa.py
Normal file
41
app/routers/api_v3_actions_idaa.py
Normal file
@@ -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.'))
|
||||||
@@ -6,7 +6,7 @@ from app.routers import (
|
|||||||
event_badge_importing,
|
event_badge_importing,
|
||||||
event_importing,
|
event_importing,
|
||||||
api_v3_actions_email,
|
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,
|
user,
|
||||||
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
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_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_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_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_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(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
|
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
|
||||||
|
|||||||
@@ -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
|
## 11. Troubleshooting 403 Forbidden
|
||||||
|
|
||||||
If you receive a 403 on a valid ID:
|
If you receive a 403 on a valid ID:
|
||||||
|
|||||||
@@ -39,6 +39,42 @@
|
|||||||
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
|
- [ ] **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`.
|
- [ ] **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)
|
## 🛡️ Security & Privacy Baseline (IDAA)
|
||||||
- **Status:** **ENFORCED**.
|
- **Status:** **ENFORCED**.
|
||||||
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.
|
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.
|
||||||
|
|||||||
@@ -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.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_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_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_accounts.py` | CRUD verification for the core Account object. |
|
||||||
| `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. |
|
| `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. |
|
| `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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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 |
|
| 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
|
./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
|
### 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.
|
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.
|
||||||
|
|
||||||
|
|||||||
219
tests/unit/test_unit_idaa_novi_verify.py
Normal file
219
tests/unit/test_unit_idaa_novi_verify.py
Normal file
@@ -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')
|
||||||
Reference in New Issue
Block a user