diff --git a/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx b/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx index f4b0db71..a3540c42 100644 --- a/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx +++ b/apps/ui/src/features/resource-settings/db/db-settings-node-switch.test.tsx @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { render } from "@testing-library/react/pure"; +import { fireEvent, render } from "@testing-library/react/pure"; import type { ReactNode } from "react"; import type { ProjectDbTarget } from "@/features/panes/target-identity"; import { @@ -16,6 +16,8 @@ import { } from "../settings-provider-db"; const NAMESPACE = "ns-switch-test"; +const PGSQL_HOST_PATTERN = /PGSQL-LEAK-HOST/; +const REDIS_HOST_PATTERN = /REDIS-HOST/; const RESOURCES_SECTION_PATTERN = /Replicas & Resources/; function dbClaim(input: { @@ -98,6 +100,79 @@ function providerElement(input: { ); } +test("a revealed connection DSN does not leak onto another DB node's rows", async () => { + await withTestDom(async (actAndDrain) => { + const POSTGRES_DSN = "postgresql://u:pw@PGSQL-LEAK-HOST:5432/db"; + const REDIS_DSN = "redis://u:pw@REDIS-HOST:6379/0"; + const { override } = stubFetch((url) => { + if (url.includes("connection-string")) { + return jsonResponse({ + value: url.includes("affine-redis") ? REDIS_DSN : POSTGRES_DSN, + }); + } + if (url.includes("affine-redis")) { + return jsonResponse(REDIS_CLAIM); + } + if (url.includes("affine-postgresql")) { + return jsonResponse(POSTGRES_CLAIM); + } + return jsonResponse({}); + }); + let rendered: ReturnType | undefined; + + try { + await actAndDrain(() => { + rendered = render( + providerElement({ + kubeconfig: "kubeconfig-switch-test", + target: postgresTarget(), + }) + ); + }); + assert.ok(rendered, "initial render"); + const view = rendered; + + await actAndDrain(() => { + fireEvent.click(view.getByLabelText("Reveal Private Connection")); + }); + assert.match( + view.container.textContent ?? "", + PGSQL_HOST_PATTERN, + "the postgres DSN is revealed on its own pane" + ); + + await actAndDrain(() => { + view.rerender( + providerElement({ + kubeconfig: "kubeconfig-switch-test", + target: redisTarget(), + }) + ); + }); + assert.doesNotMatch( + view.container.textContent ?? "", + PGSQL_HOST_PATTERN, + "the postgres DSN must not render on the redis pane" + ); + + await actAndDrain(() => { + fireEvent.click(view.getByLabelText("Reveal Private Connection")); + }); + assert.match( + view.container.textContent ?? "", + REDIS_HOST_PATTERN, + "revealing on the redis pane resolves the redis DSN" + ); + } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); + restoreGlobal(override); + } + await actAndDrain(() => undefined); + }); +}); + test("dbSettingsDataFromExactResource only accepts claims that match the target", () => { const target = postgresTarget(); const matching = dbSettingsDataFromExactResource(POSTGRES_CLAIM, target); @@ -179,6 +254,9 @@ test("DB settings provider ignores a fetched claim that belongs to another DB", "the settings sections stay in their loading/unavailable state" ); } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); restoreGlobal(override); } await actAndDrain(() => undefined); @@ -256,6 +334,9 @@ test("DB settings provider revalidates a quickly revisited node inside SWR's ded "a quick revisit must not serve the stale cached claim" ); } finally { + await actAndDrain(() => { + rendered?.unmount(); + }); restoreGlobal(override); } await actAndDrain(() => undefined); diff --git a/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx b/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx index 7ac38991..fdfb8935 100644 --- a/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx +++ b/apps/ui/src/features/resource-settings/db/db-settings-sections.tsx @@ -275,6 +275,7 @@ function DatabaseSettingsConnectionAddressRow({ publicConnectionEnabled, revealAvailable, revealedValue, + rowKeyScope, }: { connection: DatabaseNodeConnection; controlsDisabled: boolean; @@ -285,8 +286,10 @@ function DatabaseSettingsConnectionAddressRow({ publicConnectionEnabled: boolean; revealAvailable: boolean; revealedValue?: string; + /** Workload identity prefix so reveal state never crosses DB Services. */ + rowKeyScope: string; }) { - const rowKey = getDatabaseNodeConnectionKey(connection, index); + const rowKey = `${rowKeyScope}:${getDatabaseNodeConnectionKey(connection, index)}`; return ( {visibleConnections.map((connection, index) => { - const rowKey = getDatabaseNodeConnectionKey(connection, index); + const rowKey = `${rowKeyScope}:${getDatabaseNodeConnectionKey(connection, index)}`; return ( ); })} @@ -502,11 +508,18 @@ export function useDatabaseSettingsSections({ const workloadName = data.workload.name.trim(); const workloadNamespace = data.workload.namespace.trim(); const workload = data.workload; + const identityKey = `${workloadNamespace}/${workloadName}`; const { authReady: revealAvailable, resolveConnectionString } = useDbConnectionStringResolver({ kubeconfig: readOnly ? undefined : kubeconfig, }); - const { revealedRow, toggleRevealedRow } = useRevealedRow(); + const { clearRevealedRow, revealedRow, toggleRevealedRow } = useRevealedRow(); + // The pane retargets in place when the user switches DB nodes; a revealed + // connection DSN belongs to one DB Service and must not survive the switch. + // biome-ignore lint/correctness/useExhaustiveDependencies(identityKey): the effect re-runs precisely on DB Service switches to drop the revealed DSN. + useEffect(() => { + clearRevealedRow(); + }, [clearRevealedRow, identityKey]); const revealConnection = useCallback( (connection, rowKey) => { toggleRevealedRow(rowKey, () => @@ -530,7 +543,6 @@ export function useDatabaseSettingsSections({ const desiredMemoryLimit = desired?.memoryLimit; const desiredReplicas = desired?.replicas; const desiredStorageSize = desired?.storageSize; - const identityKey = `${workloadNamespace}/${workloadName}`; const submissionStore = useMemo( () => getBrowserSettingsSubmissionStore(), [] @@ -1050,6 +1062,7 @@ export function useDatabaseSettingsSections({ publicConnectionEnabled={draft.exposeNodePort} revealAvailable={revealAvailable} revealedRow={revealedRow} + rowKeyScope={identityKey} /> ), icon: Network, diff --git a/apps/ui/src/lib/use-revealed-row.ts b/apps/ui/src/lib/use-revealed-row.ts index 63bb7c03..ac9e1b6d 100644 --- a/apps/ui/src/lib/use-revealed-row.ts +++ b/apps/ui/src/lib/use-revealed-row.ts @@ -19,6 +19,8 @@ export interface RevealedRow { * silently, since nothing failed and there is nothing to show. */ export function useRevealedRow(): { + /** Hides (and discards) the revealed row immediately. */ + clearRevealedRow: () => void; revealedRow: RevealedRow | null; toggleRevealedRow: ( key: string, @@ -75,5 +77,5 @@ export function useRevealedRow(): { [clearHideTimeout, hideRevealedRow] ); - return { revealedRow, toggleRevealedRow }; + return { clearRevealedRow: hideRevealedRow, revealedRow, toggleRevealedRow }; }