diff --git a/app/models/user_models.py b/app/models/user_models.py index 4b85522..5108673 100644 --- a/app/models/user_models.py +++ b/app/models/user_models.py @@ -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) diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 8d86dde..5ddd6c3 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -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) diff --git a/app/routers/api_v3_actions_user.py b/app/routers/api_v3_actions_user.py new file mode 100644 index 0000000..d5263fb --- /dev/null +++ b/app/routers/api_v3_actions_user.py @@ -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.') diff --git a/app/routers/registry.py b/app/routers/registry.py index b7999ca..e9b22a2 100644 --- a/app/routers/registry.py +++ b/app/routers/registry.py @@ -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']) diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index 357cb45..ae480be 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -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": "", "auth_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": "", "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": "" } } +``` + +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": "" }] } + +// 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. diff --git a/tests/e2e/test_e2e_v3_user_action_routes.py b/tests/e2e/test_e2e_v3_user_action_routes.py new file mode 100644 index 0000000..4bd1baf --- /dev/null +++ b/tests/e2e/test_e2e_v3_user_action_routes.py @@ -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() diff --git a/tests/e2e/test_e2e_v3_user_auth_routes.py b/tests/e2e/test_e2e_v3_user_auth_routes.py index 79d2759..1c25e0a 100644 --- a/tests/e2e/test_e2e_v3_user_auth_routes.py +++ b/tests/e2e/test_e2e_v3_user_auth_routes.py @@ -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}")