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:
@@ -189,7 +189,7 @@ class User_New_Base(BaseModel):
|
|||||||
if rid := values.get('id_random') or values.get('user_id_random'):
|
if rid := values.get('id_random') or values.get('user_id_random'):
|
||||||
values['id'] = rid
|
values['id'] = rid
|
||||||
values['user_id'] = rid
|
values['user_id'] = rid
|
||||||
|
|
||||||
if a_rid := values.get('account_id_random'):
|
if a_rid := values.get('account_id_random'):
|
||||||
values['account_id'] = a_rid
|
values['account_id'] = a_rid
|
||||||
if c_rid := values.get('contact_id_random'):
|
if c_rid := values.get('contact_id_random'):
|
||||||
@@ -198,12 +198,22 @@ class User_New_Base(BaseModel):
|
|||||||
values['organization_id'] = o_rid
|
values['organization_id'] = o_rid
|
||||||
if p_rid := values.get('person_id_random'):
|
if p_rid := values.get('person_id_random'):
|
||||||
values['person_id'] = p_rid
|
values['person_id'] = p_rid
|
||||||
|
|
||||||
# 2. Prevent "Collision Population"
|
# 2. Prevent "Collision Population" — only strip self-reference IDs.
|
||||||
for k in ['id', 'user_id', 'account_id', 'contact_id', 'organization_id', 'person_id']:
|
# 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):
|
if k in values and not isinstance(values[k], str):
|
||||||
del values[k]
|
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
|
return values
|
||||||
|
|
||||||
@validator('password', always=True)
|
@validator('password', always=True)
|
||||||
|
|||||||
@@ -440,14 +440,9 @@ async def post_obj(
|
|||||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
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 not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||||
if 'account_id' in input_model.__fields__:
|
if obj_name == 'account':
|
||||||
obj_data['account_id'] = account.account_id
|
|
||||||
elif obj_name == 'account':
|
|
||||||
return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted.")
|
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:
|
try:
|
||||||
validated_obj = input_model(**obj_data)
|
validated_obj = input_model(**obj_data)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
@@ -459,6 +454,18 @@ async def post_obj(
|
|||||||
|
|
||||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
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):
|
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||||
new_obj_id = sql_insert_result
|
new_obj_id = sql_insert_result
|
||||||
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=obj_name)
|
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=obj_name)
|
||||||
|
|||||||
299
app/routers/api_v3_actions_user.py
Normal file
299
app/routers/api_v3_actions_user.py
Normal 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.')
|
||||||
@@ -5,7 +5,7 @@ from app.routers import (
|
|||||||
data_store,
|
data_store,
|
||||||
event_badge_importing,
|
event_badge_importing,
|
||||||
event_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,
|
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
|
||||||
)
|
)
|
||||||
@@ -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_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_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.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
|
||||||
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
|
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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:
|
If you receive a 403 on a valid ID:
|
||||||
1. Verify `x-aether-api-key` is correct.
|
1. Verify `x-aether-api-key` is correct.
|
||||||
|
|||||||
498
tests/e2e/test_e2e_v3_user_action_routes.py
Normal file
498
tests/e2e/test_e2e_v3_user_action_routes.py
Normal 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 11–22 (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()
|
||||||
@@ -201,31 +201,41 @@ def test_new_auth_key_invalid_user():
|
|||||||
# verify_password
|
# 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():
|
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 ---")
|
print("\n--- verify_password ---")
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{API_ROOT}/user/verify_password",
|
f"{API_ROOT}/user/verify_password",
|
||||||
json={"username": _test_username, "current_password": NEW_PASSWORD},
|
json={"username": _test_username, "current_password": NEW_PASSWORD},
|
||||||
headers=LEGACY_HEADERS,
|
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,
|
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():
|
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(
|
resp = requests.post(
|
||||||
f"{API_ROOT}/user/verify_password",
|
f"{API_ROOT}/user/verify_password",
|
||||||
json={"username": _test_username, "current_password": "WrongPassword999!"},
|
json={"username": _test_username, "current_password": "WrongPassword999!"},
|
||||||
headers=LEGACY_HEADERS,
|
headers=LEGACY_HEADERS,
|
||||||
)
|
)
|
||||||
# Returns data=False with 200 or a 404
|
result = _verify_result(resp)
|
||||||
data = resp.json().get("data")
|
success = result is not True
|
||||||
success = (resp.status_code in [200, 404]) and (data is False or data is None)
|
|
||||||
print_result("Wrong password rejected", success,
|
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():
|
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},
|
json={"id": _test_user_id, "current_password": NEW_PASSWORD},
|
||||||
headers=LEGACY_HEADERS,
|
headers=LEGACY_HEADERS,
|
||||||
)
|
)
|
||||||
data = resp.json().get("data")
|
result = _verify_result(resp)
|
||||||
success = resp.status_code == 200 and data is True
|
success = resp.status_code == 200 and result is True
|
||||||
print_result("Correct password (Vision ID / 'id' path)", success,
|
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():
|
def test_verify_password_missing_fields():
|
||||||
@@ -292,13 +302,16 @@ def test_lookup_by_person_invalid():
|
|||||||
|
|
||||||
|
|
||||||
def test_lookup_bad_obj_type():
|
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(
|
resp = requests.get(
|
||||||
f"{API_ROOT}/user/lookup",
|
f"{API_ROOT}/user/lookup",
|
||||||
params={"for_obj_type": "invoice", "for_obj_id": ACCOUNT_ID},
|
params={"for_obj_type": "invoice", "for_obj_id": ACCOUNT_ID},
|
||||||
headers=LEGACY_HEADERS,
|
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}")
|
f"HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user