From ca233ddbe9ad64dbae3ec2afc28d84b5c048f314 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Sun, 14 Jun 2026 10:12:41 +0900 Subject: [PATCH] feat(frontend): label the 'sbom' scan kind (badge + admin filter, EN/KO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external SBOM ingest endpoint (#406) creates scans with kind="sbom". Surface it in the UI so ingested scans render a proper label instead of a raw key: - Promote ScanKind to the runtime mirror SCAN_KIND_VALUES (source, container, sbom), matching the backend's SCAN_KIND_VALUES tuple; derive the admin scan-kind filter KIND_OPTIONS from it so the two can't drift. - Add EN/KO labels ("SBOM upload" / "SBOM 업로드") for the three dynamic kind→label maps: scans page badge, project-overview recent-scans badge, and the admin scans kind filter. - Add a catalog-mirror contract test walking SCAN_KIND_VALUES against all three label maps in both locales (CLAUDE.md §2 rule 2) so a future kind can't ship a raw i18n key. Badges carry no per-kind color, so sbom renders with the same outline style as source/container — label only. i18n:check parity clean. --- .../features/admin/scans/AdminScansPage.tsx | 4 +- apps/frontend/src/lib/projectsApi.ts | 13 +++- apps/frontend/src/locales/en/admin.json | 3 +- .../src/locales/en/project_detail.json | 3 +- apps/frontend/src/locales/en/scans.json | 3 +- apps/frontend/src/locales/ko/admin.json | 3 +- .../src/locales/ko/project_detail.json | 3 +- apps/frontend/src/locales/ko/scans.json | 3 +- apps/frontend/tests/unit/App.test.tsx | 3 + .../unit/components/AppShellSidebar.test.tsx | 3 + .../unit/contracts/catalogMirrors.test.ts | 59 ++++++++++++++++++- 11 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/features/admin/scans/AdminScansPage.tsx b/apps/frontend/src/features/admin/scans/AdminScansPage.tsx index 4141d463..5bc4ade7 100644 --- a/apps/frontend/src/features/admin/scans/AdminScansPage.tsx +++ b/apps/frontend/src/features/admin/scans/AdminScansPage.tsx @@ -31,11 +31,11 @@ import { import { useAdminScans } from "@/features/admin/scans/api/useAdminScans"; import RelativeTime from "@/components/RelativeTime"; import { cn } from "@/lib/utils"; -import type { ScanKind } from "@/lib/projectsApi"; +import { SCAN_KIND_VALUES, type ScanKind } from "@/lib/projectsApi"; const PAGE_SIZE_OPTIONS = [25, 50, 100] as const; -const KIND_OPTIONS: ScanKind[] = ["source", "container"]; +const KIND_OPTIONS: ScanKind[] = [...SCAN_KIND_VALUES]; type ScansTab = "running" | "queued" | "failed" | "all"; diff --git a/apps/frontend/src/lib/projectsApi.ts b/apps/frontend/src/lib/projectsApi.ts index 6747a6e8..c5a36493 100644 --- a/apps/frontend/src/lib/projectsApi.ts +++ b/apps/frontend/src/lib/projectsApi.ts @@ -19,7 +19,18 @@ import { api } from "@/lib/api"; // --------------------------------------------------------------------------- export type ProjectVisibility = "team" | "organization"; -export type ScanKind = "source" | "container"; +/** + * Closed scan-kind set — runtime mirror of the backend's scan `kind` values + * (`source` cdxgen→SBOM, `container` image scan, `sbom` external CycloneDX + * ingest, PR #406). Same pattern as `SCAN_STATUS_VALUES`: the array is walked + * by `tests/unit/contracts/catalogMirrors.test.ts` to assert every kind owns + * its own `page.kind.*` / `overview.recent_scans.kind.*` / + * `scans.filter.kind.*` label in both locales, so a kind added on the backend + * fails a PR-time vitest instead of silently rendering a raw i18n key. + */ +export const SCAN_KIND_VALUES = ["source", "container", "sbom"] as const; + +export type ScanKind = (typeof SCAN_KIND_VALUES)[number]; /** * Closed scan status set — runtime mirror of the backend's * `models/scan.py::SCAN_STATUS_VALUES`, same order. PR-6 FE regression diff --git a/apps/frontend/src/locales/en/admin.json b/apps/frontend/src/locales/en/admin.json index 8c9316f1..7bd06cd5 100644 --- a/apps/frontend/src/locales/en/admin.json +++ b/apps/frontend/src/locales/en/admin.json @@ -144,7 +144,8 @@ "kind_all": "All kinds", "kind": { "source": "Source", - "container": "Container" + "container": "Container", + "sbom": "SBOM upload" }, "project_label": "Project", "project_placeholder": "Filter by project name…" diff --git a/apps/frontend/src/locales/en/project_detail.json b/apps/frontend/src/locales/en/project_detail.json index 252c0a8c..6a7aefc7 100644 --- a/apps/frontend/src/locales/en/project_detail.json +++ b/apps/frontend/src/locales/en/project_detail.json @@ -187,7 +187,8 @@ }, "kind": { "source": "Source", - "container": "Container" + "container": "Container", + "sbom": "SBOM upload" } }, "gate_card": { diff --git a/apps/frontend/src/locales/en/scans.json b/apps/frontend/src/locales/en/scans.json index 8e7270a0..6b1ded21 100644 --- a/apps/frontend/src/locales/en/scans.json +++ b/apps/frontend/src/locales/en/scans.json @@ -19,7 +19,8 @@ }, "kind": { "source": "Source", - "container": "Container" + "container": "Container", + "sbom": "SBOM upload" }, "status": { "queued": "Queued", diff --git a/apps/frontend/src/locales/ko/admin.json b/apps/frontend/src/locales/ko/admin.json index 0f127146..78ce8e7f 100644 --- a/apps/frontend/src/locales/ko/admin.json +++ b/apps/frontend/src/locales/ko/admin.json @@ -144,7 +144,8 @@ "kind_all": "전체 종류", "kind": { "source": "소스", - "container": "컨테이너" + "container": "컨테이너", + "sbom": "SBOM 업로드" }, "project_label": "프로젝트", "project_placeholder": "프로젝트 이름으로 필터…" diff --git a/apps/frontend/src/locales/ko/project_detail.json b/apps/frontend/src/locales/ko/project_detail.json index 44821030..8ef19b46 100644 --- a/apps/frontend/src/locales/ko/project_detail.json +++ b/apps/frontend/src/locales/ko/project_detail.json @@ -187,7 +187,8 @@ }, "kind": { "source": "소스", - "container": "컨테이너" + "container": "컨테이너", + "sbom": "SBOM 업로드" } }, "gate_card": { diff --git a/apps/frontend/src/locales/ko/scans.json b/apps/frontend/src/locales/ko/scans.json index dc2eb4b0..5881e7a6 100644 --- a/apps/frontend/src/locales/ko/scans.json +++ b/apps/frontend/src/locales/ko/scans.json @@ -19,7 +19,8 @@ }, "kind": { "source": "소스", - "container": "컨테이너" + "container": "컨테이너", + "sbom": "SBOM 업로드" }, "status": { "queued": "대기 중", diff --git a/apps/frontend/tests/unit/App.test.tsx b/apps/frontend/tests/unit/App.test.tsx index 10173e5a..87e19edc 100644 --- a/apps/frontend/tests/unit/App.test.tsx +++ b/apps/frontend/tests/unit/App.test.tsx @@ -22,6 +22,9 @@ vi.mock("@/lib/api", () => ({ // provide those too. All return empty lists so the dashboard renders its // empty-state CTA path on the / index. vi.mock("@/lib/projectsApi", () => ({ + // Module-level constant consumed by AdminScansPage's KIND_OPTIONS; the + // wholesale mock must re-export it or the route tree fails to import. + SCAN_KIND_VALUES: ["source", "container", "sbom"] as const, listProjects: vi .fn() .mockResolvedValue({ items: [], total: 0, page: 1, size: 100 }), diff --git a/apps/frontend/tests/unit/components/AppShellSidebar.test.tsx b/apps/frontend/tests/unit/components/AppShellSidebar.test.tsx index 78491f8d..07c3d3da 100644 --- a/apps/frontend/tests/unit/components/AppShellSidebar.test.tsx +++ b/apps/frontend/tests/unit/components/AppShellSidebar.test.tsx @@ -18,6 +18,9 @@ vi.mock("@/lib/api", () => ({ })); vi.mock("@/lib/projectsApi", () => ({ + // Module-level constant consumed by AdminScansPage's KIND_OPTIONS; the + // wholesale mock must re-export it or the route tree fails to import. + SCAN_KIND_VALUES: ["source", "container", "sbom"] as const, listProjects: vi .fn() .mockResolvedValue({ items: [], total: 0, page: 1, size: 100 }), diff --git a/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts b/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts index 2294931b..4320a8aa 100644 --- a/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts +++ b/apps/frontend/tests/unit/contracts/catalogMirrors.test.ts @@ -30,14 +30,18 @@ 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_STATUS_VALUES } from "@/lib/projectsApi"; +import { SCAN_KIND_VALUES, SCAN_STATUS_VALUES } from "@/lib/projectsApi"; +import enAdmin from "@/locales/en/admin.json"; +import koAdmin from "@/locales/ko/admin.json"; import enNotifications from "@/locales/en/notifications.json"; import koNotifications from "@/locales/ko/notifications.json"; import enProjectDetail from "@/locales/en/project_detail.json"; import koProjectDetail from "@/locales/ko/project_detail.json"; import enProjects from "@/locales/en/projects.json"; import koProjects from "@/locales/ko/projects.json"; +import enScans from "@/locales/en/scans.json"; +import koScans from "@/locales/ko/scans.json"; // Shared cross-app fixture (repo root). The backend enum and this FE mirror // must both equal this list; asserting the BE side against the same file is @@ -141,6 +145,59 @@ describe("scan statuses — FE mirror of SCAN_STATUS_VALUES", () => { }); }); +describe("scan kinds — FE mirror of the scan `kind` set", () => { + // Same latent-drift class as scan statuses: `kind` was a bare string union + // until the external SBOM ingest (PR #406) added a third emitted value. Each + // emitted/selectable kind renders through a dynamic `…kind.${scan.kind}` key + // and the admin filter offers `KIND_OPTIONS`, so a missing label silently + // shows a raw i18n key (table badge) or an un-selectable raw value (filter). + it("matches the backend's closed kind set, in canonical order", () => { + expect([...SCAN_KIND_VALUES]).toEqual(["source", "container", "sbom"]); + }); + + it.each([ + ["en", enScans], + ["ko", koScans], + ])("every kind owns a %s ScansPage `page.kind.*` label", (_locale, ns) => { + const kinds = labelMap(ns, "page", "kind"); + for (const kind of SCAN_KIND_VALUES) { + expect(kinds[kind], `page.kind.${kind} missing`).toBeTruthy(); + } + }); + + it.each([ + ["en", enProjectDetail], + ["ko", koProjectDetail], + ])( + "every kind owns a %s `overview.recent_scans.kind.*` label", + (_locale, ns) => { + const kinds = labelMap(ns, "overview", "recent_scans", "kind"); + for (const kind of SCAN_KIND_VALUES) { + expect( + kinds[kind], + `overview.recent_scans.kind.${kind} missing`, + ).toBeTruthy(); + } + }, + ); + + it.each([ + ["en", enAdmin], + ["ko", koAdmin], + ])( + "every kind owns a %s admin `scans.filter.kind.*` label", + (_locale, ns) => { + const kinds = labelMap(ns, "admin", "scans", "filter", "kind"); + for (const kind of SCAN_KIND_VALUES) { + expect( + kinds[kind], + `admin scans.filter.kind.${kind} missing`, + ).toBeTruthy(); + } + }, + ); +}); + describe("vulnerability statuses — label-map half of the 7-state mirror", () => { it.each([ ["en", enProjectDetail],