feat(auth): password reset, forgot username, and admin user management for local accounts
- Add /forgot-password and /forgot-username page routes and templates
- Update login page label to "Username or Email" (both already accepted by backend)
- Add "Forgot password?" and "Forgot username?" links to login page
- Add POST /api/auth/forgot-username endpoint + send_forgot_username_email() utility
- Add admin endpoints: PATCH /local/{id}, POST /local/{id}/send-password-reset, POST /local/{id}/set-password
- Update admin_users.html with Edit, Password, and Reset action buttons + modals
- Add 23 tests; fix code review issues (import style, display_name clearing behaviour)
- Update docs/API.md and docs/UserGuide.md
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+8
-10
@@ -15,6 +15,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import FileRecord, LocalUser, UserProfile
|
from app.models import FileRecord, LocalUser, UserProfile
|
||||||
from app.utils.local_auth import generate_token, hash_password, send_password_reset_email
|
from app.utils.local_auth import generate_token, hash_password, send_password_reset_email
|
||||||
@@ -393,7 +394,8 @@ def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession,
|
|||||||
user.email = body.email
|
user.email = body.email
|
||||||
|
|
||||||
if body.display_name is not None:
|
if body.display_name is not None:
|
||||||
user.display_name = body.display_name
|
# 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:
|
if body.is_admin is not None:
|
||||||
user.is_admin = body.is_admin
|
user.is_admin = body.is_admin
|
||||||
@@ -431,9 +433,7 @@ def update_local_user(local_user_id: int, body: LocalUserUpdate, db: DbSession,
|
|||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
summary="Send a password reset email to a local user",
|
summary="Send a password reset email to a local user",
|
||||||
)
|
)
|
||||||
def admin_send_password_reset(
|
def admin_send_password_reset(local_user_id: int, request: Request, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||||
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.
|
"""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.
|
This is a last-resort tool for admins to help users who are locked out.
|
||||||
@@ -443,13 +443,11 @@ def admin_send_password_reset(
|
|||||||
Raises:
|
Raises:
|
||||||
404: Local user not found.
|
404: Local user not found.
|
||||||
"""
|
"""
|
||||||
from app.config import settings as _settings
|
|
||||||
|
|
||||||
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
|
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
|
||||||
|
|
||||||
if not _settings.email_host:
|
if not settings.email_host:
|
||||||
logger.warning("Admin requested password reset for %s but SMTP is not configured", user.email)
|
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."}
|
return {"sent": False, "reason": "SMTP is not configured on this server."}
|
||||||
|
|
||||||
@@ -500,10 +498,10 @@ def admin_set_password(
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(
|
logger.info("[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown"))
|
||||||
"[SECURITY] ADMIN_SET_PASSWORD user=%s admin=%s", user.email, _admin.get("email", "unknown")
|
|
||||||
)
|
|
||||||
return {"updated": True, "email": user.email}
|
return {"updated": True, "email": user.email}
|
||||||
|
|
||||||
|
|
||||||
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||||
"""Return profile and document statistics for a specific user."""
|
"""Return profile and document statistics for a specific user."""
|
||||||
doc_count = db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == user_id).scalar() or 0
|
doc_count = db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == user_id).scalar() or 0
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.utils.local_auth import (
|
|||||||
generate_token,
|
generate_token,
|
||||||
hash_password,
|
hash_password,
|
||||||
is_token_expired,
|
is_token_expired,
|
||||||
|
send_forgot_username_email,
|
||||||
send_password_reset_email,
|
send_password_reset_email,
|
||||||
send_verification_email,
|
send_verification_email,
|
||||||
)
|
)
|
||||||
@@ -79,6 +80,12 @@ class PasswordResetBody(BaseModel):
|
|||||||
new_password_confirm: str
|
new_password_confirm: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotUsernameBody(BaseModel):
|
||||||
|
"""Body for the forgot-username endpoint."""
|
||||||
|
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Page routes (return HTML)
|
# Page routes (return HTML)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -107,6 +114,19 @@ async def verify_email_sent_page(request: Request) -> Any:
|
|||||||
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
|
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)
|
@router.get("/forgot-password", include_in_schema=False)
|
||||||
async def forgot_password_page(request: Request) -> Any:
|
async def forgot_password_page(request: Request) -> Any:
|
||||||
"""Render the forgot-password page where users can request a reset email."""
|
"""Render the forgot-password page where users can request a reset email."""
|
||||||
@@ -350,3 +370,19 @@ async def reset_password(body: PasswordResetBody, db: DbSession) -> dict[str, st
|
|||||||
|
|
||||||
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
|
logger.info("[SECURITY] PASSWORD_RESET_SUCCESS user=%s", user.email)
|
||||||
return {"message": "Password updated successfully."}
|
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."}
|
||||||
|
|||||||
@@ -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)
|
_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"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family:Arial,sans-serif;background:#f4f4f5;margin:0;padding:32px;">
|
||||||
|
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||||
|
<h1 style="color:#4f46e5;font-size:24px;margin-bottom:8px;">Your Username</h1>
|
||||||
|
<p style="color:#374151;">You requested a reminder of your DocuElevate username.</p>
|
||||||
|
<div style="text-align:center;margin:32px 0;background:#f3f4f6;border-radius:8px;padding:20px;">
|
||||||
|
<p style="color:#6b7280;font-size:13px;margin-bottom:4px;">Your username is:</p>
|
||||||
|
<p style="color:#111827;font-size:22px;font-weight:700;font-family:monospace;">{username}</p>
|
||||||
|
</div>
|
||||||
|
<p style="color:#374151;font-size:14px;">You can sign in using your username <strong>or</strong> your email address.</p>
|
||||||
|
<p style="color:#6b7280;font-size:13px;margin-top:16px;">If you did not request this reminder, you can safely ignore this email.</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
||||||
|
<p style="color:#9ca3af;font-size:12px;text-align:center;">DocuElevate · Intelligent Document Processing</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
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:
|
def build_session_user(user: object) -> dict:
|
||||||
"""Build the session user dict for a LocalUser, matching the OAuth session format.
|
"""Build the session user dict for a LocalUser, matching the OAuth session format.
|
||||||
|
|
||||||
|
|||||||
+125
@@ -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)
|
### Settings Suggestions (Autocomplete)
|
||||||
|
|
||||||
**GET** `/api/settings/{key}/suggestions`
|
**GET** `/api/settings/{key}/suggestions`
|
||||||
|
|||||||
+23
-2
@@ -28,8 +28,29 @@ If OpenID Connect authentication is configured:
|
|||||||
3. Log in with your existing credentials on that platform
|
3. Log in with your existing credentials on that platform
|
||||||
4. You'll be redirected back to DocuElevate after successful authentication
|
4. You'll be redirected back to DocuElevate after successful authentication
|
||||||
|
|
||||||
#### User Sessions
|
#### Local User Accounts
|
||||||
- Once authenticated, your session will remain active until you log out or it expires
|
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
|
- Click the "Logout" button in the top navigation bar to end your session
|
||||||
- For security, sessions automatically expire after a period of inactivity
|
- For security, sessions automatically expire after a period of inactivity
|
||||||
|
|
||||||
|
|||||||
@@ -1122,7 +1122,7 @@ function adminUsersApp() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: this.editLocalUserModal.form.email || null,
|
email: this.editLocalUserModal.form.email || null,
|
||||||
display_name: this.editLocalUserModal.form.display_name || null,
|
display_name: this.editLocalUserModal.form.display_name,
|
||||||
is_admin: this.editLocalUserModal.form.is_admin,
|
is_admin: this.editLocalUserModal.form.is_admin,
|
||||||
is_active: this.editLocalUserModal.form.is_active,
|
is_active: this.editLocalUserModal.form.is_active,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DocuElevate - Forgot Username</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
|
||||||
|
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
|
||||||
|
<div class="flex justify-center mb-6">
|
||||||
|
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Forgot your username?</h1>
|
||||||
|
<p class="text-center text-gray-500 text-sm mb-6">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="{
|
||||||
|
email: '',
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
success: false,
|
||||||
|
async submit() {
|
||||||
|
this.error = '';
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/forgot-username', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': '{{ csrf_token }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email: this.email })
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
this.success = true;
|
||||||
|
} else {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
this.error = data.detail || 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
this.error = 'Network error. Please try again.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div x-show="success" x-cloak class="text-center py-4">
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<div class="bg-green-100 rounded-full p-4">
|
||||||
|
<i class="fas fa-envelope-open-text text-green-600 text-4xl" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-green-700 font-semibold mb-2">Check your inbox</p>
|
||||||
|
<p class="text-gray-500 text-sm mb-4">
|
||||||
|
If an account exists for that email address, your username has been sent.
|
||||||
|
Remember: you can also sign in using your email address directly.
|
||||||
|
</p>
|
||||||
|
<a href="/login"
|
||||||
|
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
|
||||||
|
>Back to sign in</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
|
||||||
|
<div x-show="error" x-cloak
|
||||||
|
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
|
||||||
|
role="alert" aria-live="polite">
|
||||||
|
<p x-text="error"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-700">
|
||||||
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||||
|
<strong>Tip:</strong> You can sign in with either your username <em>or</em> your email address — no lookup needed.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700">
|
||||||
|
Email address <span aria-hidden="true" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email" id="email" name="email" required
|
||||||
|
x-model="email"
|
||||||
|
autocomplete="email"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
|
||||||
|
style="min-height:44px;"
|
||||||
|
aria-required="true"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
style="min-height:44px;"
|
||||||
|
>
|
||||||
|
<span x-show="!loading">Send username reminder</span>
|
||||||
|
<span x-show="loading" x-cloak>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending…
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
<i class="fas fa-arrow-left mr-1" aria-hidden="true"></i> Back to sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
|
||||||
|
DocuElevate {{ app_version|default('', true) }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -35,8 +35,9 @@
|
|||||||
<form method="POST" action="/auth" class="space-y-4">
|
<form method="POST" action="/auth" class="space-y-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('', true) }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('', true) }}">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
<label for="username" class="block text-sm font-medium text-gray-700">Username or Email</label>
|
||||||
<input type="text" id="username" name="username" required
|
<input type="text" id="username" name="username" required
|
||||||
|
autocomplete="username"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,9 +51,13 @@
|
|||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-3 text-center">
|
<div class="mt-3 text-center space-x-3">
|
||||||
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">
|
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">
|
||||||
Forgot your password?
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-300" aria-hidden="true">|</span>
|
||||||
|
<a href="/forgot-username" class="text-sm text-blue-600 hover:text-blue-500">
|
||||||
|
Forgot username?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+265
-1
@@ -10,6 +10,7 @@ Covers:
|
|||||||
- Pagination and search filtering
|
- Pagination and search filtering
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -20,7 +21,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.database import Base, get_db
|
from app.database import Base, get_db
|
||||||
from app.models import FileRecord, UserProfile
|
from app.models import FileRecord, LocalUser, UserProfile
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
@@ -663,3 +664,266 @@ class TestEnsureUserProfileAdmin:
|
|||||||
# No profile should have been created
|
# No profile should have been created
|
||||||
count = au_session.query(UserProfile).count()
|
count = au_session.query(UserProfile).count()
|
||||||
assert count == 0
|
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
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ Covers:
|
|||||||
- POST /api/auth/resend-verification
|
- POST /api/auth/resend-verification
|
||||||
- POST /api/auth/request-password-reset
|
- POST /api/auth/request-password-reset
|
||||||
- POST /api/auth/reset-password
|
- POST /api/auth/reset-password
|
||||||
|
- POST /api/auth/forgot-username
|
||||||
- GET /signup (page route)
|
- GET /signup (page route)
|
||||||
- GET /verify-email-sent (page route)
|
- GET /verify-email-sent (page route)
|
||||||
- GET /reset-password (page route)
|
- GET /reset-password (page route)
|
||||||
|
- GET /forgot-password (page route)
|
||||||
|
- GET /forgot-username (page route)
|
||||||
- app/utils/local_auth utility functions
|
- app/utils/local_auth utility functions
|
||||||
- auth() login flow with LocalUser
|
- 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
|
assert resp.status_code == 200
|
||||||
users = resp.json()
|
users = resp.json()
|
||||||
assert any(u["email"] == "listed@example.com" for u in users)
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user