diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index c42908f5..69c66b2f 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -34,11 +34,12 @@ async def connect(self): """Establish database connection and ensure schema exists.""" # Ensure data directory exists Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) - + conn = await aiosqlite.connect(self.db_path) self._connection = conn conn.row_factory = aiosqlite.Row await self._create_schema() + await self._run_migrations() async def disconnect(self): """Close the current database connection.""" @@ -265,7 +266,6 @@ async def _create_schema(self): print("Added missing column 'proof' to findings table.") except Exception as e: print(f"Failed to add 'proof' to findings: {e}") - risk_cols = { "exploitability": "REAL", "confidence": "REAL", @@ -281,6 +281,24 @@ async def _create_schema(self): except Exception as e: print(f"Failed to add column {col_name}: {e}") + async def _run_migrations(self): + migrations_dir = Path(__file__).parent / "migrations" + + if not migrations_dir.exists(): + raise RuntimeError( + f"Migrations directory not found at {migrations_dir} — " + "ensure the backend package is installed correctly." + ) + + for migration_file in sorted(migrations_dir.glob("*.sql")): + sql = migration_file.read_text(encoding="utf-8") + try: + await self.connection.executescript(sql) + except Exception as exc: + raise RuntimeError( + f"Migration {migration_file.name} failed — startup aborted: {exc}" + ) from exc + await self._backfill_risk_scores() async def _backfill_risk_scores(self): @@ -326,13 +344,13 @@ async def execute(self, query: str, params: tuple = ()): async def fetchone(self, query: str, params: tuple = ()) -> Optional[Dict]: """Fetch one row.""" - async with self.connection.execute(query, params) as cursor: + async with await self.connection.execute(query, params) as cursor: row = await cursor.fetchone() return dict(row) if row else None async def fetchall(self, query: str, params: tuple = ()) -> List[Dict]: """Fetch all rows.""" - async with self.connection.execute(query, params) as cursor: + async with await self.connection.execute(query, params) as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index ebf57f60..3b8b39ca 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -18,6 +18,7 @@ from .database import init_db, db as global_db from .plugins import init_plugins from .routes import router +from .saved_views import saved_views_router from .workflows import scheduler @@ -46,29 +47,29 @@ async def lifespan(app: FastAPI): """Application lifespan manager""" # Startup logger.info("🚀 Starting SecuScan backend...") - + # Ensure directories exist settings.ensure_directories() logger.info("✓ Directories initialized") - + # Initialize database await init_db(settings.database_path) logger.info("✓ SQLite connected") await init_cache() logger.info("✓ In-memory cache initialized") - + # Load plugins await init_plugins(settings.plugins_dir) logger.info("✓ Plugins loaded") await scheduler.start() logger.info("✓ Workflow scheduler started") - + logger.info("✓ Ready to serve on %s:%d", settings.bind_address, settings.bind_port) - + yield - + # Shutdown logger.info("🛑 Shutting down SecuScan backend...") if global_db: @@ -125,6 +126,7 @@ async def redirect_api_openapi(): # Include API routes app.include_router(router) +app.include_router(saved_views_router) # Health check endpoint @app.get("/api/v1/health") @@ -132,7 +134,7 @@ async def health_check(): """Health check endpoint""" import platform import sys - + return { "status": "operational", "version": "0.1.0-alpha", @@ -160,7 +162,7 @@ async def root(): def main(): """Main entry point""" import uvicorn - + logger.info(""" ╔═══════════════════════════════════════════════════════╗ ║ ║ @@ -171,7 +173,7 @@ def main(): ║ ║ ╚═══════════════════════════════════════════════════════╝ """) - + uvicorn.run( "backend.secuscan.main:app", host=settings.bind_address, diff --git a/backend/secuscan/migrations/002_add_saved_views.sql b/backend/secuscan/migrations/002_add_saved_views.sql new file mode 100644 index 00000000..fd91d93f --- /dev/null +++ b/backend/secuscan/migrations/002_add_saved_views.sql @@ -0,0 +1,21 @@ +-- Migration: 002_add_saved_views +-- Adds the saved_views table for persisting named filter/sort/date presets +-- created by users on the Findings page. +-- +-- Design notes: +-- - name is UNIQUE so the application-level 409 on duplicate names is +-- backed by a real database constraint. +-- - filter_json stores the serialised FilterPreset object; validated by +-- the API layer (Pydantic) before insert so the column is always valid. +-- - updated_at is maintained manually on PUT so callers can detect stale +-- local copies when merging localStorage with backend state. + +CREATE TABLE IF NOT EXISTS saved_views ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + filter_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_saved_views_name ON saved_views(LOWER(name)); diff --git a/backend/secuscan/saved_views.py b/backend/secuscan/saved_views.py new file mode 100644 index 00000000..889ecc40 --- /dev/null +++ b/backend/secuscan/saved_views.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import uuid +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field, field_validator + +from .database import get_db + +saved_views_router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"]) + +_VALID_SORT_MODES = {"severity", "newest", "oldest", "target"} +_VALID_SEVERITIES = {"all", "critical", "high", "medium", "low", "info"} + + +class FilterPreset(BaseModel): + """Validated representation of the frontend filter state.""" + severity: str = "all" + target: str = "all" + scanner: str = "all" + sortMode: str = "severity" + dateFrom: str = "" + dateTo: str = "" + searchQuery: str = "" + + @field_validator("sortMode") + @classmethod + def validate_sort_mode(cls, v: str) -> str: + if v not in _VALID_SORT_MODES: + raise ValueError(f"sortMode must be one of {_VALID_SORT_MODES}") + return v + + @field_validator("severity") + @classmethod + def validate_severity(cls, v: str) -> str: + if v not in _VALID_SEVERITIES: + raise ValueError(f"severity must be one of {_VALID_SEVERITIES}") + return v + + +class SavedViewCreate(BaseModel): + """Request body for POST /saved-views.""" + name: str = Field(..., min_length=1, max_length=60) + filter_json: str + + @field_validator("name") + @classmethod + def strip_name(cls, v: str) -> str: + stripped = v.strip() + if not stripped: + raise ValueError("name cannot be blank") + return stripped + + @field_validator("filter_json") + @classmethod + def validate_filter_json(cls, v: str) -> str: + try: + data = json.loads(v) + except json.JSONDecodeError as exc: + raise ValueError(f"filter_json is not valid JSON: {exc}") from exc + FilterPreset(**data) + return v + + +class SavedViewUpdate(BaseModel): + """Request body for PUT /saved-views/{id}.""" + name: Optional[str] = Field(None, min_length=1, max_length=60) + filter_json: Optional[str] = None + + @field_validator("name") + @classmethod + def strip_name(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + stripped = v.strip() + if not stripped: + raise ValueError("name cannot be blank") + return stripped + + @field_validator("filter_json") + @classmethod + def validate_filter_json(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + data = json.loads(v) + except json.JSONDecodeError as exc: + raise ValueError(f"filter_json is not valid JSON: {exc}") from exc + FilterPreset(**data) + return v + + + + + +@saved_views_router.get("") +async def list_saved_views() -> Dict[str, Any]: + """Return all saved views ordered by creation date.""" + db = await get_db() + rows: List[Dict] = await db.fetchall( + "SELECT id, name, filter_json, created_at, updated_at " + "FROM saved_views ORDER BY created_at ASC" + ) + return {"views": rows, "total": len(rows)} + + +@saved_views_router.post("", status_code=201) +async def create_saved_view(body: SavedViewCreate) -> Dict[str, Any]: + """ + Create a new saved view. + Returns 409 if a view with the same name already exists. + """ + db = await get_db() + + + existing = await db.fetchone( + "SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?)", (body.name,) + ) + if existing: + raise HTTPException( + status_code=409, + detail=f"A saved view named '{body.name}' already exists. " + "Use PUT to overwrite it.", + ) + + view_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO saved_views (id, name, filter_json) + VALUES (?, ?, ?) + """, + (view_id, body.name, body.filter_json), + ) + return {"id": view_id, "name": body.name, "created": True} + + +@saved_views_router.put("/{view_id}") +async def update_saved_view(view_id: str, body: SavedViewUpdate) -> Dict[str, Any]: + """ + Overwrite name and/or filter_json for an existing view. + Also accepts PATCH semantics — only supplied fields are updated. + """ + db = await get_db() + + row = await db.fetchone("SELECT id FROM saved_views WHERE id = ?", (view_id,)) + if not row: + raise HTTPException(status_code=404, detail="Saved view not found") + + updates: List[str] = [] + params: List[Any] = [] + + if body.name is not None: + # Check for name collision with a *different* record + collision = await db.fetchone( + "SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND id != ?", + (body.name, view_id), + ) + if collision: + raise HTTPException( + status_code=409, + detail=f"Another saved view named '{body.name}' already exists.", + ) + updates.append("name = ?") + params.append(body.name) + + if body.filter_json is not None: + updates.append("filter_json = ?") + params.append(body.filter_json) + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + updates.append("updated_at = datetime('now')") + params.append(view_id) + + await db.execute( + f"UPDATE saved_views SET {', '.join(updates)} WHERE id = ?", + tuple(params), + ) + return {"id": view_id, "updated": True} + + +@saved_views_router.delete("/{view_id}") +async def delete_saved_view(view_id: str) -> Dict[str, Any]: + """Delete a saved view by id. Idempotent — returns 200 even if not found.""" + db = await get_db() + await db.execute("DELETE FROM saved_views WHERE id = ?", (view_id,)) + return {"id": view_id, "deleted": True} \ No newline at end of file diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py new file mode 100644 index 00000000..f8780a7a --- /dev/null +++ b/testing/backend/unit/test_saved_views.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import json +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport + +from fastapi import FastAPI +from backend.secuscan.saved_views import saved_views_router +from backend.secuscan.database import Database, get_db +import backend.secuscan.database as _db_module + + +# ─── Fixtures ───────────────────────────────────────────────────────────────── + +@pytest_asyncio.fixture +async def app_client(): + """ + Spin up an isolated FastAPI app with an in-memory SQLite database + and the saved_views_router registered. + """ + # In-memory DB — isolated per test function + test_db = Database(":memory:") + await test_db.connect() + _db_module.db = test_db + + # Minimal app + _app = FastAPI() + _app.include_router(saved_views_router) + + transport = ASGITransport(app=_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + await test_db.disconnect() + _db_module.db = None + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +VALID_PRESET = { + "severity": "critical", + "target": "example.com", + "scanner": "nmap", + "sortMode": "newest", + "dateFrom": "2025-01-01", + "dateTo": "2025-12-31", + "searchQuery": "open port", +} + +ALL_FILTER_PRESET = { + "severity": "all", + "target": "all", + "scanner": "all", + "sortMode": "severity", + "dateFrom": "", + "dateTo": "", + "searchQuery": "", +} + + +def make_body(name: str, preset: dict = VALID_PRESET) -> dict: + return {"name": name, "filter_json": json.dumps(preset)} + + +# ─── LIST (GET /saved-views) ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_list_empty(app_client: AsyncClient): + """Initially no saved views exist.""" + res = await app_client.get("/api/v1/saved-views") + assert res.status_code == 200 + body = res.json() + assert body["views"] == [] + assert body["total"] == 0 + + +# ─── CREATE (POST /saved-views) ─────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_create_success(app_client: AsyncClient): + """Creating a new view returns 201 with an id.""" + res = await app_client.post("/api/v1/saved-views", json=make_body("Critical Web Scan")) + assert res.status_code == 201 + body = res.json() + assert body["created"] is True + assert isinstance(body["id"], str) and len(body["id"]) > 0 + assert body["name"] == "Critical Web Scan" + + +@pytest.mark.asyncio +async def test_create_appears_in_list(app_client: AsyncClient): + """A created view is returned by the list endpoint.""" + await app_client.post("/api/v1/saved-views", json=make_body("My View")) + res = await app_client.get("/api/v1/saved-views") + body = res.json() + assert body["total"] == 1 + assert body["views"][0]["name"] == "My View" + stored_preset = json.loads(body["views"][0]["filter_json"]) + assert stored_preset["severity"] == "critical" + + +@pytest.mark.asyncio +async def test_create_duplicate_name_returns_409(app_client: AsyncClient): + """POSTing the same name twice returns 409.""" + await app_client.post("/api/v1/saved-views", json=make_body("Dupe")) + res = await app_client.post("/api/v1/saved-views", json=make_body("Dupe")) + assert res.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_case_insensitive_duplicate(app_client: AsyncClient): + """Name collision check is case-insensitive.""" + await app_client.post("/api/v1/saved-views", json=make_body("MyView")) + res = await app_client.post("/api/v1/saved-views", json=make_body("myview")) + assert res.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_empty_name_rejected(app_client: AsyncClient): + """Blank name is rejected with 422.""" + res = await app_client.post("/api/v1/saved-views", json={"name": " ", "filter_json": json.dumps(VALID_PRESET)}) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_invalid_json_rejected(app_client: AsyncClient): + """Non-JSON filter_json is rejected with 422.""" + res = await app_client.post("/api/v1/saved-views", json={"name": "Bad", "filter_json": "not json at all"}) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_invalid_sort_mode_rejected(app_client: AsyncClient): + """filter_json with an invalid sortMode is rejected.""" + bad_preset = {**VALID_PRESET, "sortMode": "by_moon_phase"} + res = await app_client.post("/api/v1/saved-views", json=make_body("Bad Sort", bad_preset)) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_invalid_severity_rejected(app_client: AsyncClient): + """filter_json with an invalid severity is rejected.""" + bad_preset = {**VALID_PRESET, "severity": "apocalyptic"} + res = await app_client.post("/api/v1/saved-views", json=make_body("Bad Sev", bad_preset)) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_missing_filter_json_rejected(app_client: AsyncClient): + """Request missing filter_json is rejected.""" + res = await app_client.post("/api/v1/saved-views", json={"name": "No Preset"}) + assert res.status_code == 422 + + +# ─── APPLY — list then cherry-pick (simulates frontend restore) ─────────────── + +@pytest.mark.asyncio +async def test_apply_restores_correct_preset(app_client: AsyncClient): + """ + 'Applying' a view means the frontend reads filter_json and restores state. + We verify the stored preset round-trips correctly. + """ + await app_client.post("/api/v1/saved-views", json=make_body("Pentest View", VALID_PRESET)) + list_res = await app_client.get("/api/v1/saved-views") + views = list_res.json()["views"] + assert len(views) == 1 + restored = json.loads(views[0]["filter_json"]) + assert restored == VALID_PRESET + + +# ─── OVERWRITE / UPDATE (PUT /saved-views/{id}) ─────────────────────────────── + +@pytest.mark.asyncio +async def test_overwrite_filter_json(app_client: AsyncClient): + """PUT updates filter_json for an existing view.""" + create_res = await app_client.post("/api/v1/saved-views", json=make_body("Overwrite Me")) + view_id = create_res.json()["id"] + + new_preset = {**VALID_PRESET, "severity": "high", "sortMode": "oldest"} + put_res = await app_client.put( + f"/api/v1/saved-views/{view_id}", + json={"filter_json": json.dumps(new_preset)}, + ) + assert put_res.status_code == 200 + assert put_res.json()["updated"] is True + + # Verify persisted + list_res = await app_client.get("/api/v1/saved-views") + stored = json.loads(list_res.json()["views"][0]["filter_json"]) + assert stored["severity"] == "high" + assert stored["sortMode"] == "oldest" + + +@pytest.mark.asyncio +async def test_rename_view(app_client: AsyncClient): + """PUT with only a new name renames the view.""" + create_res = await app_client.post("/api/v1/saved-views", json=make_body("Old Name")) + view_id = create_res.json()["id"] + + put_res = await app_client.put( + f"/api/v1/saved-views/{view_id}", + json={"name": "New Name"}, + ) + assert put_res.status_code == 200 + + list_res = await app_client.get("/api/v1/saved-views") + assert list_res.json()["views"][0]["name"] == "New Name" + + +@pytest.mark.asyncio +async def test_rename_to_existing_name_returns_409(app_client: AsyncClient): + """Renaming to another view's name returns 409.""" + await app_client.post("/api/v1/saved-views", json=make_body("Alpha")) + beta_res = await app_client.post("/api/v1/saved-views", json=make_body("Beta")) + beta_id = beta_res.json()["id"] + + res = await app_client.put(f"/api/v1/saved-views/{beta_id}", json={"name": "Alpha"}) + assert res.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_nonexistent_view_returns_404(app_client: AsyncClient): + """PUT on a missing id returns 404.""" + res = await app_client.put( + "/api/v1/saved-views/nonexistent-uuid", + json={"name": "Whatever"}, + ) + assert res.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_with_no_fields_returns_400(app_client: AsyncClient): + """PUT with an empty body returns 400.""" + create_res = await app_client.post("/api/v1/saved-views", json=make_body("Empty Update")) + view_id = create_res.json()["id"] + res = await app_client.put(f"/api/v1/saved-views/{view_id}", json={}) + assert res.status_code == 400 + + +@pytest.mark.asyncio +async def test_update_invalid_filter_json_rejected(app_client: AsyncClient): + """PUT with malformed filter_json returns 422.""" + create_res = await app_client.post("/api/v1/saved-views", json=make_body("Will Fail")) + view_id = create_res.json()["id"] + res = await app_client.put( + f"/api/v1/saved-views/{view_id}", + json={"filter_json": "{not: valid json}"}, + ) + assert res.status_code == 422 + + +# ─── DELETE (DELETE /saved-views/{id}) ─────────────────────────────────────── + +@pytest.mark.asyncio +async def test_delete_removes_view(app_client: AsyncClient): + """Deleted view no longer appears in the list.""" + create_res = await app_client.post("/api/v1/saved-views", json=make_body("Delete Me")) + view_id = create_res.json()["id"] + + del_res = await app_client.delete(f"/api/v1/saved-views/{view_id}") + assert del_res.status_code == 200 + assert del_res.json()["deleted"] is True + + list_res = await app_client.get("/api/v1/saved-views") + assert list_res.json()["total"] == 0 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_is_idempotent(app_client: AsyncClient): + """Deleting a non-existent id returns 200 (idempotent).""" + res = await app_client.delete("/api/v1/saved-views/does-not-exist") + assert res.status_code == 200 + assert res.json()["deleted"] is True + + +@pytest.mark.asyncio +async def test_delete_only_removes_target(app_client: AsyncClient): + """Deleting one view leaves others intact.""" + await app_client.post("/api/v1/saved-views", json=make_body("Keep Me")) + del_res = await app_client.post("/api/v1/saved-views", json=make_body("Remove Me")) + del_id = del_res.json()["id"] + + await app_client.delete(f"/api/v1/saved-views/{del_id}") + + list_res = await app_client.get("/api/v1/saved-views") + assert list_res.json()["total"] == 1 + assert list_res.json()["views"][0]["name"] == "Keep Me" + + +# ─── Security / negative path edge-cases ───────────────────────────────────── + +@pytest.mark.asyncio +async def test_name_too_long_rejected(app_client: AsyncClient): + """Names over 60 chars are rejected.""" + long_name = "x" * 61 + res = await app_client.post("/api/v1/saved-views", json=make_body(long_name)) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_filter_json_extra_fields_ignored(app_client: AsyncClient): + """Extra unknown fields in filter_json don't cause a 422 (Pydantic extra='ignore').""" + preset_with_extra = {**VALID_PRESET, "injected_field": "'; DROP TABLE saved_views; --"} + res = await app_client.post("/api/v1/saved-views", json=make_body("Extra Fields", preset_with_extra)) + # Should succeed; FilterPreset ignores unknown fields by default + assert res.status_code == 201 + + +@pytest.mark.asyncio +async def test_filter_json_with_null_values_rejected(app_client: AsyncClient): + """filter_json with null where string expected is rejected.""" + bad_preset = {**VALID_PRESET, "severity": None} + res = await app_client.post("/api/v1/saved-views", json=make_body("Null Sev", bad_preset)) + assert res.status_code == 422 + +# ── File-backed DB migration path ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_saved_views_migration_runs_for_file_db(tmp_path): + """ + Test coverage ensuring migrations resolve and execute successfully + when the database is backed by a real file path instead of ':memory:'. + """ + db_file = tmp_path / "secuscan.db" + db = Database(str(db_file)) + + try: + await db.connect() + + row = await db.fetchone( + """ + SELECT name + FROM sqlite_master + WHERE type='table' + AND name='saved_views' + """ + ) + + assert row is not None + assert row["name"] == "saved_views" + + finally: + await db.disconnect() + +@pytest.mark.asyncio +async def test_migration_failure_raises_runtime_error(tmp_path): + """A corrupted migration file must abort startup with RuntimeError.""" + from pathlib import Path + import backend.secuscan.database as _db_mod + + migrations_dir = Path(_db_mod.__file__).parent / "migrations" + broken = migrations_dir / "999_broken_test.sql" + broken.write_text("THIS IS NOT VALID SQL !!!") + + db = None + try: + db = Database(str(tmp_path / "test_fail.db")) + with pytest.raises(RuntimeError, match="startup aborted"): + await db.connect() + finally: + if db and db._connection: + await db.disconnect() + broken.unlink(missing_ok=True) \ No newline at end of file