mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
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:
co-authored by
Claude Fable 5.1
parent
d819639f0e
commit
03cb59c20d
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,90 +99,111 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const providerList = (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select a provider"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{filteredProviders.length > 0 ? (
|
||||
filteredProviders.map((provider) => {
|
||||
const isSelected = field.value === provider.value;
|
||||
|
||||
<TabsContent value={activeTab}>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select a provider"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{filteredProviders.length > 0 ? (
|
||||
filteredProviders.map((provider) => {
|
||||
const isSelected = field.value === provider.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={`${provider.label}${provider.registry ? " Registry" : ""}`}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => field.onChange(provider.value)}
|
||||
className={cn(
|
||||
"flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors",
|
||||
"focus-visible:border-button-primary focus-visible:outline-none",
|
||||
isSelected
|
||||
? "border-button-primary bg-bg-neutral-tertiary"
|
||||
: "border-border-neutral-primary bg-bg-neutral-tertiary hover:border-button-primary",
|
||||
isInvalid && "border-bg-fail",
|
||||
return (
|
||||
<button
|
||||
key={provider.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={`${provider.label}${provider.registry ? " Registry" : ""}`}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => field.onChange(provider.value)}
|
||||
className={cn(
|
||||
"flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors",
|
||||
"focus-visible:border-button-primary focus-visible:outline-none",
|
||||
isSelected
|
||||
? "border-button-primary bg-bg-neutral-tertiary"
|
||||
: "border-border-neutral-primary bg-bg-neutral-tertiary hover:border-button-primary",
|
||||
isInvalid && "border-bg-fail",
|
||||
)}
|
||||
>
|
||||
<div className="border-border-neutral-primary bg-bg-input-primary flex size-[18px] shrink-0 items-center justify-center rounded-full border shadow-xs">
|
||||
{isSelected && (
|
||||
<div className="bg-button-primary size-2.5 rounded-full" />
|
||||
)}
|
||||
>
|
||||
<div className="border-border-neutral-primary bg-bg-input-primary flex size-[18px] shrink-0 items-center justify-center rounded-full border shadow-xs">
|
||||
{isSelected && (
|
||||
<div className="bg-button-primary size-2.5 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{provider.registry ? (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={
|
||||
provider.logoUrl?.startsWith("https://")
|
||||
? provider.logoUrl
|
||||
: undefined
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<ProviderTypeIcon
|
||||
type={provider.value}
|
||||
size={26}
|
||||
/>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<ProviderTypeIcon type={provider.value} size={26} />
|
||||
)}
|
||||
<span className="text-text-neutral-primary text-sm leading-6">
|
||||
{provider.label}
|
||||
</span>
|
||||
{provider.registry && (
|
||||
<Badge variant="tag">Registry</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary py-4 text-sm">
|
||||
{lowerSearch ? (
|
||||
<>No providers found matching "{searchTerm}"</>
|
||||
) : (
|
||||
"No Registry providers available."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{provider.registry ? (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={
|
||||
provider.logoUrl?.startsWith("https://")
|
||||
? provider.logoUrl
|
||||
: undefined
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<ProviderTypeIcon type={provider.value} size={26} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<ProviderTypeIcon type={provider.value} size={26} />
|
||||
)}
|
||||
<span className="text-text-neutral-primary text-sm leading-6">
|
||||
{provider.label}
|
||||
</span>
|
||||
{provider.registry && (
|
||||
<Badge variant="tag">Registry</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary py-4 text-sm">
|
||||
{lowerSearch ? (
|
||||
<>No providers found matching "{searchTerm}"</>
|
||||
) : (
|
||||
"No Registry providers available."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
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>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
{errorMessage && (
|
||||
<FormMessage className="text-text-error-primary">
|
||||
{errorMessage}
|
||||
</FormMessage>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
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),
|
||||
|
||||
@@ -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,10 +59,13 @@ 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."
|
||||
: error.message}
|
||||
: 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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user