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
|
// COMPONENTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
function ScanProgress({ progress, status }: { progress: number; status: string }) {
|
function ScanProgress({ status }: { status: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-16 h-16">
|
<div className="w-10 h-10 border-4 border-gray-200 border-t-purple-600 rounded-full animate-spin flex-shrink-0" />
|
||||||
<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>
|
<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>
|
<p className="text-sm text-gray-500">{status}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 h-2 bg-gray-100 rounded-full overflow-hidden">
|
<div className="mt-4 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
<div
|
<div className="h-full bg-purple-600 rounded-full animate-pulse w-full" />
|
||||||
className="h-full bg-purple-600 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -166,7 +146,6 @@ export default function ScreeningPage() {
|
|||||||
const { state, dispatch } = useSDK()
|
const { state, dispatch } = useSDK()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
const [scanProgress, setScanProgress] = useState(0)
|
|
||||||
const [scanStatus, setScanStatus] = useState('')
|
const [scanStatus, setScanStatus] = useState('')
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
const [scanHistory, setScanHistory] = useState<any[]>([])
|
const [scanHistory, setScanHistory] = useState<any[]>([])
|
||||||
@@ -214,27 +193,22 @@ export default function ScreeningPage() {
|
|||||||
|
|
||||||
const startScan = async (file: File) => {
|
const startScan = async (file: File) => {
|
||||||
setIsScanning(true)
|
setIsScanning(true)
|
||||||
setScanProgress(0)
|
setScanStatus('Abhängigkeiten werden analysiert...')
|
||||||
setScanStatus('Initialisierung...')
|
|
||||||
setScanError(null)
|
setScanError(null)
|
||||||
|
|
||||||
// Show progress steps while API processes
|
// Rotate through honest status messages while backend processes
|
||||||
const progressInterval = setInterval(() => {
|
const statusMessages = [
|
||||||
setScanProgress(prev => {
|
'Abhängigkeiten werden analysiert...',
|
||||||
if (prev >= 90) return prev
|
'SBOM wird generiert...',
|
||||||
const step = Math.random() * 15 + 5
|
'Schwachstellenscan läuft...',
|
||||||
const next = Math.min(prev + step, 90)
|
'OSV.dev Datenbank wird abgefragt...',
|
||||||
const statuses = [
|
'Lizenzprüfung...',
|
||||||
'Abhaengigkeiten werden analysiert...',
|
]
|
||||||
'SBOM wird generiert...',
|
let statusIdx = 0
|
||||||
'Schwachstellenscan laeuft...',
|
const statusInterval = setInterval(() => {
|
||||||
'OSV.dev Datenbank wird abgefragt...',
|
statusIdx = (statusIdx + 1) % statusMessages.length
|
||||||
'Lizenzpruefung...',
|
setScanStatus(statusMessages[statusIdx])
|
||||||
]
|
}, 2000)
|
||||||
setScanStatus(statuses[Math.min(Math.floor(next / 20), statuses.length - 1)])
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 600)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@@ -246,7 +220,7 @@ export default function ScreeningPage() {
|
|||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|
||||||
clearInterval(progressInterval)
|
clearInterval(statusInterval)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({ error: 'Unknown error' }))
|
const err = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
@@ -255,7 +229,6 @@ export default function ScreeningPage() {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
setScanProgress(100)
|
|
||||||
setScanStatus('Abgeschlossen!')
|
setScanStatus('Abgeschlossen!')
|
||||||
|
|
||||||
// Map backend response to ScreeningResult
|
// Map backend response to ScreeningResult
|
||||||
@@ -308,10 +281,9 @@ export default function ScreeningPage() {
|
|||||||
dispatch({ type: 'ADD_SECURITY_ISSUE', payload: issue })
|
dispatch({ type: 'ADD_SECURITY_ISSUE', payload: issue })
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
clearInterval(progressInterval)
|
clearInterval(statusInterval)
|
||||||
console.error('Screening scan failed:', error)
|
console.error('Screening scan failed:', error)
|
||||||
setScanError(error.message || 'Scan fehlgeschlagen')
|
setScanError(error.message || 'Scan fehlgeschlagen')
|
||||||
setScanProgress(0)
|
|
||||||
setScanStatus('')
|
setScanStatus('')
|
||||||
} finally {
|
} finally {
|
||||||
setIsScanning(false)
|
setIsScanning(false)
|
||||||
@@ -369,7 +341,7 @@ export default function ScreeningPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scan Progress */}
|
{/* Scan Progress */}
|
||||||
{isScanning && <ScanProgress progress={scanProgress} status={scanStatus} />}
|
{isScanning && <ScanProgress status={scanStatus} />}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{state.screening && state.screening.status === 'COMPLETED' && (
|
{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))
|
return DocumentListResponse(documents=documents, total=len(documents))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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)
|
# execute call should use "header-tenant" (X-Tenant-ID takes precedence)
|
||||||
call_args = mock_session.execute.call_args
|
call_args = mock_session.execute.call_args
|
||||||
assert "header-tenant" in str(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