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
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:
@@ -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' && (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user