feat: 31 new tests, metrics LLM counters, Dockerfile caching, Makefile targets, compose limits, code fixes

This commit is contained in:
Marker689
2026-05-11 23:08:09 +03:00
parent 20bf7e6745
commit 18efcf482e
26 changed files with 840 additions and 12 deletions

17
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,17 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
- id: detect-private-key

View File

@@ -0,0 +1,27 @@
{
"id": "review-fixes-01",
"seq": "01",
"title": "Fix _validate_report mutating input dict (immutability)",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/guarddog_nexus/core/llm.py"
],
"acceptance_criteria": [
"_validate_report() returns a new dict without mutating the input argument",
"All existing test_llm* tests still pass",
"ruff check/format passes on guarddog_nexus/core/llm.py"
],
"deliverables": [
"/home/marker/guarddog-nexus/guarddog_nexus/core/llm.py"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,28 @@
{
"id": "review-fixes-02",
"seq": "02",
"title": "Fix _parse_flagged never returning False",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/guarddog_nexus/routes/web.py"
],
"acceptance_criteria": [
"_parse_flagged returns True for '1', False for '0', None for other values",
"Existing filtering behavior in /scans and /packages routes is preserved",
"Adding ?flagged=0 to scan list URL correctly filters to non-flagged scans",
"ruff check/format passes on guarddog_nexus/routes/web.py"
],
"deliverables": [
"/home/marker/guarddog-nexus/guarddog_nexus/routes/web.py"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,30 @@
{
"id": "review-fixes-03",
"seq": "03",
"title": "Fix CSV export missing .csv extension in Content-Disposition",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/guarddog_nexus/routes/api_scans.py",
"/home/marker/guarddog-nexus/guarddog_nexus/routes/api_packages.py"
],
"acceptance_criteria": [
"Content-Disposition header in api_scans.py uses 'attachment; filename=\"scans_export.csv\"'",
"Content-Disposition header in api_packages.py uses 'attachment; filename=\"packages_export.csv\"'",
"ruff check/format passes on both files",
"pytest tests/test_api* passes"
],
"deliverables": [
"/home/marker/guarddog-nexus/guarddog_nexus/routes/api_scans.py",
"/home/marker/guarddog-nexus/guarddog_nexus/routes/api_packages.py"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,31 @@
{
"id": "review-fixes-04",
"seq": "04",
"title": "Strip query params from URLs in SSRF log messages",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/guarddog_nexus/core/nexus.py",
"/home/marker/guarddog-nexus/guarddog_nexus/core/harvester.py"
],
"acceptance_criteria": [
"SSRF prevention log (nexus.py:125) logs URL host+path only, no query params",
"Download failure log (nexus.py:140) logs URL host+path only, no query params",
"harvester.py URL logs (lines 74, 89) also strip query params",
"All existing tests pass",
"ruff check/format passes"
],
"deliverables": [
"/home/marker/guarddog-nexus/guarddog_nexus/core/nexus.py",
"/home/marker/guarddog-nexus/guarddog_nexus/core/harvester.py"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,30 @@
{
"id": "review-fixes-05",
"seq": "05",
"title": "Reorder Dockerfile COPY/install for layer caching",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/Dockerfile",
"/home/marker/guarddog-nexus/pyproject.toml"
],
"acceptance_criteria": [
"pyproject.toml and README.md copied before guarddog_nexus/ source",
"uv pip install commands run before COPY guarddog_nexus/",
"Docker build succeeds: docker compose build",
"Container starts correctly: docker compose up -d",
"Layer caching works: rebuilding without source changes uses pip cache"
],
"deliverables": [
"/home/marker/guarddog-nexus/Dockerfile"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,29 @@
{
"id": "review-fixes-06",
"seq": "06",
"title": "Add resource limits and logging rotation to docker-compose.yml",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/docker-compose.yml"
],
"acceptance_criteria": [
"guarddog-nexus service has deploy.resources.limits (CPU: 2, memory: 1G) and reservations (CPU: 0.5, memory: 256M)",
"nexus service has deploy.resources.limits (CPU: 4, memory: 4G) and reservations (CPU: 1, memory: 2G)",
"All services have logging.driver: json-file with max-size: 10m and max-file: 3",
"docker compose config validates without errors",
"docker compose up works correctly"
],
"deliverables": [
"/home/marker/guarddog-nexus/docker-compose.yml"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,32 @@
{
"id": "review-fixes-07",
"seq": "07",
"title": "Fix Makefile: typecheck, check, run, setup-env targets + docker-rebuild fix",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/Makefile",
"/home/marker/guarddog-nexus/pyproject.toml"
],
"acceptance_criteria": [
"make typecheck runs mypy guarddog_nexus",
"make check runs lint + typecheck + test sequentially",
"make run starts the app with python -m guarddog_nexus.main",
"make setup-env copies .env.example to .env if .env doesn't exist",
"docker-rebuild uses 'docker compose down || true' before up (handles stopped containers)",
"make -n check shows correct command sequence",
"All targets listed in .PHONY"
],
"deliverables": [
"/home/marker/guarddog-nexus/Makefile"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,31 @@
{
"id": "review-fixes-08",
"seq": "08",
"title": "Add .pre-commit-config.yaml with ruff and mypy hooks",
"status": "pending",
"depends_on": [],
"parallel": true,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/pyproject.toml",
"/home/marker/guarddog-nexus/Makefile"
],
"acceptance_criteria": [
".pre-commit-config.yaml exists at repo root",
"Contains ruff (lint + format) hook for guarddog_nexus/ and tests/",
"Contains mypy hook with strict settings matching pyproject.toml",
"Minimum pre-commit rev: ruff v0.4+, mypy v1.10+",
"File is valid YAML (pre-commit validate-config passes or manual check)",
"pre-commit run --all-files executes without errors"
],
"deliverables": [
"/home/marker/guarddog-nexus/.pre-commit-config.yaml"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,33 @@
{
"id": "review-fixes-09",
"seq": "09",
"title": "Add GitHub Actions CI pipeline (.github/workflows/ci.yml)",
"status": "pending",
"depends_on": ["07"],
"parallel": false,
"suggested_agent": "CoderAgent",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/Dockerfile",
"/home/marker/guarddog-nexus/Makefile",
"/home/marker/guarddog-nexus/docker-compose.yml",
"/home/marker/guarddog-nexus/pyproject.toml"
],
"acceptance_criteria": [
".github/workflows/ci.yml exists with trigger on push/PR to main",
"Jobs: lint (ruff), typecheck (mypy), test (pytest), build (docker compose build)",
"Uses Python 3.12, runs make lint / make typecheck / make test",
"Docker build job uses docker compose build without pushing",
"YAML is valid and conforms to GitHub Actions schema",
"All make targets referenced in CI exist in Makefile"
],
"deliverables": [
"/home/marker/guarddog-nexus/.github/workflows/ci.yml"
],
"agent_id": null,
"started_at": null,
"completed_at": null,
"completion_summary": null
}

View File

@@ -0,0 +1,35 @@
{
"id": "review-fixes",
"name": "DevOps & Code Review Fixes",
"status": "active",
"objective": "Fix 12 issues from OpenDevopsSpecialist and CodeReviewer audits: 4 code quality fixes + 8 DevOps/infra improvements",
"context_files": [
"/home/marker/guarddog-nexus/AGENTS.md"
],
"reference_files": [
"/home/marker/guarddog-nexus/Dockerfile",
"/home/marker/guarddog-nexus/Makefile",
"/home/marker/guarddog-nexus/docker-compose.yml",
"/home/marker/guarddog-nexus/pyproject.toml",
"/home/marker/guarddog-nexus/guarddog_nexus/core/llm.py",
"/home/marker/guarddog-nexus/guarddog_nexus/core/nexus.py",
"/home/marker/guarddog-nexus/guarddog_nexus/core/harvester.py",
"/home/marker/guarddog-nexus/guarddog_nexus/routes/web.py",
"/home/marker/guarddog-nexus/guarddog_nexus/routes/api_scans.py",
"/home/marker/guarddog-nexus/guarddog_nexus/routes/api_packages.py"
],
"exit_criteria": [
"All 12 issues fixed and verified",
"ruff check passes",
"ruff format passes",
"pytest -v passes (all 137 tests)",
"mypy guarddog_nexus passes",
"docker compose build succeeds",
"make check passes (lint + typecheck + test)",
"pre-commit run --all-files passes"
],
"subtask_count": 9,
"completed_count": 0,
"created_at": "2026-05-11T00:00:00Z",
"completed_at": null
}

View File

@@ -7,11 +7,16 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ # Install dependencies first for layer caching (source changes don't invalidate)
COPY guarddog_nexus/ guarddog_nexus/ COPY pyproject.toml ./
RUN mkdir -p guarddog_nexus && echo '__version__ = "0.1.0"' > guarddog_nexus/__init__.py
RUN uv pip install . --system RUN uv pip install . --system
RUN uv pip install --system "guarddog>=2.10.0" RUN uv pip install --system "guarddog>=2.10.0"
RUN rm -rf guarddog_nexus
# Application source (frequently changes — cached dependency layers preserved)
COPY guarddog_nexus/ guarddog_nexus/
COPY README.md ./
RUN mkdir -p /data /tmp/guarddog-nexus RUN mkdir -p /data /tmp/guarddog-nexus

View File

@@ -1,4 +1,4 @@
.PHONY: install dev test lint format docker-build docker-up docker-down docker-destroy docker-rebuild docker-logs clean .PHONY: install dev test lint format typecheck check run setup-env docker-build docker-up docker-down docker-destroy docker-rebuild docker-logs docker-ps docker-shell docker-restart clean
install: install:
pip install -e . pip install -e .
@@ -16,6 +16,19 @@ format:
ruff format guarddog_nexus tests ruff format guarddog_nexus tests
ruff check --fix guarddog_nexus tests ruff check --fix guarddog_nexus tests
typecheck:
mypy guarddog_nexus
check: lint format typecheck test
@echo "All checks passed"
run:
python -m guarddog_nexus.main
setup-env:
@test -f .env || cp .env.example .env
@echo ".env ready"
docker-build: docker-build:
docker compose build docker compose build
@@ -29,11 +42,20 @@ docker-destroy:
docker compose down -v docker compose down -v
docker-rebuild: docker-rebuild:
docker compose down && docker compose up -d --build docker compose down; docker compose up -d --build
docker-logs: docker-logs:
docker compose logs -f docker compose logs -f
docker-ps:
docker compose ps
docker-shell:
docker compose exec guarddog-nexus bash
docker-restart:
docker compose restart guarddog-nexus
clean: clean:
rm -rf dist build *.egg-info rm -rf dist build *.egg-info
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true

View File

@@ -24,6 +24,19 @@ services:
nexus-setup: nexus-setup:
condition: service_completed_successfully condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
nexus: nexus:
image: sonatype/nexus3:3.79.0 image: sonatype/nexus3:3.79.0
@@ -32,6 +45,11 @@ services:
volumes: volumes:
- nexus-data:/nexus-data - nexus-data:/nexus-data
restart: unless-stopped restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck: healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8081/service/rest/v1/status"] test: ["CMD", "curl", "-sf", "http://localhost:8081/service/rest/v1/status"]
interval: 15s interval: 15s

View File

@@ -23,12 +23,13 @@ _REPORT_DEFAULTS = {
def _validate_report(report: dict) -> dict: def _validate_report(report: dict) -> dict:
result = dict(report)
for field, default in _REPORT_DEFAULTS.items(): for field, default in _REPORT_DEFAULTS.items():
if not report.get(field): if not result.get(field):
report[field] = default result[field] = default
if report["verdict"] not in ("safe", "suspicious", "malicious", "unknown"): if result["verdict"] not in ("safe", "suspicious", "malicious", "unknown"):
report["verdict"] = "unknown" result["verdict"] = "unknown"
return report return result
def _build_user_message(finding: dict) -> str: def _build_user_message(finding: dict) -> str:

View File

@@ -122,7 +122,8 @@ def parse_package_path(path: str) -> tuple[str, str]:
async def download_asset(download_url: str, dest_dir: str) -> str | None: async def download_asset(download_url: str, dest_dir: str) -> str | None:
"""Download an asset from Nexus using async httpx.""" """Download an asset from Nexus using async httpx."""
if not _validate_download_url(download_url): if not _validate_download_url(download_url):
log.warning("SSRF prevention: blocked download from %s", download_url) parsed = urlparse(download_url)
log.warning("SSRF prevention: blocked download from %s", parsed.hostname or "unknown")
return None return None
dest_path = os.path.join(dest_dir, os.path.basename(download_url.split("?")[0])) dest_path = os.path.join(dest_dir, os.path.basename(download_url.split("?")[0]))

View File

@@ -33,6 +33,19 @@ async def metrics(session: AsyncSession = Depends(get_session)) -> Response:
# Latest scan timestamp # Latest scan timestamp
latest = await session.scalar(select(func.max(Scan.started_at))) latest = await session.scalar(select(func.max(Scan.started_at)))
# LLM analysis
analyzed = (
await session.scalar(
select(func.count(Finding.id)).where(
func.json_extract(Finding.report, "$.verdict").isnot(None)
)
)
or 0
)
pending = (
await session.scalar(select(func.count(Finding.id)).where(Finding.report.is_(None))) or 0
)
lines = [ lines = [
"# HELP guarddog_scans_total Total number of package scans.", "# HELP guarddog_scans_total Total number of package scans.",
"# TYPE guarddog_scans_total counter", "# TYPE guarddog_scans_total counter",
@@ -46,6 +59,14 @@ async def metrics(session: AsyncSession = Depends(get_session)) -> Response:
"# TYPE guarddog_findings_total counter", "# TYPE guarddog_findings_total counter",
f"guarddog_findings_total {findings_total}", f"guarddog_findings_total {findings_total}",
"", "",
"# HELP guarddog_llm_analyzed_total Total findings analyzed by LLM.",
"# TYPE guarddog_llm_analyzed_total gauge",
f"guarddog_llm_analyzed_total {analyzed}",
"",
"# HELP guarddog_llm_pending_total Total findings pending LLM analysis.",
"# TYPE guarddog_llm_pending_total gauge",
f"guarddog_llm_pending_total {pending}",
"",
"# HELP guarddog_scans_by_status Scans grouped by status.", "# HELP guarddog_scans_by_status Scans grouped by status.",
"# TYPE guarddog_scans_by_status gauge", "# TYPE guarddog_scans_by_status gauge",
] ]

View File

@@ -60,7 +60,11 @@ def _render(name: str, **context) -> HTMLResponse:
def _parse_flagged(value: str) -> bool | None: def _parse_flagged(value: str) -> bool | None:
return True if value == "1" else None if value == "1":
return True
if value == "0":
return False
return None
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)

View File

@@ -247,3 +247,52 @@ async def test_health_no_db_leak(client):
for _ in range(5): for _ in range(5):
resp = await client.get("/health") resp = await client.get("/health")
assert resp.status_code == 200 assert resp.status_code == 200
# --- CSV formula injection ---
class TestCsvSafe:
def test_formula_prefixes_escaped(self):
from guarddog_nexus.routes.api_scans import _csv_safe
assert _csv_safe("=cmd|'calc'!A0") == "'=cmd|'calc'!A0"
assert _csv_safe("+SUM(1,2)") == "'+SUM(1,2)"
assert _csv_safe("-3+4") == "'-3+4"
assert _csv_safe("@REF(A1)") == "'@REF(A1)"
def test_normal_values_unchanged(self):
from guarddog_nexus.routes.api_scans import _csv_safe
assert _csv_safe("requests") == "requests"
assert _csv_safe("2.0.0") == "2.0.0"
def test_empty_string(self):
from guarddog_nexus.routes.api_scans import _csv_safe
assert _csv_safe("") == ""
def test_none_passes_through(self):
from guarddog_nexus.routes.api_scans import _csv_safe
assert _csv_safe(None) is None
@pytest.mark.asyncio
async def test_csv_export_escapes_formula_injection(client, db_session):
from guarddog_nexus.db.models import Scan, ScanStatus
scan = Scan(
package_name="=cmd|'calc'!A0",
package_version="1.0",
ecosystem="pypi",
repository="pypi-proxy",
nexus_asset_url="http://nexus:8081/repo/evil-1.0.tar.gz",
status=ScanStatus.COMPLETED.value,
)
db_session.add(scan)
await db_session.commit()
resp = await client.get("/api/v1/scans/export")
assert resp.status_code == 200
assert "'=cmd" in resp.text

34
tests/test_config.py Normal file
View File

@@ -0,0 +1,34 @@
"""Tests for config module — _env_int error path."""
import os
from unittest.mock import patch
def test_env_int_invalid_value_warns_and_returns_default():
from guarddog_nexus.config import _env_int
os.environ["TEST_PORT"] = "notanumber"
with patch("logging.getLogger") as mock_logger:
result = _env_int("TEST_PORT", 42)
assert result == 42
mock_logger.return_value.warning.assert_called_once()
del os.environ["TEST_PORT"]
def test_env_int_missing_returns_default():
from guarddog_nexus.config import _env_int
os.environ.pop("TEST_MISSING", None)
assert _env_int("TEST_MISSING", 99) == 99
def test_env_int_valid_returns_parsed():
from guarddog_nexus.config import _env_int
os.environ["TEST_VALID"] = "8080"
assert _env_int("TEST_VALID", 42) == 8080
del os.environ["TEST_VALID"]

59
tests/test_engine.py Normal file
View File

@@ -0,0 +1,59 @@
"""Tests for database engine — reaping and migrations."""
import pytest
from sqlalchemy import text
@pytest.mark.asyncio
async def test_reap_stale_analysis_resets_stuck_findings(db_session):
from guarddog_nexus.db.models import Finding
stuck = Finding(
scan_id=1,
data={"rule": "test", "severity": "WARNING", "message": "test"},
report={"status": "analyzing"},
)
db_session.add(stuck)
await db_session.commit()
from guarddog_nexus.db.engine import _engine
async with _engine.begin() as conn:
pass # ensure tables exist in _engine too
await db_session.execute(
text(
"UPDATE findings SET report = NULL "
"WHERE report IS NOT NULL "
"AND json_extract(report, '$.status') = 'analyzing'"
)
)
await db_session.commit()
await db_session.refresh(stuck)
assert stuck.report is None
@pytest.mark.asyncio
async def test_reap_stale_analysis_spares_completed_reports(db_session):
from guarddog_nexus.db.models import Finding
valid = Finding(
scan_id=1,
data={"rule": "test", "severity": "WARNING", "message": "test"},
report={"verdict": "safe", "summary": "ok"},
)
db_session.add(valid)
await db_session.commit()
await db_session.execute(
text(
"UPDATE findings SET report = NULL "
"WHERE report IS NOT NULL "
"AND json_extract(report, '$.status') = 'analyzing'"
)
)
await db_session.commit()
await db_session.refresh(valid)
assert valid.report == {"verdict": "safe", "summary": "ok"}

View File

@@ -231,3 +231,29 @@ async def test_harvest_skips_non_package_asset(db_session):
db_session, db_session,
) )
assert scan is None assert scan is None
# --- Lock cleanup ---
@pytest.mark.asyncio
async def test_cleanup_url_locks_removes_unlocked():
import asyncio
from guarddog_nexus.core.harvester import _url_lock, _url_locks
async with _url_lock:
_url_locks["locked"] = asyncio.Lock()
_url_locks["unlocked"] = asyncio.Lock()
await _url_locks["locked"].acquire()
for key in list(_url_locks.keys()):
if not _url_locks[key].locked():
_url_locks.pop(key, None)
assert "locked" in _url_locks
assert "unlocked" not in _url_locks
_url_locks["locked"].release()
_url_locks.clear()

View File

@@ -230,3 +230,97 @@ async def test_analyze_endpoint_failure(client, sample_finding):
assert "failed" in resp.text.lower() assert "failed" in resp.text.lower()
guarddog_nexus.config.config.llm_enabled = False guarddog_nexus.config.config.llm_enabled = False
# --- GET /analyze polling endpoint ---
class TestAnalyzeStatusEndpoint:
@pytest.mark.asyncio
async def test_status_finding_not_found(self, client):
resp = await client.get("/api/v1/findings/99999/analyze")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_status_returns_report_when_complete(self, client, sample_finding_with_report):
import guarddog_nexus.config
guarddog_nexus.config.config.llm_enabled = True
resp = await client.get(f"/api/v1/findings/{sample_finding_with_report.id}/analyze")
assert resp.status_code == 200
assert "safe" in resp.text
guarddog_nexus.config.config.llm_enabled = False
@pytest.mark.asyncio
async def test_status_returns_spinner_when_no_report(self, client, sample_finding):
import guarddog_nexus.config
guarddog_nexus.config.config.llm_enabled = True
resp = await client.get(f"/api/v1/findings/{sample_finding.id}/analyze")
assert resp.status_code == 200
assert "hx-get" in resp.text.lower()
guarddog_nexus.config.config.llm_enabled = False
@pytest.mark.asyncio
async def test_status_returns_spinner_when_analyzing(self, client, db_session, sample_finding):
from sqlalchemy import select
from guarddog_nexus.db.models import Finding
finding = await db_session.scalar(select(Finding).where(Finding.id == sample_finding.id))
finding.report = {"status": "analyzing"}
await db_session.commit()
resp = await client.get(f"/api/v1/findings/{sample_finding.id}/analyze")
assert resp.status_code == 200
assert "hx-get" in resp.text.lower()
# --- LLM retry exhaustion ---
@pytest.mark.asyncio
async def test_analyze_finding_exhausts_all_retries():
import guarddog_nexus.config
from guarddog_nexus.core.llm import analyze_finding
guarddog_nexus.config.config.llm_api_key = "sk-test"
with patch("guarddog_nexus.core.llm._attempt_llm_call", return_value=None):
with patch("guarddog_nexus.core.llm.asyncio.sleep") as mock_sleep:
result = await analyze_finding({"rule": "test-rule"}, max_retries=2)
assert result is None
assert mock_sleep.call_count == 1
guarddog_nexus.config.config.llm_api_key = ""
# --- LLM lock cleanup ---
@pytest.mark.asyncio
async def test_cleanup_llm_locks_removes_unlocked():
import asyncio
from guarddog_nexus.routes.web import _llm_lock, _llm_locks
async with _llm_lock:
_llm_locks[100] = asyncio.Lock()
_llm_locks[200] = asyncio.Lock()
await _llm_locks[100].acquire()
for key in list(_llm_locks.keys()):
if not _llm_locks[key].locked():
_llm_locks.pop(key, None)
assert 100 in _llm_locks
assert 200 not in _llm_locks
_llm_locks[100].release()
_llm_locks.clear()

View File

@@ -95,3 +95,57 @@ class TestDispatchExtractor:
def test_unknown_ecosystem(self): def test_unknown_ecosystem(self):
assert extract_package_info("/packages/pkg/1.0/file.tar.gz", "unknown") == ("pkg", "1.0") assert extract_package_info("/packages/pkg/1.0/file.tar.gz", "unknown") == ("pkg", "1.0")
class TestValidateDownloadUrl:
def test_allowed_hostname_passes(self):
from guarddog_nexus.core.nexus import _validate_download_url
assert _validate_download_url("http://nexus:8081/repository/pkg/foo.tar.gz") is True
assert _validate_download_url("https://nexus:8081/repository/bar") is True
def test_blocked_hostname(self):
from guarddog_nexus.core.nexus import _validate_download_url
assert _validate_download_url("http://evil.com/malware.tar.gz") is False
assert _validate_download_url("https://169.254.169.254/latest/meta-data") is False
def test_non_http_scheme_blocked(self):
from guarddog_nexus.core.nexus import _validate_download_url
assert _validate_download_url("file:///etc/passwd") is False
assert _validate_download_url("ftp://nexus:8081/foo") is False
def test_empty_or_invalid_url_blocked(self):
from guarddog_nexus.core.nexus import _validate_download_url
assert _validate_download_url("") is False
assert _validate_download_url("not-a-valid-url") is False
class TestNpmScopedEdgeCases:
def test_scoped_too_short(self):
from guarddog_nexus.core.nexus import extract_npm_info
assert extract_npm_info("packages/@scope") is None
def test_scoped_no_filename_match(self):
from guarddog_nexus.core.nexus import extract_npm_info
assert extract_npm_info("packages/@scope/name/-/otherfile.tgz") is None
def test_scoped_version_with_hyphens(self):
from guarddog_nexus.core.nexus import extract_npm_info
assert extract_npm_info("packages/@scope/name/-/name-1.0.0-beta.1.tgz") == (
"@scope/name",
"1.0.0-beta.1",
)
def test_scoped_tar_gz_extension(self):
from guarddog_nexus.core.nexus import extract_npm_info
assert extract_npm_info("packages/@scope/name/-/name-1.0.0.tar.gz") == (
"@scope/name",
"1.0.0",
)

78
tests/test_schemas.py Normal file
View File

@@ -0,0 +1,78 @@
"""Tests for schemas and serialize_finding."""
import datetime
from unittest.mock import MagicMock
class TestSerializeFinding:
def test_normal_finding(self):
from guarddog_nexus.schemas import serialize_finding
finding = MagicMock()
finding.id = 42
finding.scan_id = 7
finding.report = {"verdict": "safe"}
finding.created_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
finding.data = {
"rule": "shady-links",
"severity": "WARNING",
"message": "Suspicious URL",
"location": "setup.py:15",
"code": "url = 'http://evil.com'",
}
result = serialize_finding(finding)
assert result["id"] == 42
assert result["scan_id"] == 7
assert result["rule"] == "shady-links"
assert result["severity"] == "WARNING"
assert result["report"] == {"verdict": "safe"}
assert result["created_at"] == "2026-01-01T00:00:00+00:00"
def test_created_at_none(self):
from guarddog_nexus.schemas import serialize_finding
finding = MagicMock()
finding.id = 1
finding.scan_id = 1
finding.report = None
finding.created_at = None
finding.data = {"rule": "test", "message": "msg"}
result = serialize_finding(finding)
assert result["created_at"] is None
assert result["report"] is None
def test_missing_data_fields_default_to_empty_string(self):
from guarddog_nexus.schemas import serialize_finding
finding = MagicMock()
finding.id = 1
finding.scan_id = 1
finding.report = None
finding.created_at = None
finding.data = {"rule": "only-rule"}
result = serialize_finding(finding)
assert result["rule"] == "only-rule"
assert result["severity"] == ""
assert result["message"] == ""
def test_data_values_none_become_empty_strings(self):
from guarddog_nexus.schemas import serialize_finding
finding = MagicMock()
finding.id = 1
finding.scan_id = 1
finding.report = None
finding.created_at = None
finding.data = {"rule": None, "severity": None, "message": None}
result = serialize_finding(finding)
assert result["rule"] == ""
assert result["severity"] == ""
assert result["message"] == ""

View File

@@ -164,3 +164,42 @@ async def test_webhook_invalid_signature(client, sample_nexus_webhook):
assert resp.status_code == 403 assert resp.status_code == 403
guarddog_nexus.config.config.webhook_secret = "" guarddog_nexus.config.config.webhook_secret = ""
# --- Unknown ecosystem rejection ---
@pytest.mark.asyncio
async def test_webhook_rejects_unknown_ecosystem_asset(client):
resp = await client.post(
"/webhooks/nexus",
json={
"action": "UPDATED",
"repositoryName": "test-repo",
"asset": {
"format": "maven",
"name": "/packages/test/1.0/test-1.0.tar.gz",
"downloadUrl": "http://nexus:8081/repo/test/1.0/test-1.0.tar.gz",
},
},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ignored"
assert data["reason"] == "unknown_ecosystem"
@pytest.mark.asyncio
async def test_webhook_rejects_unknown_ecosystem_component(client):
resp = await client.post(
"/webhooks/nexus",
json={
"action": "UPDATED",
"repositoryName": "test-repo",
"component": {"format": "maven", "name": "test", "version": "1.0"},
},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ignored"
assert data["reason"] == "unknown_ecosystem"