From c43e7c4c9bb9621dc35e57039455012e8c53264e Mon Sep 17 00:00:00 2001 From: Marker689 Date: Sun, 10 May 2026 03:46:05 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BA=D1=80=D0=B8=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B3=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B0=20=E2=80=94=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Критические фиксы: - main.py: монтировать /static из web/static/ (CSS не грузился совсем) - api/scans.py: filtered total count (был всегда общий, игнорируя фильтры) - web/routes.py: исправлен VALID_SORT_FIELDS (отсутствовали ключи packages) - web/routes.py: filtered total count для web scans list - package_detail.html: f.data.X вместо f.X (findings не отображались) Чистка мёртвого кода: - config.py: удалён _parse_repos и nexus_repositories (не использовались) - web/routes.py: удалён completed_scans/failed_scans (не отображались) - удалён мёртвый guarddog_nexus/static/style.css (67-байтный стаб) Качество кода: - web/routes.py: Jinja2 Environment кэшируется на уровне модуля - Вынесен дублирующийся JS в web/static/app.js - Вынесены дублирующиеся inline-стили в CSS-классы - Исправлен duplicate class attribute в списках - Удалены гигантские SVG из empty states Тесты: - 20 новых edge-case тестов (CSV export, search/filter/sort, 404, pagination) - Добавлен sample_flagged_scan fixture - Итого: 50 тестов, все зелёные --- guarddog_nexus/api/scans.py | 12 +- guarddog_nexus/config.py | 8 +- guarddog_nexus/main.py | 2 +- guarddog_nexus/static/style.css | 1 - guarddog_nexus/web/routes.py | 52 +++--- guarddog_nexus/web/static/app.js | 25 +++ guarddog_nexus/web/static/style.css | 48 +++-- guarddog_nexus/web/templates/base.html | 1 + .../web/templates/dashboard_stats.html | 6 +- .../web/templates/package_detail.html | 54 ++---- .../web/templates/packages_list.html | 8 +- guarddog_nexus/web/templates/scan_detail.html | 40 +--- guarddog_nexus/web/templates/scans_list.html | 8 +- tests/conftest.py | 34 ++++ tests/test_api.py | 175 ++++++++++++++++++ 15 files changed, 329 insertions(+), 145 deletions(-) delete mode 100644 guarddog_nexus/static/style.css create mode 100644 guarddog_nexus/web/static/app.js diff --git a/guarddog_nexus/api/scans.py b/guarddog_nexus/api/scans.py index a2816d2..a6b0f0d 100644 --- a/guarddog_nexus/api/scans.py +++ b/guarddog_nexus/api/scans.py @@ -36,18 +36,22 @@ async def list_scans( session: AsyncSession = Depends(get_session), ): q = select(Scan) + count_q = select(func.count(Scan.id)) if flagged is not None: q = q.where(Scan.flagged == flagged) + count_q = count_q.where(Scan.flagged == flagged) if status: q = q.where(Scan.status == status) + count_q = count_q.where(Scan.status == status) if repository: q = q.where(Scan.repository == repository) + count_q = count_q.where(Scan.repository == repository) if search: pattern = f"%{search}%" - q = q.where( - Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) - ) + condition = Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) + q = q.where(condition) + count_q = count_q.where(condition) sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) sort_dir = "asc" if sort_dir.lower() == "asc" else "desc" @@ -55,7 +59,7 @@ async def list_scans( q = q.offset(offset).limit(limit) - total = await session.scalar(select(func.count(Scan.id))) + total = await session.scalar(count_q) scans = (await session.execute(q)).scalars().all() return { diff --git a/guarddog_nexus/config.py b/guarddog_nexus/config.py index cb7bbe2..c7b6c73 100644 --- a/guarddog_nexus/config.py +++ b/guarddog_nexus/config.py @@ -1,7 +1,7 @@ """Configuration via environment variables.""" import os -from dataclasses import dataclass, field +from dataclasses import dataclass @dataclass @@ -9,7 +9,6 @@ class Config: nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081") nexus_username: str = os.getenv("NEXUS_USERNAME", "admin") nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123") - nexus_repositories: list[str] = field(default_factory=lambda: _parse_repos()) database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db") @@ -26,9 +25,4 @@ class Config: temp_dir: str = os.getenv("TEMP_DIR", "/tmp/guarddog-nexus") -def _parse_repos() -> list[str]: - raw = os.getenv("NEXUS_REPOSITORIES", "") - return [r.strip() for r in raw.split(",") if r.strip()] - - config = Config() diff --git a/guarddog_nexus/main.py b/guarddog_nexus/main.py index 7bbe78a..a818227 100644 --- a/guarddog_nexus/main.py +++ b/guarddog_nexus/main.py @@ -14,7 +14,7 @@ from guarddog_nexus.logging_setup import log from guarddog_nexus.web.routes import router as web_router from guarddog_nexus.webhooks import router as webhook_router -STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") +STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static") @asynccontextmanager diff --git a/guarddog_nexus/static/style.css b/guarddog_nexus/static/style.css deleted file mode 100644 index 28213ba..0000000 --- a/guarddog_nexus/static/style.css +++ /dev/null @@ -1 +0,0 @@ -/* static/style.css - minimal overrides for Pico.css dark theme */ diff --git a/guarddog_nexus/web/routes.py b/guarddog_nexus/web/routes.py index bf08770..91378cf 100644 --- a/guarddog_nexus/web/routes.py +++ b/guarddog_nexus/web/routes.py @@ -4,6 +4,7 @@ import datetime from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse +from jinja2 import Environment, PackageLoader, select_autoescape from sqlalchemy import Integer, cast, func, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +13,12 @@ from guarddog_nexus.models import Finding, Scan router = APIRouter(tags=["web"]) -VALID_SORT_FIELDS = { +_jinja_env = Environment( + loader=PackageLoader("guarddog_nexus", "web/templates"), + autoescape=select_autoescape(), +) + +SCAN_SORT_FIELDS = { "id": Scan.id, "package_name": Scan.package_name, "started_at": Scan.started_at, @@ -20,15 +26,16 @@ VALID_SORT_FIELDS = { "total_findings": Scan.total_findings, } +PACKAGE_SORT_FIELDS = { + "name": Scan.package_name, + "last_scanned_at": Scan.started_at, + "total_findings": Scan.total_findings, + "flagged": Scan.flagged, +} + def _render(name: str, **context) -> HTMLResponse: - from jinja2 import Environment, PackageLoader, select_autoescape - - env = Environment( - loader=PackageLoader("guarddog_nexus", "web/templates"), - autoescape=select_autoescape(), - ) - template = env.get_template(name) + template = _jinja_env.get_template(name) return HTMLResponse(template.render(**context)) @@ -53,10 +60,6 @@ async def _dashboard_data(session: AsyncSession) -> dict: Scan.started_at >= func.datetime("now", "-7 days"), ) ) - completed_scans = await session.scalar( - select(func.count(Scan.id)).where(Scan.status == "completed") - ) - failed_scans = await session.scalar(select(func.count(Scan.id)).where(Scan.status == "failed")) total_findings = await session.scalar(select(func.count(Finding.id))) warnings_count = await session.scalar( @@ -115,7 +118,6 @@ async def _dashboard_data(session: AsyncSession) -> dict: max_findings = max((r.total for r in most_flagged), default=1) - # Heatmap: scans per day for last 14 days days_raw = ( await session.execute( select( @@ -133,8 +135,6 @@ async def _dashboard_data(session: AsyncSession) -> dict: "total_scans": total_scans or 0, "flagged_scans": flagged_scans or 0, "recent_flagged": recent_flagged or 0, - "completed_scans": completed_scans or 0, - "failed_scans": failed_scans or 0, "total_findings": total_findings or 0, "warnings_count": warnings_count or 0, "errors_count": errors_count or 0, @@ -162,23 +162,27 @@ async def scans_list( per_page = 50 offset = (page - 1) * per_page + count_q = select(func.count(Scan.id)) q = select(Scan) + if flagged == "1": q = q.where(Scan.flagged == True) + count_q = count_q.where(Scan.flagged == True) if status: q = q.where(Scan.status == status) + count_q = count_q.where(Scan.status == status) if search: pattern = f"%{search}%" - q = q.where( - Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) - ) + condition = Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) + q = q.where(condition) + count_q = count_q.where(condition) - sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) + sort_field = SCAN_SORT_FIELDS.get(sort_by, Scan.started_at) q = q.order_by(sort_field.desc() if sort_dir == "desc" else sort_field.asc()) q = q.offset(offset).limit(per_page) scans = (await session.execute(q)).scalars().all() - total = await session.scalar(select(func.count(Scan.id))) + total = await session.scalar(count_q) return _render( "scans_list.html", @@ -240,17 +244,17 @@ async def packages_list( Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) ) - sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) + sort_field = PACKAGE_SORT_FIELDS.get(sort_by, Scan.started_at) sort_col = func.max(sort_field) subq = subq.order_by( sort_col.desc() if sort_dir == "desc" else sort_col.asc() ) - subq = subq.subquery() - total = await session.scalar(select(func.count()).select_from(subq)) + sq = subq.subquery() + total = await session.scalar(select(func.count()).select_from(sq)) rows = ( await session.execute( - select(subq).offset(offset).limit(per_page) + select(sq).offset(offset).limit(per_page) ) ).all() diff --git a/guarddog_nexus/web/static/app.js b/guarddog_nexus/web/static/app.js new file mode 100644 index 0000000..8cee215 --- /dev/null +++ b/guarddog_nexus/web/static/app.js @@ -0,0 +1,25 @@ +// GuardDog Nexus — shared UI utilities + +function toggleFindings() { + var container = document.getElementById('findings-container'); + if (!container) return; + var details = container.querySelectorAll('details'); + if (details.length === 0) return; + var isOpen = details[0].open; + details.forEach(function (d) { d.open = !isOpen; }); + var btn = document.querySelector('.toggle-all-btn'); + if (btn) btn.textContent = isOpen ? 'Expand All' : 'Collapse All'; +} + +function copyCode(btn, codeId) { + var el = document.getElementById(codeId); + if (!el) return; + navigator.clipboard.writeText(el.textContent).then(function () { + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(function () { + btn.textContent = 'Copy'; + btn.classList.remove('copied'); + }, 2000); + }); +} diff --git a/guarddog_nexus/web/static/style.css b/guarddog_nexus/web/static/style.css index e0ee52a..900ed52 100644 --- a/guarddog_nexus/web/static/style.css +++ b/guarddog_nexus/web/static/style.css @@ -238,19 +238,9 @@ nav.sticky { /* Empty states */ .empty-state { text-align: center; - padding: 3rem 1rem; - opacity: 0.6; -} - -.empty-state svg { - width: 64px; - height: 64px; - margin-bottom: 1rem; - opacity: 0.4; -} - -.empty-state h3 { - margin-bottom: 0.5rem; + padding: 2rem 1rem; + opacity: 0.5; + font-style: italic; } /* Filter bar */ @@ -352,7 +342,37 @@ th.sortable.active .sort-icon { } } -/* Expand/Collapse all button */ +/* Finding header row */ +.finding-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +/* Finding summary */ +.finding-summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; +} + +/* Finding summary hint */ +.finding-summary-hint { + margin-left: auto; + font-size: 0.8rem; + opacity: 0.5; +} + +/* Code block toolbar */ +.code-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.25rem; +} .toggle-all-btn { font-size: 0.8rem; margin-bottom: 0.5rem; diff --git a/guarddog_nexus/web/templates/base.html b/guarddog_nexus/web/templates/base.html index 988d106..4171ffd 100644 --- a/guarddog_nexus/web/templates/base.html +++ b/guarddog_nexus/web/templates/base.html @@ -7,6 +7,7 @@ +
diff --git a/guarddog_nexus/web/templates/dashboard_stats.html b/guarddog_nexus/web/templates/dashboard_stats.html index 99eaffa..31efe4a 100644 --- a/guarddog_nexus/web/templates/dashboard_stats.html +++ b/guarddog_nexus/web/templates/dashboard_stats.html @@ -40,11 +40,7 @@ {% else %} -
- -

No findings yet

- Scan results will appear here once packages are processed. -
+

No findings yet — scan results will appear here once packages are processed.

{% endif %} {% if days %} diff --git a/guarddog_nexus/web/templates/package_detail.html b/guarddog_nexus/web/templates/package_detail.html index 36f5cc5..2da0b63 100644 --- a/guarddog_nexus/web/templates/package_detail.html +++ b/guarddog_nexus/web/templates/package_detail.html @@ -32,7 +32,7 @@ -
+

Findings ({{ findings|length }})

{% if findings|length > 1 %} @@ -42,56 +42,26 @@ {% if findings %}
{% for f in findings %} -
- - [{{ f.severity }}] - {{ f.rule }} - {% if f.location %} @ {{ f.location }}{% endif %} - click to expand +
+ + [{{ f.data.severity }}] + {{ f.data.rule }} + {% if f.data.location %} @ {{ f.data.location }}{% endif %} + click to expand
-

{{ f.message }}

- {% if f.code %} -
+

{{ f.data.message }}

+ {% if f.data.code %} +
-
{{ f.code }}
+
{{ f.data.code }}
{% endif %}
{% endfor %}
{% else %} -
- -

No findings

- Package looks clean. -
+

No findings — package looks clean.

{% endif %} {% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/guarddog_nexus/web/templates/packages_list.html b/guarddog_nexus/web/templates/packages_list.html index ff88131..730dced 100644 --- a/guarddog_nexus/web/templates/packages_list.html +++ b/guarddog_nexus/web/templates/packages_list.html @@ -12,7 +12,7 @@
- + {% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %} Export CSV @@ -53,11 +53,7 @@ {% endfor %} {% if not packages %} - - -

No packages found

- Try adjusting your search or filters. - + No packages yet — packages will appear here once scans are processed. {% endif %} diff --git a/guarddog_nexus/web/templates/scan_detail.html b/guarddog_nexus/web/templates/scan_detail.html index d370a45..11223fa 100644 --- a/guarddog_nexus/web/templates/scan_detail.html +++ b/guarddog_nexus/web/templates/scan_detail.html @@ -26,7 +26,7 @@ {% if scan.error_message %}Error{{ scan.error_message }}{% endif %} -
+

Findings ({{ scan.findings|length }})

{% if scan.findings|length > 1 %} @@ -37,16 +37,16 @@
{% for f in scan.findings %}
- + [{{ f.data.severity }}] {{ f.data.rule }} {% if f.data.location %} @ {{ f.data.location }}{% endif %} - click to expand + click to expand

{{ f.data.message }}

{% if f.data.code %} -
+
{{ f.data.code }}
@@ -56,36 +56,6 @@ {% endfor %}
{% else %} -
- -

No findings

- Package looks clean. -
+

No findings — package looks clean.

{% endif %} {% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/guarddog_nexus/web/templates/scans_list.html b/guarddog_nexus/web/templates/scans_list.html index 7da28f3..7f01cb7 100644 --- a/guarddog_nexus/web/templates/scans_list.html +++ b/guarddog_nexus/web/templates/scans_list.html @@ -19,7 +19,7 @@ - + {% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %} Export CSV @@ -64,11 +64,7 @@ {% endfor %} {% if not scans %} - - -

No scans found

- Try adjusting your search or filters. - + No scans yet — scans will appear here once packages are processed. {% endif %} diff --git a/tests/conftest.py b/tests/conftest.py index db4371c..b8947c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,40 @@ os.environ["TEMP_DIR"] = "/tmp/guarddog-nexus-test" from guarddog_nexus.database import Base, get_session # noqa: E402 from guarddog_nexus.main import app # noqa: E402 +from guarddog_nexus.models import Finding, Scan, ScanStatus # noqa: E402 + + +@pytest_asyncio.fixture +async def sample_flagged_scan(db_session): + scan = Scan( + package_name="test-pkg", + package_version="1.0", + ecosystem="pypi", + repository="pypi-proxy", + nexus_asset_url="http://nexus:8081/repository/pypi-proxy/packages/test-pkg/1.0/test-pkg-1.0.tar.gz", + sha256="abc123", + status=ScanStatus.COMPLETED.value, + total_findings=1, + flagged=True, + ) + db_session.add(scan) + await db_session.commit() + await db_session.refresh(scan) + + finding = Finding( + scan_id=scan.id, + data={ + "rule": "test_rule", + "severity": "WARNING", + "message": "Test finding", + "location": "test.py:1", + "code": "print('test')", + }, + ) + db_session.add(finding) + await db_session.commit() + await db_session.refresh(scan) + return scan @pytest_asyncio.fixture diff --git a/tests/test_api.py b/tests/test_api.py index 9de7d47..16cf72f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,8 @@ async def test_health(client): assert resp.json()["status"] == "ok" +# --- Scans --- + @pytest.mark.asyncio async def test_list_scans_empty(client): resp = await client.get("/api/v1/scans") @@ -35,6 +37,46 @@ async def test_scan_not_found(client): assert "detail" in resp.json() +@pytest.mark.asyncio +async def test_list_scans_with_filters(client): + # Filter parameters smoke test — should not 500 + for params in [ + "?flagged=true&search=test&status=completed&sort_by=id&sort_dir=asc", + "?flagged=false&search=nonexistent&sort_by=total_findings", + "?sort_by=invalid_key", + "?limit=10&offset=0", + ]: + resp = await client.get(f"/api/v1/scans{params}") + assert resp.status_code == 200, f"Failed on: {params}" + + +@pytest.mark.asyncio +async def test_scan_stats_with_data(client, sample_flagged_scan): + resp = await client.get("/api/v1/scans/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total_scans"] == 1 + assert data["flagged_scans"] == 1 + assert data["total_findings"] == 1 + + +@pytest.mark.asyncio +async def test_scans_csv_export_empty(client): + resp = await client.get("/api/v1/scans/export") + assert resp.status_code == 200 + assert "text/csv" in resp.headers["content-type"] + assert "id,package_name" in resp.text + + +@pytest.mark.asyncio +async def test_scans_csv_export_with_filter(client, sample_flagged_scan): + resp = await client.get("/api/v1/scans/export?flagged=true") + assert resp.status_code == 200 + assert sample_flagged_scan.package_name in resp.text + + +# --- Packages --- + @pytest.mark.asyncio async def test_list_packages_empty(client): resp = await client.get("/api/v1/packages") @@ -43,6 +85,54 @@ async def test_list_packages_empty(client): assert data["total"] == 0 +@pytest.mark.asyncio +async def test_list_packages_with_filters(client): + for params in [ + "?search=test&sort_by=name&sort_dir=asc", + "?flagged=false&sort_by=last_scanned_at", + "?ecosystem=pypi", + "?sort_by=invalid", + ]: + resp = await client.get(f"/api/v1/packages{params}") + assert resp.status_code == 200, f"Failed on: {params}" + + +@pytest.mark.asyncio +async def test_packages_csv_export_empty(client): + resp = await client.get("/api/v1/packages/export") + assert resp.status_code == 200 + assert "text/csv" in resp.headers["content-type"] + assert "name,version" in resp.text + + +@pytest.mark.asyncio +async def test_packages_csv_export_with_filter(client, sample_flagged_scan): + resp = await client.get("/api/v1/packages/export?flagged=true") + assert resp.status_code == 200 + assert sample_flagged_scan.package_name in resp.text + + +@pytest.mark.asyncio +async def test_package_with_data(client, sample_flagged_scan): + resp = await client.get( + f"/api/v1/packages/{sample_flagged_scan.package_name}/{sample_flagged_scan.package_version}" + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == sample_flagged_scan.package_name + assert len(data["scans"]) == 1 + assert data["flagged"] is True + + +@pytest.mark.asyncio +async def test_package_not_found(client): + resp = await client.get("/api/v1/packages/nonexistent/1.0") + assert resp.status_code == 200 + assert "detail" in resp.json() + + +# --- Findings --- + @pytest.mark.asyncio async def test_list_findings_empty(client): resp = await client.get("/api/v1/findings") @@ -51,6 +141,28 @@ async def test_list_findings_empty(client): assert data["total"] == 0 +@pytest.mark.asyncio +async def test_list_findings_with_data(client, sample_flagged_scan): + resp = await client.get("/api/v1/findings") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["findings"]) == 1 + + +@pytest.mark.asyncio +async def test_list_findings_with_filters(client, sample_flagged_scan): + for params in [ + f"?scan_id={sample_flagged_scan.id}", + "?severity=WARNING", + "?rule=test_rule", + ]: + resp = await client.get(f"/api/v1/findings{params}") + assert resp.status_code == 200, f"Failed on: {params}" + + +# --- Web UI --- + @pytest.mark.asyncio async def test_web_ui_dashboard(client): resp = await client.get("/") @@ -58,6 +170,13 @@ async def test_web_ui_dashboard(client): assert "GuardDog Nexus" in resp.text +@pytest.mark.asyncio +async def test_web_ui_dashboard_stats_fragment(client): + resp = await client.get("/dashboard/stats") + assert resp.status_code == 200 + assert "Total Scans" in resp.text + + @pytest.mark.asyncio async def test_web_ui_scans(client): resp = await client.get("/scans") @@ -65,8 +184,64 @@ async def test_web_ui_scans(client): assert "Scans" in resp.text +@pytest.mark.asyncio +async def test_web_ui_scans_with_search(client): + resp = await client.get("/scans?search=nonexistent&status=completed&sort_by=id&sort_dir=asc") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_web_ui_scans_page_out_of_range(client): + resp = await client.get("/scans?page=999") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_web_ui_scan_not_found(client): + resp = await client.get("/scans/99999") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_web_ui_scan_detail(client, sample_flagged_scan): + resp = await client.get(f"/scans/{sample_flagged_scan.id}") + assert resp.status_code == 200 + assert sample_flagged_scan.package_name in resp.text + assert "test_rule" in resp.text + + @pytest.mark.asyncio async def test_web_ui_packages(client): resp = await client.get("/packages") assert resp.status_code == 200 assert "Packages" in resp.text + + +@pytest.mark.asyncio +async def test_web_ui_packages_with_search(client): + resp = await client.get("/packages?search=test&sort_by=name&sort_dir=asc") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_web_ui_package_not_found(client): + resp = await client.get("/packages/nonexistent/1.0") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_web_ui_package_detail(client, sample_flagged_scan): + resp = await client.get( + f"/packages/{sample_flagged_scan.package_name}/{sample_flagged_scan.package_version}" + ) + assert resp.status_code == 200 + assert sample_flagged_scan.package_name in resp.text + assert "test_rule" in resp.text + + +@pytest.mark.asyncio +async def test_health_no_db_leak(client): + # Rapid health checks should not exhaust connections + for _ in range(5): + resp = await client.get("/health") + assert resp.status_code == 200