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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Type-safe API layer — replace double-casts with adapters and zod** (#206) - removed all `as unknown as T` and `as never` casts in 15 of `src/lib/api/*.ts` files. Each SDK call now goes through an adapter function or a Set-backed narrowing helper that warns on unknown enum values. `assertData` (new in `fetch.ts`) rejects empty body responses with a contextual error. `settings.ts` uses zod `.safeParse()` at the trust boundary for `getPasswordPolicy`/`getSmtpConfig`. Public `xxxApi` return types unchanged so consumer code is untouched.

### Fixed
- **Per-artifact Security tab now surfaces native scan_findings** (#368) - the Security tab on the repository view (`security-tab-content.tsx`) used to show only SBOM CVE history and Dependency-Track findings, never the native `scan_findings` table. A user who triggered a scan via `POST /api/v1/security/scan` for a specific artifact had no way to see the resulting findings on the artifact's own page — they had to navigate to `/security/scans` and find the right scan ID by name+timestamp. New `ArtifactScansSection` component lists recent scan_results rows for the artifact (status / type / counts / completed_at) with a "View findings" link to the per-scan page. Sourced from `securityApi.listArtifactScans(artifact.id)` which already existed but had no consumer.
- **`getInstallCommand` returns Gradle/SBT-native snippets instead of Maven XML** (#361) - the JVM case in `package-utils.ts` returned the same `<dependency>` XML for all three of `maven` / `gradle` / `sbt`. Users browsing a Gradle-named repo saw Maven XML in the package detail / copy-snippet UI — same bug class #333 fixed on the Setup Guide page. Now `gradle` returns `implementation 'GROUP:name:version'` and `sbt` returns `libraryDependencies += "GROUP" % "name" % "version"`. Maven output is unchanged.
- **Surface load failures in `getPasswordPolicy` and `getSmtpConfig` instead of silently falling back to defaults** (#347) - both getters previously caught any SDK error or schema mismatch and returned baked-in defaults, so a backend outage rendered as plausible-looking placeholder values on the admin Settings page (same failure mode as #334). Now the getters throw on SDK error / unparseable response, and the page renders explicit "Unavailable" states (Password Policy row + SMTP tab error alert) so an operator can tell something's actually wrong.
- **`formatBytes` returns "--" for NaN/Infinity/negative input** (#348) - previously these inputs produced nonsense strings like "NaN undefined" or "Infinity undefined" visible on the admin Settings → Storage tab. Now returns the same `--` sentinel already used elsewhere in the package/search rendering paths. Also clamps the unit index for >TB values so multi-PB byte counts render as "<n> TB" rather than indexing past the units table.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import React from "react";

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

const mockUseQuery = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => mockUseQuery(opts),
useMutation: vi.fn(),
useQueryClient: vi.fn(),
}));

vi.mock("@/lib/api/security", () => ({
default: { listArtifactScans: vi.fn() },
securityApi: { listArtifactScans: vi.fn() },
}));

vi.mock("@/lib/api/sbom", () => ({
default: { getCveHistory: vi.fn() },
sbomApi: { getCveHistory: vi.fn() },
}));

vi.mock("@/lib/api/dependency-track", () => ({
default: {},
dtApi: {},
}));

vi.mock("@/lib/error-utils", () => ({ mutationErrorToast: () => () => {} }));

vi.mock("lucide-react", () => {
const icon = () => null;
return {
ShieldAlert: icon,
ShieldCheck: icon,
AlertTriangle: icon,
Clock: icon,
ChevronDown: icon,
CheckCircle2: icon,
XCircle: icon,
Eye: icon,
Link2: icon,
Link2Off: icon,
Activity: icon,
};
});

vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));

vi.mock("@/components/ui/button", () => ({
Button: ({ children, ...rest }: React.ComponentProps<"button">) => (
<button {...rest}>{children}</button>
),
}));

vi.mock("@/components/ui/badge", () => ({
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<span className={className}>{children}</span>
),
}));

vi.mock("@/components/ui/skeleton", () => ({
Skeleton: ({ className }: { className?: string }) => (
<div data-testid="skeleton" className={className} />
),
}));

vi.mock("@/components/ui/separator", () => ({ Separator: () => <hr /> }));

vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

interface DataTableProps<T> {
data: T[];
rowKey: (r: T) => string;
columns: {
id: string;
header: React.ReactNode;
cell?: (r: T) => React.ReactNode;
accessor?: (r: T) => unknown;
}[];
emptyMessage?: string;
}

vi.mock("@/components/common/data-table", () => ({
DataTable: <T,>({ data, rowKey, columns, emptyMessage }: DataTableProps<T>) =>
data.length === 0 ? (
<div>{emptyMessage}</div>
) : (
<table>
<tbody>
{data.map((row) => {
// Invoke accessor for sortable columns to exercise the lambda
// (the real DataTable calls accessor for sort/filter).
for (const c of columns) c.accessor?.(row);
return (
<tr key={rowKey(row)} data-testid={`row-${rowKey(row)}`}>
{columns.map((c) => (
<td key={c.id}>{c.cell ? c.cell(row) : null}</td>
))}
</tr>
);
})}
</tbody>
</table>
),
}));

vi.mock("@/components/common/vuln-id-link", () => ({
VulnIdLink: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));

// ---------------------------------------------------------------------------
// Component under test
// ---------------------------------------------------------------------------

import { ArtifactScansSection } from "../artifact-scans-section";

describe("ArtifactScansSection (#368)", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());

it("renders the empty state when no scans have been run", () => {
mockUseQuery.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
isError: false,
});
render(<ArtifactScansSection artifactId="a1" />);
expect(
screen.getByText(/No security scans have been run/i),
).toBeDefined();
});

it("renders a skeleton while loading", () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
render(<ArtifactScansSection artifactId="a1" />);
expect(screen.getByTestId("skeleton")).toBeDefined();
});

it("renders an error banner when listArtifactScans fails", () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("Network down"),
});
render(<ArtifactScansSection artifactId="a1" />);
expect(screen.getByText(/Could not load scan results/i)).toBeDefined();
expect(screen.getByText(/Network down/i)).toBeDefined();
});

it("renders a row per scan with severity badges and a link to per-scan findings", () => {
mockUseQuery.mockReturnValue({
data: {
items: [
{
id: "scan-1",
artifact_id: "a1",
artifact_name: "lib.jar",
artifact_version: "1.0",
repository_id: "r1",
scan_type: "trivy",
status: "completed",
findings_count: 5,
critical_count: 1,
high_count: 2,
medium_count: 1,
low_count: 1,
info_count: 0,
scanner_version: "0.45.0",
error_message: null,
started_at: "2026-05-01T00:00:00Z",
completed_at: "2026-05-01T00:01:00Z",
created_at: "2026-05-01T00:00:00Z",
},
],
total: 1,
},
isLoading: false,
isError: false,
});
render(<ArtifactScansSection artifactId="a1" />);

expect(screen.getByTestId("row-scan-1")).toBeDefined();
expect(screen.getByText(/1 crit/i)).toBeDefined();
expect(screen.getByText(/2 high/i)).toBeDefined();
const link = screen.getByText(/View findings/i).closest("a") as HTMLAnchorElement;
expect(link).toBeDefined();
expect(link.getAttribute("href")).toBe("/security/scans/scan-1");
});

it("hides crit/high pills when those counts are zero", () => {
mockUseQuery.mockReturnValue({
data: {
items: [
{
id: "scan-2",
artifact_id: "a1",
artifact_name: null,
artifact_version: null,
repository_id: "r1",
scan_type: "trivy",
status: "completed",
findings_count: 1,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 1,
info_count: 0,
scanner_version: null,
error_message: null,
started_at: null,
completed_at: null,
created_at: "2026-05-01T00:00:00Z",
},
],
total: 1,
},
isLoading: false,
isError: false,
});
render(<ArtifactScansSection artifactId="a1" />);
expect(screen.queryByText(/crit/i)).toBeNull();
expect(screen.queryByText(/high/i)).toBeNull();
});

it("calls listArtifactScans with the supplied artifactId (#368)", async () => {
let capturedKey: unknown;
let capturedFn: (() => Promise<unknown>) | undefined;
mockUseQuery.mockImplementation(
(opts: { queryKey: unknown[]; queryFn: () => Promise<unknown> }) => {
capturedKey = opts.queryKey;
capturedFn = opts.queryFn;
return { data: { items: [], total: 0 }, isLoading: false, isError: false };
},
);
const security = (await import("@/lib/api/security")).default;
(security.listArtifactScans as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
total: 0,
});
render(<ArtifactScansSection artifactId="a1" />);
expect(capturedKey).toEqual(["security", "artifact-scans", "a1"]);
// Invoke the queryFn so coverage hits the SDK call line, and verify
// the artifactId is forwarded.
await capturedFn?.();
expect(security.listArtifactScans).toHaveBeenCalledWith("a1");
});
});
Loading
Loading