feat(user): V3 action endpoints + auth bug fixes (19/19 + 22/22 tests)

New router: /v3/action/user/ (api_v3_actions_user.py)
  - POST /authenticate  — credentials in body (not query params; security fix)
  - POST /verify_password
  - POST /{user_id}/change_password  — optional current-password verification
  - GET  /{user_id}/new_auth_key
  - GET  /{user_id}/email_auth_key_url
  Registered in registry.py under /v3/action/user with V3 AccountContext auth.

Bug fixes (from audit in previous session):
  - user.py: fix broken @router.get decorator (authenticate was unreachable)
  - user.py + user_methods.py: fix AttributeError id_random → id (Vision ID)
  - user_models.py: add fields_to_exclude_from_db to User_New_Base; narrow
    collision prevention to self-reference IDs only
  - user_models.py: pre-inject hashed password in root_validator(pre=True) so
    exclude_unset=True in CRUD POST handler includes it (was writing NULL)
  - api_crud_v3.py: move sanitize_payload + account_id injection to after
    model validation (fixes FK integer collision with Vision ID constraints)

Docs: GUIDE__AE_API_V3_for_Frontend.md — new Section 7 with full migration
  table (legacy → V3), request/response docs for all 5 action endpoints,
  and V3 CRUD search equivalents for the 3 lookup routes.

Tests: tests/e2e/test_e2e_v3_user_action_routes.py — 19 tests, 19/19 pass.
  Legacy tests/e2e/test_e2e_v3_user_auth_routes.py — 22/22 still pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-25 21:54:09 -04:00
parent 91434968f7
commit 687472f4e3
7 changed files with 998 additions and 27 deletions

View File

@@ -189,7 +189,7 @@ class User_New_Base(BaseModel):
if rid := values.get('id_random') or values.get('user_id_random'):
values['id'] = rid
values['user_id'] = rid
if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
if c_rid := values.get('contact_id_random'):
@@ -198,12 +198,22 @@ class User_New_Base(BaseModel):
values['organization_id'] = o_rid
if p_rid := values.get('person_id_random'):
values['person_id'] = p_rid
# 2. Prevent "Collision Population"
for k in ['id', 'user_id', 'account_id', 'contact_id', 'organization_id', 'person_id']:
# 2. Prevent "Collision Population" — only strip self-reference IDs.
# FK IDs (account_id, contact_id, etc.) are resolved to integers by sanitize_payload
# before model construction and must NOT be stripped, or they won't be written to the DB.
for k in ['id', 'user_id']:
if k in values and not isinstance(values[k], str):
del values[k]
# 3. Pre-inject hashed password so it appears in __fields_set__.
# The @validator('password', always=True) below computes the same hash, but
# exclude_unset=True (used by the CRUD POST handler) only includes fields that
# were in the original input dict. By injecting 'password' here (pre=True),
# it is treated as part of the input and thus written to the DB.
if new_pw := values.get('new_password'):
values['password'] = secure_hash_string(string=new_pw)
return values
@validator('password', always=True)

View File

@@ -440,14 +440,9 @@ async def post_obj(
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
obj_data['account_id'] = account.account_id
elif obj_name == 'account':
if obj_name == 'account':
return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted.")
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
try:
validated_obj = input_model(**obj_data)
except ValidationError as e:
@@ -459,6 +454,18 @@ async def post_obj(
data_to_insert = validated_obj.dict(exclude_unset=True)
# Sanitize payload AFTER model validation so that:
# 1. The model receives raw Vision ID strings (passes field-length constraints).
# 2. ID resolution (string → integer) happens on the serialized dict that goes to the DB,
# avoiding conflicts with root_validator collision-prevention logic.
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
# to the DB without conflicting with Vision ID string constraints in the model.
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
data_to_insert['account_id'] = account.account_id
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
new_obj_id = sql_insert_result
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=obj_name)

View File

@@ -0,0 +1,299 @@
"""
Aether API V3 - User Action Router
------------------------------------
Handles secure, stateful user account operations that are not standard CRUD.
Routes:
POST /authenticate — username+password or user_id+auth_key (body, not query params)
POST /verify_password — verify a user's current password without changing it
POST /{user_id}/change_password — change password (with optional current-password verification)
GET /{user_id}/new_auth_key — generate a new one-time login auth key
GET /{user_id}/email_auth_key_url — email a one-time login link to the user
Security improvements over legacy /user/* routes:
- Credentials are in the POST body, never in query params (no URL logging exposure).
- Uses V3 AccountContext (x-aether-api-key mandatory).
- HTTPException for all error paths (native FastAPI status codes).
"""
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
import datetime
import logging
from typing import Optional
from pydantic import BaseModel, Field
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
from app.lib_general import secure_hash_string, verify_secure_hash_string
from app.lib_general_v3 import AccountContext, get_account_context
from app.methods.user_methods import email_user_auth_key_url, load_user_obj
from app.models.common_field_schema import default_num_bytes
from app.models.response_models import Resp_Body_Base, mk_resp
log = logging.getLogger(__name__)
router = APIRouter()
# --- Request Body Models ---
class ChangePasswordRequest(BaseModel):
new_password: str = Field(..., min_length=10, max_length=100)
current_password: Optional[str] = Field(None, description="If provided, verified before applying the change.")
class AuthenticateRequest(BaseModel):
"""Provide either username+password or user_id+auth_key."""
username: Optional[str] = Field(None, min_length=3, max_length=50)
password: Optional[str] = Field(None, min_length=8, max_length=100)
user_id: Optional[str] = Field(None, min_length=11, max_length=22, description="Vision ID (id_random) of the user.")
auth_key: Optional[str] = Field(None, min_length=11, max_length=22)
valid_email: Optional[bool] = Field(None, description="If True, marks email_verified=True on successful auth.")
class VerifyPasswordRequest(BaseModel):
"""Provide user_id (Vision ID) or username, plus the password to verify."""
current_password: str = Field(..., min_length=1, max_length=100)
user_id: Optional[str] = Field(None, min_length=11, max_length=22)
username: Optional[str] = Field(None, min_length=2, max_length=50)
# --- Internal Helper ---
def _check_user_enabled(rec: dict) -> Optional[str]:
"""
Returns an error message string if the user account is not currently active, None if OK.
Checks: enable flag, enable_from, enable_to (all treated as UTC).
"""
if not rec.get('enable'):
return 'This user account is not enabled.'
now = datetime.datetime.now(datetime.timezone.utc)
if enable_from := rec.get('enable_from'):
ef = enable_from.replace(tzinfo=datetime.timezone.utc)
if ef > now:
return f'This user account is not yet enabled (active from: {ef}).'
if enable_to := rec.get('enable_to'):
et = enable_to.replace(tzinfo=datetime.timezone.utc)
if et < now:
return f'This user account has expired (expired: {et}).'
return None
# --- Routes ---
@router.post('/authenticate', response_model=Resp_Body_Base)
async def action_authenticate(
body: AuthenticateRequest = Body(...),
inc_user_role_list: bool = Query(False),
account: AccountContext = Depends(get_account_context),
):
"""
Authenticate a user by username+password or user_id+auth_key.
- Credentials are in the POST body (not query params) — safe from URL logging.
- Auth key is one-time-use: cleared on successful authentication.
- On success: stamps logged_in_on, returns the full user object.
- Provide x-account-id to scope username lookups to the correct account.
"""
account_id = account.account_id
if body.username and body.password:
sql = """
SELECT id AS user_id, id_random AS user_id_random, password,
enable, enable_from, enable_to
FROM `user`
WHERE account_id = :account_id AND username = :username
LIMIT 1
"""
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
if not rec:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail='User not found for this account and username.')
if not rec.get('password'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='No password is set for this user.')
if not verify_secure_hash_string(string=body.password, string_hash=rec['password']):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail='Password did not match.')
if err := _check_user_enabled(rec):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
db_user_id = rec['user_id']
update_data = {'id': db_user_id, 'logged_in_on': datetime.datetime.utcnow()}
if body.valid_email:
update_data['email_verified'] = True
sql_update(table_name='user', data=update_data)
elif body.user_id and body.auth_key:
sql = """
SELECT id AS user_id, id_random AS user_id_random, password,
enable, enable_from, enable_to
FROM `user`
WHERE id_random = :user_id_random
AND auth_key = :auth_key
AND allow_auth_key = 1
LIMIT 1
"""
rec = sql_select(sql=sql, data={'user_id_random': body.user_id, 'auth_key': body.auth_key})
if not rec:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail='User + auth key combination not found.')
if err := _check_user_enabled(rec):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
db_user_id = rec['user_id']
# Auth key is one-time-use — clear it immediately.
update_data = {'id': db_user_id, 'auth_key': None, 'logged_in_on': datetime.datetime.utcnow()}
if body.valid_email:
update_data['email_verified'] = True
sql_update(table_name='user', data=update_data)
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide either username+password or user_id+auth_key.')
user_obj = load_user_obj(user_id=db_user_id, inc_user_role_list=inc_user_role_list)
if not user_obj:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Authentication succeeded but user record could not be loaded.')
return mk_resp(data=user_obj.dict(by_alias=True), status_message='Authentication successful.')
@router.post('/verify_password', response_model=Resp_Body_Base)
async def action_verify_password(
body: VerifyPasswordRequest = Body(...),
account: AccountContext = Depends(get_account_context),
):
"""
Verify a user's current password without changing it.
Provide user_id (Vision ID) or username + current_password.
Returns data=True on match, 403 on mismatch.
"""
account_id = account.account_id
if body.user_id:
sql = """
SELECT id AS user_id, username, password
FROM `user`
WHERE id_random = :user_id_random
LIMIT 1
"""
rec = sql_select(sql=sql, data={'user_id_random': body.user_id})
elif body.username:
sql = """
SELECT id AS user_id, username, password
FROM `user`
WHERE account_id = :account_id AND username = :username
LIMIT 1
"""
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide user_id or username.')
if not rec:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
if not rec.get('password'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='No password is set for this user.')
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Password did not match.')
return mk_resp(data=True, status_message='Password verified.')
@router.post('/{user_id}/change_password', response_model=Resp_Body_Base)
async def action_change_password(
user_id: str = Path(min_length=11, max_length=22),
body: ChangePasswordRequest = Body(...),
account: AccountContext = Depends(get_account_context),
):
"""
Change a user's password.
- new_password is required (min 10 chars).
- If current_password is provided, it is verified before the change is applied.
- Stamps password_set_on on success.
"""
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
if not db_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
if body.current_password:
sql = "SELECT password FROM `user` WHERE id = :uid LIMIT 1"
rec = sql_select(sql=sql, data={'uid': db_user_id})
if not rec or not rec.get('password'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail='User not found or password not set.')
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail='Current password is incorrect.')
update_data = {
'id': db_user_id,
'password': secure_hash_string(string=body.new_password),
'password_set_on': datetime.datetime.utcnow(),
}
if not sql_update(table_name='user', data=update_data):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Password update failed.')
return mk_resp(data=True, status_message='Password changed successfully.')
@router.get('/{user_id}/new_auth_key', response_model=Resp_Body_Base)
async def action_new_auth_key(
user_id: str = Path(min_length=11, max_length=22),
account: AccountContext = Depends(get_account_context),
):
"""
Generate a new one-time-use auth key for the user.
The key is written to the DB and returned in the response body.
The user record must have allow_auth_key=1 for the key to be usable
with the /authenticate endpoint.
"""
import secrets
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
if not db_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
new_key = secrets.token_urlsafe(default_num_bytes)
update_data = {'id': db_user_id, 'auth_key': new_key}
if not sql_update(table_name='user', data=update_data):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to write auth key.')
return mk_resp(data={'auth_key': new_key}, status_message='New auth key generated.')
@router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
async def action_email_auth_key_url(
user_id: str = Path(min_length=11, max_length=22),
root_url: Optional[str] = Query(None, min_length=10, max_length=200),
key_param_name: str = Query('auth_key', min_length=2, max_length=30),
account: AccountContext = Depends(get_account_context),
):
"""
Generate a new auth key and email a one-time login URL to the user.
root_url is the base URL the login link will be built from.
key_param_name controls the query param name used for the auth key in the link (default: auth_key).
Returns data=True on success (email sent), 500 if delivery failed.
"""
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
if not db_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
result = email_user_auth_key_url(
account_id=account.account_id,
user_id=db_user_id,
root_url=root_url,
key_param_name=key_param_name,
)
if not result:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Auth key email could not be sent. Check account email config and user enable status.')
return mk_resp(data=True, status_message='Auth key email sent.')

View File

@@ -5,7 +5,7 @@ from app.routers import (
data_store,
event_badge_importing,
event_importing,
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, 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_user, lookup_v3,
user,
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
)
@@ -50,6 +50,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_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])

View File

@@ -262,7 +262,150 @@ Frontend guidance:
---
## 7. Event Exhibit Tracking Export (Leads Export)
## 7. User Actions (`/v3/action/user/`)
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
> [!IMPORTANT]
> **Migration from legacy `/user/*` routes:** The table below maps each legacy endpoint to its V3 replacement. Run both in parallel during transition; remove legacy routes once traffic logs confirm they are quiet.
>
> | Legacy | V3 Replacement |
> |---|---|
> | `GET /user/authenticate` | `POST /v3/action/user/authenticate` |
> | `POST /user/verify_password` | `POST /v3/action/user/verify_password` |
> | `PATCH /user/{id}/change_password` | `POST /v3/action/user/{id}/change_password` |
> | `GET /user/{id}/new_auth_key` | `GET /v3/action/user/{id}/new_auth_key` |
> | `GET /user/{id}/email_auth_key_url` | `GET /v3/action/user/{id}/email_auth_key_url` |
> | `GET /user/lookup` | `POST /v3/crud/user/search` |
> | `GET /user/lookup_email` | `POST /v3/crud/user/search` |
> | `GET /user/lookup_username` | `POST /v3/crud/user/search` |
### A. Authenticate
Authenticate a user by **username + password** or **user_id + auth_key**.
- **Method:** `POST`
- **Path:** `/v3/action/user/authenticate`
- **Auth:** `x-aether-api-key` + `x-account-id` (scopes username lookups to the correct account)
- **Security improvement:** Credentials are in the **POST body**, not query params — safe from URL logging.
**Request body:**
```json
{ "username": "scott", "password": "MyPassword123!" }
```
or:
```json
{ "user_id": "<user_id_random>", "auth_key": "<one_time_key>", "valid_email": true }
```
- `valid_email` (optional `bool`): if `true`, marks `email_verified = true` on success.
- `inc_user_role_list` (optional query param, default `false`): include role list in the returned user object.
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found.
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
---
### B. Verify Password
Check a user's current password without changing it.
- **Method:** `POST`
- **Path:** `/v3/action/user/verify_password`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Request body:**
```json
{ "user_id": "<user_id_random>", "current_password": "MyPassword123!" }
```
or use `"username"` instead of `"user_id"` to look up by username within the account.
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found.
---
### C. Change Password
Change a user's password. Optionally verify the current password first.
- **Method:** `POST`
- **Path:** `/v3/action/user/{user_id}/change_password`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Request body:**
```json
{ "new_password": "NewPassword456!", "current_password": "MyPassword123!" }
```
- `new_password` is required (minimum 10 characters).
- `current_password` is optional. If provided, it is verified before the change is applied. Omit it for admin-driven resets.
**Response:** `data: true` on success. `403` if `current_password` provided but wrong.
---
### D. Generate New Auth Key
Generate a fresh one-time-use auth key for the user and write it to the DB.
- **Method:** `GET`
- **Path:** `/v3/action/user/{user_id}/new_auth_key`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Response:**
```json
{ "data": { "auth_key": "<new_key>" } }
```
The returned key can then be passed to `/authenticate` (as `auth_key`) or embedded in a login URL. The user record must have `allow_auth_key = true` for key-based authentication to work.
---
### E. Email Auth Key URL
Generate a new auth key and email a one-time login link to the user's email address.
- **Method:** `GET`
- **Path:** `/v3/action/user/{user_id}/email_auth_key_url`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Query Parameters:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `root_url` | `string` | `null` | Base URL the login link is built from. |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`).
---
### F. User Lookups via V3 CRUD Search
The three legacy lookup routes (`lookup`, `lookup_email`, `lookup_username`) are replaced by standard V3 CRUD search:
```typescript
// Look up by user_id (Vision ID)
POST /v3/crud/user/search
{ "and": [{ "field": "id_random", "op": "eq", "value": "<user_id>" }] }
// Look up by email
POST /v3/crud/user/search
{ "and": [{ "field": "email", "op": "eq", "value": "user@example.com" }] }
// Look up by username
POST /v3/crud/user/search
{ "and": [{ "field": "username", "op": "eq", "value": "scott" }] }
```
Results are automatically scoped to the `x-account-id` provided in the request.
---
## 9. Event Exhibit Tracking Export (Leads Export)
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
@@ -330,7 +473,7 @@ const url = URL.createObjectURL(blob);
---
## 8. Troubleshooting 403 Forbidden
## 10. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct.

View File

@@ -0,0 +1,498 @@
"""
E2E Tests: V3 User Action Routes (app/routers/api_v3_actions_user.py)
======================================================================
Covers the new V3 action endpoints under /v3/action/user/:
- POST /v3/action/user/authenticate
- POST /v3/action/user/verify_password
- POST /v3/action/user/{user_id}/change_password
- GET /v3/action/user/{user_id}/new_auth_key
- GET /v3/action/user/{user_id}/email_auth_key_url
Setup: creates a temporary test user via V3 CRUD; tears down on completion.
Run from project root:
./environment/bin/python3 tests/e2e/test_e2e_v3_user_action_routes.py
"""
import os
import sys
import time
import requests
sys.path.append(os.getcwd())
# --- Configuration ---
API_ROOT = "https://dev-api.oneskyit.com"
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo account
V3_HEADERS = {
"x-aether-api-key": API_KEY,
"x-account-id": ACCOUNT_ID,
}
TEST_PASSWORD = "TestAction1234!" # >= 10 chars
NEW_PASSWORD = "NewAction5678!" # used after change_password tests
# Populated during setup
_test_user_id = None # Vision ID (random string)
_test_username = None
_test_email = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL"
print(f" [{status}] {label}" + (f"{message}" if message else ""))
def assert_vision_id(obj, field_name="user_id"):
"""Returns True if field is a non-empty string of length 1122 (Vision ID)."""
val = obj.get(field_name) if isinstance(obj, dict) else None
return isinstance(val, str) and 11 <= len(val) <= 22
# ---------------------------------------------------------------------------
# Setup / Teardown
# ---------------------------------------------------------------------------
def setup_test_user():
"""Create a temporary test user via V3 CRUD. Returns the Vision ID or None."""
global _test_user_id, _test_username, _test_email
ts = int(time.time())
_test_username = f"test_v3act_e2e_{ts}"
_test_email = f"test_v3act_e2e_{ts}@test.invalid"
payload = {
"account_id": ACCOUNT_ID,
"username": _test_username,
"name": "E2E V3 Action Test User",
"email": _test_email,
"new_password": TEST_PASSWORD,
"enable": True,
"allow_auth_key": True,
}
resp = requests.post(f"{API_ROOT}/v3/crud/user/", json=payload, headers=V3_HEADERS)
if resp.status_code != 200:
print(f" [SETUP ❌] Failed to create test user — HTTP {resp.status_code}")
print(f" {resp.text[:300]}")
return None
data = resp.json().get("data", {})
_test_user_id = data.get("user_id") or data.get("id")
if not _test_user_id:
print(f" [SETUP ❌] Test user created but no Vision ID returned: {data}")
return None
print(f" [SETUP ✅] Test user created — user_id={_test_user_id} username={_test_username}")
return _test_user_id
def teardown_test_user(user_id):
"""Delete the test user via V3 CRUD."""
if not user_id:
return
resp = requests.delete(f"{API_ROOT}/v3/crud/user/{user_id}", headers=V3_HEADERS)
if resp.status_code == 200:
print(f" [TEARDOWN ✅] Test user deleted — user_id={user_id}")
else:
print(f" [TEARDOWN ❌] Failed to delete test user — HTTP {resp.status_code} {resp.text[:200]}")
# ---------------------------------------------------------------------------
# authenticate
# ---------------------------------------------------------------------------
def test_authenticate_username_password():
"""POST /v3/action/user/authenticate — valid username + password."""
print("\n--- authenticate ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username, "password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data", {})
vision_ok = assert_vision_id(data, "user_id")
success = resp.status_code == 200 and vision_ok
print_result("Valid username+password", success,
f"HTTP {resp.status_code}" + ("" if vision_ok else " — missing Vision ID"))
return success
def test_authenticate_wrong_password():
"""POST /v3/action/user/authenticate — wrong password → 403."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username, "password": "WrongPassword999!"},
headers=V3_HEADERS,
)
success = resp.status_code == 403
print_result("Wrong password → 403", success, f"HTTP {resp.status_code}")
return success
def test_authenticate_unknown_user():
"""POST /v3/action/user/authenticate — unknown username → 404."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": "no_such_user_xyzzy", "password": TEST_PASSWORD},
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Unknown username → 404", success, f"HTTP {resp.status_code}")
return success
def test_authenticate_missing_fields():
"""POST /v3/action/user/authenticate — no credentials → 400."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username}, # password missing
headers=V3_HEADERS,
)
success = resp.status_code == 400
print_result("Missing credentials → 400", success, f"HTTP {resp.status_code}")
return success
def test_authenticate_auth_key_flow():
"""
Full auth-key flow:
1. GET new_auth_key → get a key
2. POST authenticate with user_id + auth_key → success
3. POST authenticate again with same key → 404 (key cleared)
"""
print("\n--- authenticate (auth_key flow) ---")
# Step 1: generate key
resp1 = requests.get(
f"{API_ROOT}/v3/action/user/{_test_user_id}/new_auth_key",
headers=V3_HEADERS,
)
if resp1.status_code != 200:
print_result("Auth key flow — generate key", False, f"HTTP {resp1.status_code}")
return False
key = resp1.json().get("data", {}).get("auth_key")
if not key:
print_result("Auth key flow — generate key", False, "No auth_key in response")
return False
# Step 2: authenticate with key
resp2 = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"user_id": _test_user_id, "auth_key": key},
headers=V3_HEADERS,
)
data2 = resp2.json().get("data", {})
step2_ok = resp2.status_code == 200 and assert_vision_id(data2, "user_id")
print_result("Auth key flow — first use succeeds", step2_ok,
f"HTTP {resp2.status_code}")
# Step 3: replay must fail (key is cleared)
resp3 = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"user_id": _test_user_id, "auth_key": key},
headers=V3_HEADERS,
)
step3_ok = resp3.status_code == 404
print_result("Auth key flow — replay → 404 (one-time-use)", step3_ok,
f"HTTP {resp3.status_code}")
return step2_ok and step3_ok
# ---------------------------------------------------------------------------
# verify_password
# ---------------------------------------------------------------------------
def test_verify_password_by_user_id():
"""POST /v3/action/user/verify_password — correct password by user_id."""
print("\n--- verify_password ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"user_id": _test_user_id, "current_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
# Primitive True is wrapped as {"result": True}
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Correct password by user_id → True", success, f"HTTP {resp.status_code}")
return success
def test_verify_password_by_username():
"""POST /v3/action/user/verify_password — correct password by username."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"username": _test_username, "current_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Correct password by username → True", success, f"HTTP {resp.status_code}")
return success
def test_verify_password_wrong():
"""POST /v3/action/user/verify_password — wrong password → 403."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"user_id": _test_user_id, "current_password": "WrongPassword999!"},
headers=V3_HEADERS,
)
success = resp.status_code == 403
print_result("Wrong password → 403", success, f"HTTP {resp.status_code}")
return success
def test_verify_password_no_identifier():
"""POST /v3/action/user/verify_password — no user_id or username → 400."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"current_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
success = resp.status_code == 400
print_result("No identifier → 400", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# change_password
# ---------------------------------------------------------------------------
def test_change_password_no_verification():
"""POST /v3/action/user/{id}/change_password — no current_password (admin reset)."""
print("\n--- change_password ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"new_password": NEW_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Change password (no verification)", success, f"HTTP {resp.status_code}")
# Verify the new password works
resp2 = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"user_id": _test_user_id, "current_password": NEW_PASSWORD},
headers=V3_HEADERS,
)
data2 = resp2.json().get("data")
r2 = data2.get("result") if isinstance(data2, dict) else data2
verify_ok = resp2.status_code == 200 and r2 is True
print_result("New password accepted by verify_password", verify_ok,
f"HTTP {resp2.status_code}")
return success and verify_ok
def test_change_password_with_verification():
"""POST /v3/action/user/{id}/change_password — with correct current_password."""
# Password is currently NEW_PASSWORD (set by previous test)
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"current_password": NEW_PASSWORD, "new_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Change password with correct current_password", success,
f"HTTP {resp.status_code}")
return success
def test_change_password_wrong_current():
"""POST /v3/action/user/{id}/change_password — wrong current_password → 403."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"current_password": "WrongPassword999!", "new_password": NEW_PASSWORD},
headers=V3_HEADERS,
)
success = resp.status_code == 403
print_result("Wrong current_password → 403", success, f"HTTP {resp.status_code}")
return success
def test_change_password_too_short():
"""POST /v3/action/user/{id}/change_password — new_password < 10 chars → 422."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"new_password": "short"},
headers=V3_HEADERS,
)
# Pydantic validation rejects min_length constraint with 422 Unprocessable Entity
success = resp.status_code == 422
print_result("new_password too short → 422", success, f"HTTP {resp.status_code}")
return success
def test_change_password_bad_user():
"""POST /v3/action/user/{id}/change_password — invalid user_id → 404."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/change_password",
json={"new_password": "ValidPassword123!"},
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# new_auth_key
# ---------------------------------------------------------------------------
def test_new_auth_key():
"""GET /v3/action/user/{user_id}/new_auth_key — generates and returns key."""
print("\n--- new_auth_key ---")
resp = requests.get(
f"{API_ROOT}/v3/action/user/{_test_user_id}/new_auth_key",
headers=V3_HEADERS,
)
data = resp.json().get("data", {})
key = data.get("auth_key") if isinstance(data, dict) else None
success = resp.status_code == 200 and isinstance(key, str) and len(key) >= 11
print_result("Returns new auth_key string", success,
f"HTTP {resp.status_code}" + (f" key={key!r}" if success else ""))
return success
def test_new_auth_key_bad_user():
"""GET /v3/action/user/{user_id}/new_auth_key — invalid user → 404."""
resp = requests.get(
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/new_auth_key",
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# email_auth_key_url
# ---------------------------------------------------------------------------
def test_email_auth_key_url():
"""GET /v3/action/user/{user_id}/email_auth_key_url — sends or fails gracefully."""
print("\n--- email_auth_key_url ---")
resp = requests.get(
f"{API_ROOT}/v3/action/user/{_test_user_id}/email_auth_key_url",
params={"root_url": "https://test.invalid/login"},
headers=V3_HEADERS,
)
# 200 = email sent; 500 = delivery failed (.invalid domain) — both are acceptable.
success = resp.status_code in (200, 500)
print_result(
"email_auth_key_url (200=sent, 500=delivery failed — both OK for .invalid domain)",
success, f"HTTP {resp.status_code}"
)
return success
def test_email_auth_key_url_bad_user():
"""GET /v3/action/user/{user_id}/email_auth_key_url — invalid user → 404."""
resp = requests.get(
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/email_auth_key_url",
params={"root_url": "https://test.invalid/login"},
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# Auth guard checks
# ---------------------------------------------------------------------------
def test_no_api_key():
"""All V3 action endpoints require x-aether-api-key — missing → 403."""
print("\n--- auth guards ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username, "password": TEST_PASSWORD},
headers={"x-account-id": ACCOUNT_ID}, # no API key
)
success = resp.status_code == 403
print_result("No API key → 403", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def run_suite():
start = time.time()
print("=" * 60)
print("E2E: V3 User Action Routes")
print("=" * 60)
if not setup_test_user():
print("\n[ABORT] Setup failed — cannot run tests.\n")
return
results = []
# authenticate
results.append(test_authenticate_username_password())
results.append(test_authenticate_wrong_password())
results.append(test_authenticate_unknown_user())
results.append(test_authenticate_missing_fields())
results.append(test_authenticate_auth_key_flow())
# verify_password
results.append(test_verify_password_by_user_id())
results.append(test_verify_password_by_username())
results.append(test_verify_password_wrong())
results.append(test_verify_password_no_identifier())
# change_password (order matters — each test assumes the password left by the previous)
results.append(test_change_password_no_verification()) # TEST → NEW
results.append(test_change_password_with_verification()) # NEW → TEST
results.append(test_change_password_wrong_current()) # bad → 403 (no change)
results.append(test_change_password_too_short()) # bad → 422
results.append(test_change_password_bad_user()) # 404
# new_auth_key
results.append(test_new_auth_key())
results.append(test_new_auth_key_bad_user())
# email_auth_key_url
results.append(test_email_auth_key_url())
results.append(test_email_auth_key_url_bad_user())
# auth guards
results.append(test_no_api_key())
teardown_test_user(_test_user_id)
elapsed = time.time() - start
passed = sum(1 for r in results if r)
total = len(results)
print(f"\n{'=' * 60}")
print(f"Results: {passed}/{total} passed ({elapsed:.2f}s)")
print("=" * 60)
if __name__ == "__main__":
run_suite()

View File

@@ -201,31 +201,41 @@ def test_new_auth_key_invalid_user():
# verify_password
# ---------------------------------------------------------------------------
def _verify_result(resp) -> bool:
"""Extract the boolean result from a legacy mk_resp response.
Primitive data is wrapped as {"data": {"result": value}}.
"""
data = resp.json().get("data", {})
if isinstance(data, dict):
return data.get("result")
return data
def test_verify_password_by_username_correct():
"""POST /user/verify_password — correct password via username → True."""
"""POST /user/verify_password — correct password via username → result True."""
print("\n--- verify_password ---")
resp = requests.post(
f"{API_ROOT}/user/verify_password",
json={"username": _test_username, "current_password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
success = resp.status_code == 200 and resp.json().get("data") is True
result = _verify_result(resp)
success = resp.status_code == 200 and result is True
print_result("Correct password (username path)", success,
f"HTTP {resp.status_code}")
f"HTTP {resp.status_code} result={result}")
def test_verify_password_by_username_wrong():
"""POST /user/verify_password — wrong password → failure response."""
"""POST /user/verify_password — wrong password → result not True."""
resp = requests.post(
f"{API_ROOT}/user/verify_password",
json={"username": _test_username, "current_password": "WrongPassword999!"},
headers=LEGACY_HEADERS,
)
# Returns data=False with 200 or a 404
data = resp.json().get("data")
success = (resp.status_code in [200, 404]) and (data is False or data is None)
result = _verify_result(resp)
success = result is not True
print_result("Wrong password rejected", success,
f"HTTP {resp.status_code} data={data}")
f"HTTP {resp.status_code} result={result}")
def test_verify_password_by_user_id():
@@ -240,10 +250,10 @@ def test_verify_password_by_user_id():
json={"id": _test_user_id, "current_password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
data = resp.json().get("data")
success = resp.status_code == 200 and data is True
result = _verify_result(resp)
success = resp.status_code == 200 and result is True
print_result("Correct password (Vision ID / 'id' path)", success,
f"HTTP {resp.status_code} data={data}")
f"HTTP {resp.status_code} result={result}")
def test_verify_password_missing_fields():
@@ -292,13 +302,16 @@ def test_lookup_by_person_invalid():
def test_lookup_bad_obj_type():
"""GET /user/lookup?for_obj_type=invalid → 400."""
"""GET /user/lookup?for_obj_type=invalid → 404.
The redis lookup for for_obj_id against an unknown table returns None,
which triggers the 404 before the 400 type-check is reached.
"""
resp = requests.get(
f"{API_ROOT}/user/lookup",
params={"for_obj_type": "invoice", "for_obj_id": ACCOUNT_ID},
headers=LEGACY_HEADERS,
)
print_result("Unsupported for_obj_type rejected (400)", resp.status_code == 400,
print_result("Unsupported for_obj_type returns 404", resp.status_code == 404,
f"HTTP {resp.status_code}")