removed old UI
This commit is contained in:
parent
4433d7d2c4
commit
f71841e922
62 changed files with 411 additions and 5815 deletions
|
|
@ -1,204 +0,0 @@
|
|||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
||||
from app.auth.rate_limit import SlidingWindowRateLimiter
|
||||
from app.db.session import get_db_session
|
||||
from app.main import app
|
||||
|
||||
CSRF_TOKEN_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
|
||||
|
||||
|
||||
class StubAuthService:
|
||||
def __init__(self, *, user: object | None) -> None:
|
||||
self._user = user
|
||||
|
||||
async def authenticate_user(self, _db_session, *, email: str, password: str):
|
||||
if self._user is None:
|
||||
return None
|
||||
if email.strip().lower() != str(self._user.email):
|
||||
return None
|
||||
if password != "correct-password":
|
||||
return None
|
||||
return self._user
|
||||
|
||||
|
||||
def _extract_csrf_token(html: str) -> str:
|
||||
match = CSRF_TOKEN_PATTERN.search(html)
|
||||
assert match is not None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
async def _override_db_session() -> AsyncIterator[object]:
|
||||
yield object()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_dependency_overrides() -> AsyncIterator[None]:
|
||||
app.dependency_overrides.clear()
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_login_requires_csrf_token() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"email": "user@example.com", "password": "correct-password"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.text == "CSRF token missing."
|
||||
|
||||
|
||||
def test_successful_login_creates_session_and_allows_dashboard(monkeypatch) -> None:
|
||||
limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60)
|
||||
app.dependency_overrides[get_db_session] = _override_db_session
|
||||
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(
|
||||
user=SimpleNamespace(id=1, email="user@example.com", is_admin=False)
|
||||
)
|
||||
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
||||
client = TestClient(app)
|
||||
monkeypatch.setattr(
|
||||
"app.web.common.get_authenticated_user",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.list_recent_runs_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.queue_status_counts_for_user",
|
||||
AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.user_settings_service.get_or_create_settings",
|
||||
AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)),
|
||||
)
|
||||
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
|
||||
login_response = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "user@example.com",
|
||||
"password": "correct-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert login_response.status_code == 303
|
||||
assert login_response.headers["location"] == "/"
|
||||
|
||||
dashboard_response = client.get("/")
|
||||
assert dashboard_response.status_code == 200
|
||||
assert 'data-test="home-hero"' in dashboard_response.text
|
||||
assert 'data-test="session-user"' in dashboard_response.text
|
||||
assert "user@example.com" in dashboard_response.text
|
||||
|
||||
|
||||
def test_login_rate_limiting_returns_429_after_threshold() -> None:
|
||||
limiter = SlidingWindowRateLimiter(max_attempts=2, window_seconds=60)
|
||||
app.dependency_overrides[get_db_session] = _override_db_session
|
||||
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(user=None)
|
||||
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
||||
client = TestClient(app)
|
||||
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
payload = {
|
||||
"email": "user@example.com",
|
||||
"password": "wrong-password",
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
first = client.post("/login", data=payload, follow_redirects=False)
|
||||
second = client.post("/login", data=payload, follow_redirects=False)
|
||||
third = client.post("/login", data=payload, follow_redirects=False)
|
||||
|
||||
assert first.status_code == 401
|
||||
assert second.status_code == 401
|
||||
assert third.status_code == 429
|
||||
assert third.headers["Retry-After"] == "60"
|
||||
|
||||
|
||||
def test_logout_requires_csrf_token_and_clears_session(monkeypatch) -> None:
|
||||
limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=60)
|
||||
app.dependency_overrides[get_db_session] = _override_db_session
|
||||
app.dependency_overrides[get_auth_service] = lambda: StubAuthService(
|
||||
user=SimpleNamespace(id=1, email="user@example.com", is_admin=False)
|
||||
)
|
||||
app.dependency_overrides[get_login_rate_limiter] = lambda: limiter
|
||||
client = TestClient(app)
|
||||
monkeypatch.setattr(
|
||||
"app.web.common.get_authenticated_user",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(id=1, email="user@example.com", is_admin=False),
|
||||
None,
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.publication_service.list_new_for_latest_run_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.list_recent_runs_for_user",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.run_service.queue_status_counts_for_user",
|
||||
AsyncMock(return_value={"queued": 0, "retrying": 0, "dropped": 0}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.web.routers.dashboard.user_settings_service.get_or_create_settings",
|
||||
AsyncMock(return_value=SimpleNamespace(request_delay_seconds=0)),
|
||||
)
|
||||
|
||||
login_page = client.get("/login")
|
||||
csrf_token = _extract_csrf_token(login_page.text)
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"email": "user@example.com",
|
||||
"password": "correct-password",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
failed_logout = client.post("/logout", data={}, follow_redirects=False)
|
||||
assert failed_logout.status_code == 403
|
||||
assert failed_logout.text == "CSRF token invalid."
|
||||
|
||||
dashboard_page = client.get("/")
|
||||
logout_token = _extract_csrf_token(dashboard_page.text)
|
||||
successful_logout = client.post(
|
||||
"/logout",
|
||||
data={"csrf_token": logout_token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert successful_logout.status_code == 303
|
||||
assert successful_logout.headers["location"] == "/login"
|
||||
assert client.get("/", follow_redirects=False).headers["location"] == "/login"
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.models import CrawlRun, RunStatus, RunTriggerType
|
||||
from app.presentation.dashboard import SECTION_TEMPLATES, build_dashboard_view_model
|
||||
from app.services.publications import UnreadPublicationItem
|
||||
|
||||
|
||||
def test_build_dashboard_view_model_maps_sections_and_unread_items() -> None:
|
||||
unread_items = [
|
||||
UnreadPublicationItem(
|
||||
publication_id=10,
|
||||
scholar_profile_id=3,
|
||||
scholar_label="Ada Lovelace",
|
||||
title="Analytical Engine Notes",
|
||||
year=None,
|
||||
citation_count=42,
|
||||
venue_text="Computing Letters",
|
||||
pub_url="https://example.test/pub-10",
|
||||
)
|
||||
]
|
||||
runs = [
|
||||
CrawlRun(
|
||||
id=77,
|
||||
user_id=1,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
status=RunStatus.SUCCESS,
|
||||
start_dt=datetime(2026, 2, 16, 18, 0, tzinfo=timezone.utc),
|
||||
scholar_count=1,
|
||||
new_pub_count=1,
|
||||
error_log={},
|
||||
)
|
||||
]
|
||||
|
||||
vm = build_dashboard_view_model(
|
||||
unread_publications=unread_items,
|
||||
recent_runs=runs,
|
||||
request_delay_seconds=7,
|
||||
queue_counts={"queued": 2, "retrying": 1, "dropped": 3},
|
||||
)
|
||||
|
||||
assert vm.section_templates == SECTION_TEMPLATES
|
||||
assert vm.run_controls.request_delay_seconds == 7
|
||||
assert vm.run_controls.queue_queued_count == 2
|
||||
assert vm.run_controls.queue_retrying_count == 1
|
||||
assert vm.run_controls.queue_dropped_count == 3
|
||||
assert vm.unread_publications[0].title == "Analytical Engine Notes"
|
||||
assert vm.unread_publications[0].year_display == "-"
|
||||
assert vm.unread_publications[0].citation_count == 42
|
||||
assert vm.run_history[0].status_label == "success"
|
||||
assert vm.run_history[0].status_badge == "ok"
|
||||
assert vm.run_history[0].started_at_display == "2026-02-16 18:00 UTC"
|
||||
assert vm.run_history[0].detail_url == "/runs/77"
|
||||
|
||||
|
||||
def test_build_dashboard_view_model_maps_failed_and_partial_statuses() -> None:
|
||||
runs = [
|
||||
CrawlRun(
|
||||
id=11,
|
||||
user_id=1,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
status=RunStatus.FAILED,
|
||||
start_dt=datetime(2026, 2, 16, 19, 0, tzinfo=timezone.utc),
|
||||
scholar_count=2,
|
||||
new_pub_count=0,
|
||||
error_log={},
|
||||
),
|
||||
CrawlRun(
|
||||
id=12,
|
||||
user_id=1,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
status=RunStatus.PARTIAL_FAILURE,
|
||||
start_dt=datetime(2026, 2, 16, 20, 0, tzinfo=timezone.utc),
|
||||
scholar_count=2,
|
||||
new_pub_count=1,
|
||||
error_log={},
|
||||
),
|
||||
]
|
||||
|
||||
vm = build_dashboard_view_model(
|
||||
unread_publications=[],
|
||||
recent_runs=runs,
|
||||
request_delay_seconds=1,
|
||||
)
|
||||
|
||||
assert [item.status_badge for item in vm.run_history] == ["danger", "warn"]
|
||||
assert [item.status_label for item in vm.run_history] == ["failed", "partial_failure"]
|
||||
|
|
@ -6,7 +6,7 @@ from app.main import app
|
|||
|
||||
|
||||
def test_healthz_returns_200_when_database_is_available(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.web.routers.health.check_database", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
|
@ -16,7 +16,7 @@ def test_healthz_returns_200_when_database_is_available(monkeypatch) -> None:
|
|||
|
||||
|
||||
def test_healthz_returns_500_when_database_is_unavailable(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.web.routers.health.check_database", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=False))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.logging_config import JsonLogFormatter, parse_redact_fields
|
||||
from app.main import app
|
||||
from app.web.middleware import REQUEST_ID_HEADER, parse_skip_paths
|
||||
from app.http.middleware import REQUEST_ID_HEADER, parse_skip_paths
|
||||
|
||||
|
||||
def test_json_log_formatter_redacts_sensitive_fields() -> None:
|
||||
|
|
@ -39,14 +40,16 @@ def test_json_log_formatter_redacts_sensitive_fields() -> None:
|
|||
assert "color_message" not in payload
|
||||
|
||||
|
||||
def test_request_logging_middleware_sets_request_id_header() -> None:
|
||||
def test_request_logging_middleware_sets_request_id_header(monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login", headers={REQUEST_ID_HEADER: "request-123"})
|
||||
|
||||
response = client.get("/healthz", headers={REQUEST_ID_HEADER: "request-123"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers[REQUEST_ID_HEADER] == "request-123"
|
||||
|
||||
|
||||
def test_parse_skip_paths_trims_and_discards_empty_segments() -> None:
|
||||
assert parse_skip_paths(" /healthz , , /static/ ") == ("/healthz", "/static/")
|
||||
assert parse_skip_paths(" /healthz , , /api/v1/metrics ") == (
|
||||
"/healthz",
|
||||
"/api/v1/metrics",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_home_page_redirects_to_login_when_unauthenticated() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_dev_ui_page_is_removed() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/dev/ui", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_login_page_renders_html() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert "scholarr" in response.text
|
||||
assert 'data-test="login-form"' in response.text
|
||||
assert 'name="csrf_token"' in response.text
|
||||
assert 'data-theme-control' in response.text
|
||||
set_cookie = response.headers["set-cookie"].lower()
|
||||
assert "httponly" in set_cookie
|
||||
assert "samesite=lax" in set_cookie
|
||||
|
||||
|
||||
def test_theme_query_parameter_accepts_supported_theme() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login?theme=spruce")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-theme="spruce"' in response.text
|
||||
|
||||
|
||||
def test_theme_query_parameter_falls_back_for_unknown_theme() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/login?theme=not-a-theme")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-theme="terracotta"' in response.text
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_phase2_pages_redirect_to_login_when_unauthenticated() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
for path in (
|
||||
"/users",
|
||||
"/runs",
|
||||
"/runs/1",
|
||||
"/publications",
|
||||
"/scholars",
|
||||
"/settings",
|
||||
"/account/password",
|
||||
):
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
Loading…
Add table
Add a link
Reference in a new issue