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:
@@ -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}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user