feat: add operational sync controls
This commit is contained in:
@@ -45,7 +45,7 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path) -> TestClient:
|
||||
def app(tmp_path: Path):
|
||||
settings = Settings(
|
||||
ADMIN_PASSWORD="admin-secret",
|
||||
SECRET_KEY="0123456789abcdef0123456789abcdef",
|
||||
@@ -54,11 +54,63 @@ def client(tmp_path: Path) -> TestClient:
|
||||
DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
|
||||
SYNC_INTERVAL_MINUTES=5,
|
||||
)
|
||||
app = create_app(settings)
|
||||
application = create_app(settings)
|
||||
try:
|
||||
yield TestClient(app)
|
||||
yield application
|
||||
finally:
|
||||
app.state.db_engine.dispose()
|
||||
application.state.db_engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app) -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _extract_csrf(html: str) -> str:
|
||||
marker = 'name="csrf_token" value="'
|
||||
start = html.index(marker) + len(marker)
|
||||
end = html.index('"', start)
|
||||
return html[start:end]
|
||||
|
||||
|
||||
class FakeSyncManager:
|
||||
def __init__(self) -> None:
|
||||
self.user_calls: list[int] = []
|
||||
self.all_calls = 0
|
||||
self.raise_already_running = False
|
||||
self.mfa_calls: list[tuple[int, str]] = []
|
||||
|
||||
async def sync_user(self, user_id: int, mfa_code: str | None = None):
|
||||
if self.raise_already_running:
|
||||
from app.sync.manager import SyncAlreadyRunning
|
||||
|
||||
raise SyncAlreadyRunning(f"sync already running for user {user_id}")
|
||||
self.user_calls.append(user_id)
|
||||
if mfa_code is not None:
|
||||
self.mfa_calls.append((user_id, mfa_code))
|
||||
from app.sync.states import SyncOutcome
|
||||
|
||||
return SyncOutcome(user_id=user_id, status="success", discovered=0, imported=0, skipped=0, failed=0)
|
||||
|
||||
async def sync_all_enabled(self):
|
||||
self.all_calls += 1
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_sync_manager() -> FakeSyncManager:
|
||||
return FakeSyncManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(app, client: TestClient, fake_sync_manager: FakeSyncManager) -> TestClient:
|
||||
page = client.get("/login")
|
||||
csrf = _extract_csrf(page.text)
|
||||
response = client.post("/login", data={"password": "admin-secret", "csrf_token": csrf}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
client.csrf_token = csrf
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
62
tests/web/test_operations.py
Normal file
62
tests/web/test_operations.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/users/1/sync",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.user_calls == [1]
|
||||
|
||||
|
||||
def test_manual_sync_reports_already_running(authenticated_client, fake_sync_manager) -> None:
|
||||
fake_sync_manager.raise_already_running = True
|
||||
response = authenticated_client.post(
|
||||
"/users/1/sync",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already running" in response.text.lower()
|
||||
|
||||
|
||||
def test_sync_all_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/sync-all",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.all_calls == 1
|
||||
|
||||
|
||||
def test_manual_sync_requires_admin(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/users/1/sync",
|
||||
data={"csrf_token": "whatever"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_manual_sync_rejects_invalid_csrf(authenticated_client) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/users/1/sync",
|
||||
data={"csrf_token": "invalid-token"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_system_page_shows_scheduler_state(app, authenticated_client) -> None:
|
||||
class FakeScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.last_tick = None
|
||||
self.next_tick = None
|
||||
|
||||
app.state.scheduler = FakeScheduler()
|
||||
|
||||
response = authenticated_client.get("/system")
|
||||
assert response.status_code == 200
|
||||
assert "1.0.0" in response.text
|
||||
assert "5" in response.text # sync_interval_minutes
|
||||
assert "0" in response.text # user_count / activity_count fresh DB
|
||||
Reference in New Issue
Block a user