From 3823186914e5c23d1a01fc7026ed04a7e5f9669b Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:27:22 +0200 Subject: [PATCH] fix(ui): hide Registry when it is unavailable to the deployment (#12847) --- ui/actions/registry/registry.adapter.ts | 16 ++++++- ui/actions/registry/registry.test.ts | 44 ++++++++++++++++++- ui/actions/registry/registry.ts | 4 ++ .../wizard/provider-wizard-modal.test.tsx | 8 +++- .../forms/connect-account-form.test.tsx | 28 +++++++++++- .../workflow/forms/connect-account-form.tsx | 3 ++ 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/ui/actions/registry/registry.adapter.ts b/ui/actions/registry/registry.adapter.ts index 4ae9de0c7d..e84fab1218 100644 --- a/ui/actions/registry/registry.adapter.ts +++ b/ui/actions/registry/registry.adapter.ts @@ -24,6 +24,7 @@ const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/"; const REGISTRY_ERROR_CODE = { KEY_REJECTED: "registry_key_rejected", UNAVAILABLE: "registry_unavailable", + PAGE_NOT_FOUND: "registry_page_not_found", } as const; // Opposite remedies, so a 409 is never read without its code. const REGISTRY_REMOVAL_CONFLICT_CODE = { @@ -216,18 +217,29 @@ export async function classifyRegistryFailure( return { status: REGISTRY_FAILURE.ERROR }; } +// An enabled backend also answers 404 (missing page): tell them apart by code. +export async function isRegistryDisabledResponse(response: Response) { + if (response.status !== 404) return false; + const codes = await getRegistryErrorCodes(response); + return !codes.includes(REGISTRY_ERROR_CODE.PAGE_NOT_FOUND); +} + function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) { return registryDiscoveryEndpoints.has(endpoint); } -async function getRegistryErrorCode(response: Response) { +async function getRegistryErrorCodes(response: Response) { const parsed = errorDocumentSchema.safeParse( await response .clone() .json() .catch(() => undefined), ); - return parsed.success ? parsed.data.errors[0]?.code : undefined; + return parsed.success ? parsed.data.errors.map(({ code }) => code) : []; +} + +async function getRegistryErrorCode(response: Response) { + return (await getRegistryErrorCodes(response))[0]; } const REGISTRY_CATALOG_PAGE_SIZE = 100; diff --git a/ui/actions/registry/registry.test.ts b/ui/actions/registry/registry.test.ts index 7ae458cc3f..a3a44ffcf9 100644 --- a/ui/actions/registry/registry.test.ts +++ b/ui/actions/registry/registry.test.ts @@ -119,14 +119,17 @@ describe("installed Registry provider discovery", () => { emptyMetadata = false, failedEndpoint, failureStatus = 500, + failureBody = {}, }: { emptyMetadata?: boolean; failedEndpoint?: string; failureStatus?: number; + failureBody?: unknown; } = {}) { fetchMock.mockImplementation((url: string) => { const endpoint = new URL(url).pathname.split("/").pop(); - if (endpoint === failedEndpoint) return jsonResponse({}, failureStatus); + if (endpoint === failedEndpoint) + return jsonResponse(failureBody, failureStatus); if (endpoint === "available-artifacts") return jsonResponse({ data: [ @@ -242,6 +245,45 @@ describe("installed Registry provider discovery", () => { }); }, ); + + it.each(["available-artifacts", "artifacts", "providers"])( + "hides Registry when the backend has it disabled and %s answers 404", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint, failureStatus: 404 }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + }, + ); + + it.each(["available-artifacts", "providers"])( + "keeps Registry visible when an enabled backend answers 404 for a missing %s page", + async (failedEndpoint) => { + mockDiscovery({ + failedEndpoint, + failureStatus: 404, + failureBody: { + errors: [{ status: "404", code: "registry_page_not_found" }], + }, + }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }, + ); + + it("keeps Registry visible when the missing-page code is not the first error", async () => { + mockDiscovery({ + failedEndpoint: "available-artifacts", + failureStatus: 404, + failureBody: { + errors: [{ code: "not_found" }, { code: "registry_page_not_found" }], + }, + }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }); }); describe("Registry guarded reads", () => { diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts index 976373a965..4d470bfc6b 100644 --- a/ui/actions/registry/registry.ts +++ b/ui/actions/registry/registry.ts @@ -47,6 +47,7 @@ import { classifyRegistryRemovalConflict, collectCompleteRegistryCatalog, isRegistryCollection, + isRegistryDisabledResponse, parseRegistryArtifactSubmission, parseRegistryCredentialSubmission, RegistryCatalogPageError, @@ -86,6 +87,9 @@ async function readRegistryResponse( return { status: REGISTRY_FAILURE.ERROR }; } if (response.ok) return response; + // A backend with Registry disabled answers 404: hide it like a denial. + if (await isRegistryDisabledResponse(response)) + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; return endpoint === REGISTRY_ENDPOINT.PROVIDERS || endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index fc913924bc..f77a866831 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Toaster } from "@/components/shadcn/toast/Toaster"; import { resetToasts } from "@/components/shadcn/toast/use-toast"; @@ -89,6 +89,8 @@ async function enterAccountDetails() { describe("provider wizard account creation", () => { beforeEach(() => { + // Registry discovery only runs in Cloud. + vi.stubEnv("UI_CLOUD_ENABLED", "true"); useProviderWizardStore.getState().reset(); resetToasts(); getInstalledRegistryProviderOptions.mockResolvedValue({ @@ -97,6 +99,10 @@ describe("provider wizard account creation", () => { }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("shows progress, blocks repeat clicks, and advances after creation", async () => { // Given let resolveCreation!: (value: typeof createdAccount) => void; diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx index 2a906d431c..763cbcb4b3 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.test.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { addProvider, updateProvider, getInstalledRegistryProviderOptions } = vi.hoisted(() => ({ @@ -86,6 +86,32 @@ describe("provider account aliases", () => { describe("Registry provider source tabs", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("never mentions Registry outside Cloud, even when discovery would fail", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + getInstalledRegistryProviderOptions.mockRejectedValue(new Error("network")); + + // When + render(); + + // Then + expect( + await screen.findByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect(getInstalledRegistryProviderOptions).not.toHaveBeenCalled(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("tab", { name: "Registry" }), + ).not.toBeInTheDocument(); }); it("hides the Registry tab when discovery denies access (Local or flag off)", async () => { diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index ddc880e3e7..ea83267cc0 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -22,6 +22,7 @@ import { REGISTRY_PROVIDER_DISCOVERY, type RegistryProviderOption, } from "@/lib/registry/provider-options"; +import { isCloud } from "@/lib/shared/env"; import { createAddProviderFormSchema, AddProviderFormValues, @@ -233,6 +234,8 @@ export const ConnectAccountForm = ({ const createdAccount = useRef(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 {