From a3684946bda21ecd7bf886f8d29448aae84792ad Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Fri, 29 May 2026 03:05:15 +0530 Subject: [PATCH 1/9] feat: add saved views API with proper migration (#235) --- backend/secuscan/database.py | 29 +- backend/secuscan/main.py | 21 +- .../migrations/002_add_saved_views.sql | 21 ++ backend/secuscan/saved_views.py | 190 +++++++++++ testing/backend/unit/test_saved_views.py | 316 ++++++++++++++++++ 5 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 backend/secuscan/migrations/002_add_saved_views.sql create mode 100644 backend/secuscan/saved_views.py create mode 100644 testing/backend/unit/test_saved_views.py diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 8ff8775e..215cfe8b 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -33,11 +33,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.""" @@ -215,6 +216,32 @@ async def _create_schema(self): except Exception as e: print(f"Failed to add 'proof' to findings: {e}") + async def _run_migrations(self): + """Apply any pending SQL migration files from the migrations directory. + + Migration files are plain .sql scripts named NNN_description.sql. + They are applied in lexicographic order and are idempotent — every + statement uses CREATE TABLE/INDEX IF NOT EXISTS so re-running is safe. + + For an in-memory database (db_path == ":memory:") the migrations + directory is resolved relative to this source file so tests work + without a real filesystem path. + """ + if self.db_path == ":memory:": + migrations_dir = Path(__file__).parent / "migrations" + else: + migrations_dir = Path(self.db_path).parent.parent / "secuscan" / "migrations" + + if not migrations_dir.exists(): + return + + 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: + print(f"Migration {migration_file.name} failed: {exc}") + async def execute(self, query: str, params: tuple = ()): """Execute a write query.""" await self.connection.execute(query, params) diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 08eb02c2..f227040d 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -17,6 +17,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 @@ -38,29 +39,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: @@ -116,7 +117,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") @@ -124,7 +125,7 @@ async def health_check(): """Health check endpoint""" import platform import sys - + return { "status": "operational", "version": "0.1.0-alpha", @@ -152,7 +153,7 @@ async def root(): def main(): """Main entry point""" import uvicorn - + logger.info(""" ╔═══════════════════════════════════════════════════════╗ ║ ║ @@ -163,7 +164,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..501e0499 --- /dev/null +++ b/testing/backend/unit/test_saved_views.py @@ -0,0 +1,316 @@ +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 +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 From 906383ba8da78a282b54fdedf7900458b37a8908 Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Fri, 29 May 2026 03:22:46 +0530 Subject: [PATCH 2/9] ci: trigger recheck From 81a2feff4dfd1e7e880279c03c503f9fb0cf1578 Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Fri, 29 May 2026 03:31:35 +0530 Subject: [PATCH 3/9] fix: define existing_finding_cols before risk_cols migration block --- backend/secuscan/database.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index ed2ec1e4..98c9df8c 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -266,6 +266,7 @@ async def _run_migrations(self): await self.connection.executescript(sql) except Exception as exc: print(f"Migration {migration_file.name} failed: {exc}") + existing_finding_cols = {col["name"] for col in await self.fetchall("PRAGMA table_info(findings)")} risk_cols = { "exploitability": "REAL", "confidence": "REAL", From 8b8e5990523e56834c577cbc48eecae6dcb739d6 Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Fri, 29 May 2026 08:20:34 +0530 Subject: [PATCH 4/9] fix: remove stray risk_cols block from _run_migrations, add await to fetchone/fetchall --- backend/secuscan/database.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 98c9df8c..37d9320e 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -240,6 +240,20 @@ 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", + "asset_exposure": "TEXT", + "risk_score": "REAL", + "risk_factors_json": "TEXT NOT NULL DEFAULT '[]'", + } + for col_name, col_type in risk_cols.items(): + if col_name not in existing_finding_cols: + try: + await self.execute(f"ALTER TABLE findings ADD COLUMN {col_name} {col_type}") + print(f"Added missing column {col_name} to findings table.") + except Exception as e: + print(f"Failed to add column {col_name}: {e}") async def _run_migrations(self): """Apply any pending SQL migration files from the migrations directory. @@ -266,21 +280,6 @@ async def _run_migrations(self): await self.connection.executescript(sql) except Exception as exc: print(f"Migration {migration_file.name} failed: {exc}") - existing_finding_cols = {col["name"] for col in await self.fetchall("PRAGMA table_info(findings)")} - risk_cols = { - "exploitability": "REAL", - "confidence": "REAL", - "asset_exposure": "TEXT", - "risk_score": "REAL", - "risk_factors_json": "TEXT NOT NULL DEFAULT '[]'", - } - for col_name, col_type in risk_cols.items(): - if col_name not in existing_finding_cols: - try: - await self.execute(f"ALTER TABLE findings ADD COLUMN {col_name} {col_type}") - print(f"Added missing column {col_name} to findings table.") - except Exception as e: - print(f"Failed to add column {col_name}: {e}") await self._backfill_risk_scores() @@ -327,13 +326,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] From 2d37eca90ffd2d2bb77019bc44b0927c63e4d93e Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Fri, 29 May 2026 23:20:45 +0530 Subject: [PATCH 5/9] Fix migration discovery and add saved views migration test --- backend/secuscan/database.py | 13 +++++++------ testing/backend/unit/test_saved_views.py | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 37d9320e..7b9dfa10 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -266,20 +266,21 @@ async def _run_migrations(self): directory is resolved relative to this source file so tests work without a real filesystem path. """ - if self.db_path == ":memory:": - migrations_dir = Path(__file__).parent / "migrations" - else: - migrations_dir = Path(self.db_path).parent.parent / "secuscan" / "migrations" + migrations_dir = Path(__file__).resolve().parent / "migrations" if not migrations_dir.exists(): - return + raise RuntimeError( + f"Migration directory not found: {migrations_dir}" + ) 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: - print(f"Migration {migration_file.name} failed: {exc}") + raise RuntimeError( + f"Migration {migration_file.name} failed" + ) from exc await self._backfill_risk_scores() diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py index 501e0499..1fdc1823 100644 --- a/testing/backend/unit/test_saved_views.py +++ b/testing/backend/unit/test_saved_views.py @@ -7,7 +7,6 @@ from fastapi import FastAPI from backend.secuscan.saved_views import saved_views_router -from backend.secuscan.database import Database from backend.secuscan.database import Database, get_db import backend.secuscan.database as _db_module @@ -314,3 +313,23 @@ async def test_filter_json_with_null_values_rejected(app_client: AsyncClient): 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 + +@pytest.mark.asyncio +async def test_saved_views_migration_runs_for_file_db(tmp_path): + db_file = tmp_path / "secuscan.db" + + db = Database(str(db_file)) + await db.connect() + + row = await db.fetchone( + """ + SELECT name + FROM sqlite_master + WHERE type='table' + AND name='saved_views' + """ + ) + + assert row is not None + + await db.disconnect() \ No newline at end of file From ef0b6665af92f41a9430b904592ce3cc94a8bfc2 Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Sun, 31 May 2026 06:00:29 +0530 Subject: [PATCH 6/9] fix: resolve migrations from package source dir, fail loudly on migration error, add file-backed DB and failure path tests --- backend/secuscan/database.py | 13 +++--- testing/backend/unit/test_saved_views.py | 53 +++++++++++++++++------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index c3bbf313..c5f461f1 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -263,16 +263,13 @@ async def _run_migrations(self): They are applied in lexicographic order and are idempotent — every statement uses CREATE TABLE/INDEX IF NOT EXISTS so re-running is safe. - For an in-memory database (db_path == ":memory:") the migrations - directory is resolved relative to this source file so tests work - without a real filesystem path. + The migrations directory is always resolved relative to this source + file so it works correctly regardless of where the database file lives. """ - migrations_dir = Path(__file__).resolve().parent / "migrations" + migrations_dir = Path(__file__).parent / "migrations" if not migrations_dir.exists(): - raise RuntimeError( - f"Migration directory not found: {migrations_dir}" - ) + return for migration_file in sorted(migrations_dir.glob("*.sql")): sql = migration_file.read_text(encoding="utf-8") @@ -280,7 +277,7 @@ async def _run_migrations(self): await self.connection.executescript(sql) except Exception as exc: raise RuntimeError( - f"Migration {migration_file.name} failed" + f"Migration {migration_file.name} failed — startup aborted: {exc}" ) from exc await self._backfill_risk_scores() diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py index 1fdc1823..d7a8e34d 100644 --- a/testing/backend/unit/test_saved_views.py +++ b/testing/backend/unit/test_saved_views.py @@ -314,22 +314,45 @@ async def test_filter_json_with_null_values_rejected(app_client: AsyncClient): 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): - db_file = tmp_path / "secuscan.db" - - db = Database(str(db_file)) - await db.connect() - - row = await db.fetchone( - """ - SELECT name - FROM sqlite_master - WHERE type='table' - AND name='saved_views' - """ +async def test_saved_views_table_created_for_file_backed_db(tmp_path): + """Migration runner resolves correctly for a real file-backed database.""" + import backend.secuscan.database as _db_mod + + db_path = str(tmp_path / "test.db") + test_db = Database(db_path) + await test_db.connect() + _db_mod.db = test_db + + # saved_views table must exist after connect() + rows = await test_db.fetchall( + "SELECT name FROM sqlite_master WHERE type='table' AND name='saved_views'" ) + assert rows[0]["name"] == "saved_views", "saved_views table was not created by migration" - assert row is not None - await db.disconnect() \ No newline at end of file + await test_db.disconnect() + _db_mod.db = None + +@pytest.mark.asyncio +async def test_migration_failure_raises_runtime_error(tmp_path): + """A corrupted migration file must abort startup with RuntimeError.""" + import shutil + from pathlib import Path + + # Find the real migrations directory next to database.py + import backend.secuscan.database as _db_mod + migrations_dir = Path(_db_mod.__file__).parent / "migrations" + + # Write a broken SQL file + broken = migrations_dir / "999_broken_test.sql" + broken.write_text("THIS IS NOT VALID SQL !!!") + + try: + db = Database(str(tmp_path / "test_fail.db")) + with pytest.raises(RuntimeError, match="startup aborted"): + await db.connect() + finally: + broken.unlink(missing_ok=True) \ No newline at end of file From 1fdc97d0cf934cc36e8aff3f25697a30978f28fe Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Sun, 31 May 2026 06:19:31 +0530 Subject: [PATCH 7/9] fix(database): fix async file-backed database test hang --- testing/backend/unit/test_saved_views.py | 43 ++++++++++++++---------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py index d7a8e34d..a37e6d68 100644 --- a/testing/backend/unit/test_saved_views.py +++ b/testing/backend/unit/test_saved_views.py @@ -317,24 +317,31 @@ async def test_filter_json_with_null_values_rejected(app_client: AsyncClient): # ── File-backed DB migration path ───────────────────────────────────────────── @pytest.mark.asyncio -async def test_saved_views_table_created_for_file_backed_db(tmp_path): - """Migration runner resolves correctly for a real file-backed database.""" - import backend.secuscan.database as _db_mod - - db_path = str(tmp_path / "test.db") - test_db = Database(db_path) - await test_db.connect() - _db_mod.db = test_db - - # saved_views table must exist after connect() - rows = await test_db.fetchall( - "SELECT name FROM sqlite_master WHERE type='table' AND name='saved_views'" - ) - assert rows[0]["name"] == "saved_views", "saved_views table was not created by migration" - - - await test_db.disconnect() - _db_mod.db = None +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): From c550b791fd9a2beb0ba47a02a12253db1f1e0742 Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Sun, 31 May 2026 06:30:07 +0530 Subject: [PATCH 8/9] Remove trailing whitespace --- testing/backend/unit/test_saved_views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py index a37e6d68..49c3a82a 100644 --- a/testing/backend/unit/test_saved_views.py +++ b/testing/backend/unit/test_saved_views.py @@ -324,22 +324,22 @@ async def test_saved_views_migration_runs_for_file_db(tmp_path): """ 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' + 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() From 4eb7b7eb7003592994f9acfec7973ca3448686e1 Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Sun, 31 May 2026 23:42:20 +0530 Subject: [PATCH 9/9] fix: raise on missing migrations dir, add cleanup to failure test --- backend/secuscan/database.py | 14 ++++---------- testing/backend/unit/test_saved_views.py | 9 ++++----- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 8b3a2048..69c66b2f 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -282,19 +282,13 @@ async def _create_schema(self): print(f"Failed to add column {col_name}: {e}") async def _run_migrations(self): - """Apply any pending SQL migration files from the migrations directory. - - Migration files are plain .sql scripts named NNN_description.sql. - They are applied in lexicographic order and are idempotent — every - statement uses CREATE TABLE/INDEX IF NOT EXISTS so re-running is safe. - - The migrations directory is always resolved relative to this source - file so it works correctly regardless of where the database file lives. - """ migrations_dir = Path(__file__).parent / "migrations" if not migrations_dir.exists(): - return + 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") diff --git a/testing/backend/unit/test_saved_views.py b/testing/backend/unit/test_saved_views.py index 49c3a82a..f8780a7a 100644 --- a/testing/backend/unit/test_saved_views.py +++ b/testing/backend/unit/test_saved_views.py @@ -346,20 +346,19 @@ async def test_saved_views_migration_runs_for_file_db(tmp_path): @pytest.mark.asyncio async def test_migration_failure_raises_runtime_error(tmp_path): """A corrupted migration file must abort startup with RuntimeError.""" - import shutil from pathlib import Path - - # Find the real migrations directory next to database.py import backend.secuscan.database as _db_mod - migrations_dir = Path(_db_mod.__file__).parent / "migrations" - # Write a broken SQL file + 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