feat(ui): install Registry checks artifacts on the API's verdict (#12843)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Alejandro Bailo
2026-09-18 16:04:58 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent d819639f0e
commit 03cb59c20d
23 changed files with 866 additions and 185 deletions
+3 -1
View File
@@ -2,6 +2,7 @@
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options";
import { createAddProviderFormSchema } from "@/types/formSchemas";
import { isKnownProviderType } from "@/types/providers";
@@ -19,7 +20,8 @@ export async function addRegistryProvider(formData: FormData) {
};
try {
const discovery = await getInstalledRegistryProviderOptions();
if (discovery.status !== "ready") return unavailable;
if (discovery.status !== REGISTRY_PROVIDER_DISCOVERY.READY)
return unavailable;
const values = createAddProviderFormSchema(
discovery.options.map((option) => option.type),
).safeParse(Object.fromEntries(formData));
@@ -65,6 +65,33 @@ describe("Registry adapter", () => {
]);
});
it("reads the built-in providers an installed artifact adds checks to", () => {
// Given / When
const artifacts = adaptRegistryTenantArtifacts({
data: [
{
type: "registry-artifacts",
id: "local-acme-builtin-checks",
attributes: {
version_spec: "latest",
extends_provider_slugs: ["AWS", "aws", " gcp "],
},
},
{
type: "registry-artifacts",
id: "older-api",
attributes: { version_spec: "latest" },
},
],
});
// Then
expect(artifacts).toMatchObject([
{ extendsProviderSlugs: ["aws", "gcp"] },
{ extendsProviderSlugs: [] },
]);
});
it.each([undefined, null, "", " "])(
"accepts an unknown resolved version %j",
(resolvedVersion) => {
@@ -387,6 +414,92 @@ describe("Registry adapter", () => {
});
});
it("reads the deployment's install verdict and refuses installs an older API never confirmed", async () => {
// Given
const document = {
data: [
{
type: "registry-artifacts",
id: "aws-checks",
attributes: {
has_checks: true,
is_installable: true,
not_installable_reason: null,
},
},
{
type: "registry-artifacts",
id: "acme-checks",
attributes: {
has_checks: true,
is_installable: false,
not_installable_reason: "checks_target_is_not_builtin",
},
},
{
type: "registry-artifacts",
id: "older-api",
attributes: { has_provider: true },
},
],
meta: { pagination: { page: 1, pages: 1, count: 3 } },
};
// When
const result = await collectCompleteRegistryCatalog(async () => document);
// Then
expect(result).toMatchObject({
status: "complete",
artifacts: [
{
normalizedName: "acme-checks",
isInstallable: false,
notInstallableReason: "checks_target_is_not_builtin",
},
{ normalizedName: "aws-checks", isInstallable: true },
{ normalizedName: "older-api", isInstallable: false },
],
});
if (result.status !== "complete") throw new Error("Incomplete fixture");
expect(result.artifacts[1]).not.toHaveProperty("notInstallableReason");
});
it("keeps an artifact uninstallable when any catalog page refuses it", async () => {
// Given
const document = {
data: [
{
type: "registry-artifacts",
id: "split",
attributes: { is_installable: true },
},
{
type: "registry-artifacts",
id: "split",
attributes: {
is_installable: false,
not_installable_reason: "artifact_compliance_not_supported",
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 2 } },
};
// When
const result = await collectCompleteRegistryCatalog(async () => document);
// Then
expect(result).toMatchObject({
artifacts: [
{
isInstallable: false,
notInstallableReason: "artifact_compliance_not_supported",
},
],
});
});
it("defaults omitted built-in status and maps explicit built-ins", async () => {
// Given
const document = {
+37
View File
@@ -2,12 +2,14 @@ import { z } from "zod";
import { isActiveRegistryCredential } from "@/lib/registry/credential-task";
import {
REGISTRY_ARTIFACT_REMOVAL,
REGISTRY_CATALOG,
REGISTRY_CATALOG_INCOMPLETE_REASON,
REGISTRY_ENDPOINT,
REGISTRY_FAILURE,
REGISTRY_MUTATION,
REGISTRY_SUBMISSION,
type RegistryArtifactRemovalConflict,
type RegistryCatalogArtifact,
type RegistryCatalogResult,
type RegistryCredentialStatus,
@@ -23,6 +25,11 @@ const REGISTRY_ERROR_CODE = {
KEY_REJECTED: "registry_key_rejected",
UNAVAILABLE: "registry_unavailable",
} as const;
// Opposite remedies, so a 409 is never read without its code.
const REGISTRY_REMOVAL_CONFLICT_CODE = {
IN_USE: "registry_artifact_in_use",
BUSY: "registry_artifact_busy",
} as const;
const REGISTRY_MUTATION_REFUSAL_COPY = {
no_installable_version: "No available version can be added.",
registry_artifact_not_found: "This artifact is no longer available.",
@@ -65,6 +72,7 @@ const tenantArtifactsSchema = z.object({
attributes: z.object({
version_spec: z.string().trim().min(1),
resolved_version: z.string().trim().nullish(),
extends_provider_slugs: z.array(z.string()).nullish(),
inserted_at: z.string().optional(),
updated_at: z.string().optional(),
}),
@@ -103,6 +111,11 @@ export function adaptRegistryTenantArtifacts(
normalizedName: id,
versionSpec: attributes.version_spec,
resolvedVersion: attributes.resolved_version || undefined,
extendsProviderSlugs: unique(
(attributes.extends_provider_slugs ?? [])
.map((slug) => slug.trim().toLowerCase())
.filter(Boolean),
),
insertedAt: attributes.inserted_at,
updatedAt: attributes.updated_at,
}));
@@ -160,6 +173,17 @@ export async function classifyRegistryMutationRefusal(
return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null;
}
export async function classifyRegistryRemovalConflict(
response: Response,
): Promise<RegistryArtifactRemovalConflict | null> {
const code = await getRegistryErrorCode(response);
if (code === REGISTRY_REMOVAL_CONFLICT_CODE.IN_USE)
return { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE };
if (code === REGISTRY_REMOVAL_CONFLICT_CODE.BUSY)
return { status: REGISTRY_ARTIFACT_REMOVAL.BUSY };
return null;
}
export async function classifyRegistryFailure(
response: Response,
endpoint: RegistryEndpoint,
@@ -234,6 +258,8 @@ const catalogAttributesSchema = z.object({
has_provider: z.boolean().optional(),
has_checks: z.boolean().optional(),
has_compliance: z.boolean().optional(),
is_installable: z.boolean().optional(),
not_installable_reason: z.string().nullish(),
check_count: safeInteger.nullish(),
compliance_count: safeInteger.nullish(),
version_count: safeInteger.optional(),
@@ -323,6 +349,10 @@ function adaptCatalogArtifact(
const parsed = catalogResourceSchema.safeParse(resource);
if (!parsed.success) return null;
const { attributes: a, id } = parsed.data;
const notInstallableReason =
a.is_installable === true
? undefined
: text(a.not_installable_reason ?? undefined);
return {
normalizedName: id,
name: text(a.name),
@@ -342,6 +372,9 @@ function adaptCatalogArtifact(
hasProvider: a.has_provider ?? false,
hasChecks: a.has_checks ?? false,
hasCompliance: a.has_compliance ?? false,
// An older API sends no verdict; never offer an install it did not confirm.
isInstallable: a.is_installable ?? false,
...(notInstallableReason ? { notInstallableReason } : {}),
checkCount: a.check_count ?? undefined,
complianceCount: a.compliance_count ?? undefined,
versionCount: a.version_count ?? 0,
@@ -380,6 +413,10 @@ function mergeArtifacts(
hasProvider: left.hasProvider || right.hasProvider,
hasChecks: left.hasChecks || right.hasChecks,
hasCompliance: left.hasCompliance || right.hasCompliance,
// Any page refusing the install wins, and its reason travels with it.
isInstallable: left.isInstallable && right.isInstallable,
notInstallableReason:
left.notInstallableReason ?? right.notInstallableReason,
checkCount: mergeCount(left.checkCount, right.checkCount),
complianceCount: mergeCount(left.complianceCount, right.complianceCount),
versionCount: Math.max(left.versionCount, right.versionCount),
+100 -16
View File
@@ -197,15 +197,20 @@ describe("installed Registry provider discovery", () => {
expect(evaluateAccessMock).not.toHaveBeenCalled();
});
it.each(["ineligible", "unknown"])(
"denies installed-provider discovery when provider access is %s",
async (status) => {
it.each([
["ineligible", "access_denied"],
["unknown", "unknown"],
] as const)(
"maps installed-provider access %s to %s",
async (status, expectedStatus) => {
// Given
evaluateProviderAccessMock.mockResolvedValue({ status });
// When / Then
expect(await getInstalledRegistryProviderOptions()).toEqual({
status: "access_denied",
});
// When
const result = await getInstalledRegistryProviderOptions();
// Then
expect(result).toEqual({ status: expectedStatus });
expect(fetchMock).not.toHaveBeenCalled();
},
);
@@ -297,7 +302,11 @@ describe("Registry guarded reads", () => {
{
type: "registry-artifacts",
id: "external-package",
attributes: { has_provider: true, is_builtin: false },
attributes: {
has_provider: true,
is_builtin: false,
is_installable: true,
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
@@ -388,6 +397,7 @@ describe("Registry guarded reads", () => {
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
@@ -433,6 +443,7 @@ describe("Registry guarded reads", () => {
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
@@ -477,6 +488,7 @@ describe("Registry guarded reads", () => {
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
@@ -673,6 +685,7 @@ describe("Registry guarded reads", () => {
{
normalizedName: "prowler-aws",
versionSpec: "latest",
extendsProviderSlugs: [],
insertedAt: "2026-03-20T12:00:00Z",
},
],
@@ -786,6 +799,7 @@ describe("Registry artifact mutations", () => {
attributes: {
has_provider: true,
is_builtin: false,
is_installable: true,
providers: ["acme"],
},
},
@@ -801,11 +815,29 @@ describe("Registry artifact mutations", () => {
});
it.each([
{ has_provider: true, is_builtin: true },
{ has_provider: false, is_builtin: false },
[
{
has_checks: true,
is_installable: false,
not_installable_reason: "checks_target_is_not_builtin",
},
"Its checks are written for a provider this deployment does not ship.",
],
[
{
has_checks: true,
is_installable: false,
not_installable_reason: "a_code_from_a_newer_api",
},
"This artifact cannot be installed in this deployment.",
],
[
{ has_provider: true, is_builtin: false },
"This artifact cannot be installed in this deployment.",
],
])(
"refuses ineligible catalog entries before POST: %j",
async (attributes) => {
"refuses what the API says cannot be installed before POST: %j",
async (attributes, message) => {
// Given
installCatalogMock.mockImplementation(() =>
jsonResponse({
@@ -824,11 +856,44 @@ describe("Registry artifact mutations", () => {
normalizedName: "later-guard",
});
// Then
expect(result).toMatchObject({ status: "refused" });
expect(result).toEqual({ status: "refused", message });
expect(fetchMock).not.toHaveBeenCalled();
},
);
it("submits a checks artifact that defines no provider once the API calls it installable", async () => {
// Given
installCatalogMock.mockImplementation(() =>
jsonResponse({
data: [
{
type: "registry-available-artifacts",
id: "later-guard",
attributes: {
has_provider: false,
has_checks: true,
is_installable: true,
providers: ["aws"],
},
},
],
meta: { pagination: { page: 1, pages: 1, count: 1 } },
}),
);
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { type: "tasks", id: "task-1" } }), {
status: 202,
headers: { "Content-Location": "/api/v1/tasks/task-1" },
}),
);
// When
const result = await addRegistryArtifact({ normalizedName: "later-guard" });
// Then
expect(result).toEqual({ status: "submitted", taskId: "task-1" });
});
it("returns an accepted Add task without reading My artifacts", async () => {
// Given
fetchMock.mockResolvedValueOnce(
@@ -988,7 +1053,27 @@ describe("Registry artifact mutations", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("reports an in-use artifact when Remove returns 409 without refreshing membership", async () => {
it.each([
["registry_artifact_in_use", "in_use"],
["registry_artifact_busy", "busy"],
])(
"tells a Remove 409 %s apart as %s without refreshing membership",
async (code, expected) => {
// Given
fetchMock.mockResolvedValueOnce(
jsonResponse({ errors: [{ code }] }, 409),
);
// When
const result = await removeRegistryArtifact("aws-guard");
// Then
expect(result).toEqual({ status: expected });
expect(fetchMock).toHaveBeenCalledTimes(1);
},
);
it("never asks someone to delete providers over a Remove 409 it cannot identify", async () => {
// Given
fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 }));
@@ -996,8 +1081,7 @@ describe("Registry artifact mutations", () => {
const result = await removeRegistryArtifact("aws-guard");
// Then
expect(result).toEqual({ status: "in_use" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({ status: "error" });
});
it.each([
+20 -15
View File
@@ -9,21 +9,22 @@ import {
evaluateRegistryAccess,
evaluateRegistryProviderAccess,
} from "@/lib/registry/access.server";
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
import { isActiveRegistryCredential } from "@/lib/registry/credential-task";
import { getRegistryNotInstallableMessage } from "@/lib/registry/installability";
import {
buildRegistryProviderOptions,
type RegistryProviderOption,
REGISTRY_PROVIDER_DISCOVERY,
type RegistryProviderDiscoveryResult,
} from "@/lib/registry/provider-options";
import {
REGISTRY_ARTIFACT_ACTION,
REGISTRY_ARTIFACT_REMOVAL,
REGISTRY_BOOTSTRAP_STATE,
REGISTRY_CATALOG,
REGISTRY_CREDENTIAL_ACTION,
REGISTRY_CREDENTIAL_READ,
REGISTRY_ENDPOINT,
REGISTRY_FAILURE,
REGISTRY_MUTATION,
REGISTRY_SUBMISSION,
type RegistryAddArtifactInput,
type RegistryArtifactRemovalResult,
@@ -43,6 +44,7 @@ import {
adaptRegistryTenantArtifacts,
classifyRegistryFailure,
classifyRegistryMutationRefusal,
classifyRegistryRemovalConflict,
collectCompleteRegistryCatalog,
isRegistryCollection,
parseRegistryArtifactSubmission,
@@ -187,14 +189,13 @@ async function readRegistryProviders(
: { status: REGISTRY_FAILURE.ERROR };
}
export async function getInstalledRegistryProviderOptions(): Promise<
| { status: "ready"; options: RegistryProviderOption[] }
| { status: "access_denied" | "error" }
> {
export async function getInstalledRegistryProviderOptions(): Promise<RegistryProviderDiscoveryResult> {
const access = (await auth())?.accessToken;
const permission = await evaluateRegistryProviderAccess(access);
if (permission.status === REGISTRY_ACCESS.UNKNOWN)
return { status: REGISTRY_PROVIDER_DISCOVERY.UNKNOWN };
if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE)
return { status: "access_denied" };
return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED };
const [catalog, installed, providers] = await Promise.all([
readCompleteRegistryCatalog(access, null),
readRegistryTenantArtifacts(access),
@@ -205,15 +206,15 @@ export async function getInstalledRegistryProviderOptions(): Promise<
(status) => status === REGISTRY_FAILURE.ACCESS_DENIED,
)
)
return { status: "access_denied" };
return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED };
if (
catalog.status !== REGISTRY_CATALOG.COMPLETE ||
installed.status !== "ready" ||
providers.status !== "ready"
)
return { status: "error" };
return { status: REGISTRY_PROVIDER_DISCOVERY.ERROR };
return {
status: "ready",
status: REGISTRY_PROVIDER_DISCOVERY.READY,
options: buildRegistryProviderOptions(
catalog.artifacts,
installed.tenantArtifacts,
@@ -387,10 +388,10 @@ export async function addRegistryArtifact({
const artifact = catalog.artifacts.find(
(entry) => entry.normalizedName === normalizedName,
);
if (!artifact || !isRegistryArtifactInstallable(artifact))
if (!artifact?.isInstallable)
return {
status: "refused",
message: "Only external provider artifacts can be added.",
status: REGISTRY_MUTATION.REFUSED,
message: getRegistryNotInstallableMessage(artifact?.notInstallableReason),
};
const selectedVersion = versionSpec?.trim() || "latest";
@@ -480,7 +481,11 @@ export async function removeRegistryArtifact(
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
if (response.status === 409) {
return { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE };
return (
(await classifyRegistryRemovalConflict(response)) ?? {
status: REGISTRY_FAILURE.ERROR,
}
);
}
if (!response.ok) return { status: REGISTRY_FAILURE.ERROR };
@@ -141,6 +141,7 @@ describe("provider selector", () => {
// Then: only the built-in providers are offered, without a Registry tab.
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: "Registry" }),
).not.toBeInTheDocument();
@@ -88,18 +88,8 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
<Controller
name="providerType"
control={control}
render={({ field }) => (
<Tabs
className="flex flex-col px-4"
value={activeTab}
onValueChange={(value) => setSelectedTab(value as ProviderTab)}
>
{registryAvailable && (
<TabsList aria-label="Provider source">
<TabsTrigger value={PROVIDER_TAB.ALL}>All providers</TabsTrigger>
<TabsTrigger value={PROVIDER_TAB.REGISTRY}>Registry</TabsTrigger>
</TabsList>
)}
render={({ field }) => {
const searchInput = (
<div className="relative z-10 shrink-0 py-4">
<SearchInput
aria-label="Search providers"
@@ -109,8 +99,8 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
onClear={() => setSearchTerm("")}
/>
</div>
<TabsContent value={activeTab}>
);
const providerList = (
<div
role="listbox"
aria-label="Select a provider"
@@ -155,10 +145,7 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
alt=""
/>
<AvatarFallback>
<ProviderTypeIcon
type={provider.value}
size={26}
/>
<ProviderTypeIcon type={provider.value} size={26} />
</AvatarFallback>
</Avatar>
) : (
@@ -184,15 +171,39 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
</p>
)}
</div>
</TabsContent>
{errorMessage && (
);
const validationMessage = errorMessage && (
<FormMessage className="text-text-error-primary">
{errorMessage}
</FormMessage>
)}
);
if (!registryAvailable) {
return (
<div className="flex flex-col px-4">
{searchInput}
<div className="mt-2">{providerList}</div>
{validationMessage}
</div>
);
}
return (
<Tabs
className="flex flex-col px-4"
value={activeTab}
onValueChange={(value) => setSelectedTab(value as ProviderTab)}
>
<TabsList aria-label="Provider source">
<TabsTrigger value={PROVIDER_TAB.ALL}>All providers</TabsTrigger>
<TabsTrigger value={PROVIDER_TAB.REGISTRY}>Registry</TabsTrigger>
</TabsList>
{searchInput}
<TabsContent value={activeTab}>{providerList}</TabsContent>
{validationMessage}
</Tabs>
)}
);
}}
/>
);
};
@@ -129,4 +129,112 @@ describe("Registry provider source tabs", () => {
"true",
);
});
it("keeps Registry hidden and offers a retry when access is unknown", async () => {
// Given
const user = userEvent.setup();
getInstalledRegistryProviderOptions
.mockResolvedValueOnce({ status: "unknown" })
.mockResolvedValueOnce({ status: "ready", options: [] });
// When
render(<ConnectAccountForm onSuccess={vi.fn()} />);
// Then
expect(
await screen.findByText("Registry providers could not be loaded"),
).toBeVisible();
expect(
screen.queryByRole("tab", { name: "Registry" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Retry Registry providers" }),
).toBeVisible();
// When
await user.click(
screen.getByRole("button", { name: "Retry Registry providers" }),
);
// Then
expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(2);
});
it("shows a retry in flight, ignores repeat clicks and keeps focus on the button", async () => {
// Given
const user = userEvent.setup();
let settleRetry: (result: { status: "error" }) => void = () => {};
getInstalledRegistryProviderOptions
.mockResolvedValueOnce({ status: "error" })
.mockReturnValueOnce(
new Promise((resolve) => {
settleRetry = resolve;
}),
);
render(<ConnectAccountForm onSuccess={vi.fn()} />);
const retry = await screen.findByRole("button", {
name: "Retry Registry providers",
});
// When
await user.click(retry);
await user.click(retry);
// Then: the warning stays mounted, so the pressed button is never lost.
expect(retry).toHaveTextContent("Retrying…");
expect(retry).toHaveAttribute("aria-disabled", "true");
expect(retry).toHaveFocus();
expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(2);
// When: the retry fails again
settleRetry({ status: "error" });
// Then
await waitFor(() =>
expect(retry).toHaveTextContent("Retry Registry providers"),
);
expect(retry).not.toHaveAttribute("aria-disabled", "true");
});
it("keeps a retry in flight when an artifact change reloads discovery meanwhile", async () => {
// Given
const user = userEvent.setup();
let settleRetry: (result: { status: "error" }) => void = () => {};
getInstalledRegistryProviderOptions
.mockResolvedValueOnce({ status: "error" })
.mockReturnValueOnce(
new Promise((resolve) => {
settleRetry = resolve;
}),
)
.mockResolvedValueOnce({ status: "error" });
render(<ConnectAccountForm onSuccess={vi.fn()} />);
const retry = await screen.findByRole("button", {
name: "Retry Registry providers",
});
await user.click(retry);
// When: an unrelated reload settles before the retry does
window.dispatchEvent(new CustomEvent("registry-artifacts-changed"));
await waitFor(() =>
expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(3),
);
await user.click(retry);
// Then: only the retry itself may end the retry
expect(retry).toHaveTextContent("Retrying…");
expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(3);
// When
settleRetry({ status: "error" });
// Then
await waitFor(() =>
expect(retry).toHaveTextContent("Retry Registry providers"),
);
});
});
@@ -18,7 +18,10 @@ import { Button, useToast } from "@/components/shadcn";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Form } from "@/components/shadcn/form";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import type { RegistryProviderOption } from "@/lib/registry/provider-options";
import {
REGISTRY_PROVIDER_DISCOVERY,
type RegistryProviderOption,
} from "@/lib/registry/provider-options";
import {
createAddProviderFormSchema,
AddProviderFormValues,
@@ -218,13 +221,14 @@ export const ConnectAccountForm = ({
const [registryOptions, setRegistryOptions] = useState<
RegistryProviderOption[]
>([]);
// Only Cloud and Private Cloud deployments with the Registry flag on answer
// discovery with "ready" or "error"; Local (OSS) and flag-off deployments
// are denied and never show the Registry tab.
// Only confirmed Cloud and Private Cloud access enables Registry source tabs.
// Unknown access stays hidden but remains retryable through the warning.
const [registryAvailable, setRegistryAvailable] = useState(false);
const [registryError, setRegistryError] = useState(false);
const [providerError, setProviderError] = useState<string | null>(null);
const [discoveryAttempt, setDiscoveryAttempt] = useState(0);
// Local state needed: a request in flight cannot be derived from the attempt count.
const [isRetryingDiscovery, setIsRetryingDiscovery] = useState(false);
const submitting = useRef(false);
const createdAccount = useRef<ConnectAccountSuccessData | null>(null);
@@ -234,9 +238,19 @@ export const ConnectAccountForm = ({
try {
const result = await getInstalledRegistryProviderOptions();
if (!active) return;
setRegistryOptions(result.status === "ready" ? result.options : []);
setRegistryAvailable(result.status !== "access_denied");
setRegistryError(result.status === "error");
setRegistryOptions(
result.status === REGISTRY_PROVIDER_DISCOVERY.READY
? result.options
: [],
);
setRegistryAvailable(
result.status === REGISTRY_PROVIDER_DISCOVERY.READY ||
result.status === REGISTRY_PROVIDER_DISCOVERY.ERROR,
);
setRegistryError(
result.status === REGISTRY_PROVIDER_DISCOVERY.ERROR ||
result.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN,
);
} catch {
if (active) {
setRegistryOptions([]);
@@ -245,7 +259,10 @@ export const ConnectAccountForm = ({
}
}
};
void load();
// Only this effect's own load ends a retry; event reloads must not.
void load().then(() => {
if (active) setIsRetryingDiscovery(false);
});
window.addEventListener("registry-artifacts-changed", load);
return () => {
active = false;
@@ -460,14 +477,20 @@ export const ConnectAccountForm = ({
<AlertDescription>
Built-in providers are available. Check the Registry
connection and try again.
{/* aria-disabled, not disabled: the pressed button keeps focus. */}
<Button
type="button"
variant="outline"
onClick={() =>
setDiscoveryAttempt((attempt) => attempt + 1)
}
aria-disabled={isRetryingDiscovery}
onClick={() => {
if (isRetryingDiscovery) return;
setIsRetryingDiscovery(true);
setDiscoveryAttempt((attempt) => attempt + 1);
}}
>
Retry Registry providers
{isRetryingDiscovery
? "Retrying…"
: "Retry Registry providers"}
</Button>
</AlertDescription>
</Alert>
@@ -28,9 +28,111 @@ const artifact: RegistryMarketplaceArtifact = {
versionCount: 1,
totalDownloads: 0,
owners: [{ name: "Prowler", type: "organization" }],
isInstallable: false,
notInstallableReason: "artifact_ships_with_prowler",
isAdded: false,
updateAvailable: false,
extendsProviderSlugs: [],
};
const checksArtifact: RegistryMarketplaceArtifact = {
...artifact,
normalizedName: "acme-aws-checks",
name: "Acme AWS checks",
isBuiltin: false,
hasProvider: false,
hasCompliance: false,
isInstallable: true,
notInstallableReason: undefined,
};
describe("Registry card install verdict", () => {
it("offers Add for checks the API calls installable, though they define no provider", async () => {
// Given
const onAdd = vi.fn();
const screen = await render(
<RegistryArtifactCard
artifact={checksArtifact}
onAdd={onAdd}
onRemove={vi.fn()}
/>,
);
// When
await screen.getByRole("button", { name: "Add Acme AWS checks" }).click();
// Then
expect(onAdd).toHaveBeenCalledOnce();
});
it("says why an artifact cannot be installed instead of leaving a dead control", async () => {
// Given / When
const screen = await render(
<RegistryArtifactCard
artifact={{
...checksArtifact,
isInstallable: false,
notInstallableReason: "checks_target_is_not_builtin",
}}
onAdd={vi.fn()}
onRemove={vi.fn()}
/>,
);
// Then
await expect
.element(
screen.getByText(
"Its checks are written for a provider this deployment does not ship.",
),
)
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: /Add/ }))
.not.toBeInTheDocument();
});
it("names the built-in providers whose scans an installed checks artifact changed", async () => {
// Given / When
const screen = await render(
<RegistryArtifactCard
artifact={{
...checksArtifact,
isAdded: true,
resolvedVersion: "0.2.2",
extendsProviderSlugs: ["aws", "gcp"],
}}
onAdd={vi.fn()}
onRemove={vi.fn()}
/>,
);
// Then
await expect
.element(
screen.getByText("Adds checks to your AWS and Google Cloud scans."),
)
.toBeVisible();
});
});
describe("Registry tenant card", () => {
it("still names the extended providers when the catalog no longer lists the artifact", async () => {
// Given / When
const screen = await render(
<RegistryTenantArtifactCard
extendsProviderSlugs={["aws"]}
normalizedName="retired-aws-checks"
onRemove={vi.fn()}
resolvedVersion="0.2.2"
/>,
);
// Then
await expect
.element(screen.getByText("Adds checks to your AWS scans."))
.toBeVisible();
});
});
describe("Registry card metadata layout", () => {
it("keeps Added when the installed version is unknown", async () => {
@@ -61,6 +163,7 @@ describe("Registry card metadata layout", () => {
artifact={{
...artifact,
isBuiltin: false,
isInstallable: true,
isAdded: true,
resolvedVersion: "1.0.0",
updateAvailable: true,
@@ -167,6 +270,7 @@ describe("Registry card metadata layout", () => {
complianceCount: 123456789,
totalDownloads: 9876543210,
isBuiltin: false,
isInstallable: true,
owners: [],
}}
onAdd={onAdd}
@@ -26,7 +26,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
import { getRegistryNotInstallableMessage } from "@/lib/registry/installability";
import { cn } from "@/lib/utils";
import { getProviderDisplayName, isKnownProviderType } from "@/types/providers";
import type { RegistryArtifactOwner } from "@/types/registry";
@@ -58,6 +58,11 @@ function capabilitySummary(artifact: RegistryMarketplaceArtifact) {
*/
const MAX_PROVIDER_LOGOS = 4;
const PROVIDER_LIST_FORMAT = new Intl.ListFormat("en", {
style: "long",
type: "conjunction",
});
interface RegistryProviderClusterProps {
providers: string[];
}
@@ -116,6 +121,22 @@ function RegistryProviderCluster({ providers }: RegistryProviderClusterProps) {
);
}
interface RegistryExtendedProvidersProps {
slugs: string[];
}
/** The only thing explaining an install that shows no provider type. */
function RegistryExtendedProviders({ slugs }: RegistryExtendedProvidersProps) {
if (slugs.length === 0) return null;
return (
<p className="text-text-neutral-secondary text-xs">
Adds checks to your{" "}
{PROVIDER_LIST_FORMAT.format(slugs.map(getProviderDisplayName))} scans.
</p>
);
}
interface RegistryOwnerRowProps {
isOfficial: boolean;
isVerified: boolean;
@@ -299,6 +320,14 @@ export function RegistryArtifactCard({
}
downloads={artifact.isBuiltin ? undefined : artifact.totalDownloads}
/>
<RegistryExtendedProviders slugs={artifact.extendsProviderSlugs} />
{!artifact.isAdded &&
!artifact.isInstallable &&
!artifact.isBuiltin && (
<p className="text-text-neutral-secondary text-xs">
{getRegistryNotInstallableMessage(artifact.notInstallableReason)}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<RegistryProviderCluster providers={artifact.providers} />
<span className="ml-auto flex flex-wrap items-center justify-end gap-2">
@@ -309,7 +338,7 @@ export function RegistryArtifactCard({
)}
{artifact.isAdded ? (
<>
{artifact.updateAvailable ? (
{artifact.updateAvailable && artifact.isInstallable ? (
<Button
aria-label={`Update ${displayName} to ${artifact.latestVersion}`}
disabled={Boolean(pendingAddName)}
@@ -338,7 +367,7 @@ export function RegistryArtifactCard({
Remove
</Button>
</>
) : isRegistryArtifactInstallable(artifact) ? (
) : artifact.isInstallable ? (
<Button
aria-label={`Add ${displayName}`}
disabled={Boolean(pendingAddName)}
@@ -357,12 +386,14 @@ export function RegistryArtifactCard({
}
interface RegistryTenantArtifactCardProps {
extendsProviderSlugs?: string[];
normalizedName: string;
onRemove: (trigger: HTMLButtonElement | null) => void;
resolvedVersion?: string;
}
export function RegistryTenantArtifactCard({
extendsProviderSlugs = [],
normalizedName,
onRemove,
resolvedVersion,
@@ -393,6 +424,7 @@ export function RegistryTenantArtifactCard({
isAdded
version={resolvedVersion || "Unknown"}
/>
<RegistryExtendedProviders slugs={extendsProviderSlugs} />
<div className="flex justify-end">
<Button
aria-label={`Remove ${normalizedName}`}
@@ -132,6 +132,7 @@ const readyState: RegistryBootstrapState = {
isBuiltin: false,
isMeta: false,
hasProvider: true,
isInstallable: true,
hasChecks: true,
hasCompliance: false,
versionCount: 2,
@@ -155,6 +156,7 @@ const readyState: RegistryBootstrapState = {
isBuiltin: false,
isMeta: false,
hasProvider: true,
isInstallable: true,
hasChecks: true,
hasCompliance: true,
versionCount: 1,
@@ -172,6 +174,7 @@ const readyState: RegistryBootstrapState = {
isBuiltin: false,
isMeta: true,
hasProvider: true,
isInstallable: true,
hasChecks: true,
hasCompliance: true,
versionCount: 4,
@@ -1311,8 +1314,11 @@ describe("RegistryExplorer", () => {
describe("when the complete catalog is ready", () => {
it.each([
{ isBuiltin: true, hasProvider: true },
{ isBuiltin: false, hasProvider: false },
{ isBuiltin: true, isInstallable: false },
{
isInstallable: false,
notInstallableReason: "artifact_defines_nothing_usable",
},
])("keeps ineligible artifacts visible without Add: %j", async (flags) => {
const state: RegistryBootstrapState = {
...readyState,
@@ -1587,6 +1593,7 @@ describe("RegistryExplorer", () => {
...readyState.catalog.artifacts[0],
isAdded: false,
updateAvailable: false,
extendsProviderSlugs: [],
checkCount: 645,
complianceCount: 45,
};
@@ -1693,6 +1700,7 @@ describe("RegistryExplorer", () => {
...readyState.catalog.artifacts[0],
isAdded: false,
updateAvailable: false,
extendsProviderSlugs: [],
owners: [
{
name: "Registry team",
@@ -1972,6 +1980,38 @@ describe("RegistryExplorer", () => {
);
});
it("treats a running scan as a wait, never as a reason to delete providers", async () => {
// Given
removeRegistryArtifactMock
.mockResolvedValueOnce({ status: "busy" })
.mockResolvedValueOnce({
status: "confirmed",
tenantArtifacts: [],
});
const screen = await render(<RegistryExplorer initialState={readyState} />);
await screen.getByRole("tab", { name: /My artifacts/ }).click();
await screen.getByRole("button", { name: "Remove AWS guard" }).click();
// When
await screen.getByRole("button", { name: "Confirm Remove" }).click();
// Then
const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
await expect
.element(dialog.getByRole("alert"))
.toHaveTextContent("A scan is using this artifact");
await expect
.element(dialog.getByRole("button", { name: "View providers" }))
.not.toBeInTheDocument();
// When: the scan finished, so the same dialog can simply try again.
await dialog.getByRole("button", { name: "Confirm Remove" }).click();
// Then
await expect.element(dialog).not.toBeInTheDocument();
expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(2);
});
it("clears the in-use Remove error on close and restores focus before reopening", async () => {
// Given
removeRegistryArtifactMock.mockResolvedValue({ status: "in_use" });
@@ -18,6 +18,7 @@ const artifact = (
hasProvider: false,
hasChecks: false,
hasCompliance: false,
isInstallable: false,
versionCount: 0,
totalDownloads: 0,
owners: [],
@@ -25,6 +26,46 @@ const artifact = (
});
describe("Registry marketplace model", () => {
it("reports a newer release for an installed checks artifact that defines no provider", () => {
// Given
const catalog = {
status: "complete" as const,
artifacts: [
artifact("aws-checks", {
latestVersion: "0.3.0",
hasChecks: true,
isInstallable: true,
}),
],
};
// When
const model = buildRegistryMarketplaceModel(
catalog,
[
{
normalizedName: "aws-checks",
versionSpec: "latest",
resolvedVersion: "0.2.2",
extendsProviderSlugs: ["aws"],
},
],
{},
"name",
);
// Then
expect(model).toMatchObject({
artifacts: [{ updateAvailable: true, extendsProviderSlugs: ["aws"] }],
myArtifacts: [
{
extendsProviderSlugs: ["aws"],
catalogArtifact: { extendsProviderSlugs: ["aws"] },
},
],
});
});
it("offers the catalog version for an installed artifact with a different resolved version", () => {
// Given
const catalog = {
@@ -66,18 +107,6 @@ describe("Registry marketplace model", () => {
{ resolvedVersion: undefined, latestVersion: "1.0.0", expected: false },
{ resolvedVersion: " ", latestVersion: "1.0.0", expected: false },
{ resolvedVersion: "1.0.0", latestVersion: undefined, expected: false },
{
resolvedVersion: "1.0.0",
latestVersion: "1.1.0",
isBuiltin: true,
expected: false,
},
{
resolvedVersion: "1.0.0",
latestVersion: "1.1.0",
hasProvider: false,
expected: false,
},
])(
"compares resolved $resolvedVersion against catalog $latestVersion ($expected)",
(example) => {
@@ -153,6 +182,7 @@ describe("Registry marketplace model", () => {
{
normalizedName: "core",
versionSpec: "latest",
extendsProviderSlugs: [],
catalogArtifact: expect.objectContaining({
normalizedName: "core",
isAdded: true,
@@ -161,6 +191,7 @@ describe("Registry marketplace model", () => {
{
normalizedName: "manual",
versionSpec: "1.2.3",
extendsProviderSlugs: [],
catalogArtifact: undefined,
},
]);
@@ -1,4 +1,3 @@
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
import {
REGISTRY_CATALOG,
type RegistryCatalogArtifact,
@@ -39,6 +38,8 @@ export interface RegistryMarketplaceArtifact extends RegistryCatalogArtifact {
isAdded: boolean;
resolvedVersion?: string;
updateAvailable: boolean;
/** Built-in providers the install adds checks to; empty until installed. */
extendsProviderSlugs: string[];
}
export interface RegistryMarketplaceMyArtifact extends RegistryTenantArtifact {
@@ -85,12 +86,13 @@ export function buildRegistryMarketplaceModel(
latestVersion,
resolvedVersion,
isAdded: Boolean(installed),
// Versions alone: a checks artifact defines no provider yet updates.
updateAvailable: Boolean(
resolvedVersion &&
latestVersion &&
resolvedVersion !== latestVersion &&
isRegistryArtifactInstallable(artifact),
resolvedVersion !== latestVersion,
),
extendsProviderSlugs: installed?.extendsProviderSlugs ?? [],
},
];
}),
@@ -110,11 +112,12 @@ export function buildRegistryMarketplaceModel(
new Set(catalog.artifacts.flatMap((artifact) => artifact.providers)),
).sort(compare),
myArtifacts: myArtifacts
.map(({ normalizedName, versionSpec, resolvedVersion }) => ({
normalizedName,
versionSpec,
resolvedVersion: resolvedVersion?.trim() || undefined,
catalogArtifact: merged.get(normalizedName),
.map((installed) => ({
normalizedName: installed.normalizedName,
versionSpec: installed.versionSpec,
resolvedVersion: installed.resolvedVersion?.trim() || undefined,
extendsProviderSlugs: installed.extendsProviderSlugs ?? [],
catalogArtifact: merged.get(installed.normalizedName),
}))
.sort((left, right) =>
compare(left.normalizedName, right.normalizedName),
+8 -5
View File
@@ -19,7 +19,6 @@ import {
} from "@/components/shadcn/tabs/tabs";
import { toast } from "@/components/shadcn/toast/use-toast";
import { executeRegistryArtifactAddition } from "@/lib/registry/artifact-execution";
import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
import { executeRegistryCredentialValidation } from "@/lib/registry/credential-execution";
import {
REGISTRY_CREDENTIAL_CHANGED,
@@ -62,7 +61,7 @@ import { RegistryToolbar } from "./registry-toolbar";
import { useRegistryRefresh } from "./use-registry-refresh";
const PAGE_SUBTITLE =
"Explore checks, compliance frameworks, and providers. Add external provider artifacts to connect new providers to your workspace.";
"Explore checks, compliance frameworks, and providers. Add artifacts to connect new providers or to run more checks on the ones you already scan.";
const REGISTRY_TAB = { EXPLORE: "explore", MINE: "mine" } as const;
type RegistryTab = (typeof REGISTRY_TAB)[keyof typeof REGISTRY_TAB];
@@ -319,7 +318,7 @@ export function RegistryExplorer({
async function handleAdd(artifact: RegistryMarketplaceArtifact) {
if (
!isRegistryArtifactInstallable(artifact) ||
!artifact.isInstallable ||
(artifact.isAdded && !artifact.updateAvailable) ||
pendingAddName ||
pendingOperation ||
@@ -426,7 +425,10 @@ export function RegistryExplorer({
if (result.status === REGISTRY_FAILURE.ACCESS_DENIED)
return router.replace("/profile");
if (result.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE) {
if (
result.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE ||
result.status === REGISTRY_ARTIFACT_REMOVAL.BUSY
) {
setRemoveError(result);
return;
}
@@ -690,7 +692,7 @@ export function RegistryExplorer({
<TabsContent className="space-y-4 pt-4" value={REGISTRY_TAB.MINE}>
<RegistryArtifactGrid
emptyMessage="No artifacts in this workspace yet."
emptyDescription="Explore the catalog to add an external provider to this workspace."
emptyDescription="Explore the catalog to add providers or checks to this workspace."
emptyActionLabel="Explore artifacts"
isEmpty={model.myArtifacts.length === 0}
onReset={() => setActiveTab(REGISTRY_TAB.EXPLORE)}
@@ -708,6 +710,7 @@ export function RegistryExplorer({
/>
) : (
<RegistryTenantArtifactCard
extendsProviderSlugs={myArtifact.extendsProviderSlugs}
normalizedName={myArtifact.normalizedName}
onRemove={(trigger) =>
openRemoveDialog(myArtifact.normalizedName, trigger)
@@ -31,6 +31,8 @@ export function RegistryRemoveDialog({
}: RegistryRemoveDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const isInUse = error?.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE;
// Busy keeps Confirm Remove: waiting is the remedy, not deleting providers.
const isBusy = error?.status === REGISTRY_ARTIFACT_REMOVAL.BUSY;
// Disabling the submit button can lose focus; restore it inside the dialog
// when a failure re-enables the actions or replaces them with recovery actions.
@@ -57,9 +59,12 @@ export function RegistryRemoveDialog({
{error && (
<Alert variant="error">
{isInUse && <AlertTitle>Artifact in use</AlertTitle>}
{isBusy && <AlertTitle>A scan is using this artifact</AlertTitle>}
<AlertDescription>
{error.status === REGISTRY_ARTIFACT_REMOVAL.IN_USE
{isInUse
? "This artifact cannot be removed because one or more providers use it. Review the associated providers before trying again."
: isBusy
? "A scan is running the checks this artifact adds. It clears by itself when the scan finishes, so try again shortly."
: error.message}
</AlertDescription>
</Alert>
-8
View File
@@ -1,8 +0,0 @@
import type { RegistryCatalogArtifact } from "@/types/registry";
/** Installation eligibility comes from catalog capabilities, never the package name. */
export function isRegistryArtifactInstallable(
artifact: Pick<RegistryCatalogArtifact, "hasProvider" | "isBuiltin">,
): boolean {
return artifact.hasProvider === true && artifact.isBuiltin === false;
}
+23
View File
@@ -0,0 +1,23 @@
const REGISTRY_NOT_INSTALLABLE_COPY = {
checks_target_is_not_builtin:
"Its checks are written for a provider this deployment does not ship.",
artifact_compliance_not_supported:
"It ships compliance frameworks as well as checks, which is not supported yet.",
artifact_targets_no_provider:
"It ships checks but names no provider to extend.",
artifact_defines_nothing_usable: "It brings neither a provider nor checks.",
artifact_ships_with_prowler:
"Its code is already inside Prowler. Nothing to install.",
} as const;
const REGISTRY_NOT_INSTALLABLE_FALLBACK =
"This artifact cannot be installed in this deployment.";
/** Explains the API's refusal code; codes it adds later get the fallback. */
export function getRegistryNotInstallableMessage(reason?: string): string {
return reason && Object.hasOwn(REGISTRY_NOT_INSTALLABLE_COPY, reason)
? REGISTRY_NOT_INSTALLABLE_COPY[
reason as keyof typeof REGISTRY_NOT_INSTALLABLE_COPY
]
: REGISTRY_NOT_INSTALLABLE_FALLBACK;
}
+26
View File
@@ -17,6 +17,7 @@ const provider: RegistryCatalogArtifact = {
isMeta: false,
hasChecks: true,
hasCompliance: false,
isInstallable: true,
versionCount: 1,
totalDownloads: 0,
owners: [],
@@ -105,6 +106,31 @@ describe("installed Registry provider options", () => {
]);
expect(buildRegistryProviderOptions(catalog, [], [])).toEqual([]);
});
it("asks whether an artifact defines a provider type, not whether it installs", () => {
// Given: installed checks for AWS, and a provider this deployment now refuses.
const catalog = [
{
...provider,
normalizedName: "aws-checks",
hasProvider: false,
providerSlug: undefined,
providers: ["aws"],
},
{ ...provider, isInstallable: false },
];
const installed = catalog.map(({ normalizedName }) => ({
normalizedName,
versionSpec: "latest",
}));
// When / Then
expect(
buildRegistryProviderOptions(catalog, installed, []).map(
(option) => option.type,
),
).toEqual(["acme"]);
});
it("uses the declared provider rather than other providers targeted by the package", () => {
const catalog = [
{ ...provider, providerSlug: "zeta", providers: ["acme", "zeta"] },
+19 -6
View File
@@ -4,14 +4,29 @@ import type {
RegistryTenantArtifact,
} from "@/types/registry";
import { isRegistryArtifactInstallable } from "./artifacts";
export interface RegistryProviderOption {
type: string;
label: string;
logoUrl?: string;
}
export const REGISTRY_PROVIDER_DISCOVERY = {
READY: "ready",
ACCESS_DENIED: "access_denied",
/** Access could not be evaluated: keep Registry hidden but retryable. */
UNKNOWN: "unknown",
ERROR: "error",
} as const;
export type RegistryProviderDiscoveryResult =
| {
status: typeof REGISTRY_PROVIDER_DISCOVERY.READY;
options: RegistryProviderOption[];
}
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED }
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.UNKNOWN }
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.ERROR };
export function buildRegistryProviderOptions(
catalog: RegistryCatalogArtifact[],
installed: RegistryTenantArtifact[],
@@ -25,10 +40,8 @@ export function buildRegistryProviderOptions(
);
const options = new Map<string, RegistryProviderOption>();
for (const artifact of catalog) {
if (
!membership.has(artifact.normalizedName) ||
!isRegistryArtifactInstallable(artifact)
)
// Defining a provider type, not installability: checks artifacts install too.
if (!membership.has(artifact.normalizedName) || !artifact.hasProvider)
continue;
const declaredType = artifact.providerSlug;
for (const type of declaredType ? [declaredType] : []) {
@@ -92,6 +92,8 @@ const catalogPages = [
has_checks: true,
has_provider: true,
is_builtin: false,
is_installable: true,
not_installable_reason: null,
is_official: true,
is_verified: true,
latest_version: "1.2.3",
@@ -107,6 +109,8 @@ const catalogPages = [
description: "Synthetic Registry fixture built-in provider",
has_provider: true,
is_builtin: true,
is_installable: false,
not_installable_reason: "artifact_ships_with_prowler",
latest_version: "1.0.0",
name: "Fixture built-in provider",
providers: ["aws"],
@@ -114,6 +118,8 @@ const catalogPages = [
catalogArtifact("fixture-shared-policy", {
description: "Synthetic Registry fixture shared policy",
has_compliance: true,
is_installable: false,
not_installable_reason: "artifact_defines_nothing_usable",
latest_version: "2.0.0",
name: "Fixture shared policy",
owner_name: "Community Fixtures",
@@ -126,6 +132,8 @@ const catalogPages = [
catalogArtifact("fixture-shared-policy", {
description: "Synthetic Registry fixture shared policy",
has_compliance: true,
is_installable: false,
not_installable_reason: "artifact_defines_nothing_usable",
latest_version: "2.0.0",
name: "Fixture shared policy",
providers: ["gcp"],
@@ -619,6 +627,8 @@ async function handleApiRequest(
name,
latest_version: "1.0.0",
has_checks: true,
is_installable: true,
not_installable_reason: null,
providers: ["aws"],
}),
)
+4 -2
View File
@@ -14,7 +14,9 @@
**Preconditions:** Local and Cloud-with-Registry-flag-off fixture servers.
**Expected Result:** Registry navigation is absent and the direct route redirects safely in both profiles.
**Flow:** Verify Registry navigation and direct-route access, then open the Add Provider selector.
**Expected Result:** Registry navigation is absent, the direct route redirects safely, and Add Provider lists built-in providers without Registry source tabs in both profiles.
## Test Case: `REGISTRY-E2E-002` - Enabled Manager Discovery
@@ -59,7 +61,7 @@
**Priority:** `critical`
**Tags:** @e2e, @registry
**Expected Result:** The All tab displays the complete paginated catalog and supports search, combined provider and capability filters, URL state, and owner logos with fallback. Built-ins display Built in without Add. Checks/compliance-only artifacts remain visible without Add. An external provider artifact installs through a 202 task and an authoritative membership read before Added appears. Removal preserves provider accounts. Reconnect, unavailable, and generic failures have actionable empty states.
**Expected Result:** The All tab displays the complete paginated catalog and supports search, combined provider and capability filters, URL state, and owner logos with fallback. Built-ins display Built in without Add. Add follows the API's `is_installable` verdict: artifacts it refuses remain visible without Add and state the reason. An external provider artifact installs through a 202 task and an authoritative membership read before Added appears. Removal preserves provider accounts. Reconnect, unavailable, and generic failures have actionable empty states.
## Test Case: `REGISTRY-E2E-006` - Pixel 5 Reduced-Motion Browsing
+15 -2
View File
@@ -119,6 +119,10 @@ export interface RegistryCatalogArtifact {
hasProvider: boolean;
hasChecks: boolean;
hasCompliance: boolean;
/** The API's verdict for this deployment; never recomputed client-side. */
isInstallable: boolean;
/** Stable API code; new codes can appear without a UI release. */
notInstallableReason?: string;
checkCount?: number;
complianceCount?: number;
versionCount: number;
@@ -130,6 +134,8 @@ export interface RegistryTenantArtifact {
normalizedName: string;
versionSpec: string;
resolvedVersion?: string;
/** Built-in providers this install adds checks to without defining them. */
extendsProviderSlugs?: string[];
insertedAt?: string;
updatedAt?: string;
}
@@ -236,15 +242,22 @@ export type RegistryMutationResult =
| RegistryFailureResult;
export const REGISTRY_ARTIFACT_REMOVAL = {
/** Providers stand on it: they must be deleted first. */
IN_USE: "in_use",
/** A scan is running its checks: it clears by itself. */
BUSY: "busy",
} as const;
export type RegistryArtifactRemovalConflict =
| { status: typeof REGISTRY_ARTIFACT_REMOVAL.IN_USE }
| { status: typeof REGISTRY_ARTIFACT_REMOVAL.BUSY };
export type RegistryArtifactRemovalResult =
| RegistryMutationResult
| { status: typeof REGISTRY_ARTIFACT_REMOVAL.IN_USE };
| RegistryArtifactRemovalConflict;
export type RegistryRemoveDialogError =
| { status: typeof REGISTRY_ARTIFACT_REMOVAL.IN_USE }
| RegistryArtifactRemovalConflict
| { status: typeof REGISTRY_FAILURE.ERROR; message: string };
export const REGISTRY_BOOTSTRAP_STATE = {