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
@@ -0,0 +1,263 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { render } from "@testing-library/react/pure";
import type { ReactNode } from "react";
import type { ProjectDbTarget } from "@/features/panes/target-identity";
import {
jsonResponse,
restoreGlobal,
stubFetch,
withTestDom,
} from "@/features/project-canvas/react-test-harness";

import {
DbSettingsProvider,
dbSettingsDataFromExactResource,
} from "../settings-provider-db";

const NAMESPACE = "ns-switch-test";
const RESOURCES_SECTION_PATTERN = /Replicas & Resources/;

function dbClaim(input: {
cpuLimit: string;
engine: string;
memoryLimit: string;
name: string;
namespace?: string;
replicas: number;
storageSize: string;
}) {
return {
metadata: {
annotations: {},
labels: {},
name: input.name,
namespace: input.namespace ?? NAMESPACE,
},
spec: {
cpuLimit: input.cpuLimit,
engine: input.engine,
exposeNodePort: false,
memoryLimit: input.memoryLimit,
replicas: input.replicas,
storageSize: input.storageSize,
},
status: {
clusterVersionRef: "16.4",
connectionStringPrivate: `db-template://${input.name}.internal:5432/db`,
phase: "Running",
},
};
}

const POSTGRES_CLAIM = dbClaim({
cpuLimit: "1",
engine: "postgresql",
memoryLimit: "2Gi",
name: "affine-postgresql",
replicas: 2,
storageSize: "20Gi",
});

const REDIS_CLAIM = dbClaim({
cpuLimit: "2",
engine: "redis",
memoryLimit: "4Gi",
name: "affine-redis",
replicas: 4,
storageSize: "8Gi",
});

function postgresTarget(): ProjectDbTarget {
return { kind: "DB", name: "affine-postgresql", namespace: NAMESPACE };
}

function redisTarget(): ProjectDbTarget {
return { kind: "DB", name: "affine-redis", namespace: NAMESPACE };
}

function sliderValues(container: HTMLElement): number[] {
return Array.from(container.querySelectorAll('[role="slider"]')).map((node) =>
Number(node.getAttribute("aria-valuenow"))
);
}

function providerElement(input: {
kubeconfig: string;
target: ProjectDbTarget;
}): ReactNode {
const noop = () => undefined;
return (
<DbSettingsProvider
kubeconfig={input.kubeconfig}
onClose={noop}
onModelChange={noop}
readOnly={false}
target={input.target}
/>
);
}

test("dbSettingsDataFromExactResource only accepts claims that match the target", () => {
const target = postgresTarget();
const matching = dbSettingsDataFromExactResource(POSTGRES_CLAIM, target);
assert.ok(matching, "a matching claim backs the pane");
assert.equal(matching.workload.name, "affine-postgresql");

assert.equal(
dbSettingsDataFromExactResource(REDIS_CLAIM, target),
null,
"a claim for another DB is rejected"
);

assert.equal(
dbSettingsDataFromExactResource(
dbClaim({
cpuLimit: "1",
engine: "postgresql",
memoryLimit: "2Gi",
name: "affine-postgresql",
namespace: "ns-other",
replicas: 2,
storageSize: "20Gi",
}),
target
),
null,
"a claim from another namespace is rejected"
);

assert.equal(
dbSettingsDataFromExactResource(
{
...POSTGRES_CLAIM,
metadata: { annotations: {}, labels: {}, name: "affine-postgresql" },
},
target
),
null,
"a claim without a namespace fails closed"
);

assert.equal(
dbSettingsDataFromExactResource(undefined, target),
null,
"no claim is null"
);
assert.equal(
dbSettingsDataFromExactResource(POSTGRES_CLAIM, null),
null,
"no target is null"
);
});

test("DB settings provider ignores a fetched claim that belongs to another DB", async () => {
await withTestDom(async (actAndDrain) => {
// Every fetch answers with the postgres claim while the pane targets redis.
const { override } = stubFetch(() => jsonResponse(POSTGRES_CLAIM));
let rendered: ReturnType<typeof render> | undefined;

try {
await actAndDrain(() => {
rendered = render(
providerElement({
kubeconfig: "kubeconfig-switch-test",
target: redisTarget(),
})
);
});
assert.ok(rendered, "render");
const { container } = rendered;
assert.equal(
sliderValues(container).length,
0,
"a foreign claim must not render another DB's cards"
);
assert.doesNotMatch(
container.textContent ?? "",
RESOURCES_SECTION_PATTERN,
"the settings sections stay in their loading/unavailable state"
);
} finally {
restoreGlobal(override);
}
await actAndDrain(() => undefined);
});
});

test("DB settings provider revalidates a quickly revisited node inside SWR's dedupe window", async () => {
await withTestDom(async (actAndDrain) => {
let postgresClaim = POSTGRES_CLAIM;
const { calls, override } = stubFetch((url) => {
if (url.includes("affine-redis")) {
return jsonResponse(REDIS_CLAIM);
}
if (url.includes("affine-postgresql")) {
return jsonResponse(postgresClaim);
}
return jsonResponse({});
});
let rendered: ReturnType<typeof render> | undefined;

try {
await actAndDrain(() => {
rendered = render(
providerElement({
kubeconfig: "kubeconfig-switch-test-2",
target: postgresTarget(),
})
);
});
assert.ok(rendered, "initial render");
const view = rendered;
assert.deepEqual(sliderValues(view.container), [2, 1, 2, 20]);

await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test-2",
target: redisTarget(),
})
);
});
assert.deepEqual(sliderValues(view.container), [4, 2, 4, 8]);

postgresClaim = dbClaim({
cpuLimit: "2",
engine: "postgresql",
memoryLimit: "8Gi",
name: "affine-postgresql",
replicas: 5,
storageSize: "50Gi",
});

// Switch back immediately — inside SWR's default 2s dedupe window.
const postgresFetchCountBefore = calls.filter((call) =>
call.url.includes("affine-postgresql")
).length;
await actAndDrain(() => {
view.rerender(
providerElement({
kubeconfig: "kubeconfig-switch-test-2",
target: postgresTarget(),
})
);
});
const postgresFetchCountAfter = calls.filter((call) =>
call.url.includes("affine-postgresql")
).length;
assert.ok(
postgresFetchCountAfter > postgresFetchCountBefore,
"revisiting a node must issue a fresh resource fetch"
);
assert.deepEqual(
sliderValues(view.container),
[5, 2, 8, 50],
"a quick revisit must not serve the stale cached claim"
);
} finally {
restoreGlobal(override);
}
await actAndDrain(() => undefined);
});
});
40 changes: 38 additions & 2 deletions apps/ui/src/features/resource-settings/settings-provider-db.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,43 @@ import { useResourceDisplayNameRename } from "./use-resource-display-name-rename

const DB_SETTINGS_FULL_VIEW = "full";

/**
* Defense in depth: only a claim whose metadata matches the target exactly
* (name and namespace; both must be present) may back the pane. A claim that
* belongs to any other DB — or one without identifying metadata — is ignored,
* so the pane falls back to its loading state instead of rendering foreign
* data. The card-refresh bug itself is fixed by revalidation (see the
* `dedupingInterval` option below), not by this guard.
*/
function dbClaimBodyForTarget(
data: ReturnType<typeof useBrainProductResource>["data"],
target: ProjectDbTarget | null
): Record<string, unknown> | undefined {
if (target == null) {
return undefined;
}
const resource = k8sGetClaimBody(data);
if (resource == null) {
return undefined;
}
const metadata = asRecord(resource.metadata);
const name = typeof metadata?.name === "string" ? metadata.name : undefined;
const namespace =
typeof metadata?.namespace === "string" ? metadata.namespace : undefined;
if (name !== target.name || namespace !== target.namespace) {
return undefined;
}
return resource;
}

export function dbSettingsDataFromExactResource(
data: ReturnType<typeof useBrainProductResource>["data"],
target: ProjectDbTarget | null
): DbSettingsData | null {
if (target == null) {
return null;
}
const resource = k8sGetClaimBody(data);
const resource = dbClaimBodyForTarget(data, target);
return resource == null
? null
: dbResourceToSettingsData(resource, {
Expand Down Expand Up @@ -76,6 +105,11 @@ export function DbSettingsProvider({
const resolvedView = resolvedDbSettingsView(view);
const dbTarget = target.kind === "DB" ? target : null;
const dbResource = useBrainProductResource({
// The pane retargets this hook in place as the user switches DB nodes.
// Revisiting a node inside SWR's default 2s dedupe window would be a
// cache hit with no revalidation, keeping the earlier claim's card
// values on screen — disable the window so every revisit refetches.
dedupingInterval: 0,
kind: "DB",
kubeconfig: dbTarget == null ? "" : (kubeconfig ?? ""),
name: dbTarget?.name ?? "",
Expand All @@ -97,7 +131,9 @@ export function DbSettingsProvider({
});
const workload = data?.workload;
const updating = workload == null ? false : isUpdating(workload);
const resourceMetadata = asRecord(k8sGetClaimBody(dbResource.data)?.metadata);
const resourceMetadata = asRecord(
dbClaimBodyForTarget(dbResource.data, dbTarget)?.metadata
);
const displayName =
dbTarget == null
? ""
Expand Down
15 changes: 13 additions & 2 deletions packages/api/src/hooks/use-product-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ import { ApiUrl } from "../utils";
export type BrainProductResourceKind = "AP" | "DB";

export interface UseBrainProductResourceOptions {
/**
* Overrides SWR's request-dedupe window for this hook instance. Surfaces
* that retarget in place (settings panes) pass 0: without it, revisiting a
* previously fetched resource inside SWR's default 2s window is a cache hit
* that skips revalidation and keeps serving the earlier claim. Polling
* callers stay on the default so their interval fetches keep coalescing.
*/
dedupingInterval?: number;
kind: BrainProductResourceKind;
kubeconfig?: string;
name: string;
Expand All @@ -28,7 +36,7 @@ function productRoute(kind: BrainProductResourceKind) {
export function useBrainProductResource(
options: UseBrainProductResourceOptions
) {
const { kind, name, namespace } = options;
const { dedupingInterval, kind, name, namespace } = options;
const kubeconfig = options.kubeconfig ?? "";
const refreshInterval = options.refreshInterval ?? 0;
const credentialKey = useMemo(
Expand Down Expand Up @@ -64,6 +72,9 @@ export function useBrainProductResource(
query: query ?? undefined,
select: (raw) => k8sGetResponseSchema.parse(raw),
}),
{ refreshInterval }
{
...(dedupingInterval === undefined ? {} : { dedupingInterval }),
refreshInterval,
}
);
}