mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-19 09:54:16 +00:00
fix(ui): show Registry subscription and setup prompts (#13038)
This commit is contained in:
1 parent
286ae6c7cf
commit
64c19b3db6
23 files changed
+571
-73
No files matched your search
@@ -190,6 +190,9 @@ export async function classifyRegistryFailure(
|
||||
endpoint: RegistryEndpoint,
|
||||
credentialStatus: RegistryCredentialStatus | null,
|
||||
): Promise<RegistryFailureResult> {
|
||||
if (response.status === 402) {
|
||||
return { status: REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED };
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
}
|
||||
@@ -198,15 +201,16 @@ export async function classifyRegistryFailure(
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
|
||||
const code = await getRegistryErrorCode(response);
|
||||
if (
|
||||
response.status === 409 &&
|
||||
credentialStatus !== null &&
|
||||
!isActiveRegistryCredential(credentialStatus)
|
||||
(code === "registry_credential_not_configured" ||
|
||||
(credentialStatus !== null &&
|
||||
!isActiveRegistryCredential(credentialStatus)))
|
||||
) {
|
||||
return { status: REGISTRY_FAILURE.ONBOARDING };
|
||||
}
|
||||
|
||||
const code = await getRegistryErrorCode(response);
|
||||
if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) {
|
||||
return { status: REGISTRY_FAILURE.RECONNECT };
|
||||
}
|
||||
|
||||
@@ -115,6 +115,45 @@ function mockRequestDeadlines() {
|
||||
}
|
||||
|
||||
describe("installed Registry provider discovery", () => {
|
||||
it("reports a missing Registry key as setup rather than a connection failure", async () => {
|
||||
// Given
|
||||
mockDiscovery({
|
||||
failedEndpoint: "providers",
|
||||
failureStatus: 409,
|
||||
failureBody: { errors: [{ code: "registry_credential_not_configured" }] },
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(await getInstalledRegistryProviderOptions()).toEqual({
|
||||
status: "onboarding",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the paywall when bootstrapping Registry requires a subscription", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 402));
|
||||
|
||||
// When / Then
|
||||
expect(await getRegistryBootstrap()).toEqual({
|
||||
status: "subscription_required",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["available-artifacts", "artifacts", "providers"])(
|
||||
"offers a paid plan when the %s read requires a subscription",
|
||||
async (failedEndpoint) => {
|
||||
// Given
|
||||
mockDiscovery({ failedEndpoint, failureStatus: 402 });
|
||||
|
||||
// When
|
||||
const result = await getInstalledRegistryProviderOptions();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ status: "subscription_required" });
|
||||
},
|
||||
);
|
||||
|
||||
function mockDiscovery({
|
||||
emptyMetadata = false,
|
||||
failedEndpoint,
|
||||
|
||||
@@ -138,6 +138,8 @@ async function classifyDiscoveryFailure(
|
||||
credential: RegistryCredentialStatus | null,
|
||||
) {
|
||||
if (response.status === 409 && credential === null) {
|
||||
const failure = await classifyRegistryFailure(response, endpoint, null);
|
||||
if (failure.status === REGISTRY_FAILURE.ONBOARDING) return failure;
|
||||
const currentCredential = await readRegistryCredential(accessToken);
|
||||
if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) {
|
||||
return currentCredential;
|
||||
@@ -211,6 +213,18 @@ export async function getInstalledRegistryProviderOptions(): Promise<RegistryPro
|
||||
)
|
||||
)
|
||||
return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED };
|
||||
if (
|
||||
[catalog.status, installed.status, providers.status].some(
|
||||
(status) => status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED,
|
||||
)
|
||||
)
|
||||
return { status: REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED };
|
||||
if (
|
||||
[catalog.status, providers.status].some(
|
||||
(status) => status === REGISTRY_FAILURE.ONBOARDING,
|
||||
)
|
||||
)
|
||||
return { status: REGISTRY_PROVIDER_DISCOVERY.ONBOARDING };
|
||||
if (
|
||||
catalog.status !== REGISTRY_CATALOG.COMPLETE ||
|
||||
installed.status !== "ready" ||
|
||||
@@ -303,8 +317,11 @@ function bootstrapReady(
|
||||
function bootstrapFailure(
|
||||
failure: RegistryFailureResult,
|
||||
): RegistryBootstrapResult {
|
||||
if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) {
|
||||
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
if (
|
||||
failure.status === REGISTRY_FAILURE.ACCESS_DENIED ||
|
||||
failure.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED
|
||||
) {
|
||||
return { status: failure.status };
|
||||
}
|
||||
return bootstrapReady({
|
||||
status:
|
||||
@@ -340,8 +357,11 @@ export async function getRegistryBootstrap(): Promise<RegistryBootstrapResult> {
|
||||
}
|
||||
|
||||
const catalog = await readCompleteRegistryCatalog(access, credential);
|
||||
if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) {
|
||||
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
if (
|
||||
catalog.status === REGISTRY_FAILURE.ACCESS_DENIED ||
|
||||
catalog.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED
|
||||
) {
|
||||
return { status: catalog.status };
|
||||
}
|
||||
if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) {
|
||||
return bootstrapReady({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getRegistryBootstrap } from "@/actions/registry/registry";
|
||||
|
||||
import RegistryPage from "./page";
|
||||
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
getRegistryBootstrap: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/registry/presentation", () => ({
|
||||
readRegistryPresentation: () => ({}),
|
||||
}));
|
||||
vi.mock("@/components/shadcn/content-layout/content-layout", () => ({
|
||||
ContentLayout: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/components/registry/registry-explorer", () => ({
|
||||
RegistryExplorer: () => <p>Registry catalog</p>,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`redirect:${path}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Registry paid access", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("offers an upgrade on direct visits without loading the catalog", async () => {
|
||||
// Given
|
||||
vi.mocked(getRegistryBootstrap).mockResolvedValue({
|
||||
status: "subscription_required",
|
||||
});
|
||||
|
||||
// When
|
||||
render(await RegistryPage());
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Unlock Registry" }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Registry catalog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps access denial separate from a paid plan upgrade", async () => {
|
||||
// Given
|
||||
vi.mocked(getRegistryBootstrap).mockResolvedValue({
|
||||
status: "access_denied",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(RegistryPage()).rejects.toThrow("redirect:/profile");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getRegistryBootstrap } from "@/actions/registry/registry";
|
||||
import { RegistryExplorer } from "@/components/registry/registry-explorer";
|
||||
import { RegistryUpgradePrompt } from "@/components/registry/registry-upgrade-prompt";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout/content-layout";
|
||||
import { readRegistryPresentation } from "@/lib/registry/presentation";
|
||||
import { REGISTRY_FAILURE } from "@/types/registry";
|
||||
@@ -15,10 +16,14 @@ export default async function RegistryPage() {
|
||||
|
||||
return (
|
||||
<ContentLayout title="Registry" icon={<Package />}>
|
||||
<RegistryExplorer
|
||||
initialState={bootstrap.state}
|
||||
registryUrl={readRegistryPresentation().registryUrl}
|
||||
/>
|
||||
{bootstrap.status === REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED ? (
|
||||
<RegistryUpgradePrompt />
|
||||
) : (
|
||||
<RegistryExplorer
|
||||
initialState={bootstrap.state}
|
||||
registryUrl={readRegistryPresentation().registryUrl}
|
||||
/>
|
||||
)}
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Registry provider discovery distinguishes paid-plan upgrades, missing credentials, and connection failures
|
||||
@@ -9,6 +9,10 @@ const { addProvider, updateProvider, getInstalledRegistryProviderOptions } =
|
||||
getInstalledRegistryProviderOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ permissions: { manage_registry: true } }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
@@ -23,6 +27,8 @@ vi.mock("@/actions/registry/registry", () => ({
|
||||
getInstalledRegistryProviderOptions,
|
||||
}));
|
||||
|
||||
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
|
||||
|
||||
import { ConnectAccountForm } from "./connect-account-form";
|
||||
|
||||
describe("provider account aliases", () => {
|
||||
@@ -84,6 +90,57 @@ describe("provider account aliases", () => {
|
||||
});
|
||||
|
||||
describe("Registry provider source tabs", () => {
|
||||
it("explains Registry setup without claiming a connection failure", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "onboarding",
|
||||
});
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Registry is not connected yet"),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Unlock Registry" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the Registry paid plan upgrade while keeping built-in providers usable", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "subscription_required",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Unlock Registry" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(useCloudUpgradeStore.getState().activeFeature).toBe("registry");
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Amazon Web Services/ }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Retry Registry providers" }),
|
||||
).not.toBeInTheDocument();
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
@@ -8,20 +8,19 @@ import { useForm, UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { addProvider, updateProvider } from "@/actions/providers/providers";
|
||||
import { addRegistryProvider } from "@/actions/providers/registry-provider";
|
||||
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
|
||||
import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector";
|
||||
import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector";
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
|
||||
import {
|
||||
RegistryUpgradePrompt,
|
||||
RegistrySetupPrompt,
|
||||
} from "@/components/registry/registry-upgrade-prompt";
|
||||
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 {
|
||||
REGISTRY_PROVIDER_DISCOVERY,
|
||||
type RegistryProviderOption,
|
||||
} from "@/lib/registry/provider-options";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options";
|
||||
import {
|
||||
createAddProviderFormSchema,
|
||||
AddProviderFormValues,
|
||||
@@ -38,6 +37,8 @@ import { isKnownProviderType } from "@/types/providers";
|
||||
|
||||
import { RadioGroupProvider } from "../../radio-group-provider";
|
||||
|
||||
import { useRegistryProviderDiscovery } from "./use-registry-provider-discovery";
|
||||
|
||||
export type FormValues = AddProviderFormValues;
|
||||
|
||||
export interface ConnectAccountSuccessData {
|
||||
@@ -219,60 +220,25 @@ export const ConnectAccountForm = ({
|
||||
const [method, setMethod] = useState<"single" | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const [registryOptions, setRegistryOptions] = useState<
|
||||
RegistryProviderOption[]
|
||||
>([]);
|
||||
// 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 {
|
||||
result: registryDiscovery,
|
||||
isRetryingDiscovery,
|
||||
retryDiscovery,
|
||||
} = useRegistryProviderDiscovery();
|
||||
const registryOptions =
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY
|
||||
? registryDiscovery.options
|
||||
: [];
|
||||
const registryAvailable =
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY ||
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR;
|
||||
const registryError =
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR ||
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
// Registry is Cloud-only: elsewhere never ask, so a failure cannot surface it.
|
||||
if (!isCloud()) return;
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const result = await getInstalledRegistryProviderOptions();
|
||||
if (!active) return;
|
||||
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([]);
|
||||
setRegistryAvailable(false);
|
||||
setRegistryError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
window.removeEventListener("registry-artifacts-changed", load);
|
||||
};
|
||||
}, [discoveryAttempt]);
|
||||
|
||||
const formSchema = createAddProviderFormSchema(
|
||||
registryOptions.map((option) => option.type),
|
||||
);
|
||||
@@ -474,6 +440,12 @@ export const ConnectAccountForm = ({
|
||||
{/* Step 1: Provider selection */}
|
||||
{prevStep === 1 && (
|
||||
<div data-tour-id="add-provider-provider-type">
|
||||
{registryDiscovery?.status ===
|
||||
REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED && (
|
||||
<RegistryUpgradePrompt />
|
||||
)}
|
||||
{registryDiscovery?.status ===
|
||||
REGISTRY_PROVIDER_DISCOVERY.ONBOARDING && <RegistrySetupPrompt />}
|
||||
{registryError && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Registry providers could not be loaded</AlertTitle>
|
||||
@@ -485,11 +457,7 @@ export const ConnectAccountForm = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-disabled={isRetryingDiscovery}
|
||||
onClick={() => {
|
||||
if (isRetryingDiscovery) return;
|
||||
setIsRetryingDiscovery(true);
|
||||
setDiscoveryAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
onClick={retryDiscovery}
|
||||
>
|
||||
{isRetryingDiscovery
|
||||
? "Retrying…"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
|
||||
import type { RegistryProviderDiscoveryResult } from "@/lib/registry/provider-options";
|
||||
|
||||
import { useRegistryProviderDiscovery } from "./use-registry-provider-discovery";
|
||||
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
getInstalledRegistryProviderOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: RegistryProviderDiscoveryResult) => void;
|
||||
const promise = new Promise<RegistryProviderDiscoveryResult>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("useRegistryProviderDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
});
|
||||
|
||||
it("ignores a stale discovery that settles after a newer one", async () => {
|
||||
// Given
|
||||
const initial = deferred();
|
||||
const afterChange = deferred();
|
||||
vi.mocked(getInstalledRegistryProviderOptions)
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockReturnValueOnce(afterChange.promise);
|
||||
const { result } = renderHook(() => useRegistryProviderDiscovery());
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("registry-artifacts-changed"));
|
||||
});
|
||||
await act(async () => {
|
||||
afterChange.resolve({ status: "subscription_required" });
|
||||
});
|
||||
await act(async () => {
|
||||
initial.resolve({ status: "ready", options: [] });
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(result.current.result).toEqual({
|
||||
status: "subscription_required",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a retry even when the request it replaces settles later", async () => {
|
||||
// Given
|
||||
const initial = deferred();
|
||||
const retry = deferred();
|
||||
vi.mocked(getInstalledRegistryProviderOptions)
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockReturnValueOnce(retry.promise);
|
||||
const { result } = renderHook(() => useRegistryProviderDiscovery());
|
||||
|
||||
// When
|
||||
act(() => result.current.retryDiscovery());
|
||||
await act(async () => {
|
||||
retry.resolve({ status: "onboarding" });
|
||||
});
|
||||
await act(async () => {
|
||||
initial.resolve({ status: "error" });
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(result.current.isRetryingDiscovery).toBe(false));
|
||||
expect(result.current.result).toEqual({ status: "onboarding" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
|
||||
import {
|
||||
REGISTRY_PROVIDER_DISCOVERY,
|
||||
type RegistryProviderDiscoveryResult,
|
||||
} from "@/lib/registry/provider-options";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
export function useRegistryProviderDiscovery() {
|
||||
// Local state needed: discovery and retries resolve asynchronously.
|
||||
const [result, setResult] = useState<RegistryProviderDiscoveryResult | null>(
|
||||
null,
|
||||
);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [isRetryingDiscovery, setIsRetryingDiscovery] = useState(false);
|
||||
// Initial, retry and artifact-change loads overlap: only the newest applies.
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloud()) return;
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
const request = ++latestRequest.current;
|
||||
const isCurrent = () => active && request === latestRequest.current;
|
||||
try {
|
||||
const discovery = await getInstalledRegistryProviderOptions();
|
||||
if (isCurrent()) setResult(discovery);
|
||||
} catch {
|
||||
if (isCurrent())
|
||||
setResult({ status: REGISTRY_PROVIDER_DISCOVERY.UNKNOWN });
|
||||
}
|
||||
};
|
||||
void load().then(() => {
|
||||
if (active) setIsRetryingDiscovery(false);
|
||||
});
|
||||
window.addEventListener("registry-artifacts-changed", load);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("registry-artifacts-changed", load);
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const retryDiscovery = () => {
|
||||
if (isRetryingDiscovery) return;
|
||||
setIsRetryingDiscovery(true);
|
||||
setAttempt((current) => current + 1);
|
||||
};
|
||||
|
||||
return { result, isRetryingDiscovery, retryDiscovery };
|
||||
}
|
||||
@@ -440,6 +440,28 @@ describe("RegistryExplorer", () => {
|
||||
expect(cardFor("AWS guard")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-renders the page when a refresh reports a lapsed subscription", async () => {
|
||||
// Given
|
||||
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
||||
refreshRegistryCollectionsMock.mockResolvedValue({
|
||||
status: "subscription_required",
|
||||
});
|
||||
// When
|
||||
await screen.getByRole("button", { name: "Refresh Registry" }).click();
|
||||
// Then
|
||||
// The mocked router never swaps the page, so the explorer may refresh again.
|
||||
await expect
|
||||
.poll(() => vi.mocked(registryRouter.refresh).mock.calls.length)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
expect(vi.mocked(registryRouter.replace)).not.toHaveBeenCalled();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
||||
.toBeEnabled();
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"Registry could not be refreshed.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["access_denied", "reconnect", "onboarding"])(
|
||||
"honors %s from a collection refresh",
|
||||
async (status) => {
|
||||
|
||||
@@ -182,6 +182,9 @@ export function RegistryExplorer({
|
||||
onResult: (result) => {
|
||||
if (result.status === "access_denied") {
|
||||
router.replace("/profile");
|
||||
} else if (result.status === "subscription_required") {
|
||||
// The server page swaps the catalog for the paid-plan prompt.
|
||||
router.refresh();
|
||||
} else if (result.status === "complete") {
|
||||
setRefreshMessage(undefined);
|
||||
setState((current) =>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
|
||||
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
|
||||
|
||||
import { RegistryUpgradePrompt } from "./registry-upgrade-prompt";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ permissions: { manage_billing: true } }),
|
||||
}));
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1280, height: 800 },
|
||||
];
|
||||
|
||||
describe("Registry paid plan prompt", () => {
|
||||
afterEach(() => {
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
it.each(VIEWPORTS)(
|
||||
"opens the shared paid plan modal on $name",
|
||||
async ({ name, width, height }) => {
|
||||
// Given
|
||||
await page.viewport(width, height);
|
||||
const screen = await render(
|
||||
<main className="bg-bg-neutral-primary min-h-screen p-6">
|
||||
<RegistryUpgradePrompt />
|
||||
<CloudUpgradeModal />
|
||||
</main>,
|
||||
);
|
||||
|
||||
// When
|
||||
await screen.getByRole("button", { name: "Unlock Registry" }).click();
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(page.getByRole("dialog", { name: "Unlock Prowler Registry" }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(page.getByRole("link", { name: "Upgrade to Unlock Registry" }))
|
||||
.toHaveAttribute("href", "/billing?feature=registry");
|
||||
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.getByRole("dialog", { name: "Unlock Prowler Registry" })
|
||||
.element()
|
||||
.getAnimations({ subtree: true })
|
||||
.every((animation) => animation.playState !== "running"),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.screenshot({
|
||||
path: `../../tests/providers/evidence/registry-paywall-${name}.png`,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RegistrySetupPrompt } from "./registry-upgrade-prompt";
|
||||
|
||||
const { permissions } = vi.hoisted(() => ({
|
||||
permissions: { manage_registry: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ permissions }),
|
||||
}));
|
||||
|
||||
describe("RegistrySetupPrompt", () => {
|
||||
beforeEach(() => {
|
||||
permissions.manage_registry = false;
|
||||
});
|
||||
|
||||
it("sends Registry managers to connect their key", () => {
|
||||
// Given
|
||||
permissions.manage_registry = true;
|
||||
|
||||
// When
|
||||
render(<RegistrySetupPrompt />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Connect Registry" }),
|
||||
).toHaveAttribute("href", "/registry");
|
||||
});
|
||||
|
||||
it("asks everyone else for an administrator", () => {
|
||||
// When
|
||||
render(<RegistrySetupPrompt />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Ask an administrator to connect Registry before adding custom providers.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Connect Registry" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
|
||||
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
export function RegistryUpgradePrompt() {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>Registry is available on paid plans</AlertTitle>
|
||||
<AlertDescription>
|
||||
Unlock custom providers, security checks, and compliance frameworks.
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REGISTRY)}
|
||||
>
|
||||
Unlock Registry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegistrySetupPrompt() {
|
||||
const { permissions } = useAuth();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>Registry is not connected yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
{permissions.manage_registry === true ? (
|
||||
<>
|
||||
Connect your Registry API key to load custom providers.
|
||||
<Button asChild variant="outline">
|
||||
<a href="/registry">Connect Registry</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
"Ask an administrator to connect Registry before adding custom providers."
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,23 @@ vi.mock("@/components/shadcn/modal", async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe("CloudUpgradeModal", () => {
|
||||
it("links the Registry paid plan upgrade to billing", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
useCloudUpgradeStore.getState().openCloudUpgrade("registry");
|
||||
|
||||
// When
|
||||
render(<CloudUpgradeModal />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Unlock Prowler Registry" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Upgrade to Unlock Registry" }),
|
||||
).toHaveAttribute("href", "/billing?feature=registry");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
modalTestState.keepContentMounted = false;
|
||||
|
||||
@@ -215,10 +215,21 @@ export const PAID_PLAN_UPGRADE_ADMIN_NOTE =
|
||||
const CLOUD_UTM_SOURCE = "prowler-cloud";
|
||||
|
||||
const PAID_PLAN_UPGRADE_UTM_CONTENT = {
|
||||
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: "registry",
|
||||
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download",
|
||||
} as const satisfies Record<PaidPlanUpgradeFeature, string>;
|
||||
|
||||
export const PAID_PLAN_UPGRADE_CONTENT = {
|
||||
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: {
|
||||
title: "Unlock Prowler Registry",
|
||||
description: "Prowler Registry is included in paid plans.",
|
||||
benefits: [
|
||||
"Connect custom cloud providers",
|
||||
"Extend your assessments with custom security checks",
|
||||
"Use private compliance frameworks across your accounts",
|
||||
],
|
||||
primaryCta: "Upgrade to Unlock Registry",
|
||||
},
|
||||
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: {
|
||||
title: "Download Your Scan Reports",
|
||||
description: "Report downloads are included in Prowler Cloud paid plans.",
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface RegistryProviderOption {
|
||||
}
|
||||
|
||||
export const REGISTRY_PROVIDER_DISCOVERY = {
|
||||
SUBSCRIPTION_REQUIRED: "subscription_required",
|
||||
ONBOARDING: "onboarding",
|
||||
READY: "ready",
|
||||
ACCESS_DENIED: "access_denied",
|
||||
/** Access could not be evaluated: keep Registry hidden but retryable. */
|
||||
@@ -20,6 +22,8 @@ export const REGISTRY_PROVIDER_DISCOVERY = {
|
||||
} as const;
|
||||
|
||||
export type RegistryProviderDiscoveryResult =
|
||||
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED }
|
||||
| { status: typeof REGISTRY_PROVIDER_DISCOVERY.ONBOARDING }
|
||||
| {
|
||||
status: typeof REGISTRY_PROVIDER_DISCOVERY.READY;
|
||||
options: RegistryProviderOption[];
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -19,6 +19,7 @@ export type CloudUpgradeFeature =
|
||||
|
||||
// Prowler Cloud features gated behind a paid plan.
|
||||
export const PAID_PLAN_UPGRADE_FEATURE = {
|
||||
REGISTRY: "registry",
|
||||
REPORT_DOWNLOAD: "report_download",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type RegistryEndpoint =
|
||||
(typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT];
|
||||
|
||||
export const REGISTRY_FAILURE = {
|
||||
SUBSCRIPTION_REQUIRED: "subscription_required",
|
||||
ACCESS_DENIED: "access_denied",
|
||||
ONBOARDING: "onboarding",
|
||||
RECONNECT: "reconnect",
|
||||
@@ -17,6 +18,7 @@ export const REGISTRY_FAILURE = {
|
||||
} as const;
|
||||
|
||||
export type RegistryFailureResult =
|
||||
| { status: typeof REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED }
|
||||
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
|
||||
| { status: typeof REGISTRY_FAILURE.ONBOARDING }
|
||||
| { status: typeof REGISTRY_FAILURE.RECONNECT }
|
||||
@@ -300,4 +302,5 @@ export type RegistryBootstrapResult =
|
||||
status: typeof REGISTRY_BOOTSTRAP_STATE.READY;
|
||||
state: RegistryBootstrapState;
|
||||
}
|
||||
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
| { status: typeof REGISTRY_FAILURE.ACCESS_DENIED }
|
||||
| { status: typeof REGISTRY_FAILURE.SUBSCRIPTION_REQUIRED };
|
||||
Reference in new issue
Block a user