fix(import+screening): GET-Alias, DELETE-Endpoint, ehrlicher Scan-Status
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 37s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-dsms-gateway (push) Has been cancelled
CI / test-python-document-crawler (push) Has been cancelled

Import-Backend:
- GET /v1/import (Root-Alias) → list_documents; behebt URL-Mismatch im Proxy
- DELETE /v1/import/{document_id} → löscht Dokument + Gap-Analyse (mit Tenant-Isolierung)
- 6 neue Tests (65 total, alle grün)

Screening-Frontend:
- Simulierten Fortschrittsbalken (Math.random) entfernt — war inhaltlich falsch
- Ersetzt durch indeterminate Spinner + rotierende ehrliche Status-Texte
  (z.B. "OSV.dev Datenbank wird abgefragt...") im 2-Sek.-Takt
- Kein scanProgress-State mehr benötigt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-05 12:07:01 +01:00
parent 3707ffe799
commit ef17151a41
3 changed files with 175 additions and 50 deletions

View File

@@ -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 (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-4">
<div className="relative w-16 h-16">
<svg className="w-16 h-16 transform -rotate-90">
<circle cx="32" cy="32" r="28" stroke="#e5e7eb" strokeWidth="4" fill="none" />
<circle
cx="32"
cy="32"
r="28"
stroke="#9333ea"
strokeWidth="4"
fill="none"
strokeDasharray={`${progress * 1.76} 176`}
strokeLinecap="round"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold text-gray-900">
{progress}%
</span>
</div>
<div className="w-10 h-10 border-4 border-gray-200 border-t-purple-600 rounded-full animate-spin flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900">Scanning...</h3>
<h3 className="font-semibold text-gray-900">Scan läuft...</h3>
<p className="text-sm text-gray-500">{status}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
<div className="mt-4 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-600 rounded-full animate-pulse w-full" />
</div>
</div>
)
@@ -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<string | null>(null)
const [scanHistory, setScanHistory] = useState<any[]>([])
@@ -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 && <ScanProgress progress={scanProgress} status={scanStatus} />}
{isScanning && <ScanProgress status={scanStatus} />}
{/* Results */}
{state.screening && state.screening.status === 'COMPLETED' && (

View File

@@ -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()

View File

@@ -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