Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ def _scan_to_out(s) -> ScanOut: # noqa: ANN001 — SQLAlchemy instance
class RiskOut(BaseModel):
id: int
device_id: int
ip_address: str
hostname: str | None
severity: str
check_id: str
title: str
Expand Down Expand Up @@ -179,7 +181,7 @@ def list_risks(
from app.models.risk import Risk

severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
stmt = select(Risk)
stmt = select(Risk).options(selectinload(Risk.device))
if severity is not None:
stmt = stmt.where(Risk.severity == severity)
if device_id is not None:
Expand Down Expand Up @@ -215,7 +217,7 @@ def get_risk(risk_id: int, db: Annotated[Session, Depends(get_db)]) -> RiskOut:
"""Return a single risk by ID."""
from app.models.risk import Risk

stmt = select(Risk).where(Risk.id == risk_id)
stmt = select(Risk).options(selectinload(Risk.device)).where(Risk.id == risk_id)
risk = db.execute(stmt).scalar_one_or_none()
if risk is None:
raise HTTPException(status_code=404, detail="Risk not found")
Expand All @@ -231,7 +233,7 @@ def device_risks(device_id: int, db: Annotated[Session, Depends(get_db)]) -> lis
device = db.execute(select(Device).where(Device.id == device_id)).scalar_one_or_none()
if device is None:
raise HTTPException(status_code=404, detail="Device not found")
stmt = select(Risk).where(Risk.device_id == device_id)
stmt = select(Risk).options(selectinload(Risk.device)).where(Risk.device_id == device_id)
risks = db.execute(stmt).scalars().all()
return [_risk_to_out(r) for r in risks]

Expand All @@ -240,6 +242,8 @@ def _risk_to_out(r) -> RiskOut: # noqa: ANN001 — SQLAlchemy instance
return RiskOut(
id=r.id,
device_id=r.device_id,
ip_address=r.device.ip_address,
hostname=r.device.hostname,
severity=r.severity,
check_id=r.check_id,
title=r.title,
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,26 @@ def test_risk_schema_fields(client, seeded_risk):
risk = next(r for r in response.json() if r["id"] == seeded_risk["risk_id"])
assert "id" in risk
assert "device_id" in risk
assert "ip_address" in risk
assert "hostname" in risk
assert "severity" in risk
assert "check_id" in risk
assert "title" in risk
assert "description" in risk
assert "detected_at" in risk


@pytest.mark.integration
def test_risk_includes_device_identity(client, seeded_db, seeded_risk):
"""Risk response must include ip_address and hostname from the linked device."""
response = client.get("/api/risks")
risk = next(r for r in response.json() if r["id"] == seeded_risk["risk_id"])
# seeded_db device has ip_address="10.0.0.1"
assert risk["ip_address"] == "10.0.0.1"
# hostname is nullable; assert the key is present with the correct type
assert risk["hostname"] is None or isinstance(risk["hostname"], str)


@pytest.mark.integration
def test_get_risk_by_id(client, seeded_risk):
risk_id = seeded_risk["risk_id"]
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/pages/RisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function RiskModal({ risk, onClose }: { risk: Risk; onClose: () => void }) {
className="text-[var(--color-accent-positive)] hover:underline"
onClick={onClose}
>
Device #{risk.device_id}
{risk.hostname || risk.ip_address}
</Link>
</dd>
</div>
Expand Down Expand Up @@ -230,7 +230,7 @@ export function RisksPage() {
<Card padding="none">
<div className="divide-y divide-[var(--color-border)]">
{risks.map((risk) => {
const device = devices.find((d) => d.id === risk.device_id);
const deviceLabel = risk.hostname || risk.ip_address;
return (
<button
key={risk.id}
Expand All @@ -242,9 +242,13 @@ export function RisksPage() {
<span className="flex-1 text-sm font-medium">
{risk.title}
</span>
<span className="text-xs text-[var(--color-text-secondary)]">
{device ? device.ip_address : `Device #${risk.device_id}`}
</span>
<Link
to={`/devices/${risk.device_id}`}
className="text-xs text-[var(--color-accent-positive)] hover:underline"
onClick={(e) => e.stopPropagation()}
>
{deviceLabel}
</Link>
<span className="text-xs text-[var(--color-text-secondary)]">
{risk.detected_at
? new Date(risk.detected_at).toLocaleDateString()
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export type Severity = "critical" | "high" | "medium" | "low";
export interface Risk {
id: number;
device_id: number;
ip_address: string;
hostname: string | null;
severity: Severity;
check_id: string;
title: string;
Expand Down