diff --git a/apps/frontend/src/features/scan/SbomConformancePanel.tsx b/apps/frontend/src/features/scan/SbomConformancePanel.tsx new file mode 100644 index 00000000..bc177043 --- /dev/null +++ b/apps/frontend/src/features/scan/SbomConformancePanel.tsx @@ -0,0 +1,259 @@ +/** + * SbomConformancePanel — feat/model3-conformance-panel. + * + * Renders the received-SBOM conformance verdict (model 3 external ingest). Pure + * presentational component: the parent fetches via `useSbomConformance` and + * passes the `SbomConformanceRead` down. Layout: + * + * ┌─────────────────────────────────────────────────────────┐ + * │ SBOM conformance [● Pass] (data-result) │ + * │ Format: CycloneDX · Components: 412 │ + * │ PURL 96% · License 88% · Hash — │ + * ├─────────────────────────────────────────────────────────┤ + * │ Check Status Detail / missing │ + * │ Timestamp [● Pass] … │ + * │ PURL coverage [● Warn] 8 components missing purl │ + * │ pkg:a pkg:b pkg:c … +5 more │ + * └─────────────────────────────────────────────────────────┘ + * + * Accessibility: every result/status badge pairs a tinted dot with a localized + * text label so color is never the only signal (CLAUDE.md "디자인 시스템" + + * WCAG). Check labels prefer the localized `conformance.check_id.{id}` string + * and fall back to the backend-supplied `check.label` for any id the FE mirror + * hasn't learned yet (forward-compat — the catalog-mirror contract test keeps + * the canonical 9 in lock-step). + */ +import { useTranslation } from "react-i18next"; + +import { Badge } from "@/components/ui/badge"; +import type { + SbomCheckStatus, + SbomConformanceCheck, + SbomConformanceRead, + SbomConformanceResult, +} from "@/lib/projectsApi"; +import { cn } from "@/lib/utils"; + +/** Max number of `missing` entries rendered before collapsing to "+N more". */ +const MISSING_VISIBLE_LIMIT = 5; + +type Tone = "success" | "medium" | "critical"; + +const RESULT_TONE: Record = { + pass: "success", + warn: "medium", + fail: "critical", +}; + +const RESULT_DOT: Record = { + pass: "bg-emerald-500", + warn: "bg-risk-medium", + fail: "bg-risk-critical", +}; + +const CHECK_TONE: Record = { + pass: "success", + warn: "medium", + fail: "critical", +}; + +const CHECK_DOT: Record = { + pass: "bg-emerald-500", + warn: "bg-risk-medium", + fail: "bg-risk-critical", +}; + +export interface SbomConformancePanelProps { + conformance: SbomConformanceRead; +} + +function formatPct(value: number | null): string { + return value == null ? "—" : `${value}%`; +} + +export function SbomConformancePanel({ + conformance, +}: SbomConformancePanelProps) { + const { t } = useTranslation("scans"); + + return ( +
+
+

+ {t("conformance.title")} +

+ +
+ +
+ + + + + +
+ +
+ {conformance.checks.map((check) => ( + + ))} +
+
+ ); +} + +interface SummaryItemProps { + label: string; + value: string; + testId: string; +} + +function SummaryItem({ label, value, testId }: SummaryItemProps) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function ResultBadge({ result }: { result: SbomConformanceResult }) { + const { t } = useTranslation("scans"); + return ( + + + {t(`conformance.result.${result}`)} + + ); +} + +function CheckStatusBadge({ status }: { status: SbomCheckStatus }) { + const { t } = useTranslation("scans"); + return ( + + + {t(`conformance.check_status.${status}`)} + + ); +} + +function CheckRow({ check }: { check: SbomConformanceCheck }) { + const { t } = useTranslation("scans"); + // Prefer the localized canonical label; fall back to the backend's label for + // any id the FE mirror hasn't enumerated yet (forward-compat). + const localized = t(`conformance.check_id.${check.id}`, { + defaultValue: "", + }); + const label = localized || check.label; + + const visible = check.missing.slice(0, MISSING_VISIBLE_LIMIT); + const overflow = check.missing.length - visible.length; + + return ( +
+
+ {label} + + {check.required + ? t("conformance.label.required") + : t("conformance.label.recommended")} + +
+ +
+ +
+ +
+ {check.detail ? ( +

{check.detail}

+ ) : null} + {check.missing.length > 0 ? ( +
    + {visible.map((item) => ( +
  • + {item} +
  • + ))} + {overflow > 0 ? ( +
  • + {t("conformance.missing_more", { count: overflow })} +
  • + ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/apps/frontend/src/features/scan/ScanDetailPage.tsx b/apps/frontend/src/features/scan/ScanDetailPage.tsx index c496a186..d968e602 100644 --- a/apps/frontend/src/features/scan/ScanDetailPage.tsx +++ b/apps/frontend/src/features/scan/ScanDetailPage.tsx @@ -15,7 +15,9 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ScanProgress } from "@/features/scan/ScanProgress"; +import { SbomConformancePanel } from "@/features/scan/SbomConformancePanel"; import { ToolLogLine } from "@/features/scan/ToolLogLine"; +import { useSbomConformance } from "@/features/scan/useSbomConformance"; import { useScanWebSocket, type ScanLogMessage, @@ -108,6 +110,14 @@ export function ScanDetailPage() { const scan = scanQuery.data; const liveStatus: ScanStatus | undefined = scan?.status; + // ---- Received-SBOM conformance (model 3). Only meaningful for `kind: "sbom"` + // ingests; the hook stays dormant otherwise. A 404 (no verdict yet / + // unreachable) is swallowed by the hook (retry:false) and we simply render no + // panel — same quiet-degrade posture as the rest of this page. + const conformanceQuery = useSbomConformance(scan?.project_id, scanId, { + enabled: scan?.kind === "sbom", + }); + // ---- Live log stream. We pass through the existing hook so reconnection, // ring buffer, and the auth handshake are all reused. const { logMessages } = useScanWebSocket(scanId ?? "", { @@ -273,6 +283,10 @@ export function ScanDetailPage() { /> + {scan.kind === "sbom" && conformanceQuery.data ? ( + + ) : null} +
0 && + typeof scanId === "string" && + scanId.length > 0; + const enabled = (options.enabled ?? true) && hasIds; + + return useQuery({ + queryKey: ["scans", scanId, "sbom-conformance"], + queryFn: () => getSbomConformance(projectId as string, scanId as string), + enabled, + staleTime: 30_000, + // 404 (no verdict yet / unreachable) is an expected terminal branch, not a + // transient failure — never retry it. + retry: false, + }); +} diff --git a/apps/frontend/src/lib/projectsApi.ts b/apps/frontend/src/lib/projectsApi.ts index c5a36493..63566836 100644 --- a/apps/frontend/src/lib/projectsApi.ts +++ b/apps/frontend/src/lib/projectsApi.ts @@ -315,6 +315,83 @@ export async function getScan(scanId: string): Promise { return data; } +// --------------------------------------------------------------------------- +// Received-SBOM conformance — model 3 (external SBOM ingest). +// +// `GET /v1/projects/{project_id}/scans/{scan_id}/conformance` returns a +// quality verdict for an uploaded SBOM (`kind: "sbom"` scans only). The verdict +// scores how usable the document is for SCA matching: PURL/license/hash +// coverage plus a list of named checks. A 404 means either the project is +// unreachable OR no verdict exists yet (existence-hide); the UI treats both as +// "no panel" rather than an error. +// --------------------------------------------------------------------------- + +export type SbomSourceFormat = + | "cyclonedx" + | "spdx-json" + | "spdx-tv" + | "unknown"; + +export type SbomConformanceResult = "pass" | "warn" | "fail"; + +export type SbomCheckStatus = "pass" | "fail" | "warn"; + +export interface SbomConformanceCheck { + id: string; + label: string; + required: boolean; + status: SbomCheckStatus; + detail: string; + missing: string[]; +} + +export interface SbomConformanceRead { + scan_id: string; + project_id: string; + source_format: SbomSourceFormat; + result: SbomConformanceResult; + n_fail: number; + n_warn: number; + component_count: number; + purl_coverage_pct: number | null; + license_coverage_pct: number | null; + hash_coverage_pct: number | null; + checks: SbomConformanceCheck[]; +} + +/** + * Canonical conformance check ids, in evaluation order. + * + * Runtime mirror of the backend's + * `services/sbom_conformance.CHECK_IDS` — kept in lock-step by the FE↔BE + * catalog-mirror contract test (`tests/unit/contracts/catalogMirrors.test.ts`) + * so a check added on the backend fails a PR-time vitest instead of silently + * rendering a raw `conformance.check_id.*` i18n key (or no label at all). + */ +export const SBOM_CHECK_IDS = [ + "timestamp", + "tools", + "top-component", + "name-version", + "purl", + "no-generic", + "transitive", + "license", + "hash", +] as const; + +export type SbomCheckId = (typeof SBOM_CHECK_IDS)[number]; + +export async function getSbomConformance( + projectId: string, + scanId: string, +): Promise { + const { data } = await api.get( + `/v1/projects/${projectId}/scans/${scanId}/conformance`, + ); + return data; +} + /** * Cancel a queued/running scan owned by the current user's team (PR-A1). * diff --git a/apps/frontend/src/locales/en/scans.json b/apps/frontend/src/locales/en/scans.json index 6b1ded21..acecf568 100644 --- a/apps/frontend/src/locales/en/scans.json +++ b/apps/frontend/src/locales/en/scans.json @@ -172,5 +172,44 @@ "forbidden": "You no longer have access to this scan.", "not_found": "This scan no longer exists.", "internal_error": "Connection lost. Reconnecting…" + }, + "conformance": { + "title": "SBOM conformance", + "result": { + "pass": "Pass", + "warn": "Warnings", + "fail": "Fail" + }, + "check_status": { + "pass": "Pass", + "warn": "Warn", + "fail": "Fail" + }, + "label": { + "source_format": "Format", + "component_count": "Components", + "purl_coverage": "PURL coverage", + "license_coverage": "License coverage", + "hash_coverage": "Hash coverage", + "required": "Required", + "recommended": "Recommended" + }, + "check_id": { + "timestamp": "Document timestamp", + "tools": "Generating tools", + "top-component": "Top-level component", + "name-version": "Name & version", + "purl": "Package URLs", + "no-generic": "No generic packages", + "transitive": "Transitive dependencies", + "license": "Declared licenses", + "hash": "File hashes" + }, + "missing_more": "+{{count}} more", + "errors": { + "not_found": "No conformance verdict is available for this scan yet.", + "network": "Could not load the conformance verdict due to a network error.", + "unknown": "Could not load the conformance verdict." + } } } diff --git a/apps/frontend/src/locales/ko/scans.json b/apps/frontend/src/locales/ko/scans.json index 5881e7a6..6c07b782 100644 --- a/apps/frontend/src/locales/ko/scans.json +++ b/apps/frontend/src/locales/ko/scans.json @@ -172,5 +172,44 @@ "forbidden": "이 스캔에 대한 접근 권한이 없습니다.", "not_found": "스캔을 찾을 수 없습니다.", "internal_error": "연결이 끊어졌습니다. 재연결 중…" + }, + "conformance": { + "title": "SBOM 적합성", + "result": { + "pass": "통과", + "warn": "경고", + "fail": "실패" + }, + "check_status": { + "pass": "통과", + "warn": "경고", + "fail": "실패" + }, + "label": { + "source_format": "형식", + "component_count": "컴포넌트", + "purl_coverage": "PURL 커버리지", + "license_coverage": "라이선스 커버리지", + "hash_coverage": "해시 커버리지", + "required": "필수", + "recommended": "권장" + }, + "check_id": { + "timestamp": "문서 타임스탬프", + "tools": "생성 도구", + "top-component": "최상위 컴포넌트", + "name-version": "이름 및 버전", + "purl": "패키지 URL", + "no-generic": "일반 패키지 없음", + "transitive": "전이 의존성", + "license": "선언된 라이선스", + "hash": "파일 해시" + }, + "missing_more": "외 {{count}}개", + "errors": { + "not_found": "이 스캔에 대한 적합성 결과가 아직 없습니다.", + "network": "네트워크 오류로 적합성 결과를 불러오지 못했습니다.", + "unknown": "적합성 결과를 불러오지 못했습니다." + } } } diff --git a/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts b/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts index 4320a8aa..02c4493e 100644 --- a/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts +++ b/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts @@ -30,7 +30,11 @@ import { NOTIFICATION_KINDS } from "@/features/notifications/api/notificationsAp import { KNOWN_OBLIGATION_KINDS } from "@/features/projects/api/obligationsApi"; import { ALL_VULNERABILITY_STATUSES } from "@/features/projects/lib/vulnerabilityTransitions"; import { visualFor } from "@/features/projects/components/ProjectStatusBadge"; -import { SCAN_KIND_VALUES, SCAN_STATUS_VALUES } from "@/lib/projectsApi"; +import { + SBOM_CHECK_IDS, + SCAN_KIND_VALUES, + SCAN_STATUS_VALUES, +} from "@/lib/projectsApi"; import enAdmin from "@/locales/en/admin.json"; import koAdmin from "@/locales/ko/admin.json"; @@ -198,6 +202,50 @@ describe("scan kinds — FE mirror of the scan `kind` set", () => { ); }); +describe("SBOM conformance — FE mirror of services/sbom_conformance.CHECK_IDS", () => { + // Same latent-drift class as scan kinds: the conformance panel renders each + // check label through a dynamic `conformance.check_id.${id}` key and the + // FE mirror constant `SBOM_CHECK_IDS` drives nothing structurally but pins + // the canonical id set + order against the backend. A check added on the BE + // would otherwise render only the backend-supplied `check.label` fallback + // (no localized string, no KO mirror) and slip through. + const RESULTS = ["pass", "warn", "fail"] as const; + + it("matches the backend's check id set, in canonical order", () => { + expect([...SBOM_CHECK_IDS]).toEqual([ + "timestamp", + "tools", + "top-component", + "name-version", + "purl", + "no-generic", + "transitive", + "license", + "hash", + ]); + }); + + it.each([ + ["en", enScans], + ["ko", koScans], + ])("every check id owns a %s `conformance.check_id.*` label", (_locale, ns) => { + const labels = labelMap(ns, "conformance", "check_id"); + for (const id of SBOM_CHECK_IDS) { + expect(labels[id], `conformance.check_id.${id} missing`).toBeTruthy(); + } + }); + + it.each([ + ["en", enScans], + ["ko", koScans], + ])("every result owns a %s `conformance.result.*` label", (_locale, ns) => { + const labels = labelMap(ns, "conformance", "result"); + for (const result of RESULTS) { + expect(labels[result], `conformance.result.${result} missing`).toBeTruthy(); + } + }); +}); + describe("vulnerability statuses — label-map half of the 7-state mirror", () => { it.each([ ["en", enProjectDetail], diff --git a/apps/frontend/tests/unit/features/scan/SbomConformancePanel.test.tsx b/apps/frontend/tests/unit/features/scan/SbomConformancePanel.test.tsx new file mode 100644 index 00000000..da1476d0 --- /dev/null +++ b/apps/frontend/tests/unit/features/scan/SbomConformancePanel.test.tsx @@ -0,0 +1,181 @@ +/** + * SbomConformancePanel — unit tests (feat/model3-conformance-panel). + * + * The panel is pure presentational, so these tests render it directly with a + * `SbomConformanceRead` fixture (no query/wire layer to mock). We assert the + * accessibility-critical behaviors: the result badge pairs a tone with the + * `data-result` attribute AND a visible label (color is not the only signal), + * every check row renders with its localized label + status badge, the + * `missing` list collapses to "+N more" past five entries, and a null coverage + * metric renders an em-dash rather than "null%". + * + * i18n is the real instance (initialized once in tests/setup.ts, default `en`), + * matching the GateResultCard / SeverityBadge test convention. + */ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SbomConformancePanel } from "@/features/scan/SbomConformancePanel"; +import type { + SbomConformanceCheck, + SbomConformanceRead, +} from "@/lib/projectsApi"; + +function check( + overrides: Partial = {}, +): SbomConformanceCheck { + return { + id: "purl", + label: "Package URLs", + required: true, + status: "pass", + detail: "", + missing: [], + ...overrides, + }; +} + +function conformance( + overrides: Partial = {}, +): SbomConformanceRead { + return { + scan_id: "11111111-1111-1111-1111-111111111111", + project_id: "22222222-2222-2222-2222-222222222222", + source_format: "cyclonedx", + result: "pass", + n_fail: 0, + n_warn: 0, + component_count: 412, + purl_coverage_pct: 96, + license_coverage_pct: 88, + hash_coverage_pct: 73, + checks: [check()], + ...overrides, + }; +} + +describe("SbomConformancePanel", () => { + it.each([ + ["pass", "success"], + ["warn", "medium"], + ["fail", "critical"], + ] as const)( + "renders the %s result badge with data-result + a visible label", + (result, _tone) => { + render( + , + ); + const badge = screen.getByTestId("conformance-badge"); + expect(badge).toHaveAttribute("data-result", result); + // Color is never the only signal — the badge carries a text label too. + expect(badge.textContent?.trim().length ?? 0).toBeGreaterThan(0); + // A decorative dot accompanies the label (aria-hidden span). + expect(badge.querySelector("span[aria-hidden]")).toBeTruthy(); + }, + ); + + it("renders each check row with a localized label and a status badge", () => { + render( + , + ); + const table = screen.getByTestId("conformance-checks-table"); + expect(within(table).getByTestId("check-timestamp")).toBeInTheDocument(); + expect(within(table).getByTestId("check-purl")).toBeInTheDocument(); + expect(within(table).getByTestId("check-hash")).toBeInTheDocument(); + + // Localized canonical label (en) renders rather than the raw id. + const purlRow = screen.getByTestId("check-purl"); + expect(purlRow.textContent).toContain("Package URLs"); + expect(purlRow).toHaveAttribute("data-required", "true"); + + // required vs recommended is shown as text, not just inferred. + expect(screen.getByTestId("check-hash")).toHaveAttribute( + "data-required", + "false", + ); + + // Each row owns a status badge with its own data-status. + const statuses = within(table).getAllByTestId("conformance-check-status"); + expect(statuses).toHaveLength(3); + expect(statuses.map((b) => b.getAttribute("data-status"))).toEqual([ + "pass", + "warn", + "fail", + ]); + }); + + it("falls back to the backend label for an unknown check id", () => { + render( + , + ); + expect(screen.getByTestId("check-future-check").textContent).toContain( + "Some future axis", + ); + }); + + it("collapses the missing list to '+N more' past five entries", () => { + const missing = ["a", "b", "c", "d", "e", "f", "g"]; // 7 → 5 shown + 2 more + render( + , + ); + const list = screen.getByTestId("check-purl-missing"); + // 5 visible chips + 1 overflow chip = 6 list items. + expect(within(list).getAllByRole("listitem")).toHaveLength(6); + const more = screen.getByTestId("check-purl-missing-more"); + expect(more.textContent).toContain("2"); + }); + + it("does not render an overflow chip at or below the limit", () => { + render( + , + ); + expect( + screen.queryByTestId("check-purl-missing-more"), + ).not.toBeInTheDocument(); + }); + + it("renders an em-dash for a null coverage metric", () => { + render( + , + ); + expect(screen.getByTestId("conformance-purl-coverage").textContent).toBe( + "96%", + ); + expect( + screen.getByTestId("conformance-license-coverage").textContent, + ).toBe("—"); + expect(screen.getByTestId("conformance-hash-coverage").textContent).toBe( + "—", + ); + }); +});