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
24 changes: 21 additions & 3 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import Annotated
from typing import Annotated, Literal

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
Expand Down Expand Up @@ -174,10 +174,13 @@ class RiskSummary(BaseModel):
total: int


_SeverityParam = Literal["critical", "high", "medium", "low"]


@router.get("/risks", response_model=list[RiskOut])
def list_risks(
db: Annotated[Session, Depends(get_db)],
severity: str | None = None,
severity: _SeverityParam | None = None,
device_id: int | None = None,
) -> list[RiskOut]:
"""Return risk findings, ordered by severity then detected_at.
Expand Down Expand Up @@ -263,6 +266,9 @@ def _risk_to_out(r) -> RiskOut: # noqa: ANN001 — SQLAlchemy instance
# ── /api/recommendations ──────────────────────────────────────────────────────

import json as _json # noqa: E402 — after router definitions to keep imports grouped above
import logging as _logging # noqa: E402 — after router definitions to keep imports grouped above

_api_logger = _logging.getLogger(__name__)


class RecommendationOut(BaseModel):
Expand Down Expand Up @@ -328,6 +334,18 @@ def device_recommendations(
return [_rec_to_out(r) for r in recs]


def _safe_load_steps(raw: str | None) -> list[str]:
"""Deserialise recommendation steps from JSON string; returns [] on failure."""
if not raw:
return []
try:
result = _json.loads(raw)
return result if isinstance(result, list) else []
except _json.JSONDecodeError:
_api_logger.warning("Malformed steps JSON in recommendation: %r", raw[:120])
return []


def _rec_to_out(r) -> RecommendationOut: # noqa: ANN001 — SQLAlchemy instance
return RecommendationOut(
id=r.id,
Expand All @@ -337,7 +355,7 @@ def _rec_to_out(r) -> RecommendationOut: # noqa: ANN001 — SQLAlchemy instance
severity=r.severity,
title=r.title,
description=r.description,
steps=_json.loads(r.steps),
steps=_safe_load_steps(r.steps),
effort=r.effort,
impact=r.impact,
created_at=r.created_at.isoformat() if r.created_at else None,
Expand Down
98 changes: 98 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
@pytest.fixture(scope="module")
def db_engine():
import app.models.device # noqa: F401 — side-effect import registers ORM tables
import app.models.recommendation # noqa: F401 — side-effect import registers ORM tables
import app.models.risk # noqa: F401 — side-effect import registers ORM tables
import app.models.scan # noqa: F401 — side-effect import registers ORM tables
from app.db import Base
Expand Down Expand Up @@ -481,3 +482,100 @@ def test_list_risks_filter_by_severity_and_device_id(client, seeded_db, db_engin
db2.execute(__import__("sqlalchemy").delete(Risk).where(Risk.id == risk_id))
db2.commit()
db2.close()


# ── #47 severity query param validation ───────────────────────────────────────


def test_invalid_severity_returns_422(client):
"""Invalid severity value must return 422, not silently return empty list."""
response = client.get("/api/risks?severity=bogus")
assert response.status_code == 422


def test_valid_severities_return_200(client):
"""Each valid severity value must be accepted."""
for sev in ("critical", "high", "medium", "low"):
response = client.get(f"/api/risks?severity={sev}")
assert response.status_code == 200, f"severity={sev} returned {response.status_code}"


# ── #48 malformed steps JSON in recommendations ───────────────────────────────


def test_malformed_steps_json_returns_empty_list(client, seeded_db, seeded_risk, db_engine):
"""Recommendation with malformed steps JSON must return 200 with steps=[]."""
import sqlalchemy
from app.models.recommendation import Recommendation # noqa: PLC0415 — deferred import
from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention; uppercase matches class naming
db = Session()
rec = Recommendation(
device_id=seeded_db["device_id"],
risk_id=seeded_risk["risk_id"],
check_id="test_check",
severity="low",
title="Test rec",
description="desc",
steps="NOT VALID JSON {{{",
effort="low",
impact="low",
)
db.add(rec)
db.commit()
rec_id = rec.id
db.close()

try:
response = client.get(f"/api/recommendations?device_id={seeded_db['device_id']}")
assert response.status_code == 200
data = response.json()
target = next((r for r in data if r["id"] == rec_id), None)
assert target is not None
assert target["steps"] == []
finally:
Session2 = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention; uppercase matches class naming
db2 = Session2()
db2.execute(sqlalchemy.delete(Recommendation).where(Recommendation.id == rec_id))
db2.commit()
db2.close()


def test_empty_steps_returns_empty_list(client, seeded_db, seeded_risk, db_engine):
"""Recommendation with empty-string steps must return 200 with steps=[]."""
import sqlalchemy
from app.models.recommendation import Recommendation # noqa: PLC0415 — deferred import
from sqlalchemy.orm import sessionmaker

Session = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention; uppercase matches class naming
db = Session()
rec = Recommendation(
device_id=seeded_db["device_id"],
risk_id=seeded_risk["risk_id"],
check_id="test_check_empty",
severity="low",
title="Test rec empty",
description="desc",
steps="",
effort="low",
impact="low",
)
db.add(rec)
db.commit()
rec_id = rec.id
db.close()

try:
response = client.get(f"/api/recommendations?device_id={seeded_db['device_id']}")
assert response.status_code == 200
data = response.json()
target = next((r for r in data if r["id"] == rec_id), None)
assert target is not None
assert target["steps"] == []
finally:
Session2 = sessionmaker(bind=db_engine) # noqa: N806 — sessionmaker convention; uppercase matches class naming
db2 = Session2()
db2.execute(sqlalchemy.delete(Recommendation).where(Recommendation.id == rec_id))
db2.commit()
db2.close()
10 changes: 8 additions & 2 deletions frontend/src/pages/RisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
* Route: /risks
*/
import React, { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { Card, Badge, SkeletonCard, PageHeader } from "../components";
import { useRisks, useRiskSummary, useDevices } from "../hooks";
import type { Risk, Severity } from "../types/api";

const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];

const SEV_LEVELS: Severity[] = ["critical", "high", "medium", "low"];

const SEV_COLOR: Record<Severity, string> = {
Expand Down Expand Up @@ -86,7 +88,11 @@ function RiskModal({ risk, onClose }: { risk: Risk; onClose: () => void }) {
}

export function RisksPage() {
const [sevFilter, setSevFilter] = useState<Severity | "">("");
const [searchParams] = useSearchParams();
const initialSev = searchParams.get("severity");
const [sevFilter, setSevFilter] = useState<Severity | "">(
SEVERITIES.includes(initialSev as Severity) ? (initialSev as Severity) : "",
);
const [devFilter, setDevFilter] = useState<number | "">("");
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null);

Expand Down