mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2171 lines
74 KiB
TypeScript
2171 lines
74 KiB
TypeScript
import { http, HttpResponse } from "msw";
|
|
import { useRouter } from "next/navigation";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { userEvent } from "vitest/browser";
|
|
|
|
import { worker } from "@/__tests__/msw/worker";
|
|
import { render } from "@/__tests__/render-browser";
|
|
import type {
|
|
RegistryArtifactRemovalResult,
|
|
RegistryBootstrapState,
|
|
} from "@/types/registry";
|
|
|
|
import { RegistryArtifactCard } from "./registry-artifact-card";
|
|
import { RegistryExplorer } from "./registry-explorer";
|
|
|
|
vi.mock("next/navigation", async () => {
|
|
const { useSyncExternalStore } = await import("react");
|
|
const router = { replace: vi.fn(), push: vi.fn(), refresh: vi.fn() };
|
|
const subscribe = (callback: () => void) => {
|
|
window.addEventListener("popstate", callback);
|
|
return () => window.removeEventListener("popstate", callback);
|
|
};
|
|
return {
|
|
useRouter: () => router,
|
|
usePathname: () => "/registry",
|
|
useSearchParams: () =>
|
|
new URLSearchParams(
|
|
useSyncExternalStore(
|
|
subscribe,
|
|
() => window.location.search,
|
|
() => "",
|
|
),
|
|
),
|
|
};
|
|
});
|
|
const originalReplaceState = window.history.replaceState.bind(window.history);
|
|
|
|
const {
|
|
disconnectRegistryCredentialMock,
|
|
executeRegistryArtifactAdditionMock,
|
|
refreshRegistryCollectionsMock,
|
|
refreshRegistryCredentialMock,
|
|
removeRegistryArtifactMock,
|
|
submitRegistryCredentialMock,
|
|
trackAndPollTaskMock,
|
|
} = vi.hoisted(() => ({
|
|
disconnectRegistryCredentialMock: vi.fn(),
|
|
executeRegistryArtifactAdditionMock: vi.fn(),
|
|
refreshRegistryCollectionsMock: vi.fn(),
|
|
refreshRegistryCredentialMock: vi.fn(),
|
|
removeRegistryArtifactMock: vi.fn(),
|
|
submitRegistryCredentialMock: vi.fn(),
|
|
trackAndPollTaskMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/actions/registry/registry", () => ({
|
|
disconnectRegistryCredential: disconnectRegistryCredentialMock,
|
|
getRegistryBootstrap: vi.fn(),
|
|
refreshRegistryCollections: refreshRegistryCollectionsMock,
|
|
refreshRegistryCredential: refreshRegistryCredentialMock,
|
|
removeRegistryArtifact: removeRegistryArtifactMock,
|
|
submitRegistryCredential: submitRegistryCredentialMock,
|
|
}));
|
|
|
|
// The credential flow watches its validation task through the house task
|
|
// watcher; integration tests drive settlement through this mock the same way
|
|
// `lib/jira-dispatch-execution.test.ts` does.
|
|
vi.mock("@/lib/registry/artifact-execution", () => ({
|
|
executeRegistryArtifactAddition: executeRegistryArtifactAdditionMock,
|
|
}));
|
|
|
|
vi.mock("@/store/task-watcher/store", () => ({
|
|
TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
|
|
trackAndPollTask: trackAndPollTaskMock,
|
|
useTaskWatcherStore: (selector: (state: { tasks: {} }) => unknown) =>
|
|
selector({ tasks: {} }),
|
|
}));
|
|
|
|
// The integration setup mocks `next/navigation` with a module-level router,
|
|
// so this "hook" is a plain function returning the shared router spies and
|
|
// is safe to call outside a component.
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
const registryRouter = useRouter();
|
|
|
|
const onboardingState: RegistryBootstrapState = {
|
|
status: "onboarding",
|
|
credential: {
|
|
configured: false,
|
|
isValid: false,
|
|
scopes: [],
|
|
validationPending: false,
|
|
},
|
|
tenantArtifacts: [],
|
|
};
|
|
|
|
const validationPendingState: RegistryBootstrapState = {
|
|
status: "validation_pending",
|
|
credential: {
|
|
configured: true,
|
|
isValid: false,
|
|
scopes: [],
|
|
validationPending: true,
|
|
},
|
|
tenantArtifacts: [],
|
|
};
|
|
|
|
const submittedResult = (priorConfigured = false) => ({
|
|
status: "submitted" as const,
|
|
taskId: "registry-task-1",
|
|
priorConfigured,
|
|
});
|
|
|
|
const readyState: RegistryBootstrapState = {
|
|
status: "ready",
|
|
credential: {
|
|
configured: true,
|
|
isValid: true,
|
|
scopes: ["catalog:read"],
|
|
validationPending: false,
|
|
},
|
|
catalog: {
|
|
status: "complete",
|
|
artifacts: [
|
|
{
|
|
normalizedName: "aws-guard",
|
|
name: "AWS guard",
|
|
description: "Already added artifact",
|
|
latestVersion: "1.2.3",
|
|
providers: ["aws"],
|
|
isVerified: true,
|
|
isOfficial: true,
|
|
isBuiltin: false,
|
|
isMeta: false,
|
|
hasProvider: true,
|
|
hasChecks: true,
|
|
hasCompliance: false,
|
|
versionCount: 2,
|
|
totalDownloads: 12,
|
|
owners: [
|
|
{
|
|
name: "Prowler",
|
|
type: "organization",
|
|
logoUrl: "https://cdn.example/prowler-logo.png",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
normalizedName: "later-guard",
|
|
name: "Later guard",
|
|
description: "Artifact collected from a later page",
|
|
latestVersion: "2.0.0",
|
|
providers: ["azure"],
|
|
isVerified: false,
|
|
isOfficial: false,
|
|
isBuiltin: false,
|
|
isMeta: false,
|
|
hasProvider: true,
|
|
hasChecks: true,
|
|
hasCompliance: true,
|
|
versionCount: 1,
|
|
totalDownloads: 3,
|
|
owners: [],
|
|
},
|
|
{
|
|
normalizedName: "cloud-guard",
|
|
name: "Cloud guard",
|
|
description: "Multi-provider artifact",
|
|
latestVersion: "3.0.0",
|
|
providers: ["aws", "gcp"],
|
|
isVerified: true,
|
|
isOfficial: true,
|
|
isBuiltin: false,
|
|
isMeta: true,
|
|
hasProvider: true,
|
|
hasChecks: true,
|
|
hasCompliance: true,
|
|
versionCount: 4,
|
|
totalDownloads: 42,
|
|
owners: [{ name: "Registry team", type: "organization" }],
|
|
},
|
|
],
|
|
},
|
|
tenantArtifacts: [
|
|
{ normalizedName: "aws-guard", versionSpec: "latest" },
|
|
{
|
|
normalizedName: "saved-artifact",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
},
|
|
],
|
|
};
|
|
|
|
async function expectRedirectedToProfile() {
|
|
await expect
|
|
.poll(() => vi.mocked(registryRouter.replace).mock.calls)
|
|
.toEqual([["/profile"]]);
|
|
}
|
|
|
|
const incompleteState: RegistryBootstrapState = {
|
|
status: "incomplete",
|
|
catalog: { status: "incomplete", reason: "page_failed", collectedCount: 100 },
|
|
};
|
|
|
|
function cardFor(name: string) {
|
|
const card = Array.from(document.querySelectorAll("li")).find((item) =>
|
|
item.textContent?.includes(name),
|
|
);
|
|
if (!card) throw new Error(`Expected a rendered card for ${name}`);
|
|
return card;
|
|
}
|
|
|
|
describe("RegistryExplorer", () => {
|
|
it("refreshes catalog and installed versions when switching tabs", async () => {
|
|
// Given
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: [
|
|
{
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
},
|
|
],
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
// When
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
// Then
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
|
|
)
|
|
.toBeVisible();
|
|
expect(refreshRegistryCollectionsMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("coalesces manual, tab and focus refreshes while preserving filters", async () => {
|
|
// Given
|
|
let resolveRefresh!: (value: unknown) => void;
|
|
refreshRegistryCollectionsMock.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveRefresh = resolve;
|
|
}),
|
|
);
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByLabelText("Search artifacts").fill("AWS");
|
|
// When: the tab change also requests a refresh, sharing any focus read.
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
window.dispatchEvent(new Event("focus"));
|
|
await screen.getByRole("tab", { name: /All/ }).click();
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
|
.toBeDisabled();
|
|
expect(document.body.textContent).toContain("Refreshing…");
|
|
expect(refreshRegistryCollectionsMock).toHaveBeenCalledOnce();
|
|
expect(cardFor("AWS guard")).toBeTruthy();
|
|
resolveRefresh({
|
|
status: "complete",
|
|
catalog: {
|
|
...readyState.catalog,
|
|
artifacts: readyState.catalog.artifacts.map((artifact) => ({
|
|
...artifact,
|
|
latestVersion: "2.0.0",
|
|
})),
|
|
},
|
|
tenantArtifacts: [
|
|
{
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.2.3",
|
|
},
|
|
],
|
|
});
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Update AWS guard to 2.0.0" }),
|
|
)
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByLabelText("Search artifacts"))
|
|
.toHaveValue("AWS");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
|
.toBeEnabled();
|
|
expect(document.body.textContent).not.toContain("Later guard");
|
|
});
|
|
|
|
it("loads a newly published artifact from the Refresh button without navigating", async () => {
|
|
// Given
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
catalog: {
|
|
...readyState.catalog,
|
|
artifacts: [
|
|
...readyState.catalog.artifacts,
|
|
{
|
|
...readyState.catalog.artifacts[0],
|
|
normalizedName: "new-artifact",
|
|
name: "New artifact",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
// When
|
|
await screen.getByRole("button", { name: "Refresh Registry" }).click();
|
|
// Then
|
|
await expect
|
|
.element(screen.getByText("New artifact", { exact: true }))
|
|
.toBeVisible();
|
|
expect(window.location.pathname).toBe("/registry");
|
|
expect(registryRouter.refresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["error", "incomplete"])(
|
|
"preserves the last complete snapshot after a %s refresh and allows retry",
|
|
async (status) => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status,
|
|
reason: "page_failed",
|
|
collectedCount: 1,
|
|
});
|
|
// When
|
|
await screen.getByRole("button", { name: "Refresh Registry" }).click();
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("alert"))
|
|
.toHaveTextContent("Showing the last available data.");
|
|
expect(cardFor("AWS guard")).toBeTruthy();
|
|
expect(cardFor("Later guard")).toBeTruthy();
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
|
.toBeEnabled();
|
|
// When / Then
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: [],
|
|
});
|
|
await screen.getByRole("button", { name: "Refresh Registry" }).click();
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Add AWS guard" }))
|
|
.toBeVisible();
|
|
await expect.element(screen.getByRole("alert")).not.toBeInTheDocument();
|
|
},
|
|
);
|
|
|
|
it("discards a stale refresh and waits for an update before reading again", async () => {
|
|
// Given
|
|
const installed = {
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
};
|
|
let stale!: (value: unknown) => void;
|
|
let update!: (value: unknown) => void;
|
|
let fresh!: (value: unknown) => void;
|
|
refreshRegistryCollectionsMock
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
stale = resolve;
|
|
}),
|
|
)
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
fresh = resolve;
|
|
}),
|
|
);
|
|
executeRegistryArtifactAdditionMock.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
update = resolve;
|
|
}),
|
|
);
|
|
const screen = await render(
|
|
<RegistryExplorer
|
|
initialState={{ ...readyState, tenantArtifacts: [installed] }}
|
|
/>,
|
|
);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
// When
|
|
await screen
|
|
.getByRole("button", { name: "Update AWS guard to 1.2.3" })
|
|
.click();
|
|
window.dispatchEvent(new Event("focus"));
|
|
stale({
|
|
status: "complete",
|
|
catalog: { status: "complete", artifacts: [] },
|
|
tenantArtifacts: [],
|
|
});
|
|
// Then: old reads cannot erase an in-flight update.
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
|
|
)
|
|
.toBeDisabled();
|
|
expect(refreshRegistryCollectionsMock).toHaveBeenCalledOnce();
|
|
const tenantArtifacts = [{ ...installed, resolvedVersion: "1.2.3" }];
|
|
update({ status: "confirmed", tenantArtifacts });
|
|
await expect
|
|
.element(screen.getByText("Added", { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.poll(() => refreshRegistryCollectionsMock.mock.calls.length)
|
|
.toBe(2);
|
|
fresh({ status: "complete", catalog: readyState.catalog, tenantArtifacts });
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
|
.toBeEnabled();
|
|
expect(cardFor("AWS guard").textContent).not.toContain("1.0.0");
|
|
await expect
|
|
.element(screen.getByText("Added", { exact: true }))
|
|
.toBeVisible();
|
|
});
|
|
|
|
it("keeps the list usable when the refresh request rejects", async () => {
|
|
// Given
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
refreshRegistryCollectionsMock.mockRejectedValue(
|
|
new Error("Network failure"),
|
|
);
|
|
// When
|
|
await screen.getByRole("button", { name: "Refresh Registry" }).click();
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("alert"))
|
|
.toHaveTextContent("Registry could not be refreshed.");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
|
.toBeEnabled();
|
|
expect(cardFor("AWS guard")).toBeTruthy();
|
|
});
|
|
|
|
it.each(["access_denied", "reconnect", "onboarding"])(
|
|
"honors %s from a collection refresh",
|
|
async (status) => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
refreshRegistryCollectionsMock.mockResolvedValue({ status });
|
|
// When
|
|
await screen.getByRole("button", { name: "Refresh Registry" }).click();
|
|
// Then
|
|
if (status === "access_denied") {
|
|
await expectRedirectedToProfile();
|
|
} else {
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", {
|
|
name: status === "reconnect" ? "Replace key" : "Connect API key",
|
|
}),
|
|
)
|
|
.toBeVisible();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retries a deferred refresh after credential replacement fails", async () => {
|
|
// Given
|
|
let stale!: (value: unknown) => void;
|
|
refreshRegistryCollectionsMock
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
stale = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: [],
|
|
});
|
|
submitRegistryCredentialMock.mockResolvedValue({
|
|
status: "replacement_failed",
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
// When
|
|
await screen.getByRole("button", { name: "Manage access" }).click();
|
|
await screen
|
|
.getByLabelText("Registry key")
|
|
.fill("synthetic-replacement-key");
|
|
await screen.getByRole("button", { name: "Replace key" }).click();
|
|
await expect.element(screen.getByRole("alert")).toBeVisible();
|
|
stale({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
// Then
|
|
await expect
|
|
.poll(() => refreshRegistryCollectionsMock.mock.calls.length)
|
|
.toBe(2);
|
|
await screen.getByRole("button", { name: "Close", exact: true }).click();
|
|
await expect
|
|
.element(screen.getByText("No artifacts in this workspace yet."))
|
|
.toBeVisible();
|
|
});
|
|
|
|
it("does not restore a removed artifact from an earlier refresh", async () => {
|
|
// Given
|
|
let stale!: (value: unknown) => void;
|
|
refreshRegistryCollectionsMock
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
stale = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: [],
|
|
});
|
|
removeRegistryArtifactMock.mockResolvedValue({
|
|
status: "confirmed",
|
|
tenantArtifacts: [],
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
// When
|
|
await screen.getByRole("button", { name: "Remove AWS guard" }).click();
|
|
await screen.getByRole("button", { name: "Confirm Remove" }).click();
|
|
await expect
|
|
.element(screen.getByText("No artifacts in this workspace yet."))
|
|
.toBeVisible();
|
|
stale({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Refresh Registry" }))
|
|
.toBeEnabled();
|
|
await expect
|
|
.element(screen.getByText("No artifacts in this workspace yet."))
|
|
.toBeVisible();
|
|
expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("refreshes on returning focus and ignores focus while hidden", async () => {
|
|
// Given
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
const visibility = vi
|
|
.spyOn(document, "visibilityState", "get")
|
|
.mockReturnValue("hidden");
|
|
refreshRegistryCollectionsMock.mockClear();
|
|
// When / Then
|
|
window.dispatchEvent(new Event("focus"));
|
|
expect(refreshRegistryCollectionsMock).not.toHaveBeenCalled();
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: [
|
|
{
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
},
|
|
],
|
|
});
|
|
visibility.mockReturnValue("visible");
|
|
window.dispatchEvent(new Event("focus"));
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
|
|
)
|
|
.toBeVisible();
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
{ status: "refused", message: "This version has been withdrawn." },
|
|
"This version has been withdrawn.",
|
|
],
|
|
[
|
|
{ status: "unavailable" },
|
|
"The Registry operation could not be completed. Try again.",
|
|
],
|
|
[
|
|
{ status: "refresh_failed" },
|
|
"Update could not be confirmed. Refresh Registry before retrying.",
|
|
],
|
|
])(
|
|
"keeps Update available after %j and allows retry",
|
|
async (result, message) => {
|
|
// Given
|
|
const installed = {
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
};
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue(result);
|
|
const screen = await render(
|
|
<RegistryExplorer
|
|
initialState={{ ...readyState, tenantArtifacts: [installed] }}
|
|
/>,
|
|
);
|
|
// When
|
|
await screen
|
|
.getByRole("button", { name: "Update AWS guard to 1.2.3" })
|
|
.click();
|
|
// Then
|
|
await expect
|
|
.element(screen.getByText(message as string, { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
|
|
)
|
|
.toBeEnabled();
|
|
expect(cardFor("AWS guard").textContent).toContain("1.0.0");
|
|
expect(document.body.textContent).not.toContain("Artifact updated");
|
|
// When / Then: retry can complete normally.
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue({
|
|
status: "confirmed",
|
|
tenantArtifacts: [{ ...installed, resolvedVersion: "1.2.3" }],
|
|
});
|
|
await screen
|
|
.getByRole("button", { name: "Update AWS guard to 1.2.3" })
|
|
.click();
|
|
await expect
|
|
.element(screen.getByText("Added", { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByText(message as string, { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
},
|
|
);
|
|
|
|
it("updates an installed artifact to the displayed version and returns to Added", async () => {
|
|
// Given
|
|
const installed = {
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "latest",
|
|
resolvedVersion: "1.0.0",
|
|
};
|
|
let complete!: (value: unknown) => void;
|
|
executeRegistryArtifactAdditionMock.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
complete = resolve;
|
|
}),
|
|
);
|
|
const screen = await render(
|
|
<RegistryExplorer
|
|
initialState={{ ...readyState, tenantArtifacts: [installed] }}
|
|
/>,
|
|
);
|
|
// When
|
|
await screen
|
|
.getByRole("button", { name: "Update AWS guard to 1.2.3" })
|
|
.click();
|
|
// Then
|
|
expect(executeRegistryArtifactAdditionMock).toHaveBeenCalledWith({
|
|
normalizedName: "aws-guard",
|
|
versionSpec: "1.2.3",
|
|
operation: "update",
|
|
});
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Update AWS guard to 1.2.3" }),
|
|
)
|
|
.toBeDisabled();
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Remove AWS guard" }))
|
|
.toBeDisabled();
|
|
expect(document.body.textContent).toContain("Updating…");
|
|
complete({
|
|
status: "confirmed",
|
|
tenantArtifacts: [{ ...installed, resolvedVersion: "1.2.3" }],
|
|
});
|
|
await expect
|
|
.element(screen.getByText("Added", { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Remove AWS guard" }))
|
|
.toBeEnabled();
|
|
expect(cardFor("AWS guard").textContent).not.toContain("1.0.0");
|
|
});
|
|
|
|
it("offers key replacement when the configured Registry rejects access", async () => {
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={{ status: "reconnect" }} />,
|
|
);
|
|
await screen.getByRole("button", { name: "Replace key" }).click();
|
|
await expect.element(screen.getByRole("dialog")).toBeVisible();
|
|
await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
worker.use(
|
|
http.get(
|
|
"https://cdn.example/prowler-logo.png",
|
|
() =>
|
|
new HttpResponse(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20" fill="black"/></svg>',
|
|
{ headers: { "Content-Type": "image/svg+xml" } },
|
|
),
|
|
),
|
|
http.get(
|
|
"https://cdn.example/expired.png",
|
|
() => new HttpResponse(null, { status: 403 }),
|
|
),
|
|
);
|
|
originalReplaceState(null, "", "/registry");
|
|
vi.spyOn(window.history, "replaceState").mockImplementation(
|
|
(data, unused, url) => {
|
|
originalReplaceState(data, unused, url);
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
},
|
|
);
|
|
disconnectRegistryCredentialMock.mockReset();
|
|
executeRegistryArtifactAdditionMock.mockReset();
|
|
refreshRegistryCollectionsMock.mockReset();
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
refreshRegistryCredentialMock.mockReset();
|
|
removeRegistryArtifactMock.mockReset();
|
|
submitRegistryCredentialMock.mockReset();
|
|
trackAndPollTaskMock.mockReset();
|
|
trackAndPollTaskMock.mockResolvedValue({
|
|
status: "ready",
|
|
result: { stored: true, error: null },
|
|
});
|
|
vi.mocked(registryRouter.replace).mockClear();
|
|
});
|
|
|
|
describe("when Registry access is not connected", () => {
|
|
it("shows the credential banner instead of a catalog", async () => {
|
|
// Given / When
|
|
await render(<RegistryExplorer initialState={onboardingState} />);
|
|
|
|
// Then
|
|
expect(document.body.textContent).toContain(
|
|
"Connect your Registry API key",
|
|
);
|
|
expect(document.body.textContent).toContain(
|
|
"A Registry API key is required to install artifacts into this workspace.",
|
|
);
|
|
expect(document.body.textContent).not.toContain(
|
|
"preserved tenant artifact",
|
|
);
|
|
expect(document.body.textContent).toContain("Explore Prowler Registry");
|
|
expect(document.body.textContent).not.toContain("Search artifacts");
|
|
});
|
|
|
|
it("lets a replacement key supersede a pending validation from the banner", async () => {
|
|
// Given: a validation that never settled must not dead-end the user
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: readyState.credential,
|
|
});
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={validationPendingState} />,
|
|
);
|
|
|
|
// Pending validation leaves the form available for a replacement.
|
|
expect(document.body.textContent).toContain(
|
|
"Registry validation in progress",
|
|
);
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect API key" }))
|
|
.toBeEnabled();
|
|
expect(document.body.textContent).not.toContain("Search artifacts");
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("replacement-key");
|
|
await screen
|
|
.getByRole("button", { name: "Connect", exact: true })
|
|
.click();
|
|
|
|
// Then: the replacement POST supersedes the pending validation
|
|
await expect
|
|
.poll(() => submitRegistryCredentialMock.mock.calls)
|
|
.toEqual([["replacement-key"]]);
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Manage access" }))
|
|
.toBeVisible();
|
|
});
|
|
});
|
|
|
|
it("moves focus into the access dialog and returns it to Connect API key", async () => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
const connectButton = screen.getByRole("button", {
|
|
name: "Connect API key",
|
|
});
|
|
|
|
// When
|
|
await connectButton.click();
|
|
|
|
// Then
|
|
await expect.element(screen.getByLabelText("Registry key")).toHaveFocus();
|
|
|
|
// When
|
|
await userEvent.keyboard("{Escape}");
|
|
|
|
// Then
|
|
await expect.element(connectButton).toHaveFocus();
|
|
});
|
|
|
|
it("presents the connect dialog with help link and cancel action", async () => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer
|
|
initialState={onboardingState}
|
|
registryKeyUrl="https://registry.private.test/keys"
|
|
/>,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(
|
|
screen.getByRole("heading", { name: "Connect Registry API key" }),
|
|
)
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByRole("link", { name: "Where do I find my key?" }))
|
|
.toHaveAttribute("href", "https://registry.private.test/keys");
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByLabelText("Registry key"))
|
|
.not.toBeInTheDocument();
|
|
});
|
|
|
|
it("preserves the catalog while a watched replacement keeps the form visible and disabled", async () => {
|
|
// Given
|
|
const key = "replacement-key";
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult(true));
|
|
trackAndPollTaskMock.mockReturnValue(new Promise(() => {}));
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
|
|
// Access stays available by its accessible name, without a text label.
|
|
expect(document.body.textContent).not.toContain("API key connected");
|
|
expect(
|
|
document.querySelector('button[aria-label="Manage access"]')?.textContent,
|
|
).toBe("");
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Manage access" }).click();
|
|
await screen.getByLabelText("Registry key").fill(key);
|
|
await screen.getByRole("button", { name: "Replace key" }).click();
|
|
|
|
// Then: the form stays visible; submit shows a disabled Connecting… state
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connecting…" }))
|
|
.toBeDisabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toBeDisabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Disconnect" }))
|
|
.toBeDisabled();
|
|
expect(document.body.textContent).toContain("Cloud guard");
|
|
expect(document.body.innerHTML).not.toContain(key);
|
|
});
|
|
|
|
it("shows the invalid-key error inline below the input and keeps the form retry-capable", async () => {
|
|
// Given
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult());
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: onboardingState.credential,
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("bad-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then: the error renders inside the dialog, below the input
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog).not.toBeNull();
|
|
await expect
|
|
.poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
|
|
.toContain("This Registry key is invalid. Check it and try again.");
|
|
const input = screen.getByLabelText("Registry key").element();
|
|
const alert = dialog!.querySelector('[role="alert"]');
|
|
expect(
|
|
input.compareDocumentPosition(alert!) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
|
|
// Then: the form is re-enabled for a retry with the key cleared
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect", exact: true }))
|
|
.toBeEnabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
|
|
await expect.element(screen.getByLabelText("Registry key")).toHaveFocus();
|
|
|
|
// When: a retry submits a fresh key through the same form
|
|
await screen.getByLabelText("Registry key").fill("second-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => submitRegistryCredentialMock.mock.calls)
|
|
.toEqual([["bad-key"], ["second-key"]]);
|
|
});
|
|
|
|
it("keeps a retry-capable form after a watcher failure", async () => {
|
|
// Given
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult());
|
|
trackAndPollTaskMock.mockRejectedValue(new Error("watcher crashed"));
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("registry-test-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog).not.toBeNull();
|
|
await expect
|
|
.poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
|
|
.toContain("Registry key validation could not be completed. Try again.");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect", exact: true }))
|
|
.toBeEnabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
|
|
});
|
|
|
|
it("recovers the form with an inline notice when the watch exhausts without settling", async () => {
|
|
// Given: the watcher gives up while the task is still unsettled (e.g. no
|
|
// worker consumes the queue) — the tracking result resolves as pending
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult());
|
|
trackAndPollTaskMock.mockResolvedValue({ status: "pending" });
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: validationPendingState.credential,
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("stuck-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then: the dialog exits Connecting… into a retry-capable form
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog).not.toBeNull();
|
|
await expect
|
|
.poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
|
|
.toContain(
|
|
"Registry key validation is taking longer than expected. Try again.",
|
|
);
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect", exact: true }))
|
|
.toBeEnabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
|
|
expect(document.body.textContent).toContain(
|
|
"Registry validation in progress",
|
|
);
|
|
});
|
|
|
|
it("recovers the form when the submit RPC rejects instead of stranding Connecting", async () => {
|
|
// Given: the server-action RPC itself rejects (network drop, dev reload)
|
|
submitRegistryCredentialMock.mockRejectedValue(new Error("rpc dropped"));
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("registry-test-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then: no stranded Connecting… — the form recovers with an inline error
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog).not.toBeNull();
|
|
await expect
|
|
.poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
|
|
.toContain("Registry key validation could not be completed. Try again.");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect", exact: true }))
|
|
.toBeEnabled();
|
|
await expect.element(screen.getByLabelText("Registry key")).toBeEnabled();
|
|
});
|
|
|
|
it("recovers the form when the post-connect collections RPC rejects", async () => {
|
|
// Given: validation succeeds but the collections server action rejects
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult());
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: readyState.credential,
|
|
});
|
|
refreshRegistryCollectionsMock.mockRejectedValue(new Error("rpc dropped"));
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("registry-test-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then: no stranded Connecting… — the form recovers with an inline error
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog).not.toBeNull();
|
|
await expect
|
|
.poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
|
|
.toContain("Registry collections could not be loaded. Try again.");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect", exact: true }))
|
|
.toBeEnabled();
|
|
});
|
|
|
|
it("keeps the dialog open with an inline notice when validation outlasts the watch", async () => {
|
|
// Given
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult());
|
|
trackAndPollTaskMock.mockResolvedValue({
|
|
status: "error",
|
|
error: "The task expired before it could be tracked to completion.",
|
|
});
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: validationPendingState.credential,
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("slow-key");
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then: an inline notice keeps the retry path available in the dialog
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog).not.toBeNull();
|
|
await expect
|
|
.poll(() => dialog!.querySelector('[role="alert"]')?.textContent)
|
|
.toContain(
|
|
"Registry key validation is taking longer than expected. Try again.",
|
|
);
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect", exact: true }))
|
|
.toBeEnabled();
|
|
// And the underlying banner reflects the pending validation
|
|
expect(document.body.textContent).toContain(
|
|
"Registry validation in progress",
|
|
);
|
|
});
|
|
|
|
it("trims and resets a write-only key before loading authoritative collections", async () => {
|
|
// Given
|
|
const key = "registry-test-key";
|
|
let resolveSubmission: ((result: unknown) => void) | undefined;
|
|
submitRegistryCredentialMock.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSubmission = resolve;
|
|
}),
|
|
);
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: readyState.credential,
|
|
});
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "complete",
|
|
catalog: readyState.catalog,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill(` ${key} `);
|
|
await screen.getByRole("button", { name: "Connect", exact: true }).click();
|
|
|
|
// Then: repeat submission is disabled and the key has left the form.
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connecting…", exact: true }))
|
|
.toBeDisabled();
|
|
await expect
|
|
.poll(() => submitRegistryCredentialMock.mock.calls)
|
|
.toEqual([[key]]);
|
|
await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
|
|
expect(document.body.innerHTML).not.toContain(key);
|
|
expect(window.location.href).not.toContain(key);
|
|
expect(localStorage.getItem("registry-key")).toBeNull();
|
|
expect(sessionStorage.getItem("registry-key")).toBeNull();
|
|
|
|
// When: the accepted task settles through the watcher
|
|
resolveSubmission?.(submittedResult());
|
|
|
|
// Then: the explorer lands in ready state and announces the connection
|
|
await expect
|
|
.element(screen.getByRole("tab", { name: /All/ }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByLabelText("Registry key"))
|
|
.not.toBeInTheDocument();
|
|
expect(refreshRegistryCredentialMock).toHaveBeenCalledTimes(1);
|
|
expect(refreshRegistryCollectionsMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
describe("when a Registry action loses authorization", () => {
|
|
it("routes to Profile once when Add is denied", async () => {
|
|
// Given
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue({
|
|
status: "access_denied",
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Add Cloud guard" }).click();
|
|
|
|
// Then
|
|
await expectRedirectedToProfile();
|
|
});
|
|
|
|
it("routes to Profile once when Remove is denied", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockResolvedValue({ status: "access_denied" });
|
|
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
|
|
await expectRedirectedToProfile();
|
|
});
|
|
|
|
it("routes to Profile once when credential submission is denied", async () => {
|
|
// Given
|
|
submitRegistryCredentialMock.mockResolvedValue({
|
|
status: "access_denied",
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("registry-test-key");
|
|
|
|
// When
|
|
await screen
|
|
.getByRole("button", { name: "Connect", exact: true })
|
|
.click();
|
|
|
|
// Then
|
|
await expectRedirectedToProfile();
|
|
});
|
|
|
|
it("routes to Profile once when disconnect is denied", async () => {
|
|
// Given
|
|
disconnectRegistryCredentialMock.mockResolvedValue({
|
|
status: "access_denied",
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
await screen.getByRole("button", { name: "Manage access" }).click();
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Disconnect" }).click();
|
|
|
|
// Then
|
|
await expectRedirectedToProfile();
|
|
});
|
|
|
|
it("routes to Profile once when post-connect collection refresh is denied", async () => {
|
|
// Given
|
|
submitRegistryCredentialMock.mockResolvedValue(submittedResult());
|
|
refreshRegistryCredentialMock.mockResolvedValue({
|
|
status: "status",
|
|
credential: readyState.credential,
|
|
});
|
|
refreshRegistryCollectionsMock.mockResolvedValue({
|
|
status: "access_denied",
|
|
});
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={onboardingState} />,
|
|
);
|
|
await screen.getByRole("button", { name: "Connect API key" }).click();
|
|
await screen.getByLabelText("Registry key").fill("registry-test-key");
|
|
|
|
// When
|
|
await screen
|
|
.getByRole("button", { name: "Connect", exact: true })
|
|
.click();
|
|
|
|
// Then
|
|
await expectRedirectedToProfile();
|
|
});
|
|
});
|
|
|
|
it("keeps the rendered catalog after a failed credential replacement", async () => {
|
|
// Given
|
|
const key = "replacement-key";
|
|
submitRegistryCredentialMock.mockResolvedValue({
|
|
status: "replacement_failed",
|
|
credential: readyState.credential,
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Manage access" }).click();
|
|
await screen.getByLabelText("Registry key").fill(key);
|
|
await screen.getByRole("button", { name: "Replace key" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("dialog"))
|
|
.toHaveTextContent("Existing access is unchanged");
|
|
expect(document.body.textContent).toContain("Cloud guard");
|
|
await expect.element(screen.getByLabelText("Registry key")).toHaveValue("");
|
|
expect(document.body.innerHTML).not.toContain(key);
|
|
});
|
|
|
|
it("recovers and retries when disconnecting Registry rejects", async () => {
|
|
// Given
|
|
disconnectRegistryCredentialMock.mockRejectedValueOnce(
|
|
new Error("Disconnect transport failed"),
|
|
);
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("button", { name: "Manage access" }).click();
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Disconnect" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("alert"))
|
|
.toHaveTextContent(
|
|
"Registry access could not be disconnected. Try again.",
|
|
);
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Cancel", exact: true }))
|
|
.toBeEnabled();
|
|
expect(document.body.textContent).toContain("Cloud guard");
|
|
expect(document.body.textContent).not.toContain(
|
|
"Disconnect transport failed",
|
|
);
|
|
|
|
// When: retry succeeds without reopening the dialog.
|
|
disconnectRegistryCredentialMock.mockResolvedValueOnce({
|
|
status: "disconnected",
|
|
credential: onboardingState.credential,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
await screen.getByRole("button", { name: "Disconnect" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Connect API key" }))
|
|
.toBeVisible();
|
|
});
|
|
|
|
it("returns to the credential banner after disconnecting Registry access", async () => {
|
|
// Given
|
|
disconnectRegistryCredentialMock.mockResolvedValue({
|
|
status: "disconnected",
|
|
credential: onboardingState.credential,
|
|
tenantArtifacts: readyState.tenantArtifacts,
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Manage access" }).click();
|
|
await screen.getByRole("button", { name: "Disconnect" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Connect your Registry API key");
|
|
expect(document.body.textContent).toContain(
|
|
"Your 2 preserved tenant artifacts will remain available in My artifacts.",
|
|
);
|
|
});
|
|
|
|
describe("when the complete catalog is ready", () => {
|
|
it.each([
|
|
{ isBuiltin: true, hasProvider: true },
|
|
{ isBuiltin: false, hasProvider: false },
|
|
])("keeps ineligible artifacts visible without Add: %j", async (flags) => {
|
|
const state: RegistryBootstrapState = {
|
|
...readyState,
|
|
catalog: {
|
|
...readyState.catalog,
|
|
artifacts: [
|
|
{
|
|
...readyState.catalog.artifacts[2],
|
|
normalizedName: "ineligible",
|
|
name: "Ineligible artifact",
|
|
...flags,
|
|
},
|
|
],
|
|
},
|
|
tenantArtifacts: [],
|
|
};
|
|
const screen = await render(<RegistryExplorer initialState={state} />);
|
|
expect(document.body.textContent).toContain("Ineligible artifact");
|
|
await expect
|
|
.element(
|
|
screen.getByRole("button", { name: "Add Ineligible artifact" }),
|
|
)
|
|
.not.toBeInTheDocument();
|
|
expect(executeRegistryArtifactAdditionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps an authoritative built-in membership removable", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockResolvedValue({
|
|
status: "confirmed",
|
|
tenantArtifacts: [
|
|
{ normalizedName: "saved-artifact", versionSpec: "1.0.0" },
|
|
],
|
|
});
|
|
const builtInMemberState: RegistryBootstrapState = {
|
|
...readyState,
|
|
catalog: {
|
|
...readyState.catalog,
|
|
artifacts: readyState.catalog.artifacts.map((artifact) =>
|
|
artifact.normalizedName === "aws-guard"
|
|
? { ...artifact, isBuiltin: true }
|
|
: artifact,
|
|
),
|
|
},
|
|
};
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={builtInMemberState} />,
|
|
);
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("status", { name: "Built in" }))
|
|
.toBeVisible();
|
|
expect(document.body.textContent).toContain("Added");
|
|
const removeButton = screen.getByRole("button", {
|
|
name: "Remove AWS guard",
|
|
});
|
|
await expect.element(removeButton).toBeVisible();
|
|
|
|
// When
|
|
await removeButton.click();
|
|
await screen.getByRole("button", { name: "Confirm Remove" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => removeRegistryArtifactMock.mock.calls)
|
|
.toEqual([["aws-guard"]]);
|
|
});
|
|
|
|
it("switches to authoritative My artifacts and back", async () => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
|
|
await expect
|
|
.element(screen.getByRole("img", { name: "Prowler", exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByText("Prowler", { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
|
|
// When
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("saved-artifact");
|
|
expect(document.body.textContent).toContain("1.0.0");
|
|
expect(document.body.textContent).not.toContain("Later guard");
|
|
|
|
// When
|
|
await screen.getByRole("tab", { name: /All/ }).click();
|
|
|
|
await userEvent.keyboard("{Escape}");
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Later guard");
|
|
});
|
|
|
|
it("derives search from all normalized catalog artifacts", async () => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByLabelText("Search artifacts").fill("later");
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Later guard");
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.not.toContain("Cloud guard");
|
|
expect(document.body.textContent).toContain("1 artifact");
|
|
});
|
|
|
|
it("filters complete results by provider", async () => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
|
|
// When
|
|
await screen.getByLabelText("Filter by provider").click();
|
|
|
|
// When
|
|
await screen.getByRole("option", { name: "Azure", exact: true }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Later guard");
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.not.toContain("Cloud guard");
|
|
});
|
|
|
|
it("combines capability choices and restores the URL with Clear All", async () => {
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
await screen.getByLabelText("Filter by capability").click();
|
|
await screen
|
|
.getByRole("option", { name: "Compliance", exact: true })
|
|
.click();
|
|
await userEvent.keyboard("{Escape}");
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.not.toContain("AWS guard");
|
|
expect(
|
|
new URLSearchParams(window.location.search).get("filter[capability]"),
|
|
).toBe("compliance");
|
|
await screen.getByRole("button", { name: /Clear/ }).click();
|
|
await expect.poll(() => document.body.textContent).toContain("AWS guard");
|
|
expect(window.location.search).toBe("");
|
|
});
|
|
|
|
it("sorts by downloads with name order as the default", async () => {
|
|
// Given
|
|
const screen = await render(
|
|
<RegistryExplorer initialState={readyState} />,
|
|
);
|
|
const order = () => {
|
|
const text = document.body.textContent ?? "";
|
|
return [
|
|
text.indexOf("Cloud guard"),
|
|
text.indexOf("AWS guard"),
|
|
text.indexOf("Later guard"),
|
|
];
|
|
};
|
|
|
|
// Then: default name order puts AWS guard first
|
|
expect(order()[1]).toBeLessThan(order()[0]);
|
|
|
|
// When
|
|
await screen.getByLabelText("Sort artifacts").click();
|
|
await screen.getByRole("option", { name: "Most downloaded" }).click();
|
|
|
|
// Then: downloads order puts Cloud guard first
|
|
await expect.poll(() => order()[0] < order()[1]).toBe(true);
|
|
await expect.poll(() => order()[1] < order()[2]).toBe(true);
|
|
});
|
|
|
|
it("shows a complete empty catalog without degrading controls", async () => {
|
|
// Given / When
|
|
const screen = await render(
|
|
<RegistryExplorer
|
|
initialState={{
|
|
...readyState,
|
|
catalog: { status: "complete", artifacts: [] },
|
|
tenantArtifacts: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
// Then
|
|
expect(document.body.textContent).toContain(
|
|
"No Registry artifacts are available.",
|
|
);
|
|
await expect
|
|
.element(screen.getByLabelText("Search artifacts"))
|
|
.toBeVisible();
|
|
});
|
|
|
|
it("counts logos and pills together toward the four-item cap and overflow", async () => {
|
|
// Given: six providers alternating known logos and dynamic pills
|
|
const blendedArtifact = {
|
|
...readyState.catalog.artifacts[2],
|
|
normalizedName: "blended-guard",
|
|
name: "Blended guard",
|
|
description: "Artifact spanning logos and pills",
|
|
latestVersion: "1.0.0",
|
|
providers: [
|
|
"aws",
|
|
"template",
|
|
"azure",
|
|
"custom-scan",
|
|
"gcp",
|
|
"local_thing",
|
|
],
|
|
};
|
|
|
|
// When
|
|
const screen = await render(
|
|
<RegistryExplorer
|
|
initialState={{
|
|
...readyState,
|
|
catalog: { status: "complete", artifacts: [blendedArtifact] },
|
|
tenantArtifacts: [],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
// Then: the cap keeps the first four items of BOTH kinds, so only the
|
|
// first two pills are visible and two items collapse into "+2".
|
|
const blendedCard = cardFor("Blended guard");
|
|
expect(blendedCard.textContent).toContain("6 providers");
|
|
expect(blendedCard.innerText).toContain("+2");
|
|
await expect
|
|
.element(
|
|
screen.getByRole("listitem").getByText("Template", { exact: true }),
|
|
)
|
|
.toBeVisible();
|
|
await expect
|
|
.element(
|
|
screen
|
|
.getByRole("listitem")
|
|
.getByText("Custom Scan", { exact: true }),
|
|
)
|
|
.toBeVisible();
|
|
await expect
|
|
.element(
|
|
screen
|
|
.getByRole("listitem")
|
|
.getByText("Local Thing", { exact: true }),
|
|
)
|
|
.not.toBeInTheDocument();
|
|
// Even collapsed providers remain named for assistive technology.
|
|
expect(blendedCard.textContent).toContain(
|
|
"Providers: AWS, Template, Azure, Custom Scan, Google Cloud, Local Thing",
|
|
);
|
|
});
|
|
|
|
it("groups artifact metadata, preserves zero counts, and hides missing values", async () => {
|
|
// Given
|
|
const artifact = {
|
|
...readyState.catalog.artifacts[0],
|
|
isAdded: false,
|
|
updateAvailable: false,
|
|
checkCount: 645,
|
|
complianceCount: 45,
|
|
};
|
|
const screen = await render(
|
|
<RegistryArtifactCard
|
|
artifact={artifact}
|
|
onAdd={() => {}}
|
|
onRemove={() => {}}
|
|
/>,
|
|
);
|
|
|
|
// Then: counts, version, and downloads share one labelled metadata block.
|
|
const metadata = screen.getByRole("group", { name: "Artifact metadata" });
|
|
await expect.element(metadata).toBeVisible();
|
|
for (const label of ["Compliance", "Checks", "Version", "Downloads"]) {
|
|
await expect
|
|
.element(metadata.getByText(label, { exact: true }))
|
|
.toBeVisible();
|
|
}
|
|
await expect
|
|
.element(metadata.getByText("45", { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(metadata.getByText("645", { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(metadata.getByText(artifact.latestVersion!, { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(metadata.getByText("12", { exact: true }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByText("45 Compliance", { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
|
|
// When / Then: zero is a known count for every metric.
|
|
await screen.rerender(
|
|
<RegistryArtifactCard
|
|
artifact={{
|
|
...artifact,
|
|
checkCount: 0,
|
|
complianceCount: 0,
|
|
totalDownloads: 0,
|
|
}}
|
|
onAdd={() => {}}
|
|
onRemove={() => {}}
|
|
/>,
|
|
);
|
|
expect(metadata.element().querySelectorAll("dd")).toHaveLength(4);
|
|
expect(
|
|
Array.from(
|
|
metadata.element().querySelectorAll("dd"),
|
|
(value) => value.textContent,
|
|
),
|
|
).toEqual(["0", "0", artifact.latestVersion, "0"]);
|
|
|
|
// When / Then: built-in artifacts keep their metadata without downloads.
|
|
await screen.rerender(
|
|
<RegistryArtifactCard
|
|
artifact={{ ...artifact, isBuiltin: true, totalDownloads: 0 }}
|
|
onAdd={() => {}}
|
|
onRemove={() => {}}
|
|
/>,
|
|
);
|
|
await expect
|
|
.element(metadata.getByText("Downloads", { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
expect(
|
|
Array.from(
|
|
metadata.element().querySelectorAll("dd"),
|
|
(value) => value.textContent,
|
|
),
|
|
).toEqual(["45", "645", artifact.latestVersion]);
|
|
|
|
// When / Then: older responses have no counts, rather than zero counts.
|
|
await screen.rerender(
|
|
<RegistryArtifactCard
|
|
artifact={{
|
|
...artifact,
|
|
checkCount: undefined,
|
|
complianceCount: undefined,
|
|
latestVersion: undefined,
|
|
}}
|
|
onAdd={() => {}}
|
|
onRemove={() => {}}
|
|
/>,
|
|
);
|
|
await expect
|
|
.element(metadata.getByText("Compliance", { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
await expect
|
|
.element(metadata.getByText("Checks", { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
await expect
|
|
.element(metadata.getByText("Version", { exact: true }))
|
|
.not.toBeInTheDocument();
|
|
await expect
|
|
.element(metadata.getByText("Downloads", { exact: true }))
|
|
.toBeVisible();
|
|
});
|
|
|
|
it("recovers the owner image when a fresh URL replaces an expired one", async () => {
|
|
const artifact = {
|
|
...readyState.catalog.artifacts[0],
|
|
isAdded: false,
|
|
updateAvailable: false,
|
|
owners: [
|
|
{
|
|
name: "Registry team",
|
|
type: "organization",
|
|
logoUrl: "https://cdn.example/expired.png",
|
|
},
|
|
],
|
|
};
|
|
const screen = await render(
|
|
<RegistryArtifactCard
|
|
artifact={artifact}
|
|
onAdd={() => {}}
|
|
onRemove={() => {}}
|
|
/>,
|
|
);
|
|
await expect
|
|
.element(screen.getByText("R", { exact: true }))
|
|
.toBeVisible();
|
|
await screen.rerender(
|
|
<RegistryArtifactCard
|
|
artifact={{
|
|
...artifact,
|
|
owners: [
|
|
{
|
|
...artifact.owners[0],
|
|
logoUrl: "https://cdn.example/prowler-logo.png",
|
|
},
|
|
],
|
|
}}
|
|
onAdd={() => {}}
|
|
onRemove={() => {}}
|
|
/>,
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
document.querySelector(
|
|
'img[src="https://cdn.example/prowler-logo.png"]',
|
|
),
|
|
)
|
|
.not.toBeNull();
|
|
expect(document.body.textContent).toContain("Registry team");
|
|
});
|
|
});
|
|
|
|
it("keeps ordinary Add errors local without redirecting to Profile", async () => {
|
|
// Given
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue({ status: "error" });
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Add Cloud guard" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("alert"))
|
|
.toHaveTextContent("Registry operation could not be completed");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Add Cloud guard" }))
|
|
.toBeEnabled();
|
|
expect(registryRouter.replace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("adds the latest version directly from the card once confirmed", async () => {
|
|
// Given
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue({
|
|
status: "confirmed",
|
|
tenantArtifacts: [
|
|
{ normalizedName: "aws-guard", versionSpec: "latest" },
|
|
{ normalizedName: "saved-artifact", versionSpec: "1.0.0" },
|
|
{ normalizedName: "cloud-guard", versionSpec: "latest" },
|
|
],
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
expect(document.body.textContent).not.toContain("Artifact added");
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Add Cloud guard" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => executeRegistryArtifactAdditionMock.mock.calls)
|
|
.toEqual([[{ normalizedName: "cloud-guard" }]]);
|
|
// The confirmed membership now offers Remove instead of Add on the card.
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Remove Cloud guard" }))
|
|
.toBeVisible();
|
|
await expect
|
|
.element(screen.getByRole("tab", { name: /My artifacts/ }))
|
|
.toHaveTextContent("3");
|
|
});
|
|
|
|
it("keeps membership unchanged when an accepted Add cannot be confirmed", async () => {
|
|
// Given
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue({
|
|
status: "refresh_failed",
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Add Cloud guard" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Registry membership could not be confirmed");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Add Cloud guard" }))
|
|
.toBeEnabled();
|
|
await expect
|
|
.element(screen.getByRole("tab", { name: /My artifacts/ }))
|
|
.toHaveTextContent("2");
|
|
});
|
|
|
|
it("keeps documented Add refusals local without redirecting to Profile", async () => {
|
|
// Given
|
|
executeRegistryArtifactAdditionMock.mockResolvedValue({
|
|
status: "refused",
|
|
message: "This version is not verified and cannot be added.",
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Add Cloud guard" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("This version is not verified and cannot be added.");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Add Cloud guard" }))
|
|
.toBeEnabled();
|
|
expect(registryRouter.replace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disables all Add buttons while an Add confirmation is pending", async () => {
|
|
// Given
|
|
executeRegistryArtifactAdditionMock.mockReturnValue(new Promise(() => {}));
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
const addCloudGuard = screen.getByRole("button", {
|
|
name: "Add Cloud guard",
|
|
});
|
|
|
|
// When
|
|
await addCloudGuard.click();
|
|
|
|
// Then
|
|
await expect.element(addCloudGuard).toBeDisabled();
|
|
await expect.element(addCloudGuard).toHaveTextContent("Adding…");
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Add Later guard" }))
|
|
.toBeDisabled();
|
|
expect(executeRegistryArtifactAdditionMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("requires confirmation before Remove and commits only after confirmation", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockResolvedValue({
|
|
status: "confirmed",
|
|
tenantArtifacts: [
|
|
{ normalizedName: "saved-artifact", versionSpec: "1.0.0" },
|
|
],
|
|
});
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Remove AWS guard" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Confirm Remove" }))
|
|
.toBeVisible();
|
|
expect(removeRegistryArtifactMock).not.toHaveBeenCalled();
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Cancel" }).click();
|
|
|
|
// Then
|
|
expect(removeRegistryArtifactMock).not.toHaveBeenCalled();
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "Remove AWS guard" }).click();
|
|
await screen.getByRole("button", { name: "Confirm Remove" }).click();
|
|
|
|
// Then
|
|
await expect
|
|
.poll(() => removeRegistryArtifactMock.mock.calls)
|
|
.toEqual([["aws-guard"]]);
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Artifact removed");
|
|
});
|
|
|
|
it("moves focus into Remove confirmation and returns it to the invoking card button", async () => {
|
|
// Given: the tenant-only artifact carries its own card Remove action
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
const removeButton = screen.getByRole("button", {
|
|
name: "Remove saved-artifact",
|
|
});
|
|
|
|
// When
|
|
await removeButton.click();
|
|
|
|
// Then
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Cancel" }))
|
|
.toHaveFocus();
|
|
|
|
// When
|
|
await userEvent.keyboard("{Escape}");
|
|
|
|
// Then
|
|
await expect.element(removeButton).toHaveFocus();
|
|
});
|
|
|
|
it("disables duplicate Remove submission while confirmation is pending", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockReturnValue(new Promise(() => {}));
|
|
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
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Removing artifact" }))
|
|
.toBeDisabled();
|
|
expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows an in-use Remove error only inside the dialog with recovery actions", async () => {
|
|
// Given
|
|
let resolveRemoval!: (result: RegistryArtifactRemovalResult) => void;
|
|
removeRegistryArtifactMock.mockReturnValue(
|
|
new Promise<RegistryArtifactRemovalResult>((resolve) => {
|
|
resolveRemoval = resolve;
|
|
}),
|
|
);
|
|
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();
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Removing artifact" }))
|
|
.toBeDisabled();
|
|
resolveRemoval({ status: "in_use" });
|
|
|
|
// Then
|
|
const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
|
|
await expect
|
|
.element(dialog.getByRole("alert"))
|
|
.toHaveTextContent("Artifact in use");
|
|
await expect
|
|
.element(dialog.getByRole("alert"))
|
|
.toHaveTextContent(
|
|
"This artifact cannot be removed because one or more providers use it. Review the associated providers before trying again.",
|
|
);
|
|
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "Confirm Remove" }))
|
|
.not.toBeInTheDocument();
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "View providers" }))
|
|
.toBeVisible();
|
|
await expect
|
|
.poll(() => dialog.element().contains(document.activeElement))
|
|
.toBe(true);
|
|
expect(cardFor("AWS guard").textContent).toContain("Remove");
|
|
expect(document.body.textContent).not.toContain("Artifact removed");
|
|
expect(document.body.textContent).not.toContain(
|
|
"Existing provider accounts will remain",
|
|
);
|
|
});
|
|
|
|
it("clears the in-use Remove error on close and restores focus before reopening", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockResolvedValue({ status: "in_use" });
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
const removeButton = screen.getByRole("button", {
|
|
name: "Remove AWS guard",
|
|
});
|
|
await removeButton.click();
|
|
await screen.getByRole("button", { name: "Confirm Remove" }).click();
|
|
const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
|
|
await expect.element(dialog.getByRole("alert")).toBeVisible();
|
|
|
|
// When: use the footer Close action, not the modal's icon button
|
|
await dialog
|
|
.getByRole("button", { name: "Close", exact: true })
|
|
.first()
|
|
.click();
|
|
|
|
// Then
|
|
await expect.element(dialog).not.toBeInTheDocument();
|
|
await expect.element(removeButton).toHaveFocus();
|
|
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(0);
|
|
|
|
// When
|
|
await removeButton.click();
|
|
|
|
// Then
|
|
await expect.element(dialog.getByRole("alert")).not.toBeInTheDocument();
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "Confirm Remove" }))
|
|
.toBeEnabled();
|
|
|
|
// When
|
|
await dialog.getByRole("button", { name: "Cancel" }).click();
|
|
await screen.getByRole("button", { name: "Remove saved-artifact" }).click();
|
|
|
|
// Then
|
|
await expect.element(dialog.getByRole("alert")).not.toBeInTheDocument();
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "Confirm Remove" }))
|
|
.toBeEnabled();
|
|
});
|
|
|
|
it("opens Providers from an in-use Remove error without another deletion", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockResolvedValue({ status: "in_use" });
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
await screen.getByRole("button", { name: "Remove AWS guard" }).click();
|
|
await screen.getByRole("button", { name: "Confirm Remove" }).click();
|
|
|
|
// When
|
|
await screen.getByRole("button", { name: "View providers" }).click();
|
|
|
|
// Then
|
|
expect(registryRouter.push).toHaveBeenCalledWith("/providers");
|
|
expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("recovers inside the Remove dialog when the server action rejects", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockRejectedValueOnce(
|
|
new Error("Failed to fetch"),
|
|
);
|
|
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(
|
|
"The Registry operation could not be completed. Try again.",
|
|
);
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "Confirm Remove" }))
|
|
.toBeEnabled();
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "Cancel" }))
|
|
.toBeEnabled();
|
|
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
|
|
expect(document.body.textContent).not.toContain("Artifact removed");
|
|
});
|
|
|
|
it("clears a Remove error while retrying and commits only after confirmation", async () => {
|
|
// Given
|
|
let resolveRemoval!: (result: RegistryArtifactRemovalResult) => void;
|
|
removeRegistryArtifactMock
|
|
.mockResolvedValueOnce({ status: "error" })
|
|
.mockReturnValueOnce(
|
|
new Promise<RegistryArtifactRemovalResult>((resolve) => {
|
|
resolveRemoval = resolve;
|
|
}),
|
|
);
|
|
const screen = await render(<RegistryExplorer initialState={readyState} />);
|
|
await screen.getByRole("tab", { name: /My artifacts/ }).click();
|
|
await screen.getByRole("button", { name: "Remove AWS guard" }).click();
|
|
await screen.getByRole("button", { name: "Confirm Remove" }).click();
|
|
const dialog = screen.getByRole("dialog", { name: "Remove artifact" });
|
|
await expect.element(dialog.getByRole("alert")).toBeVisible();
|
|
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
|
|
|
|
// When
|
|
await dialog.getByRole("button", { name: "Confirm Remove" }).click();
|
|
|
|
// Then
|
|
await expect.element(dialog.getByRole("alert")).not.toBeInTheDocument();
|
|
await expect
|
|
.element(dialog.getByRole("button", { name: "Removing artifact" }))
|
|
.toBeDisabled();
|
|
expect(cardFor("AWS guard").textContent).toContain("Remove");
|
|
expect(document.body.textContent).not.toContain("Artifact removed");
|
|
expect(removeRegistryArtifactMock).toHaveBeenCalledTimes(2);
|
|
|
|
// When
|
|
await userEvent.keyboard("{Escape}");
|
|
|
|
// Then
|
|
await expect.element(dialog).toBeVisible();
|
|
|
|
// When
|
|
resolveRemoval({
|
|
status: "confirmed",
|
|
tenantArtifacts: [
|
|
{ normalizedName: "saved-artifact", versionSpec: "1.0.0" },
|
|
],
|
|
});
|
|
|
|
// Then
|
|
await expect.element(dialog).not.toBeInTheDocument();
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Remove AWS guard" }))
|
|
.not.toBeInTheDocument();
|
|
await expect
|
|
.poll(() => document.body.textContent)
|
|
.toContain("Artifact removed");
|
|
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps My artifacts visible when a Remove refresh cannot confirm absence", async () => {
|
|
// Given
|
|
removeRegistryArtifactMock.mockResolvedValue({ status: "refresh_failed" });
|
|
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("Registry membership could not be confirmed");
|
|
expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
|
|
await expect
|
|
.element(screen.getByRole("button", { name: "Confirm Remove" }))
|
|
.toBeVisible();
|
|
});
|
|
|
|
describe("when complete catalog data is unavailable", () => {
|
|
it("keeps incomplete catalog controls and metrics hidden while exposing Retry", async () => {
|
|
// Given / When
|
|
await render(<RegistryExplorer initialState={incompleteState} />);
|
|
|
|
// Then
|
|
expect(document.body.textContent).toContain(
|
|
"Registry catalog is incomplete",
|
|
);
|
|
expect(document.body.textContent).toContain("Retry");
|
|
expect(document.body.textContent).not.toContain("Search artifacts");
|
|
});
|
|
|
|
it("labels documented unavailability as stale and leaves generic errors generic", async () => {
|
|
// Given / When
|
|
await render(
|
|
<RegistryExplorer initialState={{ status: "unavailable" }} />,
|
|
);
|
|
|
|
// Then
|
|
expect(document.body.textContent).toContain("stale or unavailable");
|
|
|
|
// Given / When
|
|
await render(<RegistryExplorer initialState={{ status: "error" }} />);
|
|
|
|
// Then
|
|
expect(document.body.textContent).toContain("unexpected Registry error");
|
|
expect(document.body.textContent).not.toContain("Reconnect Registry");
|
|
});
|
|
});
|
|
});
|