Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
from contextlib import asynccontextmanager
from .request_middleware import RequestIDMiddleware

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.responses import JSONResponse
from .request_context import get_request_id

from .config import settings
from .auth import init_api_key
Expand Down Expand Up @@ -164,6 +168,27 @@ async def redirect_api_openapi():
)
app.add_middleware(RequestIDMiddleware)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"detail": exc.errors(),
"request_id": get_request_id()
}
)

@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.detail,
"request_id": get_request_id()
},
headers=getattr(exc, "headers", None)
)

# Include API routes
app.include_router(router)
app.include_router(saved_views_router)
Expand Down
2 changes: 2 additions & 0 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str:
from .vault import VaultCrypto
from .workflows import scheduler
from .auth import require_api_key, get_current_owner
from .request_context import get_request_id
from .execution_context import is_offensive_validation, normalize_execution_context
from .finding_intelligence import build_asset_summary, build_finding_groups
from .knowledgebase import KnowledgeBase
Expand Down Expand Up @@ -297,6 +298,7 @@ def _report_generation_error_response(task_id: str, report_format: str) -> JSONR
"task_id": task_id,
"format": report_format,
},
"request_id": get_request_id(),
},
)

Expand Down
1 change: 1 addition & 0 deletions backend/secuscan/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds:
return elapsed >= schedule_seconds
async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]]):
logger.info("Running workflow %s with %d step(s)", workflow_id, len(steps))
db = await get_db()
for step in steps:
plugin_id = step.get("plugin_id")
inputs = step.get("inputs") or {}
Expand Down
23 changes: 22 additions & 1 deletion frontend/testing/unit/pages/ToolConfigDynamic.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ToolConfig from '../../../src/pages/ToolConfig'
import { getPluginSchema, listPlugins, startTask } from '../../../src/api'
import {
getPluginSchema,
listPlugins,
startTask,
getSettings,
listTargetPolicies,
listCredentialProfiles,
listSessionProfiles,
} from '../../../src/api'
import { routes } from '../../../src/routes'

const addToast = vi.fn()
Expand All @@ -15,6 +23,10 @@ vi.mock('../../../src/api', () => ({
listPlugins: vi.fn(),
getPluginSchema: vi.fn(),
startTask: vi.fn(),
getSettings: vi.fn(),
listTargetPolicies: vi.fn(),
listCredentialProfiles: vi.fn(),
listSessionProfiles: vi.fn(),
}))

describe('ToolConfig dynamic schema flow', () => {
Expand Down Expand Up @@ -74,6 +86,10 @@ describe('ToolConfig dynamic schema flow', () => {
created_at: 'now',
stream_url: '/api/v1/task/task-123/stream',
})
vi.mocked(getSettings).mockResolvedValue({ sandbox: { default_timeout: 600 } })
vi.mocked(listTargetPolicies).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCredentialProfiles).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listSessionProfiles).mockResolvedValue({ items: [], total: 0 })
})

it('renders dynamic fields and submits startTask with consent', async () => {
Expand Down Expand Up @@ -108,6 +124,11 @@ describe('ToolConfig dynamic schema flow', () => {
}),
true,
'quick',
expect.objectContaining({
scan_profile: 'standard',
validation_mode: 'proof',
evidence_level: 'standard',
}),
)
})
})
Expand Down
16 changes: 15 additions & 1 deletion frontend/testing/unit/pages/ToolConfigTimeout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ToolConfig from '../../../src/pages/ToolConfig'
import { getPluginSchema, listPlugins, startTask, getSettings } from '../../../src/api'
import {
getPluginSchema,
listPlugins,
startTask,
getSettings,
listTargetPolicies,
listCredentialProfiles,
listSessionProfiles,
} from '../../../src/api'
import { routes } from '../../../src/routes'

const addToast = vi.fn()
Expand All @@ -16,6 +24,9 @@ vi.mock('../../../src/api', () => ({
getPluginSchema: vi.fn(),
startTask: vi.fn(),
getSettings: vi.fn(),
listTargetPolicies: vi.fn(),
listCredentialProfiles: vi.fn(),
listSessionProfiles: vi.fn(),
}))

describe('ToolConfig timeout control', () => {
Expand Down Expand Up @@ -60,6 +71,9 @@ describe('ToolConfig timeout control', () => {

vi.mocked(getSettings).mockResolvedValue({ sandbox: { default_timeout: 600 } })
vi.mocked(startTask).mockResolvedValue({ task_id: 'task-1', status: 'queued', created_at: 'now', stream_url: '' })
vi.mocked(listTargetPolicies).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCredentialProfiles).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listSessionProfiles).mockResolvedValue({ items: [], total: 0 })
})

it('renders integer input with constrained min/max', async () => {
Expand Down
12 changes: 11 additions & 1 deletion frontend/testing/unit/pages/Workflows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,17 @@ describe('Workflows — create action', () => {
name: 'Nightly Scan',
schedule_seconds: 7200,
enabled: true,
steps: [{ plugin_id: '', inputs: {} }],
steps: [
{
plugin_id: '',
inputs: {},
execution_context: {
scan_profile: 'standard',
validation_mode: 'proof',
evidence_level: 'standard',
},
},
],
})
})
})
Expand Down
15 changes: 10 additions & 5 deletions testing/backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import pytest
from fastapi.testclient import TestClient


@pytest.fixture
def anyio_backend():
return "asyncio"
Expand All @@ -22,7 +21,6 @@ def anyio_backend():
from backend.secuscan.ratelimit import concurrent_limiter, rate_limiter
from backend.secuscan import auth as auth_module


@pytest.fixture(autouse=True)
def setup_test_environment(monkeypatch):
"""Override settings for tests to ensure isolated execution."""
Expand Down Expand Up @@ -52,8 +50,6 @@ def anyio_backend():
"""Force AnyIO tests to run on asyncio (trio is not a dependency in CI)."""
return "asyncio"



@pytest.fixture
def test_client(setup_test_environment):
"""Provides a synchronous test client backed by initialized async services."""
Expand All @@ -68,7 +64,6 @@ async def setup():
await reset_all_endpoint_limiters()
except ImportError:
pass
await init_db(settings.database_path)
await init_plugins(settings.plugins_dir)

asyncio.run(setup())
Expand All @@ -91,3 +86,13 @@ async def teardown():
await database_module.db.disconnect()

asyncio.run(teardown())

@pytest.fixture(autouse=True)
def db_cleanup_fixture():
yield
if database_module.db:
import asyncio
try:
asyncio.run(database_module.db.disconnect())
except Exception:
pass
8 changes: 5 additions & 3 deletions testing/backend/unit/test_api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
@pytest.fixture()
def client_with_key(setup_test_environment):
"""TestClient with a valid API key pre-seeded."""
asyncio.run(init_db(settings.database_path))
asyncio.run(init_plugins(settings.plugins_dir))
api_key = auth_module.init_api_key(settings.data_dir)
with TestClient(app) as c:
yield c, api_key
Expand All @@ -38,9 +36,13 @@ def test_existing_key_reloaded(self, tmp_path):
assert k1 == k2

def test_key_file_permissions(self, tmp_path):
import sys
auth_module.init_api_key(str(tmp_path))
mode = (tmp_path / ".api_key").stat().st_mode & 0o777
assert mode == 0o600
if sys.platform == "win32":
assert mode == 0o666
else:
assert mode == 0o600

def test_secuscan_api_key_file_env_var(self, tmp_path, monkeypatch):
custom_path = tmp_path / "secrets" / "my_api_key"
Expand Down
76 changes: 76 additions & 0 deletions testing/backend/unit/test_request_id_error_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import pytest
from unittest.mock import patch
from backend.secuscan.database import get_db

@pytest.mark.asyncio
async def test_validation_error_request_id_contract(test_client):
# Case 1: 422 from validation error (missing required field 'plugin_id')
response = test_client.post("/api/v1/task/start", json={"inputs": {"target": "127.0.0.1"}})
assert response.status_code == 422

body = response.json()
assert "detail" in body
assert "request_id" in body
assert isinstance(body["request_id"], str)
assert len(body["request_id"]) > 0

# Check headers
assert "X-Request-ID" in response.headers
assert response.headers["X-Request-ID"] == body["request_id"]

@pytest.mark.asyncio
async def test_http_exception_request_id_contract(test_client):
# Case 2: 404 from HTTPException (e.g. non-existent task status endpoint)
response = test_client.get("/api/v1/task/non-existent-task-id-abc/status")
assert response.status_code == 404

body = response.json()
assert "detail" in body
assert "request_id" in body
assert isinstance(body["request_id"], str)
assert len(body["request_id"]) > 0

# Check headers
assert "X-Request-ID" in response.headers
assert response.headers["X-Request-ID"] == body["request_id"]

@pytest.mark.asyncio
async def test_report_generation_error_request_id_contract(test_client):
# Case 3: 500 report generation error helper payload
db = await get_db()
await db.execute(
"""
INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, status, consent_granted, safe_mode, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("test-task-999", "nmap", "nmap", "127.0.0.1", '{"target":"127.0.0.1"}', "completed", 1, 1, "default")
)

with patch("backend.secuscan.reporting.reporting.generate_csv_report", side_effect=Exception("Simulated report failure")):
response = test_client.get("/api/v1/task/test-task-999/report/csv")
assert response.status_code == 500

body = response.json()
assert body["error"] == "report_generation_failed"
assert "request_id" in body
assert isinstance(body["request_id"], str)
assert len(body["request_id"]) > 0

assert "X-Request-ID" in response.headers
assert response.headers["X-Request-ID"] == body["request_id"]

@pytest.mark.asyncio
async def test_client_supplied_request_id_contract(test_client):
# Case 4: Round-tripping a client-supplied X-Request-ID
client_request_id = "test-client-req-id-12345"
response = test_client.get(
"/api/v1/task/non-existent-task-id-xyz/status",
headers={"X-Request-ID": client_request_id}
)
assert response.status_code == 404

body = response.json()
assert "detail" in body
assert body["request_id"] == client_request_id

assert response.headers["X-Request-ID"] == client_request_id
Loading