File size: 12,564 Bytes
11463f1 80551d2 d487ee3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 | """
Regression tests for the QA bug batch (BUG-002, 006, 007, 008, 010, 011)
and the password reset feature (BUG-004).
Validation is exercised through the Pydantic schemas directly so no database is
required; the reset flow uses a fake session.
"""
import os
import sys
from datetime import datetime, timedelta
import pytest
from pydantic import ValidationError
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from models.schemas import ( # noqa: E402
RegisterRequest,
ResetPasswordRequest,
)
def _register(username="validuser", email="user@example.com", password="password1"):
return RegisterRequest(username=username, email=email, password=password)
# ── BUG-006: invalid email format accepted ───────────────────────────────────
class TestEmailValidation:
@pytest.mark.parametrize(
"email",
[
"user@gmail", # the exact repro: ".com" removed
"user@",
"@example.com",
"plainstring",
"user @example.com",
"",
],
)
def test_rejects_malformed_email(self, email):
with pytest.raises(ValidationError):
_register(email=email)
def test_accepts_valid_email(self):
assert _register(email="user@example.com").email == "user@example.com"
def test_normalizes_case(self):
# Prevents "User@x.com" and "user@x.com" becoming two accounts.
assert _register(email="User@Example.COM").email == "user@example.com"
# ── BUG-007: invalid password format accepted ────────────────────────────────
class TestPasswordValidation:
@pytest.mark.parametrize(
"password",
[
" ", # the repro: spaces only
"abcdefg ", # letters + space, no digit
"letters only", # no digit
"12345678", # no letter
"short1", # under 8 characters
"",
],
)
def test_rejects_weak_password(self, password):
with pytest.raises(ValidationError):
_register(password=password)
def test_accepts_valid_password(self):
assert _register(password="password1").password == "password1"
def test_rejects_password_over_bcrypt_byte_limit(self):
# 4 bytes per emoji: passes an 8-character check but bcrypt would
# silently truncate it at 72 bytes.
with pytest.raises(ValidationError):
_register(password="ab1" + "\U0001f600" * 20)
# ── BUG-008 / BUG-010: invalid username accepted ─────────────────────────────
class TestUsernameValidation:
@pytest.mark.parametrize(
"username",
[
"@@@", # BUG-008 repro
"123", # BUG-008 repro: digits only
"has space", # BUG-010
"brackets[]", # BUG-010
"emoji\U0001f600", # BUG-010
"ab", # under 3 characters
"-leading",
"trailing-",
"x" * 33,
],
)
def test_rejects_invalid_username(self, username):
with pytest.raises(ValidationError):
_register(username=username)
@pytest.mark.parametrize(
"username",
["validuser", "user_name", "user.name", "user-name", "a1b", "User123"],
)
def test_accepts_valid_username(self, username):
assert _register(username=username).username == username
# ── BUG-004: password reset ──────────────────────────────────────────────────
class FakeQuery:
def __init__(self, user):
self._user = user
def filter(self, *_args, **_kwargs):
return self
def first(self):
return self._user
class FakeSession:
def __init__(self, user=None):
self._user = user
self.commits = 0
def query(self, *_args, **_kwargs):
return FakeQuery(self._user)
def commit(self):
self.commits += 1
def refresh(self, _obj):
pass
class FakeUser:
def __init__(self):
self.hashed_password = "old-hash"
self.reset_token_hash = None
self.reset_token_expires = None
class TestPasswordReset:
def test_token_is_stored_hashed_not_in_plaintext(self):
from services.auth_service import create_reset_token
user = FakeUser()
token = create_reset_token(FakeSession(user), user)
assert token
assert user.reset_token_hash != token
assert len(user.reset_token_hash) == 64 # sha256 hex
assert user.reset_token_expires > datetime.utcnow()
def test_valid_token_sets_password_and_is_single_use(self):
from services.auth_service import (
consume_reset_token,
create_reset_token,
verify_password,
)
user = FakeUser()
token = create_reset_token(FakeSession(user), user)
result = consume_reset_token(FakeSession(user), token, "newpassword1")
assert result is user
assert verify_password("newpassword1", user.hashed_password)
# Token is burned, so replaying it fails.
assert user.reset_token_hash is None
assert consume_reset_token(FakeSession(None), token, "another1") is None
def test_expired_token_is_rejected(self):
from services.auth_service import consume_reset_token, create_reset_token
user = FakeUser()
token = create_reset_token(FakeSession(user), user)
user.reset_token_expires = datetime.utcnow() - timedelta(minutes=1)
assert consume_reset_token(FakeSession(user), token, "newpassword1") is None
assert user.hashed_password == "old-hash"
def test_unknown_token_is_rejected(self):
from services.auth_service import consume_reset_token
assert consume_reset_token(FakeSession(None), "bogus", "newpassword1") is None
def test_reset_enforces_same_password_rule_as_registration(self):
with pytest.raises(ValidationError):
ResetPasswordRequest(token="t", password=" ")
assert ResetPasswordRequest(token="t", password="password1").password == "password1"
# ── BUG-002: ReDoc bundle must be pinned, not a floating tag ─────────────────
class TestReDoc:
def test_redoc_page_pins_an_exact_bundle_version(self):
from fastapi.testclient import TestClient
from main import app
response = TestClient(app).get("/redoc")
assert response.status_code == 200
# "redoc@next" is a floating pre-release tag that currently ships a
# bundle which fails to boot, leaving the page blank.
assert "redoc@next" not in response.text
assert "redoc@2.1.5/bundles/redoc.standalone.js" in response.text
# ── BUG-011: summarizer must answer in the input language ────────────────────
class TestSummarizeLanguage:
def test_prompt_instructs_model_to_keep_input_language(self):
from unittest.mock import patch
from services import summarize_service
with patch("services.llm_client.llm_chat", return_value="ok") as mock_chat:
summarize_service._summarize_llm("कुछ पाठ", "paragraph", 150)
prompt = mock_chat.call_args.kwargs["user_prompt"]
system = mock_chat.call_args.kwargs["system_prompt"]
assert "SAME language" in prompt
assert "Do not translate" in prompt
assert "same language" in system
def test_bullet_mode_also_preserves_language(self):
from unittest.mock import patch
from services import summarize_service
with patch("services.llm_client.llm_chat", return_value="ok") as mock_chat:
summarize_service._summarize_llm("कुछ पाठ", "bullet", 150)
assert "SAME language" in mock_chat.call_args.kwargs["user_prompt"]
class TestPasswordCharacterRule:
"""QA follow-up on BUG-007: emoji and spaces slipped through when a letter
and a digit were also present."""
@pytest.mark.parametrize(
"password",
[
"abc123 456", # space alongside letter + digit
"pass 1234",
"abc123\U0001f600", # emoji alongside letter + digit
"\U0001f600abc12345",
"abc\t12345", # tab
"caf\u00e912345", # non-ASCII letter
],
)
def test_rejects_spaces_and_emoji(self, password):
with pytest.raises(ValidationError):
_register(password=password)
@pytest.mark.parametrize("password", ["password1", "Str0ngPass", "P@ssw0rd!", "a1b2c3d4"])
def test_still_accepts_normal_passwords(self, password):
assert _register(password=password).password == password
# ── Mail transport: HF Spaces blocks SMTP ports, so Brevo's API is preferred ──
class TestMailTransport:
def _settings(self, monkeypatch, **overrides):
from config import settings
defaults = {
"brevo_api_key": "", "smtp_host": "", "smtp_from": "noreply@example.com",
"email_from_name": "Anovo", "smtp_user": "", "smtp_password": "",
"smtp_port": 587, "smtp_use_tls": True,
}
for key, value in {**defaults, **overrides}.items():
monkeypatch.setattr(settings, key, value, raising=False)
def test_prefers_the_brevo_api_when_a_key_is_set(self, monkeypatch):
from unittest.mock import MagicMock, patch
from services import mailer
# Both configured: the API must win, because SMTP cannot connect on HF.
self._settings(monkeypatch, brevo_api_key="xkeysib-test", smtp_host="smtp-relay.brevo.com")
response = MagicMock(status_code=201, text="")
with patch("services.mailer.httpx.post", return_value=response) as post, \
patch("services.mailer.smtplib.SMTP") as smtp:
assert mailer.send_email("user@example.com", "Subject", "Body") is True
smtp.assert_not_called()
payload = post.call_args.kwargs["json"]
assert payload["to"] == [{"email": "user@example.com"}]
assert payload["sender"]["email"] == "noreply@example.com"
assert post.call_args.kwargs["headers"]["api-key"] == "xkeysib-test"
def test_brevo_rejection_returns_false_and_is_logged(self, monkeypatch, caplog):
from unittest.mock import MagicMock, patch
from services import mailer
self._settings(monkeypatch, brevo_api_key="xkeysib-test")
response = MagicMock(status_code=400, text='{"message":"Sender not valid"}')
with patch("services.mailer.httpx.post", return_value=response):
assert mailer.send_email("user@example.com", "Subject", "Body") is False
assert "Brevo rejected" in caplog.text
def test_falls_back_to_smtp_without_an_api_key(self, monkeypatch):
from unittest.mock import MagicMock, patch
from services import mailer
self._settings(monkeypatch, smtp_host="smtp.example.com", smtp_user="u", smtp_password="p")
with patch("services.mailer.smtplib.SMTP") as smtp:
smtp.return_value.__enter__.return_value = MagicMock()
assert mailer.send_email("user@example.com", "Subject", "Body") is True
smtp.assert_called_once()
def test_logs_the_message_when_nothing_is_configured(self, monkeypatch, caplog):
from services import mailer
self._settings(monkeypatch)
assert mailer.send_email("user@example.com", "Subject", "Body") is False
assert "No email provider configured" in caplog.text
def test_reset_email_carries_the_link_and_expiry(self, monkeypatch):
from unittest.mock import MagicMock, patch
from services import mailer
self._settings(monkeypatch, brevo_api_key="xkeysib-test")
with patch("services.mailer.httpx.post", return_value=MagicMock(status_code=201, text="")) as post:
mailer.send_password_reset("user@example.com", "https://anovo.vercel.app/reset-password?token=abc", 30)
content = post.call_args.kwargs["json"]["textContent"]
assert "https://anovo.vercel.app/reset-password?token=abc" in content
assert "30 minutes" in content
|