Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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: {
Expand Down Expand Up @@ -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<typeof render> | 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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 17 additions & 4 deletions apps/ui/src/features/resource-settings/db/db-settings-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ function DatabaseSettingsConnectionAddressRow({
publicConnectionEnabled,
revealAvailable,
revealedValue,
rowKeyScope,
}: {
connection: DatabaseNodeConnection;
controlsDisabled: boolean;
Expand All @@ -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 (
<DatabaseConnectionRow
Expand Down Expand Up @@ -351,6 +354,7 @@ function DatabaseSettingsConnectionAddressList({
publicConnectionEnabled,
revealAvailable,
revealedRow,
rowKeyScope,
}: {
connections: readonly DatabaseNodeConnection[];
controlsDisabled: boolean;
Expand All @@ -360,6 +364,7 @@ function DatabaseSettingsConnectionAddressList({
publicConnectionEnabled: boolean;
revealAvailable: boolean;
revealedRow: RevealedRow | null;
rowKeyScope: string;
}) {
const visibleConnections = connections.filter(shouldShowConnectionAddress);

Expand All @@ -381,7 +386,7 @@ function DatabaseSettingsConnectionAddressList({
data-slot="database-settings-connection-address-list"
>
{visibleConnections.map((connection, index) => {
const rowKey = getDatabaseNodeConnectionKey(connection, index);
const rowKey = `${rowKeyScope}:${getDatabaseNodeConnectionKey(connection, index)}`;
return (
<DatabaseSettingsConnectionAddressRow
connection={connection}
Expand All @@ -396,6 +401,7 @@ function DatabaseSettingsConnectionAddressList({
revealedValue={
revealedRow?.key === rowKey ? revealedRow.value : undefined
}
rowKeyScope={rowKeyScope}
/>
);
})}
Expand Down Expand Up @@ -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<DatabaseSettingsConnectionRevealHandler>(
(connection, rowKey) => {
toggleRevealedRow(rowKey, () =>
Expand All @@ -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(),
[]
Expand Down Expand Up @@ -1050,6 +1062,7 @@ export function useDatabaseSettingsSections({
publicConnectionEnabled={draft.exposeNodePort}
revealAvailable={revealAvailable}
revealedRow={revealedRow}
rowKeyScope={identityKey}
/>
),
icon: Network,
Expand Down
4 changes: 3 additions & 1 deletion apps/ui/src/lib/use-revealed-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -75,5 +77,5 @@ export function useRevealedRow(): {
[clearHideTimeout, hideRevealedRow]
);

return { revealedRow, toggleRevealedRow };
return { clearRevealedRow: hideRevealedRow, revealedRow, toggleRevealedRow };
}