e518bce922
Resolve all merge conflicts between our automation feature branch and current main (v0.163.0, 920 commits ahead). Conflicts resolved: - app/api/__init__.py: add automation_router alongside main's new routers (classification_rules, qr_auth, sessions, system_reset) - app/config.py: add main's new settings (dropbox_use_global_credentials, factory_reset_on_startup, enable_factory_reset) - app/models.py: add main's new models (ClassificationRuleModel, UserSession, QRLoginChallenge, SharePoint integration type) - app/utils/settings_service.py: merge automation_hooks_enabled with main's new metadata entries - docs/API.md: merge automation API docs with main's classification rules docs - docs/ConfigurationGuide.md: add factory reset settings - tests/conftest.py: import both AutomationHook and new main models Migration renumbered: - 037_add_automation_hooks → 040_add_automation_hooks - down_revision: 039_add_classification_rules (was 036_add_document_translation_fields) - Chain: 036 → 037 → 038 → 039 → 040 (automation hooks) For all non-automation files with conflicts, main's version was taken since our branch did not modify those files (conflicts were from a stale prior merge). Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/cb62f012-3b69-4415-835e-3857ce3e9f45
394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.database import Base
|
|
from app.models import (
|
|
DocumentMetadata,
|
|
FileProcessingStep,
|
|
FileRecord,
|
|
ProcessingLog,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_workdir():
|
|
"""Create a temporary workdir populated with sample user data."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create data subdirectories with dummy files
|
|
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
|
|
d = Path(tmpdir) / subdir
|
|
d.mkdir()
|
|
(d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
|
|
|
|
# Create cache files
|
|
for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
|
|
(Path(tmpdir) / cache).write_text("{}")
|
|
|
|
# Create a per-user watch folder cache
|
|
(Path(tmpdir) / "user_wf_42.json").write_text("{}")
|
|
|
|
# Create a loose PDF in workdir root
|
|
(Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
|
|
|
|
yield tmpdir
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_db_session():
|
|
"""Fresh in-memory database with sample user data rows."""
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(bind=engine)
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
|
|
# Seed with sample data
|
|
fr = FileRecord(
|
|
filehash="abc123",
|
|
original_filename="test.pdf",
|
|
local_filename="uuid.pdf",
|
|
file_size=1024,
|
|
mime_type="application/pdf",
|
|
)
|
|
session.add(fr)
|
|
session.flush()
|
|
|
|
session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
|
|
session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
|
|
session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
|
|
session.commit()
|
|
|
|
yield session
|
|
|
|
session.close()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests for app/utils/system_reset.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWipeWorkdirData:
|
|
"""Tests for _wipe_workdir_data()."""
|
|
|
|
def test_removes_data_subdirs(self, reset_workdir):
|
|
from app.utils.system_reset import _wipe_workdir_data
|
|
|
|
result = _wipe_workdir_data(reset_workdir)
|
|
|
|
# All data subdirectories should be gone
|
|
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
|
|
assert not (Path(reset_workdir) / subdir).exists()
|
|
|
|
assert result["deleted_dirs"] == 5
|
|
|
|
def test_removes_cache_files(self, reset_workdir):
|
|
from app.utils.system_reset import _wipe_workdir_data
|
|
|
|
result = _wipe_workdir_data(reset_workdir)
|
|
|
|
assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
|
|
assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
|
|
assert not (Path(reset_workdir) / "user_wf_42.json").exists()
|
|
assert result["deleted_files"] >= 3
|
|
|
|
def test_removes_loose_document_files(self, reset_workdir):
|
|
from app.utils.system_reset import _wipe_workdir_data
|
|
|
|
_wipe_workdir_data(reset_workdir)
|
|
assert not (Path(reset_workdir) / "abc123.pdf").exists()
|
|
|
|
def test_preserves_workdir_directory(self, reset_workdir):
|
|
from app.utils.system_reset import _wipe_workdir_data
|
|
|
|
_wipe_workdir_data(reset_workdir)
|
|
assert Path(reset_workdir).is_dir()
|
|
|
|
def test_handles_empty_workdir(self):
|
|
"""No errors when workdir has no data dirs or caches."""
|
|
from app.utils.system_reset import _wipe_workdir_data
|
|
|
|
with tempfile.TemporaryDirectory() as empty_dir:
|
|
result = _wipe_workdir_data(empty_dir)
|
|
assert result["deleted_dirs"] == 0
|
|
assert result["deleted_files"] == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWipeDatabase:
|
|
"""Tests for _wipe_database()."""
|
|
|
|
def test_deletes_all_user_data(self, reset_db_session):
|
|
from app.utils.system_reset import _wipe_database
|
|
|
|
result = _wipe_database(reset_db_session)
|
|
|
|
assert result.get("files", 0) >= 1
|
|
assert result.get("processing_logs", 0) >= 1
|
|
assert result.get("file_processing_steps", 0) >= 1
|
|
assert result.get("document_metadata", 0) >= 1
|
|
|
|
def test_tables_are_empty_after_wipe(self, reset_db_session):
|
|
from app.utils.system_reset import _wipe_database
|
|
|
|
_wipe_database(reset_db_session)
|
|
|
|
assert reset_db_session.query(FileRecord).count() == 0
|
|
assert reset_db_session.query(ProcessingLog).count() == 0
|
|
assert reset_db_session.query(FileProcessingStep).count() == 0
|
|
assert reset_db_session.query(DocumentMetadata).count() == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPerformFullReset:
|
|
"""Tests for perform_full_reset()."""
|
|
|
|
def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
|
|
from app.utils.system_reset import perform_full_reset
|
|
|
|
with patch("app.utils.system_reset.settings") as mock_settings:
|
|
mock_settings.workdir = reset_workdir
|
|
result = perform_full_reset(reset_db_session)
|
|
|
|
assert "database" in result
|
|
assert "filesystem" in result
|
|
assert reset_db_session.query(FileRecord).count() == 0
|
|
assert not (Path(reset_workdir) / "original").exists()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPerformResetAndReimport:
|
|
"""Tests for perform_reset_and_reimport()."""
|
|
|
|
def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
|
|
from app.utils.system_reset import perform_reset_and_reimport
|
|
|
|
with patch("app.utils.system_reset.settings") as mock_settings:
|
|
mock_settings.workdir = reset_workdir
|
|
mock_settings.watch_folders = ""
|
|
mock_settings.watch_folder_delete_after_process = False
|
|
result = perform_reset_and_reimport(reset_db_session)
|
|
|
|
reimport_dir = Path(reset_workdir) / "reimport"
|
|
assert reimport_dir.is_dir()
|
|
assert result["reimport"]["files_moved"] >= 1
|
|
|
|
# DB should be wiped
|
|
assert reset_db_session.query(FileRecord).count() == 0
|
|
|
|
# Reimport folder should contain the original file
|
|
reimport_files = list(reimport_dir.iterdir())
|
|
assert len(reimport_files) >= 1
|
|
|
|
def test_configures_watch_folder(self, reset_db_session, reset_workdir):
|
|
from app.utils.system_reset import perform_reset_and_reimport
|
|
|
|
with patch("app.utils.system_reset.settings") as mock_settings:
|
|
mock_settings.workdir = reset_workdir
|
|
mock_settings.watch_folders = "/some/other/folder"
|
|
mock_settings.watch_folder_delete_after_process = False
|
|
perform_reset_and_reimport(reset_db_session)
|
|
|
|
reimport_path = str(Path(reset_workdir) / "reimport")
|
|
# watch_folders should now include the reimport path
|
|
assert reimport_path in mock_settings.watch_folders
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStartupReset:
|
|
"""Tests for perform_startup_reset()."""
|
|
|
|
def test_startup_reset_calls_full_reset(self):
|
|
from app.utils.system_reset import perform_startup_reset
|
|
|
|
with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
|
|
with patch("app.database.SessionLocal") as mock_sl:
|
|
mock_db = mock_sl.return_value
|
|
perform_startup_reset()
|
|
|
|
mock_reset.assert_called_once_with(mock_db)
|
|
mock_db.close.assert_called_once()
|
|
|
|
def test_startup_reset_handles_errors(self):
|
|
from app.utils.system_reset import perform_startup_reset
|
|
|
|
with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
|
|
with patch("app.database.SessionLocal") as mock_sl:
|
|
mock_db = mock_sl.return_value
|
|
# Should not raise
|
|
perform_startup_reset()
|
|
mock_db.rollback.assert_called_once()
|
|
mock_db.close.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests for API endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSystemResetApi:
|
|
"""Tests for the /api/admin/system-reset/ endpoints."""
|
|
|
|
def test_full_reset_requires_admin(self, client):
|
|
"""Non-admin users get 403."""
|
|
response = client.post(
|
|
"/api/admin/system-reset/full",
|
|
json={"confirmation": "DELETE"},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
def test_full_reset_requires_feature_flag(self, client):
|
|
"""Returns 404 when ENABLE_FACTORY_RESET is false."""
|
|
from app.api.system_reset import _require_admin
|
|
|
|
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
|
try:
|
|
with patch("app.api.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = False
|
|
response = client.post(
|
|
"/api/admin/system-reset/full",
|
|
json={"confirmation": "DELETE"},
|
|
)
|
|
finally:
|
|
client.app.dependency_overrides.pop(_require_admin, None)
|
|
assert response.status_code == 404
|
|
|
|
def test_full_reset_requires_confirmation(self, client):
|
|
"""Wrong confirmation string gets 400."""
|
|
from app.api.system_reset import _require_admin
|
|
|
|
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
|
try:
|
|
with patch("app.api.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = True
|
|
response = client.post(
|
|
"/api/admin/system-reset/full",
|
|
json={"confirmation": "WRONG"},
|
|
)
|
|
finally:
|
|
client.app.dependency_overrides.pop(_require_admin, None)
|
|
|
|
assert response.status_code == 400
|
|
|
|
def test_reimport_requires_confirmation(self, client):
|
|
"""Wrong confirmation string gets 400."""
|
|
from app.api.system_reset import _require_admin
|
|
|
|
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
|
try:
|
|
with patch("app.api.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = True
|
|
response = client.post(
|
|
"/api/admin/system-reset/reimport",
|
|
json={"confirmation": "WRONG"},
|
|
)
|
|
finally:
|
|
client.app.dependency_overrides.pop(_require_admin, None)
|
|
|
|
assert response.status_code == 400
|
|
|
|
def test_status_endpoint(self, client):
|
|
"""The status endpoint returns feature-flag state."""
|
|
from app.api.system_reset import _require_admin
|
|
|
|
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
|
try:
|
|
response = client.get("/api/admin/system-reset/status")
|
|
finally:
|
|
client.app.dependency_overrides.pop(_require_admin, None)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "enabled" in data
|
|
assert "factory_reset_on_startup" in data
|
|
|
|
def test_full_reset_success(self, client):
|
|
"""Full reset succeeds with correct confirmation and feature flag."""
|
|
from app.api.system_reset import _require_admin
|
|
|
|
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
|
try:
|
|
with patch("app.api.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = True
|
|
with patch(
|
|
"app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
|
|
):
|
|
response = client.post(
|
|
"/api/admin/system-reset/full",
|
|
json={"confirmation": "DELETE"},
|
|
)
|
|
finally:
|
|
client.app.dependency_overrides.pop(_require_admin, None)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ok"
|
|
|
|
def test_reimport_success(self, client):
|
|
"""Reimport succeeds with correct confirmation."""
|
|
from app.api.system_reset import _require_admin
|
|
|
|
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
|
try:
|
|
with patch("app.api.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = True
|
|
with patch(
|
|
"app.utils.system_reset.perform_reset_and_reimport",
|
|
return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
|
|
):
|
|
response = client.post(
|
|
"/api/admin/system-reset/reimport",
|
|
json={"confirmation": "REIMPORT"},
|
|
)
|
|
finally:
|
|
client.app.dependency_overrides.pop(_require_admin, None)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests for the view
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSystemResetView:
|
|
"""Tests for the /admin/system-reset view."""
|
|
|
|
def test_view_redirects_when_disabled(self, client):
|
|
"""When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
|
|
with client:
|
|
client.cookies.set("session", "test")
|
|
with patch("app.views.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = False
|
|
response = client.get("/admin/system-reset", follow_redirects=False)
|
|
# Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
|
|
assert response.status_code in (302, 307)
|
|
|
|
def test_view_requires_auth(self, client):
|
|
"""Unauthenticated users are redirected away from the page."""
|
|
with patch("app.views.system_reset.settings") as mock_s:
|
|
mock_s.enable_factory_reset = True
|
|
mock_s.factory_reset_on_startup = False
|
|
response = client.get("/admin/system-reset", follow_redirects=False)
|
|
# Should redirect to login since there's no active session
|
|
assert response.status_code in (302, 307)
|