إنجاز: تفعيل خاصية استعادة كلمة المرور وإعادة تعيينها

- إضافة نقاط نهاية لخاصيتي استعادة كلمة المرور وإعادة تعيينها في الواجهة الخلفية.

- إنشاء اختبارات لخاصية إعادة تعيين كلمة المرور لضمان كفاءتها وأمانها.

- تطوير صفحات واجهة المستخدم لخاصيتي استعادة كلمة المرور وإعادة تعيينها مع معالجة النماذج.

- دمج حدود تحميل ديناميكية لأنواع ملفات مختلفة بناءً على خطط المستخدمين.

- تقديم أداة جديدة لتغيير حجم الصور مع إمكانية تعديل الأبعاد وإعدادات الجودة.

- تحديث نظام التوجيه والتنقل ليشمل أدوات جديدة وميزات مصادقة.

- تحسين تجربة المستخدم من خلال معالجة الأخطاء ورسائل التغذية الراجعة المناسبة.

- إضافة دعم التدويل للميزات الجديدة باللغات الإنجليزية والعربية والفرنسية.
This commit is contained in:
Your Name
2026-03-07 14:23:50 +02:00
parent 0ad2ba0f02
commit 71f7d0382d
27 changed files with 1460 additions and 7 deletions

View File

@@ -89,6 +89,7 @@ def create_app(config_name=None):
from app.routes.pdf_tools import pdf_tools_bp
from app.routes.flowchart import flowchart_bp
from app.routes.v1.tools import v1_bp
from app.routes.config import config_bp
app.register_blueprint(health_bp, url_prefix="/api")
app.register_blueprint(auth_bp, url_prefix="/api/auth")
@@ -104,5 +105,6 @@ def create_app(config_name=None):
app.register_blueprint(tasks_bp, url_prefix="/api/tasks")
app.register_blueprint(download_bp, url_prefix="/api/download")
app.register_blueprint(v1_bp, url_prefix="/api/v1")
app.register_blueprint(config_bp, url_prefix="/api/config")
return app

View File

@@ -1,5 +1,6 @@
"""Flask extensions initialization."""
from celery import Celery
from celery.schedules import crontab
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
@@ -33,6 +34,14 @@ def init_celery(app):
"app.tasks.flowchart_tasks.*": {"queue": "flowchart"},
}
# Celery Beat — periodic tasks
celery.conf.beat_schedule = {
"cleanup-expired-files": {
"task": "app.tasks.maintenance_tasks.cleanup_expired_files",
"schedule": crontab(minute="*/30"),
},
}
class ContextTask(celery.Task):
"""Make Celery tasks work with Flask app context."""
abstract = True

View File

@@ -8,7 +8,12 @@ from app.services.account_service import (
authenticate_user,
create_user,
get_user_by_id,
get_user_by_email,
create_password_reset_token,
verify_and_consume_reset_token,
update_user_password,
)
from app.services.email_service import send_password_reset_email
from app.utils.auth import (
get_current_user_id,
login_user_session,
@@ -98,3 +103,48 @@ def me_route():
return jsonify({"authenticated": False, "user": None}), 200
return jsonify({"authenticated": True, "user": user}), 200
@auth_bp.route("/forgot-password", methods=["POST"])
@limiter.limit("5/hour")
def forgot_password_route():
"""Send a password reset email if the account exists.
Always returns 200 to avoid leaking whether an email is registered.
"""
data = request.get_json(silent=True) or {}
email = str(data.get("email", "")).strip().lower()
if not email or not EMAIL_PATTERN.match(email):
return jsonify({"message": "If that email is registered, a reset link has been sent."}), 200
user = get_user_by_email(email)
if user is not None:
token = create_password_reset_token(user["id"])
send_password_reset_email(email, token)
return jsonify({"message": "If that email is registered, a reset link has been sent."}), 200
@auth_bp.route("/reset-password", methods=["POST"])
@limiter.limit("10/hour")
def reset_password_route():
"""Consume a reset token and set a new password."""
data = request.get_json(silent=True) or {}
token = str(data.get("token", "")).strip()
password = str(data.get("password", ""))
if not token:
return jsonify({"error": "Reset token is required."}), 400
if len(password) < MIN_PASSWORD_LENGTH:
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters."}), 400
if len(password) > MAX_PASSWORD_LENGTH:
return jsonify({"error": f"Password must be {MAX_PASSWORD_LENGTH} characters or less."}), 400
user_id = verify_and_consume_reset_token(token)
if user_id is None:
return jsonify({"error": "Invalid or expired reset token."}), 400
update_user_password(user_id, password)
return jsonify({"message": "Password updated successfully. You can now sign in."}), 200

View File

@@ -0,0 +1,32 @@
"""Public configuration endpoint — returns dynamic upload limits."""
from flask import Blueprint, jsonify
from app.services.policy_service import (
get_effective_file_size_limits_mb,
get_usage_summary_for_user,
resolve_web_actor,
FREE_PLAN,
)
config_bp = Blueprint("config", __name__)
@config_bp.route("", methods=["GET"])
def get_config():
"""Return dynamic upload limits and (if logged-in) usage summary.
Anonymous callers get free-plan limits.
Authenticated callers get plan-aware limits + quota usage.
"""
actor = resolve_web_actor()
file_limits_mb = get_effective_file_size_limits_mb(actor.plan)
payload: dict = {
"file_limits_mb": file_limits_mb,
"max_upload_mb": max(file_limits_mb.values()),
}
if actor.user_id is not None:
payload["usage"] = get_usage_summary_for_user(actor.user_id, actor.plan)
return jsonify(payload), 200

View File

@@ -5,7 +5,7 @@ import logging
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from flask import current_app
from werkzeug.security import check_password_hash, generate_password_hash
@@ -160,6 +160,35 @@ def init_account_db():
"ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''"
)
# Password reset tokens
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_prt_token_hash
ON password_reset_tokens(token_hash);
CREATE TABLE IF NOT EXISTS file_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
file_path TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_file_events_created
ON file_events(created_at DESC);
"""
)
def create_user(email: str, password: str) -> dict:
"""Create a new user and return the public record."""
@@ -515,3 +544,99 @@ def has_task_access(user_id: int, source: str, task_id: str) -> bool:
).fetchone()
return row is not None
# ---------------------------------------------------------------------------
# Password reset tokens
# ---------------------------------------------------------------------------
def get_user_by_email(email: str) -> dict | None:
"""Fetch a public user record by email."""
email = _normalize_email(email)
with _connect() as conn:
row = conn.execute(
"SELECT id, email, plan, created_at FROM users WHERE email = ?",
(email,),
).fetchone()
return _serialize_user(row)
def create_password_reset_token(user_id: int) -> str:
"""Generate a password-reset token (returned raw) and store its hash."""
raw_token = secrets.token_urlsafe(48)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = _utc_now()
# Expire in 1 hour
expires = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
with _connect() as conn:
# Invalidate any previous unused tokens for this user
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL",
(now, user_id),
)
conn.execute(
"""
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?)
""",
(user_id, token_hash, expires, now),
)
return raw_token
def verify_and_consume_reset_token(raw_token: str) -> int | None:
"""Verify a reset token. Returns user_id if valid, else None. Marks it used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = _utc_now()
with _connect() as conn:
row = conn.execute(
"""
SELECT id, user_id, expires_at
FROM password_reset_tokens
WHERE token_hash = ? AND used_at IS NULL
""",
(token_hash,),
).fetchone()
if row is None:
return None
# Check expiry
if row["expires_at"] < now:
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
return None
# Mark used
conn.execute(
"UPDATE password_reset_tokens SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
return row["user_id"]
def update_user_password(user_id: int, new_password: str) -> bool:
"""Update a user's password hash."""
now = _utc_now()
password_hash = generate_password_hash(new_password)
with _connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
(password_hash, now, user_id),
)
return True
def log_file_event(event_type: str, file_path: str | None = None, detail: str | None = None) -> None:
"""Record a file lifecycle event (upload, download, cleanup, etc.)."""
with _connect() as conn:
conn.execute(
"INSERT INTO file_events (event_type, file_path, detail, created_at) VALUES (?, ?, ?, ?)",
(event_type, file_path, detail, _utc_now()),
)

View File

@@ -0,0 +1,72 @@
"""Email service — sends transactional emails via SMTP."""
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import current_app
logger = logging.getLogger(__name__)
def _get_smtp_config() -> dict:
"""Read SMTP settings from Flask config."""
return {
"host": current_app.config.get("SMTP_HOST", ""),
"port": current_app.config.get("SMTP_PORT", 587),
"user": current_app.config.get("SMTP_USER", ""),
"password": current_app.config.get("SMTP_PASSWORD", ""),
"from_addr": current_app.config.get("SMTP_FROM", "noreply@saas-pdf.com"),
"use_tls": current_app.config.get("SMTP_USE_TLS", True),
}
def send_email(to: str, subject: str, html_body: str) -> bool:
"""Send an HTML email. Returns True on success."""
cfg = _get_smtp_config()
if not cfg["host"]:
logger.warning("SMTP not configured — email to %s suppressed.", to)
return False
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = cfg["from_addr"]
msg["To"] = to
msg.attach(MIMEText(html_body, "html"))
try:
if cfg["use_tls"]:
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
server.starttls()
else:
server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=10)
if cfg["user"]:
server.login(cfg["user"], cfg["password"])
server.sendmail(cfg["from_addr"], [to], msg.as_string())
server.quit()
logger.info("Email sent to %s: %s", to, subject)
return True
except Exception:
logger.exception("Failed to send email to %s", to)
return False
def send_password_reset_email(to: str, token: str) -> bool:
"""Send a password reset link."""
frontend = current_app.config.get("FRONTEND_URL", "http://localhost:5173")
reset_link = f"{frontend}/reset-password?token={token}"
html = f"""
<div style="font-family: sans-serif; max-width: 480px; margin: auto;">
<h2>Password Reset</h2>
<p>You requested a password reset for your SaaS-PDF account.</p>
<p><a href="{reset_link}" style="display:inline-block;padding:12px 24px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;">
Reset Password
</a></p>
<p style="color:#666;font-size:14px;">This link expires in 1 hour. If you didn't request this, you can safely ignore this email.</p>
</div>
"""
return send_email(to, "Reset your SaaS-PDF password", html)

View File

@@ -0,0 +1,92 @@
"""Periodic maintenance tasks — file cleanup and logging."""
import logging
import os
import shutil
import time
from app.extensions import celery
logger = logging.getLogger(__name__)
@celery.task(name="app.tasks.maintenance_tasks.cleanup_expired_files")
def cleanup_expired_files():
"""Remove upload/output directories older than FILE_EXPIRY_SECONDS.
Runs as a Celery Beat periodic task.
Logs a summary of scanned/deleted/freed counts.
"""
from flask import current_app
expiry = current_app.config.get("FILE_EXPIRY_SECONDS", 1800)
upload_dir = current_app.config.get("UPLOAD_FOLDER", "/tmp/uploads")
output_dir = current_app.config.get("OUTPUT_FOLDER", "/tmp/outputs")
total_stats = {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
for target_dir in [upload_dir, output_dir]:
stats = _cleanup_dir(target_dir, expiry)
for key in total_stats:
total_stats[key] += stats[key]
logger.info(
"Cleanup complete: scanned=%d deleted=%d freed=%.1fMB errors=%d",
total_stats["scanned"],
total_stats["deleted"],
total_stats["freed_bytes"] / (1024 * 1024),
total_stats["errors"],
)
# Log cleanup event
try:
from app.services.account_service import log_file_event
log_file_event(
"cleanup",
detail=f"deleted={total_stats['deleted']} freed={total_stats['freed_bytes']} errors={total_stats['errors']}",
)
except Exception:
logger.debug("Could not log file_event for cleanup")
return total_stats
def _cleanup_dir(directory: str, expiry_seconds: int) -> dict:
"""Scan one directory and remove expired sub-directories."""
stats = {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
if not os.path.isdir(directory):
return stats
now = time.time()
for entry in os.listdir(directory):
full_path = os.path.join(directory, entry)
if not os.path.isdir(full_path):
continue
stats["scanned"] += 1
try:
mod_time = os.path.getmtime(full_path)
except OSError:
stats["errors"] += 1
continue
if (now - mod_time) <= expiry_seconds:
continue
try:
dir_size = sum(
os.path.getsize(os.path.join(dp, f))
for dp, _, filenames in os.walk(full_path)
for f in filenames
)
shutil.rmtree(full_path)
stats["deleted"] += 1
stats["freed_bytes"] += dir_size
logger.debug("Deleted expired: %s (%.1fKB)", entry, dir_size / 1024)
except Exception:
logger.exception("Failed to delete %s", full_path)
stats["errors"] += 1
return stats

View File

@@ -11,3 +11,4 @@ import app.tasks.image_tasks # noqa: F401
import app.tasks.video_tasks # noqa: F401
import app.tasks.pdf_tools_tasks # noqa: F401
import app.tasks.flowchart_tasks # noqa: F401
import app.tasks.maintenance_tasks # noqa: F401

View File

@@ -86,6 +86,18 @@ class BaseConfig:
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions"
)
# SMTP (for password reset emails)
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@saas-pdf.com")
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
# Feature flags
FEATURE_EDITOR = os.getenv("FEATURE_EDITOR", "false").lower() == "true"
class DevelopmentConfig(BaseConfig):
"""Development configuration."""

View File

@@ -0,0 +1,53 @@
"""Tests for GET /api/config — dynamic upload limits."""
import pytest
class TestConfigEndpoint:
"""Tests for the public config endpoint."""
def test_anonymous_gets_free_limits(self, client):
"""Anonymous users receive free-plan file limits."""
resp = client.get("/api/config")
assert resp.status_code == 200
data = resp.get_json()
assert "file_limits_mb" in data
assert "max_upload_mb" in data
limits = data["file_limits_mb"]
assert limits["pdf"] == 20
assert limits["word"] == 15
assert limits["image"] == 10
assert limits["video"] == 50
assert limits["homepageSmartUpload"] == 50
# No usage section for anon
assert "usage" not in data
def test_authenticated_free_user_gets_usage(self, client, app):
"""Logged-in free user receives limits + usage summary."""
# Register + login
client.post("/api/auth/register", json={
"email": "config_test@example.com",
"password": "TestPassword123!",
})
client.post("/api/auth/login", json={
"email": "config_test@example.com",
"password": "TestPassword123!",
})
resp = client.get("/api/config")
assert resp.status_code == 200
data = resp.get_json()
assert data["file_limits_mb"]["pdf"] == 20
assert "usage" in data
usage = data["usage"]
assert usage["plan"] == "free"
assert "web_quota" in usage
assert "api_quota" in usage
def test_max_upload_mb_is_correct(self, client):
"""max_upload_mb should equal the largest single-type limit."""
resp = client.get("/api/config")
data = resp.get_json()
limits = data["file_limits_mb"]
assert data["max_upload_mb"] == max(limits.values())

View File

@@ -0,0 +1,116 @@
"""Tests for the cleanup_expired_files periodic maintenance task."""
import os
import time
import pytest
from unittest.mock import patch
from app.tasks.maintenance_tasks import _cleanup_dir
class TestCleanupDir:
"""Tests for _cleanup_dir helper."""
def test_returns_zeros_for_missing_directory(self):
stats = _cleanup_dir("/no/such/path", 1800)
assert stats == {"scanned": 0, "deleted": 0, "freed_bytes": 0, "errors": 0}
def test_skips_files_in_root(self, tmp_path):
"""Regular files in the root should be ignored (only dirs scanned)."""
(tmp_path / "regular.txt").write_text("hello")
stats = _cleanup_dir(str(tmp_path), 1800)
assert stats["scanned"] == 0
assert stats["deleted"] == 0
def test_keeps_recent_directory(self, tmp_path):
"""Directories younger than expiry should remain untouched."""
sub = tmp_path / "recent_job"
sub.mkdir()
(sub / "file.pdf").write_bytes(b"%PDF-1.4 test")
stats = _cleanup_dir(str(tmp_path), 1800)
assert stats["scanned"] == 1
assert stats["deleted"] == 0
assert sub.exists()
def test_deletes_expired_directory(self, tmp_path):
"""Directories older than expiry should be removed."""
sub = tmp_path / "old_job"
sub.mkdir()
(sub / "file.pdf").write_bytes(b"%PDF-1.4 test")
# Set mtime to 1 hour ago
old_time = time.time() - 3600
os.utime(str(sub), (old_time, old_time))
stats = _cleanup_dir(str(tmp_path), 1800)
assert stats["scanned"] == 1
assert stats["deleted"] == 1
assert stats["freed_bytes"] > 0
assert not sub.exists()
def test_counts_freed_bytes(self, tmp_path):
"""Freed bytes should approximately match the size of deleted files."""
sub = tmp_path / "old_job"
sub.mkdir()
content = b"A" * 4096
(sub / "data.bin").write_bytes(content)
old_time = time.time() - 3600
os.utime(str(sub), (old_time, old_time))
stats = _cleanup_dir(str(tmp_path), 1800)
assert stats["freed_bytes"] >= 4096
def test_mixed_old_and_new(self, tmp_path):
"""Only expired directories are deleted, recent ones kept."""
old = tmp_path / "expired_dir"
old.mkdir()
(old / "a.txt").write_text("old")
old_time = time.time() - 7200
os.utime(str(old), (old_time, old_time))
recent = tmp_path / "fresh_dir"
recent.mkdir()
(recent / "b.txt").write_text("new")
stats = _cleanup_dir(str(tmp_path), 1800)
assert stats["scanned"] == 2
assert stats["deleted"] == 1
assert not old.exists()
assert recent.exists()
class TestCleanupExpiredFilesTask:
"""Integration test for the Celery task via direct invocation."""
def test_task_runs_and_returns_stats(self, app):
"""Task should return a summary dict."""
# Create an expired directory in uploads
upload_dir = app.config["UPLOAD_FOLDER"]
expired = os.path.join(upload_dir, "expired_session")
os.makedirs(expired, exist_ok=True)
with open(os.path.join(expired, "test.pdf"), "wb") as f:
f.write(b"%PDF-TEST")
old_time = time.time() - 7200
os.utime(expired, (old_time, old_time))
with app.app_context():
from app.tasks.maintenance_tasks import cleanup_expired_files
result = cleanup_expired_files()
assert isinstance(result, dict)
assert result["deleted"] >= 1
assert result["freed_bytes"] > 0
assert not os.path.exists(expired)
def test_task_leaves_recent_alone(self, app):
"""Task should not delete recent directories."""
upload_dir = app.config["UPLOAD_FOLDER"]
recent = os.path.join(upload_dir, "recent_session")
os.makedirs(recent, exist_ok=True)
with open(os.path.join(recent, "test.pdf"), "wb") as f:
f.write(b"%PDF-TEST")
with app.app_context():
from app.tasks.maintenance_tasks import cleanup_expired_files
result = cleanup_expired_files()
assert result["deleted"] == 0
assert os.path.exists(recent)

View File

@@ -0,0 +1,132 @@
"""Tests for forgot-password and reset-password endpoints."""
import pytest
from unittest.mock import patch
class TestForgotPassword:
"""Tests for POST /api/auth/forgot-password."""
def test_forgot_password_returns_200_for_unknown_email(self, client):
"""Should always return 200 to avoid leaking registration info."""
resp = client.post("/api/auth/forgot-password", json={
"email": "doesnotexist@example.com",
})
assert resp.status_code == 200
assert "message" in resp.get_json()
def test_forgot_password_returns_200_for_registered_email(self, client):
"""Should return 200 and trigger email sending."""
client.post("/api/auth/register", json={
"email": "reset_user@example.com",
"password": "TestPassword123!",
})
client.post("/api/auth/logout")
with patch("app.routes.auth.send_password_reset_email") as mock_send:
mock_send.return_value = True
resp = client.post("/api/auth/forgot-password", json={
"email": "reset_user@example.com",
})
assert resp.status_code == 200
mock_send.assert_called_once()
def test_forgot_password_bad_email_format(self, client):
"""Still returns 200 even for bad email format (no info leak)."""
resp = client.post("/api/auth/forgot-password", json={
"email": "not-an-email",
})
assert resp.status_code == 200
class TestResetPassword:
"""Tests for POST /api/auth/reset-password."""
def test_reset_password_missing_token(self, client):
"""Should reject when token is empty."""
resp = client.post("/api/auth/reset-password", json={
"token": "",
"password": "NewPassword123!",
})
assert resp.status_code == 400
def test_reset_password_invalid_token(self, client):
"""Should reject unknown token."""
resp = client.post("/api/auth/reset-password", json={
"token": "totally-invalid-token",
"password": "NewPassword123!",
})
assert resp.status_code == 400
def test_reset_password_short_password(self, client):
"""Should reject short passwords."""
resp = client.post("/api/auth/reset-password", json={
"token": "some-token",
"password": "short",
})
assert resp.status_code == 400
def test_reset_password_full_flow(self, client, app):
"""Register → forgot → get token → reset → login with new password."""
# Register
client.post("/api/auth/register", json={
"email": "fullreset@example.com",
"password": "OldPassword123!",
})
client.post("/api/auth/logout")
# Create reset token directly
from app.services.account_service import get_user_by_email, create_password_reset_token
with app.app_context():
user = get_user_by_email("fullreset@example.com")
token = create_password_reset_token(user["id"])
# Reset
resp = client.post("/api/auth/reset-password", json={
"token": token,
"password": "NewPassword123!",
})
assert resp.status_code == 200
# Login with new password
resp = client.post("/api/auth/login", json={
"email": "fullreset@example.com",
"password": "NewPassword123!",
})
assert resp.status_code == 200
# Old password should fail
client.post("/api/auth/logout")
resp = client.post("/api/auth/login", json={
"email": "fullreset@example.com",
"password": "OldPassword123!",
})
assert resp.status_code == 401
def test_reset_token_cannot_be_reused(self, client, app):
"""A reset token should be consumed on use and fail on second use."""
client.post("/api/auth/register", json={
"email": "reuse@example.com",
"password": "OldPassword123!",
})
client.post("/api/auth/logout")
from app.services.account_service import get_user_by_email, create_password_reset_token
with app.app_context():
user = get_user_by_email("reuse@example.com")
token = create_password_reset_token(user["id"])
# First use — should succeed
resp = client.post("/api/auth/reset-password", json={
"token": token,
"password": "NewPassword123!",
})
assert resp.status_code == 200
# Second use — should fail
resp = client.post("/api/auth/reset-password", json={
"token": token,
"password": "AnotherPassword123!",
})
assert resp.status_code == 400