refactor: реструктуризация — core/, db/, routes/, web/

guarddog_nexus/
├── core/          scanner, harvester, nexus, llm
├── db/            engine, models, queries
├── routes/        webhooks, api_*, web
└── web/           templates + static

- 11 файлов перемещено (git mv — сохранена история)
- Все импорты обновлены (~15 файлов)
- main.py, tests — исправлены пути
- 50/50 тестов, ruff clean
This commit is contained in:
Marker689
2026-05-10 07:17:41 +03:00
parent 22dc87851a
commit 8726b65808
21 changed files with 80 additions and 80 deletions

View File

@@ -9,17 +9,17 @@ import tempfile
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.constants import ( from ..constants import (
DEFAULT_ECOSYSTEM, DEFAULT_ECOSYSTEM,
ERROR_MESSAGE_MAX_LENGTH, ERROR_MESSAGE_MAX_LENGTH,
PACKAGE_EXTENSIONS, PACKAGE_EXTENSIONS,
SCAN_ERROR_DOWNLOAD_FAILED, SCAN_ERROR_DOWNLOAD_FAILED,
) )
from guarddog_nexus.logging_setup import log from ..db.models import Finding, Scan, ScanStatus
from guarddog_nexus.models import Finding, Scan, ScanStatus from ..logging_setup import log
from guarddog_nexus.nexus_client import compute_sha256, download_asset, extract_package_info from .nexus import compute_sha256, download_asset, extract_package_info
from guarddog_nexus.scanner import scan_package from .scanner import scan_package
# Per-URL locks to avoid parallel scans of the same asset # Per-URL locks to avoid parallel scans of the same asset
_url_locks: dict[str, asyncio.Lock] = {} _url_locks: dict[str, asyncio.Lock] = {}
@@ -193,7 +193,7 @@ async def harvest(
async def _run_llm_analysis(findings: list[Finding], session: AsyncSession) -> list[dict]: async def _run_llm_analysis(findings: list[Finding], session: AsyncSession) -> list[dict]:
"""Run LLM analysis on findings and persist reports to the database.""" """Run LLM analysis on findings and persist reports to the database."""
from guarddog_nexus.llm import analyze_finding from .llm import analyze_finding
reports = [] reports = []
for finding in findings: for finding in findings:

View File

@@ -7,9 +7,9 @@ import json
import httpx import httpx
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.constants import LLM_ANALYSIS_SYSTEM_PROMPT from ..constants import LLM_ANALYSIS_SYSTEM_PROMPT
from guarddog_nexus.logging_setup import log from ..logging_setup import log
def _build_user_message(finding: dict) -> str: def _build_user_message(finding: dict) -> str:

View File

@@ -5,13 +5,13 @@ import os
import httpx import httpx
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.constants import ( from ..constants import (
NPM_PATH_PREFIX, NPM_PATH_PREFIX,
PYPI_PATH_PREFIX, PYPI_PATH_PREFIX,
SHA256_CHUNK_SIZE, SHA256_CHUNK_SIZE,
) )
from guarddog_nexus.logging_setup import log from ..logging_setup import log
def extract_pypi_info(asset_path: str) -> tuple[str, str] | None: def extract_pypi_info(asset_path: str) -> tuple[str, str] | None:

View File

@@ -3,8 +3,8 @@
import asyncio import asyncio
import json import json
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.constants import ( from ..constants import (
DEFAULT_ECOSYSTEM, DEFAULT_ECOSYSTEM,
DEFAULT_FINDING_SEVERITY, DEFAULT_FINDING_SEVERITY,
GUARDDOG_OUTPUT_FORMAT, GUARDDOG_OUTPUT_FORMAT,
@@ -14,7 +14,7 @@ from guarddog_nexus.constants import (
SCAN_ERROR_JSON_PARSE, SCAN_ERROR_JSON_PARSE,
SCAN_ERROR_TIMEOUT, SCAN_ERROR_TIMEOUT,
) )
from guarddog_nexus.logging_setup import log from ..logging_setup import log
async def scan_package(filepath: str, ecosystem: str = DEFAULT_ECOSYSTEM) -> dict: async def scan_package(filepath: str, ecosystem: str = DEFAULT_ECOSYSTEM) -> dict:

View File

@@ -20,7 +20,7 @@ class Base(DeclarativeBase):
async def _migrate(): async def _migrate():
"""Add any missing columns from model definitions to existing SQLite tables.""" """Add any missing columns from model definitions to existing SQLite tables."""
import guarddog_nexus.models # noqa: F401 import guarddog_nexus.db.models # noqa: F401
async with _engine.connect() as conn: async with _engine.connect() as conn:
for table in Base.metadata.sorted_tables: for table in Base.metadata.sorted_tables:
@@ -63,7 +63,7 @@ async def _migrate():
async def init_db(): async def init_db():
import guarddog_nexus.models # noqa: F401 import guarddog_nexus.db.models # noqa: F401
async with _engine.begin() as conn: async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)

View File

@@ -6,7 +6,7 @@ from enum import Enum
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from guarddog_nexus.database import Base from guarddog_nexus.db.engine import Base
class ScanStatus(str, Enum): class ScanStatus(str, Enum):

View File

@@ -20,7 +20,7 @@ from guarddog_nexus.constants import (
SCAN_SORT_FIELDS, SCAN_SORT_FIELDS,
TOP_RULES_LIMIT, TOP_RULES_LIMIT,
) )
from guarddog_nexus.models import Finding, Scan from guarddog_nexus.db.models import Finding, Scan
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scan list query builder # Scan list query builder

View File

@@ -5,8 +5,8 @@ import logging
import sys import sys
from logging.handlers import SysLogHandler from logging.handlers import SysLogHandler
from guarddog_nexus.config import config from .config import config
from guarddog_nexus.constants import APP_PACKAGE from .constants import APP_PACKAGE
class JsonFormatter(logging.Formatter): class JsonFormatter(logging.Formatter):

View File

@@ -7,13 +7,13 @@ import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from guarddog_nexus.api import findings, packages, scans
from guarddog_nexus.config import config from guarddog_nexus.config import config
from guarddog_nexus.constants import APP_DESCRIPTION, APP_NAME, APP_PACKAGE, STATIC_MOUNT_PATH from guarddog_nexus.constants import APP_DESCRIPTION, APP_NAME, APP_PACKAGE, STATIC_MOUNT_PATH
from guarddog_nexus.database import init_db from guarddog_nexus.db.engine import init_db
from guarddog_nexus.logging_setup import log from guarddog_nexus.logging_setup import log
from guarddog_nexus.web.routes import router as web_router from guarddog_nexus.routes import api_findings, api_packages, api_scans
from guarddog_nexus.webhooks import router as webhook_router from guarddog_nexus.routes.web import router as web_router
from guarddog_nexus.routes.webhooks import router as webhook_router
STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static") STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static")
@@ -34,9 +34,9 @@ app = FastAPI(
) )
app.include_router(webhook_router) app.include_router(webhook_router)
app.include_router(scans.router) app.include_router(api_scans.router)
app.include_router(packages.router) app.include_router(api_packages.router)
app.include_router(findings.router) app.include_router(api_findings.router)
app.include_router(web_router) app.include_router(web_router)
if os.path.isdir(STATIC_DIR): if os.path.isdir(STATIC_DIR):

View File

View File

@@ -4,16 +4,16 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.constants import ( from ..constants import (
DEFAULT_OFFSET, DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
JSON_PATH_RULE, JSON_PATH_RULE,
JSON_PATH_SEVERITY, JSON_PATH_SEVERITY,
MAX_PAGE_SIZE, MAX_PAGE_SIZE,
) )
from guarddog_nexus.database import get_session from ..db.engine import get_session
from guarddog_nexus.models import Finding from ..db.models import Finding
router = APIRouter(prefix="/api/v1/findings", tags=["findings"]) router = APIRouter(prefix="/api/v1/findings", tags=["findings"])
@@ -70,7 +70,7 @@ async def analyze_finding_endpoint(
if not finding: if not finding:
return {"detail": "Not found"} return {"detail": "Not found"}
from guarddog_nexus.llm import analyze_finding from ..core.llm import analyze_finding
report = await analyze_finding(finding.data) report = await analyze_finding(finding.data)
if report is None: if report is None:

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.constants import ( from ..constants import (
CSV_MEDIA_TYPE, CSV_MEDIA_TYPE,
DEFAULT_OFFSET, DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
@@ -16,9 +16,9 @@ from guarddog_nexus.constants import (
DEFAULT_SORT_DIR, DEFAULT_SORT_DIR,
MAX_PAGE_SIZE, MAX_PAGE_SIZE,
) )
from guarddog_nexus.database import get_session from ..db.engine import get_session
from guarddog_nexus.models import Finding, Scan from ..db.models import Finding, Scan
from guarddog_nexus.queries import build_package_list_query from ..db.queries import build_package_list_query
router = APIRouter(prefix="/api/v1/packages", tags=["packages"]) router = APIRouter(prefix="/api/v1/packages", tags=["packages"])

View File

@@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from guarddog_nexus.constants import ( from ..constants import (
CSV_MEDIA_TYPE, CSV_MEDIA_TYPE,
DEFAULT_OFFSET, DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
@@ -16,9 +16,9 @@ from guarddog_nexus.constants import (
DEFAULT_SORT_DIR, DEFAULT_SORT_DIR,
MAX_PAGE_SIZE, MAX_PAGE_SIZE,
) )
from guarddog_nexus.database import get_session from ..db.engine import get_session
from guarddog_nexus.models import Scan from ..db.models import Scan
from guarddog_nexus.queries import build_scan_list_query, get_dashboard_stats from ..db.queries import build_scan_list_query, get_dashboard_stats
router = APIRouter(prefix="/api/v1/scans", tags=["scans"]) router = APIRouter(prefix="/api/v1/scans", tags=["scans"])

View File

@@ -8,16 +8,16 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.constants import ( from ..constants import (
APP_PACKAGE, APP_PACKAGE,
DEFAULT_SORT_BY_PACKAGES, DEFAULT_SORT_BY_PACKAGES,
DEFAULT_SORT_BY_SCANS, DEFAULT_SORT_BY_SCANS,
DEFAULT_SORT_DIR, DEFAULT_SORT_DIR,
WEB_PER_PAGE, WEB_PER_PAGE,
) )
from guarddog_nexus.database import get_session from ..db.engine import get_session
from guarddog_nexus.models import Finding, Scan from ..db.models import Finding, Scan
from guarddog_nexus.queries import ( from ..db.queries import (
build_package_list_query, build_package_list_query,
build_scan_list_query, build_scan_list_query,
get_dashboard_stats, get_dashboard_stats,
@@ -206,8 +206,8 @@ async def analyze_finding_htmx(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""HTMX fragment: trigger LLM analysis and return styled result HTML.""" """HTMX fragment: trigger LLM analysis and return styled result HTML."""
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.llm import analyze_finding from ..core.llm import analyze_finding
if not config.llm_enabled: if not config.llm_enabled:
return HTMLResponse( return HTMLResponse(

View File

@@ -7,8 +7,8 @@ import re
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
from guarddog_nexus.config import config from ..config import config
from guarddog_nexus.constants import ( from ..constants import (
DEFAULT_ECOSYSTEM, DEFAULT_ECOSYSTEM,
METADATA_PATTERNS, METADATA_PATTERNS,
PACKAGE_EXTENSIONS, PACKAGE_EXTENSIONS,
@@ -19,9 +19,9 @@ from guarddog_nexus.constants import (
WEBHOOK_STATUS_ACCEPTED, WEBHOOK_STATUS_ACCEPTED,
WEBHOOK_STATUS_IGNORED, WEBHOOK_STATUS_IGNORED,
) )
from guarddog_nexus.database import get_session from ..core.harvester import harvest
from guarddog_nexus.harvester import harvest from ..db.engine import get_session
from guarddog_nexus.logging_setup import log from ..logging_setup import log
router = APIRouter(prefix="/webhooks", tags=["webhooks"]) router = APIRouter(prefix="/webhooks", tags=["webhooks"])
@@ -141,7 +141,7 @@ async def nexus_webhook(
async def _scan_component(repository: str, name: str, version: str, ecosystem: str): async def _scan_component(repository: str, name: str, version: str, ecosystem: str):
from guarddog_nexus.nexus_client import nexus_get from ..core.nexus import nexus_get
api_path = ( api_path = (
f"/service/rest/v1/search" f"/service/rest/v1/search"

View File

@@ -18,9 +18,9 @@ os.environ["LOG_SYSLOG_HOST"] = ""
os.environ["TEMP_DIR"] = "/tmp/guarddog-nexus-test" os.environ["TEMP_DIR"] = "/tmp/guarddog-nexus-test"
from guarddog_nexus.constants import DEFAULT_ECOSYSTEM, SEVERITY_WARNING # noqa: E402 from guarddog_nexus.constants import DEFAULT_ECOSYSTEM, SEVERITY_WARNING # noqa: E402
from guarddog_nexus.database import Base, get_session # noqa: E402 from guarddog_nexus.db.engine import Base, get_session # noqa: E402
from guarddog_nexus.db.models import Finding, Scan, ScanStatus # noqa: E402
from guarddog_nexus.main import app # noqa: E402 from guarddog_nexus.main import app # noqa: E402
from guarddog_nexus.models import Finding, Scan, ScanStatus # noqa: E402
@pytest_asyncio.fixture @pytest_asyncio.fixture

View File

@@ -5,16 +5,16 @@ from unittest.mock import patch
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from guarddog_nexus.harvester import harvest from guarddog_nexus.core.harvester import harvest
from guarddog_nexus.models import Finding from guarddog_nexus.db.models import Finding
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_harvest_new_package(db_session, guarddog_normalized_flagged): async def test_harvest_new_package(db_session, guarddog_normalized_flagged):
with ( with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl, patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha, patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan, patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
): ):
mock_dl.return_value = "/tmp/test-package.tar.gz" mock_dl.return_value = "/tmp/test-package.tar.gz"
mock_sha.return_value = "abc123" mock_sha.return_value = "abc123"
@@ -50,9 +50,9 @@ async def test_harvest_new_package(db_session, guarddog_normalized_flagged):
async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged): async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged):
"""Same SHA256 as existing scan → skip, don't re-scan.""" """Same SHA256 as existing scan → skip, don't re-scan."""
with ( with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl, patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha, patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan, patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
): ):
mock_dl.return_value = "/tmp/test.tar.gz" mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "deadbeef" mock_sha.return_value = "deadbeef"
@@ -86,9 +86,9 @@ async def test_harvest_same_sha256_skips(db_session, guarddog_normalized_flagged
async def test_harvest_different_sha256_scans_again(db_session, guarddog_normalized_flagged): async def test_harvest_different_sha256_scans_again(db_session, guarddog_normalized_flagged):
"""Same name/version, different SHA256 → new scan.""" """Same name/version, different SHA256 → new scan."""
with ( with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl, patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha, patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan, patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
): ):
mock_dl.return_value = "/tmp/test.tar.gz" mock_dl.return_value = "/tmp/test.tar.gz"
mock_scan.return_value = guarddog_normalized_flagged mock_scan.return_value = guarddog_normalized_flagged
@@ -123,9 +123,9 @@ async def test_harvest_different_sha256_scans_again(db_session, guarddog_normali
async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalized_flagged): async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalized_flagged):
"""Concurrent webhooks for same URL: first proceeding, second skips as PENDING.""" """Concurrent webhooks for same URL: first proceeding, second skips as PENDING."""
with ( with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl, patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha, patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan, patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
): ):
mock_dl.return_value = "/tmp/test.tar.gz" mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "aaa" mock_sha.return_value = "aaa"
@@ -147,9 +147,9 @@ async def test_harvest_skips_active_scan_same_url(db_session, guarddog_normalize
async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_flagged): async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_flagged):
"""Same URL twice: second run hits SHA256 dedup (first already completed).""" """Same URL twice: second run hits SHA256 dedup (first already completed)."""
with ( with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl, patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha, patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan, patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
): ):
mock_dl.return_value = "/tmp/test.tar.gz" mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "ccc" mock_sha.return_value = "ccc"
@@ -182,9 +182,9 @@ async def test_harvest_same_url_sha256_dedup(db_session, guarddog_normalized_fla
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_harvest_clean_package(db_session, guarddog_normalized_clean): async def test_harvest_clean_package(db_session, guarddog_normalized_clean):
with ( with (
patch("guarddog_nexus.harvester.download_asset") as mock_dl, patch("guarddog_nexus.core.harvester.download_asset") as mock_dl,
patch("guarddog_nexus.harvester.compute_sha256") as mock_sha, patch("guarddog_nexus.core.harvester.compute_sha256") as mock_sha,
patch("guarddog_nexus.harvester.scan_package") as mock_scan, patch("guarddog_nexus.core.harvester.scan_package") as mock_scan,
): ):
mock_dl.return_value = "/tmp/test.tar.gz" mock_dl.return_value = "/tmp/test.tar.gz"
mock_sha.return_value = "abc" mock_sha.return_value = "abc"
@@ -205,7 +205,7 @@ async def test_harvest_clean_package(db_session, guarddog_normalized_clean):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_harvest_download_failure(db_session): async def test_harvest_download_failure(db_session):
with patch("guarddog_nexus.harvester.download_asset") as mock_dl: with patch("guarddog_nexus.core.harvester.download_asset") as mock_dl:
mock_dl.return_value = None mock_dl.return_value = None
scan = await harvest( scan = await harvest(

View File

@@ -1,6 +1,6 @@
"""Tests for GuardDog scanner integration.""" """Tests for GuardDog scanner integration."""
from guarddog_nexus.scanner import _normalize_output from guarddog_nexus.core.scanner import _normalize_output
def test_normalize_clean_output(guarddog_output_clean): def test_normalize_clean_output(guarddog_output_clean):

View File

@@ -34,7 +34,7 @@ async def test_webhook_ignores_created_action(client, sample_nexus_webhook):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webhook_accepts_asset_updated(client, sample_nexus_webhook): async def test_webhook_accepts_asset_updated(client, sample_nexus_webhook):
sample_nexus_webhook["action"] = "UPDATED" sample_nexus_webhook["action"] = "UPDATED"
with patch("guarddog_nexus.webhooks._scan_in_background") as _mock: with patch("guarddog_nexus.routes.webhooks._scan_in_background") as _mock:
resp = await client.post("/webhooks/nexus", json=sample_nexus_webhook) resp = await client.post("/webhooks/nexus", json=sample_nexus_webhook)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["status"] == "accepted" assert resp.json()["status"] == "accepted"
@@ -68,7 +68,7 @@ async def test_webhook_no_asset_or_component(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webhook_accepts_component(client, sample_nexus_component_webhook): async def test_webhook_accepts_component(client, sample_nexus_component_webhook):
with patch("guarddog_nexus.webhooks._scan_component") as _mock: with patch("guarddog_nexus.routes.webhooks._scan_component") as _mock:
resp = await client.post("/webhooks/nexus", json=sample_nexus_component_webhook) resp = await client.post("/webhooks/nexus", json=sample_nexus_component_webhook)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()