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)