fix(ui): hide Registry when it is unavailable to the deployment (#12847)

This commit is contained in:
Alejandro Bailo
2026-09-21 12:27:22 +02:00
committed by GitHub
parent 03cb59c20d
commit 3823186914
6 changed files with 98 additions and 5 deletions
+14 -2
View File
@@ -24,6 +24,7 @@ const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/";
const REGISTRY_ERROR_CODE = { const REGISTRY_ERROR_CODE = {
KEY_REJECTED: "registry_key_rejected", KEY_REJECTED: "registry_key_rejected",
UNAVAILABLE: "registry_unavailable", UNAVAILABLE: "registry_unavailable",
PAGE_NOT_FOUND: "registry_page_not_found",
} as const; } as const;
// Opposite remedies, so a 409 is never read without its code. // Opposite remedies, so a 409 is never read without its code.
const REGISTRY_REMOVAL_CONFLICT_CODE = { const REGISTRY_REMOVAL_CONFLICT_CODE = {
@@ -216,18 +217,29 @@ export async function classifyRegistryFailure(
return { status: REGISTRY_FAILURE.ERROR }; 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) { function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) {
return registryDiscoveryEndpoints.has(endpoint); return registryDiscoveryEndpoints.has(endpoint);
} }
async function getRegistryErrorCode(response: Response) { async function getRegistryErrorCodes(response: Response) {
const parsed = errorDocumentSchema.safeParse( const parsed = errorDocumentSchema.safeParse(
await response await response
.clone() .clone()
.json() .json()
.catch(() => undefined), .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; const REGISTRY_CATALOG_PAGE_SIZE = 100;
+43 -1
View File
@@ -119,14 +119,17 @@ describe("installed Registry provider discovery", () => {
emptyMetadata = false, emptyMetadata = false,
failedEndpoint, failedEndpoint,
failureStatus = 500, failureStatus = 500,
failureBody = {},
}: { }: {
emptyMetadata?: boolean; emptyMetadata?: boolean;
failedEndpoint?: string; failedEndpoint?: string;
failureStatus?: number; failureStatus?: number;
failureBody?: unknown;
} = {}) { } = {}) {
fetchMock.mockImplementation((url: string) => { fetchMock.mockImplementation((url: string) => {
const endpoint = new URL(url).pathname.split("/").pop(); 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") if (endpoint === "available-artifacts")
return jsonResponse({ return jsonResponse({
data: [ 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", () => { describe("Registry guarded reads", () => {
+4
View File
@@ -47,6 +47,7 @@ import {
classifyRegistryRemovalConflict, classifyRegistryRemovalConflict,
collectCompleteRegistryCatalog, collectCompleteRegistryCatalog,
isRegistryCollection, isRegistryCollection,
isRegistryDisabledResponse,
parseRegistryArtifactSubmission, parseRegistryArtifactSubmission,
parseRegistryCredentialSubmission, parseRegistryCredentialSubmission,
RegistryCatalogPageError, RegistryCatalogPageError,
@@ -86,6 +87,9 @@ async function readRegistryResponse(
return { status: REGISTRY_FAILURE.ERROR }; return { status: REGISTRY_FAILURE.ERROR };
} }
if (response.ok) return response; 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 || return endpoint === REGISTRY_ENDPOINT.PROVIDERS ||
endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS
@@ -1,6 +1,6 @@
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 { Toaster } from "@/components/shadcn/toast/Toaster";
import { resetToasts } from "@/components/shadcn/toast/use-toast"; import { resetToasts } from "@/components/shadcn/toast/use-toast";
@@ -89,6 +89,8 @@ async function enterAccountDetails() {
describe("provider wizard account creation", () => { describe("provider wizard account creation", () => {
beforeEach(() => { beforeEach(() => {
// Registry discovery only runs in Cloud.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useProviderWizardStore.getState().reset(); useProviderWizardStore.getState().reset();
resetToasts(); resetToasts();
getInstalledRegistryProviderOptions.mockResolvedValue({ 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 () => { it("shows progress, blocks repeat clicks, and advances after creation", async () => {
// Given // Given
let resolveCreation!: (value: typeof createdAccount) => void; let resolveCreation!: (value: typeof createdAccount) => void;
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 } = const { addProvider, updateProvider, getInstalledRegistryProviderOptions } =
vi.hoisted(() => ({ vi.hoisted(() => ({
@@ -86,6 +86,32 @@ describe("provider account aliases", () => {
describe("Registry provider source tabs", () => { describe("Registry provider source tabs", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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(<ConnectAccountForm onSuccess={vi.fn()} />);
// 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 () => { it("hides the Registry tab when discovery denies access (Local or flag off)", async () => {
@@ -22,6 +22,7 @@ import {
REGISTRY_PROVIDER_DISCOVERY, REGISTRY_PROVIDER_DISCOVERY,
type RegistryProviderOption, type RegistryProviderOption,
} from "@/lib/registry/provider-options"; } from "@/lib/registry/provider-options";
import { isCloud } from "@/lib/shared/env";
import { import {
createAddProviderFormSchema, createAddProviderFormSchema,
AddProviderFormValues, AddProviderFormValues,
@@ -233,6 +234,8 @@ export const ConnectAccountForm = ({
const createdAccount = useRef<ConnectAccountSuccessData | null>(null); const createdAccount = useRef<ConnectAccountSuccessData | null>(null);
useEffect(() => { useEffect(() => {
// Registry is Cloud-only: elsewhere never ask, so a failure cannot surface it.
if (!isCloud()) return;
let active = true; let active = true;
const load = async () => { const load = async () => {
try { try {