From 71bb345d662c0e069a4a0bdca475d6c24f366343 Mon Sep 17 00:00:00 2001 From: rajesh-puripanda Date: Mon, 1 Jun 2026 22:29:42 +0530 Subject: [PATCH 1/6] feat(audit): add audit log API routes + frontend page (#327) --- backend/secuscan/routes.py | 129 +++++++++++++- frontend/src/App.tsx | 2 + frontend/src/api.ts | 54 ++++++ frontend/src/components/Sidebar.tsx | 1 + frontend/src/pages/AuditLog.tsx | 258 ++++++++++++++++++++++++++++ frontend/src/routes.ts | 1 + 6 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/AuditLog.tsx diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index cf62d1ef..fff481b9 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -11,6 +11,8 @@ import os import shutil import uuid +import csv +import io import asyncio from pathlib import Path from urllib.parse import urlparse @@ -943,14 +945,22 @@ async def delete_task_records(task_ids: List[str]): ) all_task_rows.extend(rows) - # Delete associated records in chunks + # Log deletion before removing records + for tid in task_ids: + await db.log_audit( + event_type="task_deleted", + message=f"Task {tid} deleted with all associated findings and reports", + severity="info", + task_id=tid, + ) + + # Delete associated records in chunks (preserve audit_log for accountability) for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] placeholders = ",".join(["?"] * len(chunk)) - await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) # Cleanup files on disk for row in all_task_rows: @@ -1052,6 +1062,115 @@ async def clear_all_tasks(): } +@router.get("/audit") +async def get_audit_logs( + event_type: Optional[str] = None, + plugin_id: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + page: int = 1, + per_page: int = 50, +): + """Return paginated audit log entries, filterable by event_type, plugin_id, and date range.""" + db = await get_db() + where_clauses: List[str] = [] + params: List[Any] = [] + + if event_type: + where_clauses.append("event_type = ?") + params.append(event_type) + if plugin_id: + where_clauses.append("plugin_id = ?") + params.append(plugin_id) + if date_from: + where_clauses.append("timestamp >= ?") + params.append(date_from) + if date_to: + where_clauses.append("timestamp <= ?") + params.append(date_to) + + where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + + # Total count + count_row = await db.fetchone(f"SELECT COUNT(*) as cnt FROM audit_log{where_sql}", tuple(params)) + total = count_row["cnt"] if count_row else 0 + + # Paginated results + offset = (page - 1) * per_page + rows = await db.fetchall( + f"SELECT * FROM audit_log{where_sql} ORDER BY timestamp DESC LIMIT ? OFFSET ?", + tuple(params) + (per_page, offset), + ) + + entries = parse_json_fields(rows, ["context_json"]) + + return { + "entries": entries, + "total": total, + "page": page, + "per_page": per_page, + "total_pages": max(1, (total + per_page - 1) // per_page), + } + + +@router.get("/audit/export") +async def export_audit_logs( + format: str = "json", + event_type: Optional[str] = None, + plugin_id: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, +): + """Export audit log as CSV or JSON.""" + db = await get_db() + where_clauses: List[str] = [] + params: List[Any] = [] + + if event_type: + where_clauses.append("event_type = ?") + params.append(event_type) + if plugin_id: + where_clauses.append("plugin_id = ?") + params.append(plugin_id) + if date_from: + where_clauses.append("timestamp >= ?") + params.append(date_from) + if date_to: + where_clauses.append("timestamp <= ?") + params.append(date_to) + + where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + rows = await db.fetchall( + f"SELECT id, timestamp, event_type, severity, message, task_id, plugin_id, context_json FROM audit_log{where_sql} ORDER BY timestamp DESC", + tuple(params), + ) + + if format == "csv": + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["id", "timestamp", "event_type", "severity", "message", "task_id", "plugin_id", "context"]) + for row in rows: + writer.writerow([ + row["id"], row["timestamp"], row["event_type"], + row["severity"], row["message"], row["task_id"], + row["plugin_id"], row.get("context_json", "{}"), + ]) + csv_bytes = output.getvalue().encode("utf-8") + return Response( + content=csv_bytes, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=secuscan-audit-log.csv"}, + ) + + # Default JSON + entries = parse_json_fields(rows, ["context_json"]) + return Response( + content=json.dumps(entries, indent=2), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=secuscan-audit-log.json"}, + ) + + @router.get("/settings") async def get_settings(): """Get current settings""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4f6b39f..3ea036f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' +import AuditLog from './pages/AuditLog' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider, ToastContainer } from './components/ToastContext' @@ -26,6 +27,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a683815a..7c855501 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -298,3 +298,57 @@ export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean } method: 'DELETE', }) } +/* ─── Audit Log ─────────────────────────────────────────── */ + +export interface AuditEntry { + id: number + timestamp: string + event_type: string + severity: string + user_id: string | null + ip_address: string | null + message: string + context_json: Record + task_id: string | null + plugin_id: string | null +} + +export interface AuditListResponse { + entries: AuditEntry[] + total: number + page: number + per_page: number + total_pages: number +} + +export interface AuditQueryParams { + event_type?: string + plugin_id?: string + date_from?: string + date_to?: string + page?: number + per_page?: number +} + +export function getAuditLogs(params?: AuditQueryParams): Promise { + const qs = new URLSearchParams() + if (params?.event_type) qs.set('event_type', params.event_type) + if (params?.plugin_id) qs.set('plugin_id', params.plugin_id) + if (params?.date_from) qs.set('date_from', params.date_from) + if (params?.date_to) qs.set('date_to', params.date_to) + if (params?.page) qs.set('page', String(params.page)) + if (params?.per_page) qs.set('per_page', String(params.per_page)) + return request(`/audit?${qs.toString()}`) +} + +export async function exportAuditLogs(params?: AuditQueryParams, format: 'csv' | 'json' = 'csv'): Promise { + const qs = new URLSearchParams() + qs.set('format', format) + if (params?.event_type) qs.set('event_type', params.event_type) + if (params?.plugin_id) qs.set('plugin_id', params.plugin_id) + if (params?.date_from) qs.set('date_from', params.date_from) + if (params?.date_to) qs.set('date_to', params.date_to) + const response = await fetch(`${API_BASE}/audit/export?${qs.toString()}`) + if (!response.ok) throw new Error(`Export failed: ${response.status}`) + return response.blob() +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b..af6d6abc 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -168,6 +168,7 @@ export default function Sidebar() { + diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx new file mode 100644 index 00000000..8b12ef0c --- /dev/null +++ b/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useState, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + getAuditLogs, + exportAuditLogs, + type AuditEntry, + type AuditQueryParams, +} from '../api' +import { formatDateLong } from '../utils/date' + +const EVENT_TYPES = [ + 'task_created', 'task_started', 'task_completed', 'task_failed', + 'task_cancelled', 'task_deleted', 'report_downloaded', +] + +const SEVERITY_COLORS: Record = { + info: 'text-cyan', + warning: 'text-amber', + error: 'text-rag-red', +} + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.03 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.25 } }, +} + +export default function AuditLog() { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [total, setTotal] = useState(0) + const [expandedId, setExpandedId] = useState(null) + const [filters, setFilters] = useState({ per_page: 50 }) + const [exporting, setExporting] = useState(false) + + const fetchLogs = useCallback(async () => { + setLoading(true) + try { + const data = await getAuditLogs({ ...filters, page }) + setEntries(data.entries) + setTotalPages(data.total_pages) + setTotal(data.total) + } catch (err) { + console.error('Failed to fetch audit logs:', err) + } finally { + setLoading(false) + } + }, [filters, page]) + + useEffect(() => { + fetchLogs() + }, [fetchLogs]) + + const handleExport = async (format: 'csv' | 'json') => { + setExporting(true) + try { + const blob = await exportAuditLogs(filters, format) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `secuscan-audit-log.${format}` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + } catch (err) { + console.error('Export failed:', err) + } finally { + setExporting(false) + } + } + + return ( + + {/* Header */} +
+
+

Audit Log

+

+ {total} event{total !== 1 ? 's' : ''} recorded +

+
+
+ + +
+
+ + {/* Filters */} +
+
+ + +
+
+ + { setFilters(f => ({ ...f, date_from: e.target.value || undefined })); setPage(1) }} + className="bg-bg-tertiary border-4 border-black px-3 py-2 text-[11px] font-bold text-primary outline-none" + /> +
+
+ + { setFilters(f => ({ ...f, date_to: e.target.value || undefined })); setPage(1) }} + className="bg-bg-tertiary border-4 border-black px-3 py-2 text-[11px] font-bold text-primary outline-none" + /> +
+ {(filters.event_type || filters.date_from || filters.date_to) && ( + + )} +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map((entry) => ( + + setExpandedId(expandedId === entry.id ? null : entry.id)} + > + + + + + + + + + {expandedId === entry.id && ( + + + + )} + + )) + )} + +
TimestampEventSeverityMessageTaskPlugin
+ Loading... +
+ No audit events found +
+ {formatDateLong(entry.timestamp)} + + + {entry.event_type.replace(/_/g, ' ')} + + + + {entry.severity} + + + {entry.message} + + {entry.task_id ? entry.task_id.slice(0, 8) + '...' : '-'} + + {entry.plugin_id || '-'} + + + {expandedId === entry.id ? 'expand_less' : 'expand_more'} + +
+
+                          {JSON.stringify(entry.context_json, null, 2)}
+                        
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ) +} diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 38caa98f..7da1213c 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -6,6 +6,7 @@ export const routes = { scans: '/scans', reports: '/reports', workflows: '/workflows', + audit: '/audit', settings: '/settings', task: '/task/:taskId', } as const From 58d1f14056d4a7ca2f698e353b923bb8394182cb Mon Sep 17 00:00:00 2001 From: rajesh-puripanda <174464133+rajesh-puripanda@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:25:08 +0530 Subject: [PATCH 2/6] fix: resolve merge conflicts and add audit route tests --- .../backend/integration/test_audit_routes.py | 208 ++++++++++++++++++ .../backend/integration/test_task_cleanup.py | 4 +- 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 testing/backend/integration/test_audit_routes.py diff --git a/testing/backend/integration/test_audit_routes.py b/testing/backend/integration/test_audit_routes.py new file mode 100644 index 00000000..2c252ab5 --- /dev/null +++ b/testing/backend/integration/test_audit_routes.py @@ -0,0 +1,208 @@ +""" +Tests for audit log API routes — /api/v1/audit and /api/v1/audit/export, +plus verification that delete_task_records preserves audit_log entries. +""" + +import uuid + +import pytest + + +def _seed_audit_data(event_type="scan_start", severity="info", message="test event", + plugin_id=None, task_id=None): + return { + "event_type": event_type, + "severity": severity, + "message": message, + "plugin_id": plugin_id, + "task_id": task_id, + } + + +def test_get_audit_logs_empty(test_client): + response = test_client.get("/api/v1/audit") + assert response.status_code == 200 + data = response.json() + assert data["entries"] == [] + assert data["total"] == 0 + assert data["page"] == 1 + assert data["per_page"] == 50 + assert data["total_pages"] == 1 + + +def test_get_audit_logs_with_data(test_client): + import asyncio + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + for i in range(5): + await db.log_audit( + event_type="scan_completed", + message=f"Scan {i} completed", + severity="info", + task_id=str(uuid.uuid4()), + ) + + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 5 + assert len(data["entries"]) >= 5 + assert data["page"] == 1 + assert data["per_page"] == 50 + + +def test_audit_pagination_bounds(test_client): + import asyncio + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + for i in range(10): + await db.log_audit( + event_type="scan_completed", + message=f"Batch {i}", + severity="info", + ) + + asyncio.run(seed()) + + resp_page_1 = test_client.get("/api/v1/audit?page=1&per_page=3") + assert resp_page_1.status_code == 200 + d1 = resp_page_1.json() + assert len(d1["entries"]) == 3 + assert d1["total_pages"] == 4 + + resp_page_2 = test_client.get("/api/v1/audit?page=2&per_page=3") + assert resp_page_2.status_code == 200 + d2 = resp_page_2.json() + assert len(d2["entries"]) == 3 + + resp_page_4 = test_client.get("/api/v1/audit?page=4&per_page=3") + assert resp_page_4.status_code == 200 + d4 = resp_page_4.json() + assert len(d4["entries"]) == 1 + + resp_page_5 = test_client.get("/api/v1/audit?page=5&per_page=3") + assert resp_page_5.status_code == 200 + d5 = resp_page_5.json() + assert d5["entries"] == [] + + +def test_audit_filter_by_event_type(test_client): + import asyncio + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + await db.log_audit(event_type="scan_start", message="started", severity="info") + await db.log_audit(event_type="scan_completed", message="done", severity="info") + await db.log_audit(event_type="task_deleted", message="removed", severity="warning") + + asyncio.run(seed()) + + resp = test_client.get('/api/v1/audit?event_type=scan_completed') + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + for entry in data["entries"]: + assert entry["event_type"] == "scan_completed" + + +def test_audit_export_json(test_client): + import asyncio + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + await db.log_audit(event_type="scan_start", message="export test", severity="info") + + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=json") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert "filename=secuscan-audit-log.json" in response.headers["content-disposition"] + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + +def test_audit_export_csv(test_client): + import asyncio + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + await db.log_audit(event_type="scan_start", message="csv export", severity="info") + + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=csv") + assert response.status_code == 200 + assert response.headers["content-type"] == "text/csv" + assert "filename=secuscan-audit-log.csv" in response.headers["content-disposition"] + body = response.text + assert body.startswith("id,timestamp,event_type,severity,message,task_id,plugin_id,context") + assert "csv export" in body + + +def test_audit_export_default_format_json(test_client): + response = test_client.get("/api/v1/audit/export") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + +def test_audit_export_invalid_format(test_client): + response = test_client.get("/api/v1/audit/export?format=xml") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + +def test_audit_log_preserved_on_task_deletion(test_client): + import asyncio + import json + from unittest.mock import patch, AsyncMock, MagicMock + + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + task_id = str(uuid.uuid4()) + await db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " + "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", + (task_id,), + ) + await db.log_audit( + event_type="scan_completed", + message=f"Task {task_id} completed", + severity="info", + task_id=task_id, + ) + return task_id + + task_id = asyncio.run(seed()) + + with patch("backend.secuscan.routes.executor") as mock_exec: + mock_exec.get_task_status = AsyncMock(return_value={"status": "completed"}) + + delete_resp = test_client.delete(f"/api/v1/task/{task_id}") + assert delete_resp.status_code == 200 + + async def verify(): + db = await get_db() + tasks = await db.fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,)) + assert len(tasks) == 0 + + audit_rows = await db.fetchall( + "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", + (task_id,), + ) + assert len(audit_rows) == 1 + assert audit_rows[0]["event_type"] == "scan_completed" + + asyncio.run(verify()) diff --git a/testing/backend/integration/test_task_cleanup.py b/testing/backend/integration/test_task_cleanup.py index 797bfe29..f11ff983 100644 --- a/testing/backend/integration/test_task_cleanup.py +++ b/testing/backend/integration/test_task_cleanup.py @@ -183,7 +183,7 @@ async def test_delete_task_returns_200_and_removes_row(app_client): @pytest.mark.asyncio async def test_delete_task_also_removes_associated_records(app_client): - """Deleting a task cascades to findings, reports, and audit_log.""" + """Deleting a task cascades to findings and reports but preserves audit_log.""" db = app_client._db db_path = app_client._db_path task_id = await insert_task(db, status="completed") @@ -201,7 +201,7 @@ async def test_delete_task_also_removes_associated_records(app_client): assert len(rows) == 0, "Report should have been deleted" rows = await db_fetchall(db_path, "SELECT id FROM audit_log WHERE task_id = ?", (task_id,)) - assert len(rows) == 0, "Audit log rows should have been deleted" + assert len(rows) == 1, "Audit log rows should be preserved for accountability" @pytest.mark.asyncio From 003d0dc8e388bc819d8778a46ac810e16901af13 Mon Sep 17 00:00:00 2001 From: rajesh-puripanda <174464133+rajesh-puripanda@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:31:03 +0530 Subject: [PATCH 3/6] fix(test): convert audit test to async to fix event-loop conflict The sync test used asyncio.run() inside pytest functions, which created a new event loop. aiosqlite connections are bound to the loop they were created in, causing a RuntimeError. Rewritten as async tests using pytest.mark.asyncio and a shared async_client fixture in integration/conftest.py. --- testing/backend/integration/conftest.py | 65 ++++++ .../backend/integration/test_audit_routes.py | 205 +++++++----------- 2 files changed, 143 insertions(+), 127 deletions(-) create mode 100644 testing/backend/integration/conftest.py diff --git a/testing/backend/integration/conftest.py b/testing/backend/integration/conftest.py new file mode 100644 index 00000000..7c0843ed --- /dev/null +++ b/testing/backend/integration/conftest.py @@ -0,0 +1,65 @@ +""" +Shared fixtures for integration tests under testing/backend/integration/. +""" + +import asyncio +import sys +import tempfile +from pathlib import Path + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +repo_root = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(repo_root)) + +from backend.secuscan.main import app +from backend.secuscan.config import settings +from backend.secuscan import database as db_module +from backend.secuscan import cache as cache_module +from backend.secuscan import auth as auth_module + + +@pytest_asyncio.fixture +async def async_client(): + """ + Yield an AsyncClient wired to the FastAPI app with: + - a real isolated temp SQLite DB + - a real in-memory cache (no Redis needed) + """ + import tempfile as _tf + + tmp_dir = _tf.TemporaryDirectory() + tmp_path = tmp_dir.name + + db_path = f"{tmp_path}/test_secuscan.db" + old_db_path = settings.database_path + old_data_dir = settings.data_dir + settings.data_dir = tmp_path + settings.database_path = db_path + + await cache_module.init_cache() + + test_db = await db_module.init_db(db_path) + + auth_dir = _tf.TemporaryDirectory() + api_key = auth_module.init_api_key(auth_dir.name) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"X-Api-Key": api_key}, + ) as client: + client._db = test_db + client._db_path = db_path + yield client + + await test_db.disconnect() + db_module.db = None + await cache_module.cache.disconnect() + cache_module.cache = None + settings.database_path = old_db_path + settings.data_dir = old_data_dir + auth_dir.cleanup() + tmp_dir.cleanup() diff --git a/testing/backend/integration/test_audit_routes.py b/testing/backend/integration/test_audit_routes.py index 2c252ab5..c67480c3 100644 --- a/testing/backend/integration/test_audit_routes.py +++ b/testing/backend/integration/test_audit_routes.py @@ -8,19 +8,9 @@ import pytest -def _seed_audit_data(event_type="scan_start", severity="info", message="test event", - plugin_id=None, task_id=None): - return { - "event_type": event_type, - "severity": severity, - "message": message, - "plugin_id": plugin_id, - "task_id": task_id, - } - - -def test_get_audit_logs_empty(test_client): - response = test_client.get("/api/v1/audit") +@pytest.mark.asyncio +async def test_get_audit_logs_empty(async_client): + response = await async_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["entries"] == [] @@ -30,23 +20,18 @@ def test_get_audit_logs_empty(test_client): assert data["total_pages"] == 1 -def test_get_audit_logs_with_data(test_client): - import asyncio - from backend.secuscan.database import get_db - - async def seed(): - db = await get_db() - for i in range(5): - await db.log_audit( - event_type="scan_completed", - message=f"Scan {i} completed", - severity="info", - task_id=str(uuid.uuid4()), - ) - - asyncio.run(seed()) +@pytest.mark.asyncio +async def test_get_audit_logs_with_data(async_client): + db = async_client._db + for i in range(5): + await db.log_audit( + event_type="scan_completed", + message=f"Scan {i} completed", + severity="info", + task_id=str(uuid.uuid4()), + ) - response = test_client.get("/api/v1/audit") + response = await async_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["total"] >= 5 @@ -55,56 +40,46 @@ async def seed(): assert data["per_page"] == 50 -def test_audit_pagination_bounds(test_client): - import asyncio - from backend.secuscan.database import get_db - - async def seed(): - db = await get_db() - for i in range(10): - await db.log_audit( - event_type="scan_completed", - message=f"Batch {i}", - severity="info", - ) - - asyncio.run(seed()) +@pytest.mark.asyncio +async def test_audit_pagination_bounds(async_client): + db = async_client._db + for i in range(10): + await db.log_audit( + event_type="scan_completed", + message=f"Batch {i}", + severity="info", + ) - resp_page_1 = test_client.get("/api/v1/audit?page=1&per_page=3") + resp_page_1 = await async_client.get("/api/v1/audit?page=1&per_page=3") assert resp_page_1.status_code == 200 d1 = resp_page_1.json() assert len(d1["entries"]) == 3 assert d1["total_pages"] == 4 - resp_page_2 = test_client.get("/api/v1/audit?page=2&per_page=3") + resp_page_2 = await async_client.get("/api/v1/audit?page=2&per_page=3") assert resp_page_2.status_code == 200 d2 = resp_page_2.json() assert len(d2["entries"]) == 3 - resp_page_4 = test_client.get("/api/v1/audit?page=4&per_page=3") + resp_page_4 = await async_client.get("/api/v1/audit?page=4&per_page=3") assert resp_page_4.status_code == 200 d4 = resp_page_4.json() assert len(d4["entries"]) == 1 - resp_page_5 = test_client.get("/api/v1/audit?page=5&per_page=3") + resp_page_5 = await async_client.get("/api/v1/audit?page=5&per_page=3") assert resp_page_5.status_code == 200 d5 = resp_page_5.json() assert d5["entries"] == [] -def test_audit_filter_by_event_type(test_client): - import asyncio - from backend.secuscan.database import get_db +@pytest.mark.asyncio +async def test_audit_filter_by_event_type(async_client): + db = async_client._db + await db.log_audit(event_type="scan_start", message="started", severity="info") + await db.log_audit(event_type="scan_completed", message="done", severity="info") + await db.log_audit(event_type="task_deleted", message="removed", severity="warning") - async def seed(): - db = await get_db() - await db.log_audit(event_type="scan_start", message="started", severity="info") - await db.log_audit(event_type="scan_completed", message="done", severity="info") - await db.log_audit(event_type="task_deleted", message="removed", severity="warning") - - asyncio.run(seed()) - - resp = test_client.get('/api/v1/audit?event_type=scan_completed') + resp = await async_client.get("/api/v1/audit?event_type=scan_completed") assert resp.status_code == 200 data = resp.json() assert data["total"] >= 1 @@ -112,17 +87,12 @@ async def seed(): assert entry["event_type"] == "scan_completed" -def test_audit_export_json(test_client): - import asyncio - from backend.secuscan.database import get_db - - async def seed(): - db = await get_db() - await db.log_audit(event_type="scan_start", message="export test", severity="info") - - asyncio.run(seed()) +@pytest.mark.asyncio +async def test_audit_export_json(async_client): + db = async_client._db + await db.log_audit(event_type="scan_start", message="export test", severity="info") - response = test_client.get("/api/v1/audit/export?format=json") + response = await async_client.get("/api/v1/audit/export?format=json") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" assert "filename=secuscan-audit-log.json" in response.headers["content-disposition"] @@ -131,17 +101,12 @@ async def seed(): assert len(data) >= 1 -def test_audit_export_csv(test_client): - import asyncio - from backend.secuscan.database import get_db +@pytest.mark.asyncio +async def test_audit_export_csv(async_client): + db = async_client._db + await db.log_audit(event_type="scan_start", message="csv export", severity="info") - async def seed(): - db = await get_db() - await db.log_audit(event_type="scan_start", message="csv export", severity="info") - - asyncio.run(seed()) - - response = test_client.get("/api/v1/audit/export?format=csv") + response = await async_client.get("/api/v1/audit/export?format=csv") assert response.status_code == 200 assert response.headers["content-type"] == "text/csv" assert "filename=secuscan-audit-log.csv" in response.headers["content-disposition"] @@ -150,59 +115,45 @@ async def seed(): assert "csv export" in body -def test_audit_export_default_format_json(test_client): - response = test_client.get("/api/v1/audit/export") +@pytest.mark.asyncio +async def test_audit_export_default_format_json(async_client): + response = await async_client.get("/api/v1/audit/export") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -def test_audit_export_invalid_format(test_client): - response = test_client.get("/api/v1/audit/export?format=xml") +@pytest.mark.asyncio +async def test_audit_export_invalid_format(async_client): + response = await async_client.get("/api/v1/audit/export?format=xml") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -def test_audit_log_preserved_on_task_deletion(test_client): - import asyncio - import json - from unittest.mock import patch, AsyncMock, MagicMock - - from backend.secuscan.database import get_db - - async def seed(): - db = await get_db() - task_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " - "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", - (task_id,), - ) - await db.log_audit( - event_type="scan_completed", - message=f"Task {task_id} completed", - severity="info", - task_id=task_id, - ) - return task_id - - task_id = asyncio.run(seed()) - - with patch("backend.secuscan.routes.executor") as mock_exec: - mock_exec.get_task_status = AsyncMock(return_value={"status": "completed"}) - - delete_resp = test_client.delete(f"/api/v1/task/{task_id}") - assert delete_resp.status_code == 200 - - async def verify(): - db = await get_db() - tasks = await db.fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,)) - assert len(tasks) == 0 - - audit_rows = await db.fetchall( - "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", - (task_id,), - ) - assert len(audit_rows) == 1 - assert audit_rows[0]["event_type"] == "scan_completed" - - asyncio.run(verify()) +@pytest.mark.asyncio +async def test_audit_log_preserved_on_task_deletion(async_client): + db = async_client._db + task_id = str(uuid.uuid4()) + await db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " + "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", + (task_id,), + ) + await db.log_audit( + event_type="scan_completed", + message=f"Task {task_id} completed", + severity="info", + task_id=task_id, + ) + + delete_resp = await async_client.delete(f"/api/v1/task/{task_id}") + assert delete_resp.status_code == 200 + + tasks = await db.fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,)) + assert len(tasks) == 0 + + audit_rows = await db.fetchall( + "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", + (task_id,), + ) + assert len(audit_rows) == 1 + assert audit_rows[0]["event_type"] == "scan_completed" From ecbbf7d59e40d909a059ba1606cb768aa4c62f59 Mon Sep 17 00:00:00 2001 From: rajesh-puripanda <174464133+rajesh-puripanda@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:37:31 +0530 Subject: [PATCH 4/6] fix(test): seed audit tests via direct aiosqlite to avoid event-loop conflict --- testing/backend/integration/conftest.py | 65 ------ .../backend/integration/test_audit_routes.py | 215 +++++++++++------- 2 files changed, 132 insertions(+), 148 deletions(-) delete mode 100644 testing/backend/integration/conftest.py diff --git a/testing/backend/integration/conftest.py b/testing/backend/integration/conftest.py deleted file mode 100644 index 7c0843ed..00000000 --- a/testing/backend/integration/conftest.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Shared fixtures for integration tests under testing/backend/integration/. -""" - -import asyncio -import sys -import tempfile -from pathlib import Path - -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient - -repo_root = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(repo_root)) - -from backend.secuscan.main import app -from backend.secuscan.config import settings -from backend.secuscan import database as db_module -from backend.secuscan import cache as cache_module -from backend.secuscan import auth as auth_module - - -@pytest_asyncio.fixture -async def async_client(): - """ - Yield an AsyncClient wired to the FastAPI app with: - - a real isolated temp SQLite DB - - a real in-memory cache (no Redis needed) - """ - import tempfile as _tf - - tmp_dir = _tf.TemporaryDirectory() - tmp_path = tmp_dir.name - - db_path = f"{tmp_path}/test_secuscan.db" - old_db_path = settings.database_path - old_data_dir = settings.data_dir - settings.data_dir = tmp_path - settings.database_path = db_path - - await cache_module.init_cache() - - test_db = await db_module.init_db(db_path) - - auth_dir = _tf.TemporaryDirectory() - api_key = auth_module.init_api_key(auth_dir.name) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"X-Api-Key": api_key}, - ) as client: - client._db = test_db - client._db_path = db_path - yield client - - await test_db.disconnect() - db_module.db = None - await cache_module.cache.disconnect() - cache_module.cache = None - settings.database_path = old_db_path - settings.data_dir = old_data_dir - auth_dir.cleanup() - tmp_dir.cleanup() diff --git a/testing/backend/integration/test_audit_routes.py b/testing/backend/integration/test_audit_routes.py index c67480c3..1799c214 100644 --- a/testing/backend/integration/test_audit_routes.py +++ b/testing/backend/integration/test_audit_routes.py @@ -5,12 +5,19 @@ import uuid +import aiosqlite import pytest -@pytest.mark.asyncio -async def test_get_audit_logs_empty(async_client): - response = await async_client.get("/api/v1/audit") +@pytest.fixture +def db_path(test_client): + """Return the DB path used by test_client by reading settings after setup.""" + from backend.secuscan.config import settings + return settings.database_path + + +def test_get_audit_logs_empty(test_client): + response = test_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["entries"] == [] @@ -20,18 +27,21 @@ async def test_get_audit_logs_empty(async_client): assert data["total_pages"] == 1 -@pytest.mark.asyncio -async def test_get_audit_logs_with_data(async_client): - db = async_client._db - for i in range(5): - await db.log_audit( - event_type="scan_completed", - message=f"Scan {i} completed", - severity="info", - task_id=str(uuid.uuid4()), - ) - - response = await async_client.get("/api/v1/audit") +def test_get_audit_logs_with_data(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for i in range(5): + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), f"Scan {i} completed"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["total"] >= 5 @@ -40,46 +50,59 @@ async def test_get_audit_logs_with_data(async_client): assert data["per_page"] == 50 -@pytest.mark.asyncio -async def test_audit_pagination_bounds(async_client): - db = async_client._db - for i in range(10): - await db.log_audit( - event_type="scan_completed", - message=f"Batch {i}", - severity="info", - ) - - resp_page_1 = await async_client.get("/api/v1/audit?page=1&per_page=3") +def test_audit_pagination_bounds(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for i in range(10): + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), f"Batch {i}"), + ) + await conn.commit() + asyncio.run(seed()) + + resp_page_1 = test_client.get("/api/v1/audit?page=1&per_page=3") assert resp_page_1.status_code == 200 d1 = resp_page_1.json() assert len(d1["entries"]) == 3 assert d1["total_pages"] == 4 - resp_page_2 = await async_client.get("/api/v1/audit?page=2&per_page=3") + resp_page_2 = test_client.get("/api/v1/audit?page=2&per_page=3") assert resp_page_2.status_code == 200 d2 = resp_page_2.json() assert len(d2["entries"]) == 3 - resp_page_4 = await async_client.get("/api/v1/audit?page=4&per_page=3") + resp_page_4 = test_client.get("/api/v1/audit?page=4&per_page=3") assert resp_page_4.status_code == 200 d4 = resp_page_4.json() assert len(d4["entries"]) == 1 - resp_page_5 = await async_client.get("/api/v1/audit?page=5&per_page=3") + resp_page_5 = test_client.get("/api/v1/audit?page=5&per_page=3") assert resp_page_5.status_code == 200 d5 = resp_page_5.json() assert d5["entries"] == [] -@pytest.mark.asyncio -async def test_audit_filter_by_event_type(async_client): - db = async_client._db - await db.log_audit(event_type="scan_start", message="started", severity="info") - await db.log_audit(event_type="scan_completed", message="done", severity="info") - await db.log_audit(event_type="task_deleted", message="removed", severity="warning") - - resp = await async_client.get("/api/v1/audit?event_type=scan_completed") +def test_audit_filter_by_event_type(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for evt in [("scan_start", "started", "info"), + ("scan_completed", "done", "info"), + ("task_deleted", "removed", "warning")]: + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, ?, ?, ?, datetime('now'))", + (str(uuid.uuid4()), evt[0], evt[2], evt[1]), + ) + await conn.commit() + asyncio.run(seed()) + + resp = test_client.get("/api/v1/audit?event_type=scan_completed") assert resp.status_code == 200 data = resp.json() assert data["total"] >= 1 @@ -87,12 +110,20 @@ async def test_audit_filter_by_event_type(async_client): assert entry["event_type"] == "scan_completed" -@pytest.mark.asyncio -async def test_audit_export_json(async_client): - db = async_client._db - await db.log_audit(event_type="scan_start", message="export test", severity="info") - - response = await async_client.get("/api/v1/audit/export?format=json") +def test_audit_export_json(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), "export test"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=json") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" assert "filename=secuscan-audit-log.json" in response.headers["content-disposition"] @@ -101,12 +132,20 @@ async def test_audit_export_json(async_client): assert len(data) >= 1 -@pytest.mark.asyncio -async def test_audit_export_csv(async_client): - db = async_client._db - await db.log_audit(event_type="scan_start", message="csv export", severity="info") - - response = await async_client.get("/api/v1/audit/export?format=csv") +def test_audit_export_csv(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), "csv export"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=csv") assert response.status_code == 200 assert response.headers["content-type"] == "text/csv" assert "filename=secuscan-audit-log.csv" in response.headers["content-disposition"] @@ -115,45 +154,55 @@ async def test_audit_export_csv(async_client): assert "csv export" in body -@pytest.mark.asyncio -async def test_audit_export_default_format_json(async_client): - response = await async_client.get("/api/v1/audit/export") +def test_audit_export_default_format_json(test_client): + response = test_client.get("/api/v1/audit/export") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -@pytest.mark.asyncio -async def test_audit_export_invalid_format(async_client): - response = await async_client.get("/api/v1/audit/export?format=xml") +def test_audit_export_invalid_format(test_client): + response = test_client.get("/api/v1/audit/export?format=xml") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -@pytest.mark.asyncio -async def test_audit_log_preserved_on_task_deletion(async_client): - db = async_client._db - task_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " - "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", - (task_id,), - ) - await db.log_audit( - event_type="scan_completed", - message=f"Task {task_id} completed", - severity="info", - task_id=task_id, - ) - - delete_resp = await async_client.delete(f"/api/v1/task/{task_id}") - assert delete_resp.status_code == 200 - - tasks = await db.fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,)) - assert len(tasks) == 0 - - audit_rows = await db.fetchall( - "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", - (task_id,), - ) - assert len(audit_rows) == 1 - assert audit_rows[0]["event_type"] == "scan_completed" +def test_audit_log_preserved_on_task_deletion(test_client, db_path): + import asyncio + from unittest.mock import patch, AsyncMock, MagicMock + + from backend.secuscan.database import get_db + + async def seed(): + db = await get_db() + task_id = str(uuid.uuid4()) + await db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " + "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", + (task_id,), + ) + await db.log_audit( + event_type="scan_completed", + message=f"Task {task_id} completed", + severity="info", + task_id=task_id, + ) + return task_id + + task_id = asyncio.run(seed()) + + with patch("backend.secuscan.routes.executor") as mock_exec: + mock_exec.get_task_status = AsyncMock(return_value={"status": "completed"}) + delete_resp = test_client.delete(f"/api/v1/task/{task_id}") + assert delete_resp.status_code == 200 + + async def verify(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + rows = await conn.execute_fetchall( + "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", + (task_id,), + ) + assert len(rows) == 1 + assert rows[0][1] == "scan_completed" + + asyncio.run(verify()) From bbde35b9fd4a016b3448ff2fbadc771003ea1a98 Mon Sep 17 00:00:00 2001 From: rajesh-puripanda <174464133+rajesh-puripanda@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:38:28 +0530 Subject: [PATCH 5/6] fix(test): use pytest-asyncio with local async_client fixture (matching test_task_cleanup approach) --- .../backend/integration/test_audit_routes.py | 241 +++++++++--------- 1 file changed, 116 insertions(+), 125 deletions(-) diff --git a/testing/backend/integration/test_audit_routes.py b/testing/backend/integration/test_audit_routes.py index 1799c214..b47d3189 100644 --- a/testing/backend/integration/test_audit_routes.py +++ b/testing/backend/integration/test_audit_routes.py @@ -5,19 +5,49 @@ import uuid -import aiosqlite import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient -@pytest.fixture -def db_path(test_client): - """Return the DB path used by test_client by reading settings after setup.""" - from backend.secuscan.config import settings - return settings.database_path +@pytest_asyncio.fixture +async def db_path(tmp_path): + return str(tmp_path / "test_secuscan.db") -def test_get_audit_logs_empty(test_client): - response = test_client.get("/api/v1/audit") +@pytest_asyncio.fixture +async def async_client(db_path): + from backend.secuscan.main import app + from backend.secuscan import database as db_module + from backend.secuscan import cache as cache_module + from backend.secuscan import auth as auth_module + import tempfile + + await cache_module.init_cache() + + test_db = await db_module.init_db(db_path) + + with tempfile.TemporaryDirectory() as tmp_auth_dir: + api_key = auth_module.init_api_key(tmp_auth_dir) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"X-Api-Key": api_key}, + ) as client: + client._db = test_db + client._db_path = db_path + yield client + + await test_db.disconnect() + db_module.db = None + await cache_module.cache.disconnect() + cache_module.cache = None + + +@pytest.mark.asyncio +async def test_get_audit_logs_empty(async_client): + response = await async_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["entries"] == [] @@ -27,21 +57,18 @@ def test_get_audit_logs_empty(test_client): assert data["total_pages"] == 1 -def test_get_audit_logs_with_data(test_client, db_path): - import asyncio - async def seed(): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - for i in range(5): - await conn.execute( - "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " - "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", - (str(uuid.uuid4()), f"Scan {i} completed"), - ) - await conn.commit() - asyncio.run(seed()) - - response = test_client.get("/api/v1/audit") +@pytest.mark.asyncio +async def test_get_audit_logs_with_data(async_client): + db = async_client._db + for i in range(5): + await db.log_audit( + event_type="scan_completed", + message=f"Scan {i} completed", + severity="info", + task_id=str(uuid.uuid4()), + ) + + response = await async_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["total"] >= 5 @@ -50,59 +77,46 @@ async def seed(): assert data["per_page"] == 50 -def test_audit_pagination_bounds(test_client, db_path): - import asyncio - async def seed(): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - for i in range(10): - await conn.execute( - "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " - "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", - (str(uuid.uuid4()), f"Batch {i}"), - ) - await conn.commit() - asyncio.run(seed()) - - resp_page_1 = test_client.get("/api/v1/audit?page=1&per_page=3") +@pytest.mark.asyncio +async def test_audit_pagination_bounds(async_client): + db = async_client._db + for i in range(10): + await db.log_audit( + event_type="scan_completed", + message=f"Batch {i}", + severity="info", + ) + + resp_page_1 = await async_client.get("/api/v1/audit?page=1&per_page=3") assert resp_page_1.status_code == 200 d1 = resp_page_1.json() assert len(d1["entries"]) == 3 assert d1["total_pages"] == 4 - resp_page_2 = test_client.get("/api/v1/audit?page=2&per_page=3") + resp_page_2 = await async_client.get("/api/v1/audit?page=2&per_page=3") assert resp_page_2.status_code == 200 d2 = resp_page_2.json() assert len(d2["entries"]) == 3 - resp_page_4 = test_client.get("/api/v1/audit?page=4&per_page=3") + resp_page_4 = await async_client.get("/api/v1/audit?page=4&per_page=3") assert resp_page_4.status_code == 200 d4 = resp_page_4.json() assert len(d4["entries"]) == 1 - resp_page_5 = test_client.get("/api/v1/audit?page=5&per_page=3") + resp_page_5 = await async_client.get("/api/v1/audit?page=5&per_page=3") assert resp_page_5.status_code == 200 d5 = resp_page_5.json() assert d5["entries"] == [] -def test_audit_filter_by_event_type(test_client, db_path): - import asyncio - async def seed(): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - for evt in [("scan_start", "started", "info"), - ("scan_completed", "done", "info"), - ("task_deleted", "removed", "warning")]: - await conn.execute( - "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " - "VALUES (?, ?, ?, ?, datetime('now'))", - (str(uuid.uuid4()), evt[0], evt[2], evt[1]), - ) - await conn.commit() - asyncio.run(seed()) - - resp = test_client.get("/api/v1/audit?event_type=scan_completed") +@pytest.mark.asyncio +async def test_audit_filter_by_event_type(async_client): + db = async_client._db + await db.log_audit(event_type="scan_start", message="started", severity="info") + await db.log_audit(event_type="scan_completed", message="done", severity="info") + await db.log_audit(event_type="task_deleted", message="removed", severity="warning") + + resp = await async_client.get("/api/v1/audit?event_type=scan_completed") assert resp.status_code == 200 data = resp.json() assert data["total"] >= 1 @@ -110,20 +124,12 @@ async def seed(): assert entry["event_type"] == "scan_completed" -def test_audit_export_json(test_client, db_path): - import asyncio - async def seed(): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - await conn.execute( - "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " - "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", - (str(uuid.uuid4()), "export test"), - ) - await conn.commit() - asyncio.run(seed()) - - response = test_client.get("/api/v1/audit/export?format=json") +@pytest.mark.asyncio +async def test_audit_export_json(async_client): + db = async_client._db + await db.log_audit(event_type="scan_start", message="export test", severity="info") + + response = await async_client.get("/api/v1/audit/export?format=json") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" assert "filename=secuscan-audit-log.json" in response.headers["content-disposition"] @@ -132,20 +138,12 @@ async def seed(): assert len(data) >= 1 -def test_audit_export_csv(test_client, db_path): - import asyncio - async def seed(): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - await conn.execute( - "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " - "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", - (str(uuid.uuid4()), "csv export"), - ) - await conn.commit() - asyncio.run(seed()) - - response = test_client.get("/api/v1/audit/export?format=csv") +@pytest.mark.asyncio +async def test_audit_export_csv(async_client): + db = async_client._db + await db.log_audit(event_type="scan_start", message="csv export", severity="info") + + response = await async_client.get("/api/v1/audit/export?format=csv") assert response.status_code == 200 assert response.headers["content-type"] == "text/csv" assert "filename=secuscan-audit-log.csv" in response.headers["content-disposition"] @@ -154,55 +152,48 @@ async def seed(): assert "csv export" in body -def test_audit_export_default_format_json(test_client): - response = test_client.get("/api/v1/audit/export") +@pytest.mark.asyncio +async def test_audit_export_default_format_json(async_client): + response = await async_client.get("/api/v1/audit/export") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -def test_audit_export_invalid_format(test_client): - response = test_client.get("/api/v1/audit/export?format=xml") +@pytest.mark.asyncio +async def test_audit_export_invalid_format(async_client): + response = await async_client.get("/api/v1/audit/export?format=xml") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -def test_audit_log_preserved_on_task_deletion(test_client, db_path): - import asyncio - from unittest.mock import patch, AsyncMock, MagicMock - - from backend.secuscan.database import get_db - - async def seed(): - db = await get_db() - task_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " - "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", - (task_id,), - ) - await db.log_audit( - event_type="scan_completed", - message=f"Task {task_id} completed", - severity="info", - task_id=task_id, - ) - return task_id - - task_id = asyncio.run(seed()) +@pytest.mark.asyncio +async def test_audit_log_preserved_on_task_deletion(async_client): + db = async_client._db + task_id = str(uuid.uuid4()) + await db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " + "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", + (task_id,), + ) + await db.log_audit( + event_type="scan_completed", + message=f"Task {task_id} completed", + severity="info", + task_id=task_id, + ) + from unittest.mock import patch, AsyncMock, MagicMock with patch("backend.secuscan.routes.executor") as mock_exec: mock_exec.get_task_status = AsyncMock(return_value={"status": "completed"}) - delete_resp = test_client.delete(f"/api/v1/task/{task_id}") + delete_resp = await async_client.delete(f"/api/v1/task/{task_id}") assert delete_resp.status_code == 200 - async def verify(): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - rows = await conn.execute_fetchall( - "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", - (task_id,), - ) - assert len(rows) == 1 - assert rows[0][1] == "scan_completed" - - asyncio.run(verify()) + tasks = await db.fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,)) + assert len(tasks) == 0 + + audit_rows = await db.fetchall( + "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", + (task_id,), + ) + assert len(audit_rows) == 1 + assert audit_rows[0]["event_type"] == "scan_completed" From 86cf0e667624811777f8d684bc80f54c552b89a7 Mon Sep 17 00:00:00 2001 From: rajesh-puripanda <174464133+rajesh-puripanda@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:44:28 +0530 Subject: [PATCH 6/6] fix(test): seed all audit tests via direct aiosqlite (avoid get_db event loop conflict) --- .../backend/integration/test_audit_routes.py | 241 +++++++++--------- 1 file changed, 124 insertions(+), 117 deletions(-) diff --git a/testing/backend/integration/test_audit_routes.py b/testing/backend/integration/test_audit_routes.py index b47d3189..c9b8f2ba 100644 --- a/testing/backend/integration/test_audit_routes.py +++ b/testing/backend/integration/test_audit_routes.py @@ -5,49 +5,18 @@ import uuid +import aiosqlite import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient -@pytest_asyncio.fixture -async def db_path(tmp_path): - return str(tmp_path / "test_secuscan.db") +@pytest.fixture +def db_path(test_client): + from backend.secuscan.config import settings + return settings.database_path -@pytest_asyncio.fixture -async def async_client(db_path): - from backend.secuscan.main import app - from backend.secuscan import database as db_module - from backend.secuscan import cache as cache_module - from backend.secuscan import auth as auth_module - import tempfile - - await cache_module.init_cache() - - test_db = await db_module.init_db(db_path) - - with tempfile.TemporaryDirectory() as tmp_auth_dir: - api_key = auth_module.init_api_key(tmp_auth_dir) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"X-Api-Key": api_key}, - ) as client: - client._db = test_db - client._db_path = db_path - yield client - - await test_db.disconnect() - db_module.db = None - await cache_module.cache.disconnect() - cache_module.cache = None - - -@pytest.mark.asyncio -async def test_get_audit_logs_empty(async_client): - response = await async_client.get("/api/v1/audit") +def test_get_audit_logs_empty(test_client): + response = test_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["entries"] == [] @@ -57,18 +26,21 @@ async def test_get_audit_logs_empty(async_client): assert data["total_pages"] == 1 -@pytest.mark.asyncio -async def test_get_audit_logs_with_data(async_client): - db = async_client._db - for i in range(5): - await db.log_audit( - event_type="scan_completed", - message=f"Scan {i} completed", - severity="info", - task_id=str(uuid.uuid4()), - ) - - response = await async_client.get("/api/v1/audit") +def test_get_audit_logs_with_data(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for i in range(5): + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), f"Scan {i} completed"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit") assert response.status_code == 200 data = response.json() assert data["total"] >= 5 @@ -77,46 +49,59 @@ async def test_get_audit_logs_with_data(async_client): assert data["per_page"] == 50 -@pytest.mark.asyncio -async def test_audit_pagination_bounds(async_client): - db = async_client._db - for i in range(10): - await db.log_audit( - event_type="scan_completed", - message=f"Batch {i}", - severity="info", - ) - - resp_page_1 = await async_client.get("/api/v1/audit?page=1&per_page=3") +def test_audit_pagination_bounds(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for i in range(10): + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), f"Batch {i}"), + ) + await conn.commit() + asyncio.run(seed()) + + resp_page_1 = test_client.get("/api/v1/audit?page=1&per_page=3") assert resp_page_1.status_code == 200 d1 = resp_page_1.json() assert len(d1["entries"]) == 3 assert d1["total_pages"] == 4 - resp_page_2 = await async_client.get("/api/v1/audit?page=2&per_page=3") + resp_page_2 = test_client.get("/api/v1/audit?page=2&per_page=3") assert resp_page_2.status_code == 200 d2 = resp_page_2.json() assert len(d2["entries"]) == 3 - resp_page_4 = await async_client.get("/api/v1/audit?page=4&per_page=3") + resp_page_4 = test_client.get("/api/v1/audit?page=4&per_page=3") assert resp_page_4.status_code == 200 d4 = resp_page_4.json() assert len(d4["entries"]) == 1 - resp_page_5 = await async_client.get("/api/v1/audit?page=5&per_page=3") + resp_page_5 = test_client.get("/api/v1/audit?page=5&per_page=3") assert resp_page_5.status_code == 200 d5 = resp_page_5.json() assert d5["entries"] == [] -@pytest.mark.asyncio -async def test_audit_filter_by_event_type(async_client): - db = async_client._db - await db.log_audit(event_type="scan_start", message="started", severity="info") - await db.log_audit(event_type="scan_completed", message="done", severity="info") - await db.log_audit(event_type="task_deleted", message="removed", severity="warning") - - resp = await async_client.get("/api/v1/audit?event_type=scan_completed") +def test_audit_filter_by_event_type(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for evt in [("scan_start", "started", "info"), + ("scan_completed", "done", "info"), + ("task_deleted", "removed", "warning")]: + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, ?, ?, ?, datetime('now'))", + (str(uuid.uuid4()), evt[0], evt[2], evt[1]), + ) + await conn.commit() + asyncio.run(seed()) + + resp = test_client.get("/api/v1/audit?event_type=scan_completed") assert resp.status_code == 200 data = resp.json() assert data["total"] >= 1 @@ -124,12 +109,20 @@ async def test_audit_filter_by_event_type(async_client): assert entry["event_type"] == "scan_completed" -@pytest.mark.asyncio -async def test_audit_export_json(async_client): - db = async_client._db - await db.log_audit(event_type="scan_start", message="export test", severity="info") - - response = await async_client.get("/api/v1/audit/export?format=json") +def test_audit_export_json(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), "export test"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=json") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" assert "filename=secuscan-audit-log.json" in response.headers["content-disposition"] @@ -138,12 +131,20 @@ async def test_audit_export_json(async_client): assert len(data) >= 1 -@pytest.mark.asyncio -async def test_audit_export_csv(async_client): - db = async_client._db - await db.log_audit(event_type="scan_start", message="csv export", severity="info") - - response = await async_client.get("/api/v1/audit/export?format=csv") +def test_audit_export_csv(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), "csv export"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=csv") assert response.status_code == 200 assert response.headers["content-type"] == "text/csv" assert "filename=secuscan-audit-log.csv" in response.headers["content-disposition"] @@ -152,48 +153,54 @@ async def test_audit_export_csv(async_client): assert "csv export" in body -@pytest.mark.asyncio -async def test_audit_export_default_format_json(async_client): - response = await async_client.get("/api/v1/audit/export") +def test_audit_export_default_format_json(test_client): + response = test_client.get("/api/v1/audit/export") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -@pytest.mark.asyncio -async def test_audit_export_invalid_format(async_client): - response = await async_client.get("/api/v1/audit/export?format=xml") +def test_audit_export_invalid_format(test_client): + response = test_client.get("/api/v1/audit/export?format=xml") assert response.status_code == 200 assert response.headers["content-type"] == "application/json" -@pytest.mark.asyncio -async def test_audit_log_preserved_on_task_deletion(async_client): - db = async_client._db - task_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " - "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", - (task_id,), - ) - await db.log_audit( - event_type="scan_completed", - message=f"Task {task_id} completed", - severity="info", - task_id=task_id, - ) - - from unittest.mock import patch, AsyncMock, MagicMock +def test_audit_log_preserved_on_task_deletion(test_client, db_path): + import asyncio + from unittest.mock import patch, AsyncMock + + async def seed(): + task_id = str(uuid.uuid4()) + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " + "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", + (task_id,), + ) + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, task_id, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, ?, datetime('now'))", + (str(uuid.uuid4()), f"Task {task_id} completed", task_id), + ) + await conn.commit() + return task_id + + task_id = asyncio.run(seed()) + with patch("backend.secuscan.routes.executor") as mock_exec: mock_exec.get_task_status = AsyncMock(return_value={"status": "completed"}) - delete_resp = await async_client.delete(f"/api/v1/task/{task_id}") + delete_resp = test_client.delete(f"/api/v1/task/{task_id}") assert delete_resp.status_code == 200 - tasks = await db.fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,)) - assert len(tasks) == 0 - - audit_rows = await db.fetchall( - "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", - (task_id,), - ) - assert len(audit_rows) == 1 - assert audit_rows[0]["event_type"] == "scan_completed" + async def verify(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + rows = await conn.execute_fetchall( + "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", + (task_id,), + ) + assert len(rows) == 1 + assert rows[0][1] == "scan_completed" + + asyncio.run(verify())