fix(dashboard): drop the resource map ConfigMaps once the map is unreadable

React Query keeps the last successful list when a refetch fails. After the
resource map became forbidden or went away, the hook went on parsing that
old copy, so the ConfigMaps tab stayed with stale names, and a malformed
map kept its old error, instead of the tab going away as it does when the
map is unreadable from the start.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
(cherry picked from commit 8ce3f853af)
This commit is contained in:
Yan Bondarenko committed 2026-10-07 11:00:42 +03:00
1 parent 2610fb8565
commit 07be46d37c
3 files changed
+48 -7

No files matched your search

@@ -1,19 +1,24 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter, Routes, Route } from "react-router"
import { K8sApiError } from "@cozystack/k8s-client"
// Drive useK8sGet's result per test; the page's loading/error guards are the unit under test.
const h = vi.hoisted(() => ({
get: { data: undefined as unknown, isLoading: true, error: undefined as unknown },
configMaps: [] as unknown[],
configMapsError: undefined as unknown,
tabLabels: [] as string[],
}))
vi.mock("@cozystack/k8s-client", () => ({
vi.mock("@cozystack/k8s-client", async (importOriginal) => ({
K8sApiError: (await importOriginal<typeof import("@cozystack/k8s-client")>()).K8sApiError,
useK8sGet: () => h.get,
useK8sDelete: () => ({ mutateAsync: vi.fn() }),
// Presence probes (use-resource-presence.ts) — report empty lists.
useK8sList: (ref: { plural: string }) => ({ data: { items: ref.plural === "configmaps" ? h.configMaps : [] }, isLoading: false }),
useK8sList: (ref: { plural: string }) => ref.plural === "configmaps"
? { data: { items: h.configMaps }, error: h.configMapsError, isLoading: false }
: { data: { items: [] }, isLoading: false },
}))
vi.mock("../../lib/app-definitions.ts", () => ({
useApplicationDefinitions: () => ({
@@ -89,4 +94,19 @@ describe("ApplicationDetailPage configuration", () => {
renderPage()
expect(h.tabLabels).toContain("ConfigMaps")
})
it("drops the ConfigMaps tab when the resource map stops being readable", () => {
h.get = {
data: { kind: "Postgres", metadata: { name: "demo", namespace: "tenant-test" } },
isLoading: false,
error: undefined,
}
h.configMaps = [{ metadata: { name: "demo-resourcemap" }, data: {
resources: "- apiVersion: v1\n kind: ConfigMap\n name: postgres-demo-config",
} }]
h.configMapsError = new K8sApiError(403, "forbidden")
h.tabLabels = []
renderPage()
expect(h.tabLabels).not.toContain("ConfigMaps")
})
})
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { fireEvent, screen, waitFor } from "@testing-library/react"
import { act, fireEvent, screen, waitFor } from "@testing-library/react"
import { K8sApiError, K8sClient } from "@cozystack/k8s-client"
import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types"
import { renderWithK8sProvider } from "../../test-utils/render.tsx"
@@ -24,7 +24,7 @@ function Configuration() {
return <ConfigMapsTab namespace={namespace} names={maps.names} error={maps.error} />
}
function makeClient(error?: Error) {
function makeClient(error?: Error, map = resources) {
const client = new K8sClient()
vi.spyOn(client, "watch").mockReturnValue(() => {})
vi.spyOn(client, "list").mockImplementation(async (_group, _version, _plural, ns, options) => {
@@ -32,7 +32,7 @@ function makeClient(error?: Error) {
const name = options?.fieldSelector?.replace("metadata.name=", "")
if (name === "foundationdb-demo-resourcemap") {
return { apiVersion: "v1", kind: "ConfigMapList", metadata: { resourceVersion: "1" }, items: [
{ metadata: { name, namespace }, data: { resources } },
{ metadata: { name, namespace }, data: { resources: map } },
] }
}
expect(name).toBe("foundationdb-demo-config")
@@ -88,6 +88,27 @@ describe("application ConfigMaps", () => {
expect(screen.queryByText("No configuration values.")).not.toBeInTheDocument()
})
it.each([403, 404])("drops the ConfigMaps when the resource map answers %i after a successful read", async (status) => {
const client = makeClient()
const { queryClient } = renderWithK8sProvider(<Configuration />, { client })
expect(await screen.findByText(clusterFile)).toBeInTheDocument()
vi.mocked(client.list).mockRejectedValue(new K8sApiError(status, "resource map unavailable"))
await act(() => queryClient.invalidateQueries())
expect(await screen.findByText("No ConfigMaps.")).toBeInTheDocument()
expect(screen.queryByText(clusterFile)).not.toBeInTheDocument()
expect(screen.queryByRole("alert")).not.toBeInTheDocument()
})
it("drops a malformed-map error when the resource map turns forbidden", async () => {
const client = makeClient(undefined, "not a list")
const { queryClient } = renderWithK8sProvider(<Configuration />, { client })
expect(await screen.findByRole("alert")).toHaveTextContent("must contain a list")
vi.mocked(client.list).mockRejectedValue(new K8sApiError(403, "resource map unavailable"))
await act(() => queryClient.invalidateQueries())
expect(await screen.findByText("No ConfigMaps.")).toBeInTheDocument()
expect(screen.queryByRole("alert")).not.toBeInTheDocument()
})
it("ignores non-ConfigMap and cross-namespace entries and removes duplicate names", () => {
expect(configMapNames(JSON.stringify([
{ apiVersion: "v1", kind: "ConfigMap", name: "connection" },
@@ -35,7 +35,8 @@ export function useApplicationConfigMaps(
{ apiGroup: "", apiVersion: "v1", plural: "configmaps", namespace },
{ enabled: !!name && !!namespace, fieldSelector: `metadata.name=${name}`, retry: false },
)
const resources = query.data?.items.find((item) => item.metadata.name === name)?.data?.resources
const unavailable = query.error && query.error instanceof K8sApiError && [403, 404].includes(query.error.status)
const resources = unavailable ? undefined : query.data?.items.find((item) => item.metadata.name === name)?.data?.resources
const parsed = useMemo(() => {
try {
return { names: configMapNames(resources, namespace ?? ""), error: null }
@@ -43,6 +44,5 @@ export function useApplicationConfigMaps(
return { names: [], error: error instanceof Error ? error : new Error("Invalid resource map.") }
}
}, [resources, namespace])
const unavailable = query.error && query.error instanceof K8sApiError && [403, 404].includes(query.error.status)
return { ...parsed, isLoading: query.isLoading, error: parsed.error ?? (unavailable ? null : query.error) ?? null }
}