mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
feat(compliance): add resource metadata tab inside req find (#11187)
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213)
|
||||
- "Resource Metadata / Evidence" tab in the finding detail drawer—reachable from the compliance requirement findings view, the Findings page, and the Resources view—rendering the affected resource's details and metadata as syntax-highlighted JSON with copy-to-clipboard, via a shared `ResourceMetadataPanel` also reused by the resource detail view [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -116,6 +116,84 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
});
|
||||
|
||||
it("should extract resource metadata and details from the included resource", () => {
|
||||
// Given — finding with an included resource exposing metadata + details
|
||||
createDictMock.mockImplementation((type: string) =>
|
||||
type === "resources"
|
||||
? {
|
||||
"resource-1": {
|
||||
id: "resource-1",
|
||||
attributes: {
|
||||
uid: "image:python:3.12",
|
||||
name: "python",
|
||||
type: "Python",
|
||||
details: "Python 3.12 base image",
|
||||
metadata: '{"PkgName":"requests","Versions":["2.0"]}',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const input = {
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "image_vulnerability",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: { checktitle: "Image Vulnerability" },
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [{ id: "resource-1" }] },
|
||||
scan: { data: null },
|
||||
},
|
||||
},
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].resourceDetails).toBe("Python 3.12 base image");
|
||||
expect(result[0].resourceMetadata).toBe(
|
||||
'{"PkgName":"requests","Versions":["2.0"]}',
|
||||
);
|
||||
});
|
||||
|
||||
it("should default resource metadata and details to null when absent", () => {
|
||||
// Given — valid finding without an included resource
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: { checktitle: "S3 Check" },
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [] },
|
||||
scan: { data: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result[0].resourceDetails).toBeNull();
|
||||
expect(result[0].resourceMetadata).toBeNull();
|
||||
});
|
||||
|
||||
it("should normalize a single finding response into a one-item drawer array", () => {
|
||||
// Given — getFindingById returns a single JSON:API resource object
|
||||
const input = {
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface ResourceDrawerFinding {
|
||||
resourceRegion: string;
|
||||
resourceType: string;
|
||||
resourceGroup: string;
|
||||
resourceDetails: string | null;
|
||||
resourceMetadata: Record<string, unknown> | string | null;
|
||||
// Provider
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
@@ -260,6 +262,14 @@ export function adaptFindingsByResourceResponse(
|
||||
resourceRegion: (resourceAttrs.region as string | undefined) || "-",
|
||||
resourceType: (resourceAttrs.type as string | undefined) || "-",
|
||||
resourceGroup: (meta.resourcegroup as string | undefined) || "-",
|
||||
resourceDetails:
|
||||
(resourceAttrs.details as string | null | undefined) ?? null,
|
||||
resourceMetadata:
|
||||
(resourceAttrs.metadata as
|
||||
| Record<string, unknown>
|
||||
| string
|
||||
| null
|
||||
| undefined) ?? null,
|
||||
// Provider
|
||||
providerType: ((providerAttrs.provider as string | undefined) ||
|
||||
"aws") as ProviderType,
|
||||
|
||||
+160
@@ -215,6 +215,7 @@ vi.mock("@/components/shared/query-code-editor", () => ({
|
||||
HCL: "hcl",
|
||||
BICEP: "bicep",
|
||||
YAML: "yaml",
|
||||
JSON: "json",
|
||||
},
|
||||
QueryCodeEditor: ({
|
||||
ariaLabel,
|
||||
@@ -426,6 +427,8 @@ const mockFinding: ResourceDrawerFinding = {
|
||||
resourceRegion: "us-east-1",
|
||||
resourceType: "Bucket",
|
||||
resourceGroup: "default",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789",
|
||||
@@ -1764,3 +1767,160 @@ describe("ResourceDetailDrawerContent — other findings delta/muted indicator",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — Metadata tab", () => {
|
||||
const getMetadataEditor = () =>
|
||||
screen
|
||||
.queryAllByTestId("query-code-editor")
|
||||
.find(
|
||||
(editor) =>
|
||||
editor.getAttribute("data-aria-label") === "Resource metadata",
|
||||
);
|
||||
|
||||
it("should render a Metadata tab trigger", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Resource Metadata / Evidence" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the resource metadata as formatted JSON and copy it to the clipboard", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const findingWithMetadata: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
resourceDetails: "Python",
|
||||
resourceMetadata: {
|
||||
VulnerabilityID: "CVE-2026-0001",
|
||||
PkgName: "requests",
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={findingWithMetadata}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — Details section + JSON editor are rendered
|
||||
expect(screen.getByText("Details:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Python")).toBeInTheDocument();
|
||||
|
||||
const metadataEditor = getMetadataEditor();
|
||||
expect(metadataEditor).toBeDefined();
|
||||
expect(metadataEditor).toHaveAttribute("data-language", "json");
|
||||
expect(metadataEditor?.textContent).toContain("CVE-2026-0001");
|
||||
|
||||
// When — copy the metadata JSON
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Copy Resource metadata" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockClipboardWriteText).toHaveBeenCalledWith(
|
||||
JSON.stringify(findingWithMetadata.resourceMetadata, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse stringified resource metadata", () => {
|
||||
// Given
|
||||
const findingWithStringMetadata: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
resourceMetadata: '{"PkgName":"requests"}',
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={findingWithStringMetadata}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(getMetadataEditor()?.textContent).toContain("requests");
|
||||
});
|
||||
|
||||
it("should show an empty state when no metadata or details are available", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText("No metadata available for this resource."),
|
||||
).toBeInTheDocument();
|
||||
expect(getMetadataEditor()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show a metadata skeleton while navigating", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={mockResourceRow}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByTestId("metadata-navigation-skeleton"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No metadata available for this resource."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+34
@@ -53,6 +53,7 @@ import {
|
||||
QueryCodeEditor,
|
||||
type QueryEditorLanguage,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
@@ -845,6 +846,9 @@ export function ResourceDetailDrawerContent({
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
|
||||
<TabsTrigger value="remediation">Remediation</TabsTrigger>
|
||||
<TabsTrigger value="metadata">
|
||||
Resource Metadata / Evidence
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="other-findings">
|
||||
Findings for this resource
|
||||
</TabsTrigger>
|
||||
@@ -1072,6 +1076,21 @@ export function ResourceDetailDrawerContent({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Metadata */}
|
||||
<TabsContent
|
||||
value="metadata"
|
||||
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
|
||||
>
|
||||
{isNavigating ? (
|
||||
<MetadataNavigationSkeleton />
|
||||
) : (
|
||||
<ResourceMetadataPanel
|
||||
metadata={f?.resourceMetadata}
|
||||
details={f?.resourceDetails}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings for this resource */}
|
||||
<TabsContent
|
||||
value="other-findings"
|
||||
@@ -1400,6 +1419,21 @@ function EventsNavigationSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataNavigationSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid="metadata-navigation-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton lineWidths={["w-20", "w-full", "w-3/4"]} />
|
||||
</Card>
|
||||
<Skeleton className="h-56 w-full rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFindingRow({
|
||||
finding,
|
||||
isOptimisticallyMuted,
|
||||
|
||||
@@ -86,6 +86,8 @@ function makeDrawerFinding(
|
||||
resourceRegion: "us-east-1",
|
||||
resourceType: "bucket",
|
||||
resourceGroup: "default",
|
||||
resourceDetails: null,
|
||||
resourceMetadata: null,
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123",
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useState } from "react";
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
import { FindingDetailDrawer } from "@/components/findings/table";
|
||||
import {
|
||||
Card,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -23,10 +22,7 @@ import {
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import { ExternalResourceLink } from "@/components/shared/external-resource-link";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
|
||||
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
||||
import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
@@ -46,29 +42,6 @@ const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
const parseMetadata = (
|
||||
metadata: Record<string, unknown> | string | null | undefined,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!metadata) return null;
|
||||
|
||||
if (typeof metadata === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// After the !metadata check above, metadata can only be object at this point
|
||||
// (null was already filtered, string was handled)
|
||||
if (typeof metadata === "object") {
|
||||
return metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildCustomBreadcrumbs = (
|
||||
_resourceName: string,
|
||||
findingTitle?: string,
|
||||
@@ -202,9 +175,6 @@ export const ResourceDetailContent = ({
|
||||
attributes.groups && attributes.groups.length > 0
|
||||
? attributes.groups.map(getGroupLabel).join(", ")
|
||||
: "-";
|
||||
const parsedMetadata = parseMetadata(attributes.metadata);
|
||||
const hasMetadata =
|
||||
parsedMetadata !== null && Object.entries(parsedMetadata).length > 0;
|
||||
const tagEntries = Object.entries(resourceTags);
|
||||
const hasTags = tagEntries.length > 0;
|
||||
|
||||
@@ -403,38 +373,10 @@ export const ResourceDetailContent = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata" className="flex flex-col gap-4">
|
||||
{attributes.details && attributes.details.trim() !== "" && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Details:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm break-words whitespace-pre-wrap">
|
||||
{attributes.details}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasMetadata && parsedMetadata && (
|
||||
<QueryCodeEditor
|
||||
ariaLabel="Resource metadata"
|
||||
visibleLabel={null}
|
||||
language={QUERY_EDITOR_LANGUAGE.JSON}
|
||||
value={JSON.stringify(parsedMetadata, null, 2)}
|
||||
copyValue={JSON.stringify(parsedMetadata, null, 2)}
|
||||
editable={false}
|
||||
minHeight={220}
|
||||
showCopyButton
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!attributes.details?.trim() && !hasMetadata && (
|
||||
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
|
||||
No metadata available for this resource.
|
||||
</p>
|
||||
)}
|
||||
<ResourceMetadataPanel
|
||||
metadata={attributes.metadata}
|
||||
details={attributes.details}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags" className="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ResourceMetadataPanel } from "./resource-metadata-panel";
|
||||
|
||||
vi.mock("@/components/shadcn/card/card", () => ({
|
||||
Card: ({ children, variant }: { children: ReactNode; variant?: string }) => (
|
||||
<div data-slot="card" data-variant={variant}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/query-code-editor", () => ({
|
||||
QUERY_EDITOR_LANGUAGE: {
|
||||
JSON: "json",
|
||||
},
|
||||
QueryCodeEditor: ({
|
||||
ariaLabel,
|
||||
value,
|
||||
copyValue,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="query-code-editor"
|
||||
data-aria-label={ariaLabel}
|
||||
data-value={value}
|
||||
data-copy-value={copyValue}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const EMPTY_STATE = "No metadata available for this resource.";
|
||||
|
||||
describe("ResourceMetadataPanel", () => {
|
||||
it("renders only the details card when just details are present", () => {
|
||||
render(
|
||||
<ResourceMetadataPanel metadata={null} details="Some resource details" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Details:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Some resource details")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("query-code-editor")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the metadata editor when just metadata is present", () => {
|
||||
render(
|
||||
<ResourceMetadataPanel
|
||||
metadata={{ VulnerabilityID: "CVE-2026-0001" }}
|
||||
details={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId("query-code-editor");
|
||||
expect(editor).toBeInTheDocument();
|
||||
// value and copyValue must reference the same serialized string.
|
||||
const serialized = JSON.stringify(
|
||||
{ VulnerabilityID: "CVE-2026-0001" },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
expect(editor).toHaveAttribute("data-value", serialized);
|
||||
expect(editor).toHaveAttribute("data-copy-value", serialized);
|
||||
expect(screen.queryByText("Details:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both the details card and the metadata editor", () => {
|
||||
render(
|
||||
<ResourceMetadataPanel
|
||||
metadata='{"PkgName":"requests"}'
|
||||
details="Detected on instance i-123"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Detected on instance i-123")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("query-code-editor")).toBeInTheDocument();
|
||||
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when neither details nor metadata are present", () => {
|
||||
render(<ResourceMetadataPanel metadata={null} details={null} />);
|
||||
|
||||
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Details:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("query-code-editor")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the empty state for an empty metadata object", () => {
|
||||
render(<ResourceMetadataPanel metadata={{}} details=" " />);
|
||||
|
||||
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
import { parseMetadata } from "@/lib/resource-metadata";
|
||||
|
||||
interface ResourceMetadataPanelProps {
|
||||
metadata: Record<string, unknown> | string | null | undefined;
|
||||
details: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "Metadata" panel for a resource.
|
||||
*
|
||||
* Renders the resource `details` text and its `metadata` as formatted JSON
|
||||
* with a copy-to-clipboard action, falling back to an empty state when
|
||||
* neither is available. Reused by the resource detail view and the finding
|
||||
* detail drawer (compliance requirement findings view) to keep the UX
|
||||
* consistent across surfaces.
|
||||
*/
|
||||
export function ResourceMetadataPanel({
|
||||
metadata,
|
||||
details,
|
||||
}: ResourceMetadataPanelProps) {
|
||||
const parsedMetadata = parseMetadata(metadata);
|
||||
const hasMetadata =
|
||||
parsedMetadata !== null && Object.keys(parsedMetadata).length > 0;
|
||||
const hasDetails = Boolean(details?.trim());
|
||||
const formattedMetadata =
|
||||
hasMetadata && parsedMetadata
|
||||
? JSON.stringify(parsedMetadata, null, 2)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDetails && (
|
||||
<Card variant="inner">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Details:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm break-words whitespace-pre-wrap">
|
||||
{details}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{formattedMetadata && (
|
||||
<QueryCodeEditor
|
||||
ariaLabel="Resource metadata"
|
||||
visibleLabel={null}
|
||||
language={QUERY_EDITOR_LANGUAGE.JSON}
|
||||
value={formattedMetadata}
|
||||
copyValue={formattedMetadata}
|
||||
editable={false}
|
||||
minHeight={220}
|
||||
showCopyButton
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasDetails && !hasMetadata && (
|
||||
<p className="text-text-neutral-tertiary py-8 text-center text-sm">
|
||||
No metadata available for this resource.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseMetadata } from "./resource-metadata";
|
||||
|
||||
describe("parseMetadata", () => {
|
||||
it("should return null for nullish or empty values", () => {
|
||||
expect(parseMetadata(null)).toBeNull();
|
||||
expect(parseMetadata(undefined)).toBeNull();
|
||||
expect(parseMetadata("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should parse a JSON object string into an object", () => {
|
||||
expect(parseMetadata('{"PkgName":"requests","Versions":["2.0"]}')).toEqual({
|
||||
PkgName: "requests",
|
||||
Versions: ["2.0"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when the string is not valid JSON", () => {
|
||||
expect(parseMetadata("not-json")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when the JSON string is not an object", () => {
|
||||
expect(parseMetadata("42")).toBeNull();
|
||||
expect(parseMetadata('"plain string"')).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when the JSON string is an array", () => {
|
||||
expect(parseMetadata("[1,2,3]")).toBeNull();
|
||||
expect(parseMetadata("[]")).toBeNull();
|
||||
expect(parseMetadata('[{"PkgName":"requests"}]')).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when the value is already an array", () => {
|
||||
expect(
|
||||
parseMetadata([1, 2, 3] as unknown as Record<string, unknown>),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the object as-is when already an object", () => {
|
||||
const metadata = { VulnerabilityID: "CVE-2026-0001" };
|
||||
expect(parseMetadata(metadata)).toBe(metadata);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Normalizes a resource `metadata` value into a plain object.
|
||||
*
|
||||
* The API stores resource metadata as a `TextField`, so it can arrive as a
|
||||
* JSON string, an already-parsed object, or be empty. Returns `null` when the
|
||||
* value is missing or not a JSON object so callers can render an empty state.
|
||||
*/
|
||||
export const parseMetadata = (
|
||||
metadata: Record<string, unknown> | string | null | undefined,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!metadata) return null;
|
||||
|
||||
if (typeof metadata === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata);
|
||||
return typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// After the !metadata check above, metadata can only be a non-null object
|
||||
// here (null was filtered, string was handled). Arrays are excluded too so
|
||||
// the Record<string, unknown> return type stays honest.
|
||||
if (typeof metadata === "object" && !Array.isArray(metadata)) {
|
||||
return metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user