diff --git a/app/api/admin_users.py b/app/api/admin_users.py index c0f92cbd..67d453ef 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -7,7 +7,7 @@ user accounts directly, without requiring email verification. """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, status @@ -15,9 +15,10 @@ from pydantic import BaseModel, Field from sqlalchemy import func from sqlalchemy.orm import Session +from app.config import settings from app.database import get_db from app.models import FileRecord, LocalUser, UserProfile -from app.utils.local_auth import hash_password +from app.utils.local_auth import generate_token, hash_password, send_password_reset_email logger = logging.getLogger(__name__) router = APIRouter(prefix="/admin/users", tags=["admin-users"]) @@ -123,6 +124,21 @@ class LocalUserCreate(BaseModel): is_admin: bool = Field(default=False, description="Grant admin privileges") +class LocalUserUpdate(BaseModel): + """Body for admin-updating a local (email/password) user account.""" + + email: str | None = Field(default=None, max_length=255, description="New email address") + display_name: str | None = Field(default=None, max_length=255, description="New display name") + is_admin: bool | None = Field(default=None, description="Grant or revoke admin privileges") + is_active: bool | None = Field(default=None, description="Activate or deactivate the account") + + +class LocalUserSetPassword(BaseModel): + """Body for admin setting a temporary password for a local user.""" + + password: str = Field(..., min_length=8, max_length=128, description="New temporary password") + + class LocalUserResponse(BaseModel): """Summary of a local user account.""" @@ -355,6 +371,137 @@ def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> N logger.info("Admin deleted local user account: %s", user.email) +@router.patch("/local/{local_user_id}", summary="Update a local user account") +def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update the email address, display name, admin flag, or active status of a local user account. + + Only fields explicitly provided (non-None) are modified. If the email is changed + the associated UserProfile row is also updated to keep ``user_id`` in sync. + + Raises: + 404: Local user not found. + 409: The new email is already taken by another account. + """ + user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.") + + old_email = user.email + + if body.email is not None and body.email != user.email: + if db.query(LocalUser).filter(LocalUser.email == body.email, LocalUser.id != local_user_id).first(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.") + user.email = body.email + + if body.display_name is not None: + # Normalise empty string to None so that clearing the field removes the display name + user.display_name = body.display_name or None + + if body.is_admin is not None: + user.is_admin = body.is_admin + + if body.is_active is not None: + user.is_active = body.is_active + + try: + db.flush() + # Keep UserProfile.user_id in sync when email changes + if body.email is not None and body.email != old_email: + profile = db.query(UserProfile).filter(UserProfile.user_id == old_email).first() + if profile: + profile.user_id = body.email + db.commit() + db.refresh(user) + except Exception: + db.rollback() + raise + + logger.info("Admin updated local user %s (id=%d)", user.email, user.id) + return { + "id": user.id, + "email": user.email, + "username": user.username, + "display_name": user.display_name, + "is_active": user.is_active, + "is_admin": user.is_admin, + "created_at": user.created_at.isoformat() if user.created_at else None, + } + + +@router.post( + "/local/{local_user_id}/send-password-reset", + status_code=status.HTTP_200_OK, + summary="Send a password reset email to a local user", +) +def admin_send_password_reset(local_user_id: int, request: Request, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Generate a password reset token and email the reset link to the local user. + + This is a last-resort tool for admins to help users who are locked out. + Returns ``{"sent": true}`` on success and ``{"sent": false, "reason": "..."}`` when + SMTP is not configured or sending fails. + + Raises: + 404: Local user not found. + """ + user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.") + + if not settings.email_host: + logger.warning("Admin requested password reset for %s but SMTP is not configured", user.email) + return {"sent": False, "reason": "SMTP is not configured on this server."} + + token = generate_token() + user.password_reset_token = token + user.password_reset_sent_at = datetime.now(tz=timezone.utc) + db.commit() + + base_url = str(request.base_url).rstrip("/") + try: + send_password_reset_email(user.email, user.username, token, base_url) + except Exception as exc: + logger.warning("Admin-triggered password reset email failed for %s: %s", user.email, exc) + return {"sent": False, "reason": str(exc)} + + logger.info("[SECURITY] ADMIN_PASSWORD_RESET_EMAIL user=%s admin=%s", user.email, _admin.get("email", "unknown")) + return {"sent": True, "email": user.email} + + +@router.post( + "/local/{local_user_id}/set-password", + status_code=status.HTTP_200_OK, + summary="Set a temporary password for a local user account", +) +def admin_set_password( + local_user_id: int, body: LocalUserSetPassword, db: DbSession, _admin: AdminUser +) -> dict[str, Any]: + """Directly set a new password for a local user without requiring an email token. + + Use this as a last resort when email delivery is unavailable. The user + should be advised to change their password after logging in. + + Raises: + 404: Local user not found. + """ + user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.") + + user.hashed_password = hash_password(body.password) + # Clear any outstanding reset tokens + user.password_reset_token = None + user.password_reset_sent_at = None + + try: + db.commit() + except Exception: + db.rollback() + raise + + logger.info("[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown")) + return {"updated": True, "email": user.email} + + @router.get("/{user_id:path}", summary="Get details for a single user") def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]: """Return profile and document statistics for a specific user.""" diff --git a/app/api/local_auth.py b/app/api/local_auth.py index 75f341bd..be6df664 100644 --- a/app/api/local_auth.py +++ b/app/api/local_auth.py @@ -31,6 +31,7 @@ from app.utils.local_auth import ( generate_token, hash_password, is_token_expired, + send_forgot_username_email, send_password_reset_email, send_verification_email, ) @@ -79,6 +80,12 @@ class PasswordResetBody(BaseModel): new_password_confirm: str +class ForgotUsernameBody(BaseModel): + """Body for the forgot-username endpoint.""" + + email: str + + # --------------------------------------------------------------------------- # Page routes (return HTML) # --------------------------------------------------------------------------- @@ -107,6 +114,32 @@ async def verify_email_sent_page(request: Request) -> Any: return templates.TemplateResponse("verify_email_sent.html", {"request": request}) +@router.get("/forgot-username", include_in_schema=False) +async def forgot_username_page(request: Request) -> Any: + """Render the forgot-username page where users can request a username reminder email.""" + return templates.TemplateResponse( + "forgot_username.html", + { + "request": request, + "csrf_token": getattr(request.state, "csrf_token", ""), + "app_version": settings.version, + }, + ) + + +@router.get("/forgot-password", include_in_schema=False) +async def forgot_password_page(request: Request) -> Any: + """Render the forgot-password page where users can request a reset email.""" + return templates.TemplateResponse( + "forgot_password.html", + { + "request": request, + "csrf_token": getattr(request.state, "csrf_token", ""), + "app_version": settings.version, + }, + ) + + @router.get("/reset-password", include_in_schema=False) async def reset_password_page(request: Request) -> Any: """Render the password reset form page.""" @@ -337,3 +370,19 @@ async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, st logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email) return {"message": "Password updated successfully."} + + +@router.post("/api/auth/forgot-username") +async def forgot_username(body: ForgotUsernameBody, db: DbSession) -> dict[str, str]: + """Send a username reminder email. + + Always returns 200 to avoid leaking whether an email is registered. + """ + user = db.query(LocalUser).filter(LocalUser.email == body.email).first() + if user: + try: + send_forgot_username_email(user.email, user.username) + except Exception as exc: + logger.warning("Failed to send forgot-username email to %s: %s", user.email, exc) + + return {"message": "Username reminder sent if account exists."} diff --git a/app/utils/local_auth.py b/app/utils/local_auth.py index 669d6819..20a54719 100644 --- a/app/utils/local_auth.py +++ b/app/utils/local_auth.py @@ -164,6 +164,41 @@ def send_password_reset_email(email: str, username: str, token: str, base_url: s _smtp_send(subject, html_body, plain_body, email) +def send_forgot_username_email(email: str, username: str) -> None: + """Send an email reminding the user of their username. + + Args: + email: Recipient email address. + username: The user's username to include in the message. + """ + subject = "Your DocuElevate username" + html_body = f""" + + + +
+

Your Username

+

You requested a reminder of your DocuElevate username.

+
+

Your username is:

+

{username}

+
+

You can sign in using your username or your email address.

+

If you did not request this reminder, you can safely ignore this email.

+
+

DocuElevate · Intelligent Document Processing

+
+ +""" + plain_body = ( + f"You requested a reminder of your DocuElevate username.\n\n" + f"Your username is: {username}\n\n" + "You can sign in using your username or your email address.\n\n" + "If you did not request this, please ignore this email." + ) + _smtp_send(subject, html_body, plain_body, email) + + def build_session_user(user: object) -> dict: """Build the session user dict for a LocalUser, matching the OAuth session format. diff --git a/docs/API.md b/docs/API.md index e3bca54a..809bc0a6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -844,6 +844,131 @@ problem. --- +**GET** `/api/admin/users/local` + +List all local (email/password) user accounts with basic metadata. + +--- + +**POST** `/api/admin/users/local` + +Create a new local user account (admin-only, immediately active — no email verification required). + +**Request body**: +```json +{ + "email": "user@example.com", + "username": "alice", + "display_name": "Alice Smith", + "password": "securepassword", + "is_admin": false +} +``` + +--- + +**PATCH** `/api/admin/users/local/{local_user_id}` + +Update an existing local user account. Only the provided (non-null) fields are modified. +If the email is changed, the associated `UserProfile.user_id` is also updated automatically. + +**Request body** (all fields optional): +```json +{ + "email": "newemail@example.com", + "display_name": "Alice Wonderland", + "is_admin": true, + "is_active": false +} +``` + +**Error Responses**: +- `404`: Local user not found +- `409`: New email already taken by another account + +--- + +**POST** `/api/admin/users/local/{local_user_id}/send-password-reset` + +Send a password reset email to a local user on their behalf. Useful when a user is locked out. +Returns `{"sent": true}` on success or `{"sent": false, "reason": "..."}` when SMTP is not +configured or sending fails (never returns an error status so the admin always gets feedback). + +**Error Responses**: +- `404`: Local user not found + +--- + +**POST** `/api/admin/users/local/{local_user_id}/set-password` + +Directly set a new password for a local user without requiring an email token (last resort when +email delivery is unavailable). The user should be advised to change their password after logging in. + +**Request body**: +```json +{ + "password": "temporarypassword" +} +``` + +**Error Responses**: +- `404`: Local user not found +- `422`: Password shorter than 8 characters + +--- + +**DELETE** `/api/admin/users/local/{local_user_id}` + +Delete a local user account by numeric ID. The associated `UserProfile` is also removed. Documents +owned by this user are **not** deleted. Returns `204 No Content` on success. + +--- + +### Local Authentication (self-service) + +These endpoints are for local (email/password) users and do not require authentication. + +**POST** `/api/auth/request-password-reset` + +Send a password reset email. Always returns 200 to avoid leaking whether an email is registered. + +**Request body**: +```json +{ "email": "user@example.com" } +``` + +--- + +**POST** `/api/auth/reset-password` + +Set a new password using a valid reset token (received via email). + +**Request body**: +```json +{ + "token": "the-token-from-email", + "new_password": "newpassword", + "new_password_confirm": "newpassword" +} +``` + +**Error Responses**: +- `400`: Token is invalid or expired +- `422`: Passwords do not match + +--- + +**POST** `/api/auth/forgot-username` + +Send a username reminder email. Always returns 200 to avoid leaking whether an email is registered. + +**Request body**: +```json +{ "email": "user@example.com" } +``` + +--- + ### Settings Suggestions (Autocomplete) **GET** `/api/settings/{key}/suggestions` diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 59ffdc40..a6332bc9 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -28,8 +28,29 @@ If OpenID Connect authentication is configured: 3. Log in with your existing credentials on that platform 4. You'll be redirected back to DocuElevate after successful authentication -#### User Sessions -- Once authenticated, your session will remain active until you log out or it expires +#### Local User Accounts +If your administrator has created a local (email/password) account for you: + +1. You'll see a "Sign in with username" form on the login page +2. Enter your **username or email address** — both are accepted +3. Enter your password and click **Sign in** + +##### Forgot your password? +If you can't remember your password: +1. Click **Forgot password?** below the sign-in form +2. Enter your email address and click **Send reset link** +3. Check your inbox for a password reset email (valid for 24 hours) +4. Click the link in the email and enter your new password + +##### Forgot your username? +If you can't remember your username: +1. Click **Forgot username?** below the sign-in form +2. Enter your email address and click **Send username reminder** +3. You'll receive an email with your username + +> **Tip:** You can always sign in with your email address directly — you don't need to look up your username. + + - Click the "Logout" button in the top navigation bar to end your session - For security, sessions automatically expire after a period of inactivity diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index be8e4fdc..360dcb2c 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -508,13 +508,38 @@ + + + @@ -595,6 +620,116 @@ + + + + + +
({})); + this.editLocalUserModal.error = err.detail || 'Failed to update account.'; + } + } catch (e) { + this.editLocalUserModal.error = 'Network error: ' + e.message; + } finally { + this.editLocalUserModal.saving = false; + } + }, + + openSetPasswordModal(lu) { + this.setPasswordModal.id = lu.id; + this.setPasswordModal.username = lu.username; + this.setPasswordModal.password = ''; + this.setPasswordModal.error = ''; + this.setPasswordModal.saving = false; + this.setPasswordModal.open = true; + }, + + async submitSetPassword() { + this.setPasswordModal.error = ''; + this.setPasswordModal.saving = true; + try { + const resp = await fetch(`/api/admin/users/local/${this.setPasswordModal.id}/set-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + body: JSON.stringify({ password: this.setPasswordModal.password }), + }); + if (resp.ok) { + this.setPasswordModal.open = false; + this.showAlert('success', 'Password set', `Password for "${this.setPasswordModal.username}" has been updated.`); + } else { + const err = await resp.json().catch(() => ({})); + this.setPasswordModal.error = err.detail || 'Failed to set password.'; + } + } catch (e) { + this.setPasswordModal.error = 'Network error: ' + e.message; + } finally { + this.setPasswordModal.saving = false; + } + }, + + async sendPasswordReset(lu) { + try { + const resp = await fetch(`/api/admin/users/local/${lu.id}/send-password-reset`, { + method: 'POST', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + }); + const data = await resp.json().catch(() => ({})); + if (resp.ok && data.sent) { + this.showAlert('success', 'Email sent', `Password reset email sent to "${lu.email}".`); + } else if (resp.ok && !data.sent) { + this.showAlert('error', 'Email not sent', data.reason || 'SMTP is not configured.'); + } else { + this.showAlert('error', 'Failed', data.detail || resp.statusText); + } + } catch (e) { + this.showAlert('error', 'Network error', e.message); + } + }, + async executeDeleteLocalUser() { this.deleteLocalUserModal.deleting = true; try { diff --git a/frontend/templates/forgot_password.html b/frontend/templates/forgot_password.html new file mode 100644 index 00000000..148f01fe --- /dev/null +++ b/frontend/templates/forgot_password.html @@ -0,0 +1,118 @@ + + + + + + DocuElevate - Forgot Password + + + + + +
+
+ DocuElevate Logo +
+ +

Forgot your password?

+

+ Enter your email address and we'll send you a link to reset your password. +

+ +
+
+
+
+ +
+
+

Check your inbox

+

+ If an account exists for that email address, a password reset link has been sent. The link expires in 24 hours. +

+ Back to sign in +
+ +
+ + +
+ + +
+ + +
+
+ + +
+
+ DocuElevate {{ app_version|default('', true) }} +
+ + diff --git a/frontend/templates/forgot_username.html b/frontend/templates/forgot_username.html new file mode 100644 index 00000000..c8c9b8d2 --- /dev/null +++ b/frontend/templates/forgot_username.html @@ -0,0 +1,125 @@ + + + + + + DocuElevate - Forgot Username + + + + + +
+
+ DocuElevate Logo +
+ +

Forgot your username?

+

+ Enter the email address associated with your account and we'll send you your username. + You can also sign in directly with your email address. +

+ +
+
+
+
+ +
+
+

Check your inbox

+

+ If an account exists for that email address, your username has been sent. + Remember: you can also sign in using your email address directly. +

+ Back to sign in +
+ +
+ + +
+ + Tip: You can sign in with either your username or your email address — no lookup needed. +
+ +
+ + +
+ + +
+
+ + +
+
+ DocuElevate {{ app_version|default('', true) }} +
+ + diff --git a/frontend/templates/login.html b/frontend/templates/login.html index 5ead49b9..bcfbe815 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -35,8 +35,9 @@
- +
@@ -50,6 +51,15 @@ Sign in
+
+ + Forgot password? + + + + Forgot username? + +
{% if show_oauth %} diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py index 7f215278..02cf3369 100644 --- a/tests/test_admin_users.py +++ b/tests/test_admin_users.py @@ -10,6 +10,7 @@ Covers: - Pagination and search filtering """ +from datetime import datetime, timezone from unittest.mock import MagicMock import pytest @@ -20,7 +21,7 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.database import Base, get_db -from app.models import FileRecord, UserProfile +from app.models import FileRecord, LocalUser, UserProfile # --------------------------------------------------------------------------- # Fixtures @@ -663,3 +664,266 @@ class TestEnsureUserProfileAdmin: # No profile should have been created count = au_session.query(UserProfile).count() assert count == 0 + + +# --------------------------------------------------------------------------- +# Local user admin management: update, send-password-reset, set-password +# --------------------------------------------------------------------------- + + +def _make_local_user(session, email: str = "lu@example.com", username: str = "luuser", **kwargs) -> LocalUser: + """Insert a LocalUser row and return it.""" + from app.utils.local_auth import hash_password + + defaults = { + "hashed_password": hash_password("password123"), + "is_active": True, + "is_admin": False, + } + defaults.update(kwargs) + user = LocalUser(email=email, username=username, **defaults) + session.add(user) + session.commit() + session.refresh(user) + return user + + +class TestAdminUpdateLocalUser: + """Tests for PATCH /api/admin/users/local/{id}.""" + + @pytest.mark.unit + def test_update_email(self, au_client, au_session): + """PATCH can change the email address of a local user.""" + user = _make_local_user(au_session, email="old@example.com", username="updateemail") + + resp = au_client.patch( + f"/api/admin/users/local/{user.id}", + json={"email": "new@example.com"}, + ) + assert resp.status_code == 200 + assert resp.json()["email"] == "new@example.com" + + au_session.refresh(user) + assert user.email == "new@example.com" + + @pytest.mark.unit + def test_update_email_syncs_user_profile(self, au_client, au_session): + """PATCH email also updates UserProfile.user_id for the matching profile.""" + user = _make_local_user(au_session, email="synced@example.com", username="synceduser") + _make_profile(au_session, "synced@example.com") + + au_client.patch( + f"/api/admin/users/local/{user.id}", + json={"email": "synced_new@example.com"}, + ) + + from app.models import UserProfile + + old_profile = au_session.query(UserProfile).filter_by(user_id="synced@example.com").first() + new_profile = au_session.query(UserProfile).filter_by(user_id="synced_new@example.com").first() + assert old_profile is None + assert new_profile is not None + + @pytest.mark.unit + def test_update_email_conflict_returns_409(self, au_client, au_session): + """PATCH returns 409 when the new email is already taken.""" + _make_local_user(au_session, email="taken@example.com", username="takenuser") + user = _make_local_user(au_session, email="mine@example.com", username="myuser") + + resp = au_client.patch( + f"/api/admin/users/local/{user.id}", + json={"email": "taken@example.com"}, + ) + assert resp.status_code == 409 + + @pytest.mark.unit + def test_update_is_admin(self, au_client, au_session): + """PATCH can grant or revoke admin privileges.""" + user = _make_local_user(au_session, email="grantadmin@example.com", username="grantadmin") + assert user.is_admin is False + + resp = au_client.patch( + f"/api/admin/users/local/{user.id}", + json={"is_admin": True}, + ) + assert resp.status_code == 200 + assert resp.json()["is_admin"] is True + + au_session.refresh(user) + assert user.is_admin is True + + @pytest.mark.unit + def test_update_is_active(self, au_client, au_session): + """PATCH can deactivate a user account.""" + user = _make_local_user(au_session, email="deactivate@example.com", username="deactivateuser") + + resp = au_client.patch( + f"/api/admin/users/local/{user.id}", + json={"is_active": False}, + ) + assert resp.status_code == 200 + assert resp.json()["is_active"] is False + + au_session.refresh(user) + assert user.is_active is False + + @pytest.mark.unit + def test_update_display_name(self, au_client, au_session): + """PATCH can update the display name.""" + user = _make_local_user(au_session, email="displayname@example.com", username="displaynameuser") + + resp = au_client.patch( + f"/api/admin/users/local/{user.id}", + json={"display_name": "Alice Wonderland"}, + ) + assert resp.status_code == 200 + assert resp.json()["display_name"] == "Alice Wonderland" + + @pytest.mark.unit + def test_update_nonexistent_user_returns_404(self, au_client): + """PATCH on unknown ID returns 404.""" + resp = au_client.patch("/api/admin/users/local/99999", json={"email": "x@example.com"}) + assert resp.status_code == 404 + + +class TestAdminSendPasswordReset: + """Tests for POST /api/admin/users/local/{id}/send-password-reset.""" + + @pytest.mark.unit + def test_send_reset_email_success(self, au_client, au_session): + """Returns sent=True when SMTP is configured and sending succeeds.""" + from unittest.mock import patch + + user = _make_local_user(au_session, email="resetme@example.com", username="resetmeuser") + + with ( + patch("app.api.admin_users.settings") as mock_settings, + patch("app.api.admin_users.send_password_reset_email") as mock_send, + ): + mock_settings.email_host = "smtp.example.com" + mock_settings.version = "test" + resp = au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset") + + assert resp.status_code == 200 + data = resp.json() + assert data["sent"] is True + assert data["email"] == "resetme@example.com" + mock_send.assert_called_once() + + @pytest.mark.unit + def test_send_reset_email_no_smtp_returns_not_sent(self, au_client, au_session): + """Returns sent=False with reason when SMTP is not configured.""" + from unittest.mock import patch + + user = _make_local_user(au_session, email="nosmtp@example.com", username="nosmtpuser") + + with patch("app.api.admin_users.settings") as mock_settings: + mock_settings.email_host = "" + resp = au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset") + + assert resp.status_code == 200 + data = resp.json() + assert data["sent"] is False + assert "smtp" in data["reason"].lower() + + @pytest.mark.unit + def test_send_reset_email_smtp_failure_returns_not_sent(self, au_client, au_session): + """Returns sent=False with reason when SMTP sending fails.""" + from unittest.mock import patch + + user = _make_local_user(au_session, email="smtperr@example.com", username="smtperruser") + + with ( + patch("app.api.admin_users.settings") as mock_settings, + patch("app.api.admin_users.send_password_reset_email", side_effect=RuntimeError("connection refused")), + ): + mock_settings.email_host = "smtp.example.com" + resp = au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset") + + assert resp.status_code == 200 + assert resp.json()["sent"] is False + + @pytest.mark.unit + def test_send_reset_email_unknown_user_returns_404(self, au_client): + """Returns 404 for unknown local_user_id.""" + resp = au_client.post("/api/admin/users/local/99999/send-password-reset") + assert resp.status_code == 404 + + @pytest.mark.unit + def test_send_reset_stores_token(self, au_client, au_session): + """Password reset token is persisted to the DB.""" + from unittest.mock import patch + + user = _make_local_user(au_session, email="tokenstore@example.com", username="tokenstoreuser") + assert user.password_reset_token is None + + with ( + patch("app.api.admin_users.settings") as mock_settings, + patch("app.api.admin_users.send_password_reset_email"), + ): + mock_settings.email_host = "smtp.example.com" + au_client.post(f"/api/admin/users/local/{user.id}/send-password-reset") + + au_session.refresh(user) + assert user.password_reset_token is not None + assert user.password_reset_sent_at is not None + + +class TestAdminSetPassword: + """Tests for POST /api/admin/users/local/{id}/set-password.""" + + @pytest.mark.unit + def test_set_password_success(self, au_client, au_session): + """Returns updated=True and changes the hashed password.""" + from app.utils.local_auth import verify_password + + user = _make_local_user(au_session, email="setpw@example.com", username="setpwuser") + + resp = au_client.post( + f"/api/admin/users/local/{user.id}/set-password", + json={"password": "brandnewpassword"}, + ) + assert resp.status_code == 200 + assert resp.json()["updated"] is True + + au_session.refresh(user) + assert verify_password("brandnewpassword", user.hashed_password) + + @pytest.mark.unit + def test_set_password_too_short_returns_422(self, au_client, au_session): + """Returns 422 when password is shorter than 8 characters.""" + user = _make_local_user(au_session, email="shortpw@example.com", username="shortpwuser") + + resp = au_client.post( + f"/api/admin/users/local/{user.id}/set-password", + json={"password": "short"}, + ) + assert resp.status_code == 422 + + @pytest.mark.unit + def test_set_password_clears_reset_token(self, au_client, au_session): + """Setting a password clears any outstanding password_reset_token.""" + from app.utils.local_auth import generate_token + + user = _make_local_user(au_session, email="cleartok@example.com", username="cleartokuser") + user.password_reset_token = generate_token() + user.password_reset_sent_at = datetime.now(tz=timezone.utc) + au_session.commit() + + au_client.post( + f"/api/admin/users/local/{user.id}/set-password", + json={"password": "clearedpassword"}, + ) + + au_session.refresh(user) + assert user.password_reset_token is None + assert user.password_reset_sent_at is None + + @pytest.mark.unit + def test_set_password_unknown_user_returns_404(self, au_client): + """Returns 404 for unknown local_user_id.""" + resp = au_client.post( + "/api/admin/users/local/99999/set-password", + json={"password": "doesnotmatter"}, + ) + assert resp.status_code == 404 diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py index c6a11c98..52cb9708 100644 --- a/tests/test_local_auth.py +++ b/tests/test_local_auth.py @@ -6,9 +6,12 @@ Covers: - POST /api/auth/resend-verification - POST /api/auth/request-password-reset - POST /api/auth/reset-password +- POST /api/auth/forgot-username - GET /signup (page route) - GET /verify-email-sent (page route) - GET /reset-password (page route) +- GET /forgot-password (page route) +- GET /forgot-username (page route) - app/utils/local_auth utility functions - auth() login flow with LocalUser """ @@ -801,3 +804,116 @@ def test_admin_local_user_list_after_create(admin_session_client): assert resp.status_code == 200 users = resp.json() assert any(u["email"] == "listed@example.com" for u in users) + + +# --------------------------------------------------------------------------- +# Integration tests: forgot-username endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_forgot_username_returns_200_for_existing_email(la_client, la_session): + """POST /api/auth/forgot-username returns 200 and sends email when account exists.""" + la_session.add( + LocalUser( + email="remindme@example.com", + username="remindmeuser", + hashed_password=hash_password("pw123456"), + is_active=True, + ) + ) + la_session.commit() + + with patch("app.api.local_auth.send_forgot_username_email") as mock_send: + resp = la_client.post("/api/auth/forgot-username", json={"email": "remindme@example.com"}) + + assert resp.status_code == 200 + assert "reminder" in resp.json()["message"].lower() + mock_send.assert_called_once_with("remindme@example.com", "remindmeuser") + + +@pytest.mark.integration +def test_forgot_username_returns_200_for_unknown_email(la_client): + """POST /api/auth/forgot-username always returns 200 (no info leak).""" + with patch("app.api.local_auth.send_forgot_username_email") as mock_send: + resp = la_client.post("/api/auth/forgot-username", json={"email": "nobody@example.com"}) + + assert resp.status_code == 200 + mock_send.assert_not_called() + + +@pytest.mark.integration +def test_forgot_username_smtp_failure_does_not_raise(la_client, la_session): + """POST /api/auth/forgot-username returns 200 even when SMTP fails.""" + la_session.add( + LocalUser( + email="smtpfail@example.com", + username="smtpfailuser", + hashed_password=hash_password("pw123456"), + is_active=True, + ) + ) + la_session.commit() + + with patch("app.api.local_auth.send_forgot_username_email", side_effect=RuntimeError("SMTP down")): + resp = la_client.post("/api/auth/forgot-username", json={"email": "smtpfail@example.com"}) + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Integration tests: new page routes +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_forgot_password_page(la_client): + """GET /forgot-password returns 200.""" + with patch("app.api.local_auth.settings") as mock_settings: + mock_settings.version = "test" + resp = la_client.get("/forgot-password") + assert resp.status_code == 200 + assert b"password" in resp.content.lower() + + +@pytest.mark.integration +def test_forgot_username_page(la_client): + """GET /forgot-username returns 200.""" + with patch("app.api.local_auth.settings") as mock_settings: + mock_settings.version = "test" + resp = la_client.get("/forgot-username") + assert resp.status_code == 200 + assert b"username" in resp.content.lower() + + +# --------------------------------------------------------------------------- +# Unit tests: send_forgot_username_email utility +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_send_forgot_username_email_calls_smtp(): + """send_forgot_username_email calls _smtp_send with the username.""" + from app.utils.local_auth import send_forgot_username_email + + with patch("app.utils.local_auth._smtp_send") as mock_smtp: + send_forgot_username_email("u@example.com", "myusername") + + mock_smtp.assert_called_once() + args = mock_smtp.call_args[0] + # subject, html_body, plain_body, recipient + assert "myusername" in args[1] # HTML body + assert "myusername" in args[2] # plain body + assert args[3] == "u@example.com" + + +@pytest.mark.unit +def test_send_forgot_username_email_no_smtp_raises(): + """send_forgot_username_email raises RuntimeError when EMAIL_HOST is not set.""" + from app.utils.local_auth import send_forgot_username_email + + with patch("app.utils.local_auth.settings") as mock_settings: + mock_settings.email_host = "" + + with pytest.raises(RuntimeError, match="SMTP"): + send_forgot_username_email("u@example.com", "myusername")