diff --git a/admin-compliance/app/sdk/screening/page.tsx b/admin-compliance/app/sdk/screening/page.tsx index 8f58fce..99e58da 100644 --- a/admin-compliance/app/sdk/screening/page.tsx +++ b/admin-compliance/app/sdk/screening/page.tsx @@ -8,38 +8,18 @@ import { useSDK, ScreeningResult, SecurityIssue, SBOMComponent, BacklogItem } fr // COMPONENTS // ============================================================================= -function ScanProgress({ progress, status }: { progress: number; status: string }) { +function ScanProgress({ status }: { status: string }) { return (
-
- - - - - - {progress}% - -
+
-

Scanning...

+

Scan läuft...

{status}

-
-
+
+
) @@ -166,7 +146,6 @@ export default function ScreeningPage() { const { state, dispatch } = useSDK() const router = useRouter() const [isScanning, setIsScanning] = useState(false) - const [scanProgress, setScanProgress] = useState(0) const [scanStatus, setScanStatus] = useState('') const [scanError, setScanError] = useState(null) const [scanHistory, setScanHistory] = useState([]) @@ -214,27 +193,22 @@ export default function ScreeningPage() { const startScan = async (file: File) => { setIsScanning(true) - setScanProgress(0) - setScanStatus('Initialisierung...') + setScanStatus('Abhängigkeiten werden analysiert...') setScanError(null) - // Show progress steps while API processes - const progressInterval = setInterval(() => { - setScanProgress(prev => { - if (prev >= 90) return prev - const step = Math.random() * 15 + 5 - const next = Math.min(prev + step, 90) - const statuses = [ - 'Abhaengigkeiten werden analysiert...', - 'SBOM wird generiert...', - 'Schwachstellenscan laeuft...', - 'OSV.dev Datenbank wird abgefragt...', - 'Lizenzpruefung...', - ] - setScanStatus(statuses[Math.min(Math.floor(next / 20), statuses.length - 1)]) - return next - }) - }, 600) + // Rotate through honest status messages while backend processes + const statusMessages = [ + 'Abhängigkeiten werden analysiert...', + 'SBOM wird generiert...', + 'Schwachstellenscan läuft...', + 'OSV.dev Datenbank wird abgefragt...', + 'Lizenzprüfung...', + ] + let statusIdx = 0 + const statusInterval = setInterval(() => { + statusIdx = (statusIdx + 1) % statusMessages.length + setScanStatus(statusMessages[statusIdx]) + }, 2000) try { const formData = new FormData() @@ -246,7 +220,7 @@ export default function ScreeningPage() { body: formData, }) - clearInterval(progressInterval) + clearInterval(statusInterval) if (!response.ok) { const err = await response.json().catch(() => ({ error: 'Unknown error' })) @@ -255,7 +229,6 @@ export default function ScreeningPage() { const data = await response.json() - setScanProgress(100) setScanStatus('Abgeschlossen!') // Map backend response to ScreeningResult @@ -308,10 +281,9 @@ export default function ScreeningPage() { dispatch({ type: 'ADD_SECURITY_ISSUE', payload: issue }) }) } catch (error: any) { - clearInterval(progressInterval) + clearInterval(statusInterval) console.error('Screening scan failed:', error) setScanError(error.message || 'Scan fehlgeschlagen') - setScanProgress(0) setScanStatus('') } finally { setIsScanning(false) @@ -369,7 +341,7 @@ export default function ScreeningPage() { )} {/* Scan Progress */} - {isScanning && } + {isScanning && } {/* Results */} {state.screening && state.screening.status === 'COMPLETED' && ( diff --git a/backend-compliance/compliance/api/import_routes.py b/backend-compliance/compliance/api/import_routes.py index f0199c9..b280bf3 100644 --- a/backend-compliance/compliance/api/import_routes.py +++ b/backend-compliance/compliance/api/import_routes.py @@ -400,3 +400,46 @@ async def list_documents(tenant_id: str = "default"): return DocumentListResponse(documents=documents, total=len(documents)) finally: db.close() + + +@router.get("", response_model=DocumentListResponse) +async def list_documents_root( + tenant_id: str = "default", + x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), +): + """Alias: GET /v1/import → list documents (proxy-compatible URL).""" + tid = x_tenant_id or tenant_id + return await list_documents(tenant_id=tid) + + +@router.delete("/{document_id}") +async def delete_document( + document_id: str, + tenant_id: str = "default", + x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), +): + """Delete an imported document and its gap analysis.""" + tid = x_tenant_id or tenant_id + db = SessionLocal() + try: + # Delete gap analysis first (FK dependency) + db.execute( + "DELETE FROM compliance_gap_analyses WHERE document_id = :doc_id AND tenant_id = :tid", + {"doc_id": document_id, "tid": tid}, + ) + result = db.execute( + "DELETE FROM compliance_imported_documents WHERE id = :doc_id AND tenant_id = :tid", + {"doc_id": document_id, "tid": tid}, + ) + db.commit() + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Document not found") + return {"success": True, "deleted_id": document_id} + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to delete document {document_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to delete document") + finally: + db.close() diff --git a/backend-compliance/tests/test_import_routes.py b/backend-compliance/tests/test_import_routes.py index 0c80c92..997fb7b 100644 --- a/backend-compliance/tests/test_import_routes.py +++ b/backend-compliance/tests/test_import_routes.py @@ -557,3 +557,113 @@ class TestGapAnalysisEndpoint: # execute call should use "header-tenant" (X-Tenant-ID takes precedence) call_args = mock_session.execute.call_args assert "header-tenant" in str(call_args) + + +class TestListDocumentsRootEndpoint: + """API tests for GET /v1/import (root alias — proxy-compatible URL).""" + + def test_root_alias_returns_documents(self): + """GET /v1/import returns same result as /v1/import/documents.""" + with patch("compliance.api.import_routes.SessionLocal") as MockSL: + mock_session = MagicMock() + MockSL.return_value = mock_session + mock_result = MagicMock() + mock_result.fetchall.return_value = [ + ["uuid-1", "dsfa.pdf", "application/pdf", 1024, "DSFA", 0.85, + [], [], "analyzed", None, "2024-01-15"], + ] + mock_session.execute.return_value = mock_result + + response = _client_import.get("/v1/import", params={"tenant_id": TENANT_ID}) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["documents"][0]["filename"] == "dsfa.pdf" + + def test_root_alias_empty(self): + """GET /v1/import returns empty list when no documents.""" + with patch("compliance.api.import_routes.SessionLocal") as MockSL: + mock_session = MagicMock() + MockSL.return_value = mock_session + mock_result = MagicMock() + mock_result.fetchall.return_value = [] + mock_session.execute.return_value = mock_result + + response = _client_import.get("/v1/import", params={"tenant_id": TENANT_ID}) + + assert response.status_code == 200 + assert response.json()["total"] == 0 + + +class TestDeleteDocumentEndpoint: + """API tests for DELETE /v1/import/{document_id}.""" + + def test_delete_existing_document(self): + """Deletes document and returns success.""" + with patch("compliance.api.import_routes.SessionLocal") as MockSL: + mock_session = MagicMock() + MockSL.return_value = mock_session + mock_result = MagicMock() + mock_result.rowcount = 1 + mock_session.execute.return_value = mock_result + + response = _client_import.delete( + "/v1/import/doc-uuid-001", + params={"tenant_id": TENANT_ID}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["deleted_id"] == "doc-uuid-001" + mock_session.commit.assert_called_once() + + def test_delete_not_found(self): + """Returns 404 when document does not exist for tenant.""" + with patch("compliance.api.import_routes.SessionLocal") as MockSL: + mock_session = MagicMock() + MockSL.return_value = mock_session + mock_result = MagicMock() + mock_result.rowcount = 0 + mock_session.execute.return_value = mock_result + + response = _client_import.delete( + "/v1/import/nonexistent-doc", + params={"tenant_id": TENANT_ID}, + ) + + assert response.status_code == 404 + + def test_delete_uses_tenant_isolation(self): + """Tenant ID is passed to the delete query.""" + with patch("compliance.api.import_routes.SessionLocal") as MockSL: + mock_session = MagicMock() + MockSL.return_value = mock_session + mock_result = MagicMock() + mock_result.rowcount = 1 + mock_session.execute.return_value = mock_result + + _client_import.delete( + "/v1/import/doc-uuid", + params={"tenant_id": "custom-tenant"}, + ) + + # Both execute calls should use the tenant ID + call_args_list = mock_session.execute.call_args_list + for call in call_args_list: + assert "custom-tenant" in str(call) + + def test_delete_db_error_returns_500(self): + """Database error returns 500.""" + with patch("compliance.api.import_routes.SessionLocal") as MockSL: + mock_session = MagicMock() + MockSL.return_value = mock_session + mock_session.execute.side_effect = Exception("DB error") + + response = _client_import.delete( + "/v1/import/doc-uuid", + params={"tenant_id": TENANT_ID}, + ) + + assert response.status_code == 500