anovo-api / tests /test_auth_validation.py
rushabh13's picture
mail: send via Brevo's HTTPS API, since HF Spaces blocks SMTP ports
d487ee3
Raw
History Blame Contribute Delete
12.6 kB
"""
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