fix(merge): resolve conflicts with main v0.92.0 keeping path-param regression tests
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+265
-1
@@ -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
|
||||
|
||||
+444
-17
@@ -236,6 +236,254 @@ class TestBackupTaskHelpers:
|
||||
result = _db_path()
|
||||
assert result is None
|
||||
|
||||
def test_db_backend_sqlite(self):
|
||||
"""_db_backend() returns 'sqlite' for SQLite URLs."""
|
||||
from app.tasks.backup_tasks import _db_backend
|
||||
|
||||
with patch("app.tasks.backup_tasks.settings") as mock_settings:
|
||||
mock_settings.database_url = "sqlite:////tmp/test.db"
|
||||
assert _db_backend() == "sqlite"
|
||||
|
||||
def test_db_backend_postgresql(self):
|
||||
"""_db_backend() returns 'postgresql' for PostgreSQL URLs."""
|
||||
from app.tasks.backup_tasks import _db_backend
|
||||
|
||||
with patch("app.tasks.backup_tasks.settings") as mock_settings:
|
||||
mock_settings.database_url = "postgresql://user:pass@localhost/db"
|
||||
assert _db_backend() == "postgresql"
|
||||
|
||||
def test_db_backend_mysql(self):
|
||||
"""_db_backend() returns 'mysql' for MySQL URLs."""
|
||||
from app.tasks.backup_tasks import _db_backend
|
||||
|
||||
with patch("app.tasks.backup_tasks.settings") as mock_settings:
|
||||
mock_settings.database_url = "mysql+pymysql://user:pass@localhost/db"
|
||||
assert _db_backend() == "mysql"
|
||||
|
||||
def test_archive_ext_sqlite(self):
|
||||
"""_archive_ext_for_backend() returns '.db.gz' for sqlite."""
|
||||
from app.tasks.backup_tasks import _archive_ext_for_backend
|
||||
|
||||
assert _archive_ext_for_backend("sqlite") == ".db.gz"
|
||||
|
||||
def test_archive_ext_postgresql(self):
|
||||
"""_archive_ext_for_backend() returns '.pgsql.gz' for postgresql."""
|
||||
from app.tasks.backup_tasks import _archive_ext_for_backend
|
||||
|
||||
assert _archive_ext_for_backend("postgresql") == ".pgsql.gz"
|
||||
|
||||
def test_archive_ext_mysql(self):
|
||||
"""_archive_ext_for_backend() returns '.mysql.gz' for mysql."""
|
||||
from app.tasks.backup_tasks import _archive_ext_for_backend
|
||||
|
||||
assert _archive_ext_for_backend("mysql") == ".mysql.gz"
|
||||
|
||||
def test_archive_ext_unknown(self):
|
||||
"""_archive_ext_for_backend() falls back to '.sql.gz' for unknown backends."""
|
||||
from app.tasks.backup_tasks import _archive_ext_for_backend
|
||||
|
||||
assert _archive_ext_for_backend("mssql") == ".sql.gz"
|
||||
|
||||
def test_dump_postgresql_success(self, tmp_path):
|
||||
"""_dump_postgresql() streams pg_dump output into a gzip archive."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _dump_postgresql
|
||||
|
||||
dest = tmp_path / "dump.pgsql.gz"
|
||||
fake_sql = b"-- PostgreSQL database dump\nSELECT 1;\n"
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdout.read.side_effect = [fake_sql, b""]
|
||||
mock_proc.stderr.read.return_value = b""
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
_dump_postgresql("postgresql://user:pass@localhost/testdb", dest)
|
||||
|
||||
assert dest.exists()
|
||||
with gzip.open(str(dest), "rb") as gz:
|
||||
assert gz.read() == fake_sql
|
||||
|
||||
def test_dump_postgresql_failure(self, tmp_path):
|
||||
"""_dump_postgresql() raises RuntimeError when pg_dump fails."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _dump_postgresql
|
||||
|
||||
dest = tmp_path / "dump.pgsql.gz"
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdout.read.side_effect = [b""]
|
||||
mock_proc.stderr.read.return_value = b"FATAL: connection refused"
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
with pytest.raises(RuntimeError, match="pg_dump exited with code 1"):
|
||||
_dump_postgresql("postgresql://user:pass@localhost/testdb", dest)
|
||||
|
||||
def test_dump_mysql_success(self, tmp_path):
|
||||
"""_dump_mysql() streams mysqldump output into a gzip archive."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _dump_mysql
|
||||
|
||||
dest = tmp_path / "dump.mysql.gz"
|
||||
fake_sql = b"-- MySQL dump\nCREATE TABLE t (id INT);\n"
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdout.read.side_effect = [fake_sql, b""]
|
||||
mock_proc.stderr.read.return_value = b""
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
_dump_mysql("mysql+pymysql://user:pass@localhost/testdb", dest)
|
||||
|
||||
assert dest.exists()
|
||||
with gzip.open(str(dest), "rb") as gz:
|
||||
assert gz.read() == fake_sql
|
||||
|
||||
def test_dump_mysql_failure(self, tmp_path):
|
||||
"""_dump_mysql() raises RuntimeError when mysqldump fails."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _dump_mysql
|
||||
|
||||
dest = tmp_path / "dump.mysql.gz"
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.stdout.read.side_effect = [b""]
|
||||
mock_proc.stderr.read.return_value = b"ERROR: Access denied"
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
with pytest.raises(RuntimeError, match="mysqldump exited with code 1"):
|
||||
_dump_mysql("mysql+pymysql://user:pass@localhost/testdb", dest)
|
||||
|
||||
def test_restore_sqlite_success(self, tmp_path):
|
||||
"""_restore_sqlite() applies a valid SQL dump to a SQLite file."""
|
||||
from app.tasks.backup_tasks import _restore_sqlite
|
||||
|
||||
db_file = tmp_path / "test.db"
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
conn.execute("CREATE TABLE old (id INTEGER)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Create a valid dump archive
|
||||
sql = "BEGIN TRANSACTION;\nCREATE TABLE new_tbl (x TEXT);\nCOMMIT;\n"
|
||||
archive = tmp_path / "dump.db.gz"
|
||||
with gzip.open(str(archive), "wt") as gz:
|
||||
gz.write(sql)
|
||||
|
||||
_restore_sqlite(db_file, archive)
|
||||
|
||||
conn2 = sqlite3.connect(str(db_file))
|
||||
tables = [r[0] for r in conn2.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
conn2.close()
|
||||
assert "new_tbl" in tables
|
||||
|
||||
def test_restore_sqlite_invalid_gz(self, tmp_path):
|
||||
"""_restore_sqlite() raises ValueError for corrupt gzip content."""
|
||||
from app.tasks.backup_tasks import _restore_sqlite
|
||||
|
||||
db_file = tmp_path / "test.db"
|
||||
db_file.write_bytes(b"")
|
||||
archive = tmp_path / "bad.db.gz"
|
||||
archive.write_bytes(b"not gzip data")
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to decompress"):
|
||||
_restore_sqlite(db_file, archive)
|
||||
|
||||
def test_restore_sqlite_invalid_sql(self, tmp_path):
|
||||
"""_restore_sqlite() raises ValueError for invalid SQL content."""
|
||||
from app.tasks.backup_tasks import _restore_sqlite
|
||||
|
||||
db_file = tmp_path / "test.db"
|
||||
db_file.write_bytes(b"")
|
||||
archive = tmp_path / "bad.db.gz"
|
||||
with gzip.open(str(archive), "wt") as gz:
|
||||
gz.write("THIS IS NOT VALID SQL!!!;\n")
|
||||
|
||||
with pytest.raises(ValueError, match="invalid SQL"):
|
||||
_restore_sqlite(db_file, archive)
|
||||
|
||||
def test_restore_postgresql_success(self, tmp_path):
|
||||
"""_restore_postgresql() pipes the archive to psql."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _restore_postgresql
|
||||
|
||||
fake_sql = b"-- PostgreSQL dump\nSELECT 1;\n"
|
||||
archive = tmp_path / "dump.pgsql.gz"
|
||||
with gzip.open(str(archive), "wb") as gz:
|
||||
gz.write(fake_sql)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate.return_value = (b"", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
_restore_postgresql("postgresql://user:pass@localhost/testdb", archive)
|
||||
|
||||
mock_proc.communicate.assert_called_once_with(input=fake_sql)
|
||||
|
||||
def test_restore_postgresql_failure(self, tmp_path):
|
||||
"""_restore_postgresql() raises RuntimeError when psql fails."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _restore_postgresql
|
||||
|
||||
archive = tmp_path / "dump.pgsql.gz"
|
||||
with gzip.open(str(archive), "wb") as gz:
|
||||
gz.write(b"SELECT 1;")
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate.return_value = (b"", b"ERROR: invalid input")
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
with pytest.raises(RuntimeError, match="psql exited with code 1"):
|
||||
_restore_postgresql("postgresql://user:pass@localhost/testdb", archive)
|
||||
|
||||
def test_restore_mysql_success(self, tmp_path):
|
||||
"""_restore_mysql() pipes the archive to mysql."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _restore_mysql
|
||||
|
||||
fake_sql = b"-- MySQL dump\nSELECT 1;\n"
|
||||
archive = tmp_path / "dump.mysql.gz"
|
||||
with gzip.open(str(archive), "wb") as gz:
|
||||
gz.write(fake_sql)
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate.return_value = (b"", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
_restore_mysql("mysql+pymysql://user:pass@localhost/testdb", archive)
|
||||
|
||||
mock_proc.communicate.assert_called_once_with(input=fake_sql)
|
||||
|
||||
def test_restore_mysql_failure(self, tmp_path):
|
||||
"""_restore_mysql() raises RuntimeError when mysql fails."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.tasks.backup_tasks import _restore_mysql
|
||||
|
||||
archive = tmp_path / "dump.mysql.gz"
|
||||
with gzip.open(str(archive), "wb") as gz:
|
||||
gz.write(b"SELECT 1;")
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate.return_value = (b"", b"ERROR: Access denied")
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("app.tasks.backup_tasks.subprocess.Popen", return_value=mock_proc):
|
||||
with pytest.raises(RuntimeError, match="mysql exited with code 1"):
|
||||
_restore_mysql("mysql+pymysql://user:pass@localhost/testdb", archive)
|
||||
|
||||
def test_apply_retention_prunes_old(self, tmp_path, db_session):
|
||||
"""_apply_retention() deletes backups beyond the retention limit."""
|
||||
from app.tasks.backup_tasks import _apply_retention
|
||||
@@ -352,20 +600,28 @@ class TestCreateBackupTask:
|
||||
result = create_backup("hourly")
|
||||
assert result["status"] == "disabled"
|
||||
|
||||
def test_non_sqlite_db(self):
|
||||
"""create_backup returns unsupported_db for non-SQLite databases."""
|
||||
def test_unsupported_db_backend(self):
|
||||
"""create_backup returns unsupported_db for backends other than sqlite/postgresql/mysql."""
|
||||
from app.tasks.backup_tasks import create_backup
|
||||
|
||||
with (
|
||||
patch("app.tasks.backup_tasks.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._db_path", return_value=None),
|
||||
):
|
||||
with patch("app.tasks.backup_tasks.settings") as mock_settings:
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = "mssql+pyodbc://user:pass@server/db"
|
||||
result = create_backup("hourly")
|
||||
assert result["status"] == "unsupported_db"
|
||||
|
||||
def test_in_memory_sqlite_unsupported(self):
|
||||
"""create_backup returns unsupported_db for in-memory SQLite."""
|
||||
from app.tasks.backup_tasks import create_backup
|
||||
|
||||
with patch("app.tasks.backup_tasks.settings") as mock_settings:
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = "sqlite:///:memory:"
|
||||
result = create_backup("hourly")
|
||||
assert result["status"] == "unsupported_db"
|
||||
|
||||
def test_missing_db_file(self, tmp_path):
|
||||
"""create_backup returns error when the DB file does not exist."""
|
||||
"""create_backup returns error when the SQLite DB file does not exist."""
|
||||
from app.tasks.backup_tasks import create_backup
|
||||
|
||||
missing = tmp_path / "does_not_exist.db"
|
||||
@@ -375,6 +631,7 @@ class TestCreateBackupTask:
|
||||
patch("app.tasks.backup_tasks._db_path", return_value=missing),
|
||||
):
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = f"sqlite:///{missing}"
|
||||
result = create_backup("hourly")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@@ -400,6 +657,7 @@ class TestCreateBackupTask:
|
||||
):
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = f"sqlite:///{db_file}"
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_sl.return_value.__exit__ = MagicMock(return_value=False)
|
||||
@@ -408,6 +666,69 @@ class TestCreateBackupTask:
|
||||
assert result["status"] == "ok"
|
||||
assert "filename" in result
|
||||
assert result["filename"].startswith("backup_hourly_")
|
||||
assert result["filename"].endswith(".db.gz")
|
||||
|
||||
def test_successful_backup_postgresql(self, tmp_path):
|
||||
"""create_backup creates a .pgsql.gz archive for PostgreSQL databases."""
|
||||
from app.tasks.backup_tasks import create_backup
|
||||
|
||||
backup_dir = tmp_path / "backups"
|
||||
backup_dir.mkdir()
|
||||
|
||||
def fake_pg_dump(db_url: str, dest: Path) -> None:
|
||||
with gzip.open(str(dest), "wb") as gz:
|
||||
gz.write(b"-- PostgreSQL dump\n")
|
||||
|
||||
with (
|
||||
patch("app.tasks.backup_tasks.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._backup_dir", return_value=backup_dir),
|
||||
patch("app.tasks.backup_tasks._dump_postgresql", side_effect=fake_pg_dump),
|
||||
patch("app.tasks.backup_tasks._upload_remote", return_value=None),
|
||||
patch("app.tasks.backup_tasks._apply_retention"),
|
||||
patch("app.tasks.backup_tasks._prune_remote_backups"),
|
||||
patch("app.tasks.backup_tasks.SessionLocal") as mock_sl,
|
||||
):
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = "postgresql://user:pass@localhost/testdb"
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_sl.return_value.__exit__ = MagicMock(return_value=False)
|
||||
result = create_backup("daily")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["filename"].endswith(".pgsql.gz")
|
||||
assert "daily" in result["filename"]
|
||||
|
||||
def test_successful_backup_mysql(self, tmp_path):
|
||||
"""create_backup creates a .mysql.gz archive for MySQL databases."""
|
||||
from app.tasks.backup_tasks import create_backup
|
||||
|
||||
backup_dir = tmp_path / "backups"
|
||||
backup_dir.mkdir()
|
||||
|
||||
def fake_mysql_dump(db_url: str, dest: Path) -> None:
|
||||
with gzip.open(str(dest), "wb") as gz:
|
||||
gz.write(b"-- MySQL dump\n")
|
||||
|
||||
with (
|
||||
patch("app.tasks.backup_tasks.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._backup_dir", return_value=backup_dir),
|
||||
patch("app.tasks.backup_tasks._dump_mysql", side_effect=fake_mysql_dump),
|
||||
patch("app.tasks.backup_tasks._upload_remote", return_value=None),
|
||||
patch("app.tasks.backup_tasks._apply_retention"),
|
||||
patch("app.tasks.backup_tasks._prune_remote_backups"),
|
||||
patch("app.tasks.backup_tasks.SessionLocal") as mock_sl,
|
||||
):
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = "mysql+pymysql://user:pass@localhost/testdb"
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_sl.return_value.__exit__ = MagicMock(return_value=False)
|
||||
result = create_backup("weekly")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["filename"].endswith(".mysql.gz")
|
||||
assert "weekly" in result["filename"]
|
||||
|
||||
def test_invalid_backup_type_defaults_to_hourly(self, tmp_path):
|
||||
"""create_backup normalises unknown backup_type to 'hourly'."""
|
||||
@@ -431,6 +752,7 @@ class TestCreateBackupTask:
|
||||
patch("app.tasks.backup_tasks.SessionLocal") as mock_sl,
|
||||
):
|
||||
mock_settings.backup_enabled = True
|
||||
mock_settings.database_url = f"sqlite:///{db_file}"
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||
mock_sl.return_value.__exit__ = MagicMock(return_value=False)
|
||||
@@ -549,19 +871,24 @@ class TestBackupAPIEndpoints:
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_restore_wrong_extension(self, admin_client):
|
||||
"""POST /api/admin/backup/restore rejects non-.db.gz files."""
|
||||
"""POST /api/admin/backup/restore rejects files with wrong extension for current backend."""
|
||||
# Default test env uses sqlite:///:memory: → expects .db.gz
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.zip", b"data", "application/zip")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_restore_invalid_gz_content(self, admin_client):
|
||||
def test_restore_invalid_gz_content(self, admin_client, tmp_path):
|
||||
"""POST /api/admin/backup/restore rejects corrupt gzip data."""
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.db.gz", b"not gzip data at all", "application/gzip")},
|
||||
)
|
||||
db_file = tmp_path / "test.db"
|
||||
db_file.write_bytes(b"")
|
||||
|
||||
with patch("app.tasks.backup_tasks._db_path", return_value=db_file):
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.db.gz", b"not gzip data at all", "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_restore_valid_archive(self, admin_client, tmp_path):
|
||||
@@ -581,11 +908,11 @@ class TestBackupAPIEndpoints:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "restored"
|
||||
|
||||
def test_restore_non_sqlite_db(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 400 for non-SQLite database."""
|
||||
sql = "BEGIN TRANSACTION;\nCOMMIT;\n"
|
||||
gz_data = gzip.compress(sql.encode())
|
||||
def test_restore_in_memory_sqlite(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 400 for in-memory SQLite (no file to restore to)."""
|
||||
gz_data = gzip.compress(b"BEGIN TRANSACTION;\nCOMMIT;\n")
|
||||
|
||||
# _db_path() returns None for :memory: URLs → 400
|
||||
with patch("app.tasks.backup_tasks._db_path", return_value=None):
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
@@ -593,6 +920,106 @@ class TestBackupAPIEndpoints:
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_restore_wrong_extension_for_postgresql(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 400 when uploading .db.gz for PostgreSQL backend."""
|
||||
gz_data = gzip.compress(b"-- PostgreSQL dump")
|
||||
|
||||
with patch("app.config.settings") as mock_settings:
|
||||
mock_settings.database_url = "postgresql://user:pass@localhost/testdb"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.db.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_restore_wrong_extension_for_mysql(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 400 when uploading .db.gz for MySQL backend."""
|
||||
gz_data = gzip.compress(b"-- MySQL dump")
|
||||
|
||||
with patch("app.config.settings") as mock_settings:
|
||||
mock_settings.database_url = "mysql+pymysql://user:pass@localhost/testdb"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.db.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_restore_postgresql_success(self, admin_client):
|
||||
"""POST /api/admin/backup/restore succeeds for PostgreSQL database."""
|
||||
gz_data = gzip.compress(b"-- PostgreSQL dump\n")
|
||||
|
||||
with (
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._restore_postgresql") as mock_restore,
|
||||
):
|
||||
mock_settings.database_url = "postgresql://user:pass@localhost/testdb"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.pgsql.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "restored"
|
||||
mock_restore.assert_called_once()
|
||||
|
||||
def test_restore_mysql_success(self, admin_client):
|
||||
"""POST /api/admin/backup/restore succeeds for MySQL database."""
|
||||
gz_data = gzip.compress(b"-- MySQL dump\n")
|
||||
|
||||
with (
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._restore_mysql") as mock_restore,
|
||||
):
|
||||
mock_settings.database_url = "mysql+pymysql://user:pass@localhost/testdb"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.mysql.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "restored"
|
||||
mock_restore.assert_called_once()
|
||||
|
||||
def test_restore_postgresql_runtime_error(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 500 when psql command fails."""
|
||||
gz_data = gzip.compress(b"-- PostgreSQL dump\n")
|
||||
|
||||
with (
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._restore_postgresql", side_effect=RuntimeError("psql failed")),
|
||||
):
|
||||
mock_settings.database_url = "postgresql://user:pass@localhost/testdb"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.pgsql.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_restore_postgresql_missing_binary(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 500 when psql binary is missing."""
|
||||
gz_data = gzip.compress(b"-- PostgreSQL dump\n")
|
||||
|
||||
with (
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("app.tasks.backup_tasks._restore_postgresql", side_effect=FileNotFoundError("psql not found")),
|
||||
):
|
||||
mock_settings.database_url = "postgresql://user:pass@localhost/testdb"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.pgsql.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_restore_unsupported_backend(self, admin_client):
|
||||
"""POST /api/admin/backup/restore returns 400 for an unsupported database backend."""
|
||||
gz_data = gzip.compress(b"-- some dump\n")
|
||||
|
||||
with patch("app.config.settings") as mock_settings:
|
||||
mock_settings.database_url = "mssql+pyodbc://user:pass@server/db"
|
||||
resp = admin_client.post(
|
||||
"/api/admin/backup/restore",
|
||||
files={"file": ("backup.sql.gz", gz_data, "application/gzip")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# View tests
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user