diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 33d58bf6..c75f7693 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -91,6 +91,7 @@ pub struct PortForwardResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodInfo { pub name: String, + pub namespace: String, pub status: String, pub ready: String, pub age: String, @@ -1107,6 +1108,13 @@ fn parse_pods_json(json_str: &str) -> Result, String> { .unwrap_or("unknown") .to_string(); + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + let status = item .get("status") .and_then(|s| s.get("phase")) @@ -1153,6 +1161,7 @@ fn parse_pods_json(json_str: &str) -> Result, String> { pods.push(PodInfo { name, + namespace, status, ready, age, diff --git a/src/components/Kubernetes/PodList.tsx b/src/components/Kubernetes/PodList.tsx index 29d5dd5a..9606ac7f 100644 --- a/src/components/Kubernetes/PodList.tsx +++ b/src/components/Kubernetes/PodList.tsx @@ -32,6 +32,9 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) const [isDeleting, setIsDeleting] = useState(false); const [editError, setEditError] = useState(null); + // namespace prop is retained for API compatibility (parent uses it to drive list fetches) + void namespace; + const getPodStatusColor = (status: string) => { switch (status.toLowerCase()) { case "running": @@ -52,7 +55,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) const openEdit = async (pod: PodInfo) => { setEditError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name); + const yaml = await getResourceYamlCmd(clusterId, "pods", pod.namespace, pod.name); setActiveModal({ type: "edit", pod, yaml }); } catch (err) { setEditError(err instanceof Error ? err.message : String(err)); @@ -65,9 +68,9 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) setIsDeleting(true); try { if (force) { - await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + await forceDeleteResourceCmd(clusterId, "pods", modal.pod.namespace, modal.pod.name); } else { - await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + await deleteResourceCmd(clusterId, "pods", modal.pod.namespace, modal.pod.name); } setActiveModal(null); onRefresh?.(); @@ -167,7 +170,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) open onOpenChange={(o) => { if (!o) setActiveModal(null); }} clusterId={clusterId} - namespace={namespace} + namespace={activeModal.pod.namespace} podName={activeModal.pod.name} containers={activeModal.pod.containers} /> @@ -178,7 +181,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) open onOpenChange={(o) => { if (!o) setActiveModal(null); }} clusterId={clusterId} - namespace={namespace} + namespace={activeModal.pod.namespace} podName={activeModal.pod.name} containers={activeModal.pod.containers} /> @@ -189,7 +192,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) open onOpenChange={(o) => { if (!o) setActiveModal(null); }} clusterId={clusterId} - namespace={namespace} + namespace={activeModal.pod.namespace} podName={activeModal.pod.name} containers={activeModal.pod.containers} /> @@ -199,7 +202,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) ({ + LogsModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/ShellExecModal", () => ({ + ShellExecModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/AttachModal", () => ({ + AttachModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/EditResourceModal", () => ({ + EditResourceModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/ConfirmDeleteDialog", () => ({ + ConfirmDeleteDialog: ({ + onConfirm, + resourceName, + }: { + onConfirm: () => void; + resourceName: string; + }) => ( +
+ {resourceName} + +
+ ), +})); + +type MockedInvoke = typeof invoke & { + mockResolvedValue: (v: unknown) => void; + mockRejectedValue: (e: Error) => void; + mockImplementation: (fn: (cmd: string) => Promise) => void; +}; + +const mockInvoke = invoke as MockedInvoke; + +// A pod whose own namespace ("default") differs from the filter prop ("all") +const mockPod: PodInfo = { + name: "test-pod", + namespace: "default", + status: "Running", + ready: "1/1", + age: "1h", + containers: ["app"], +}; + +function openActionMenu() { + const trigger = screen.getByRole("button", { name: /actions/i }); + fireEvent.click(trigger); +} + +describe("PodList — namespace isolation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Edit action calls getResourceYamlCmd with pod.namespace ("default"), not filter "all"', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "get_resource_yaml") { + return Promise.resolve("apiVersion: v1\nkind: Pod"); + } + return Promise.resolve(undefined); + }); + + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^edit$/i })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + namespace: "default", + resourceType: "pods", + resourceName: "test-pod", + }); + }); + }); + + it('Delete action calls deleteResourceCmd with pod.namespace ("default"), not filter "all"', async () => { + mockInvoke.mockResolvedValue(undefined); + + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^delete$/i })); + + await waitFor(() => { + expect(screen.getByTestId("confirm-delete")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + namespace: "default", + resourceType: "pods", + resourceName: "test-pod", + }); + }); + }); + + it('Force Delete action calls forceDeleteResourceCmd with pod.namespace ("default"), not filter "all"', async () => { + mockInvoke.mockResolvedValue(undefined); + + // Force Delete is only visible when pod is Running or Pending + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^force delete$/i })); + + await waitFor(() => { + expect(screen.getByTestId("confirm-delete")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("force_delete_resource", { + clusterId: "c1", + namespace: "default", + resourceType: "pods", + resourceName: "test-pod", + }); + }); + }); + + it('Logs modal receives pod.namespace ("default"), not filter "all"', async () => { + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^logs$/i })); + + await waitFor(() => { + const modal = screen.getByTestId("logs-modal"); + expect(modal.getAttribute("data-namespace")).toBe("default"); + }); + }); + + it('Shell modal receives pod.namespace ("default"), not filter "all"', async () => { + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^shell$/i })); + + await waitFor(() => { + const modal = screen.getByTestId("shell-modal"); + expect(modal.getAttribute("data-namespace")).toBe("default"); + }); + }); + + it('Attach modal receives pod.namespace ("default"), not filter "all"', async () => { + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^attach$/i })); + + await waitFor(() => { + const modal = screen.getByTestId("attach-modal"); + expect(modal.getAttribute("data-namespace")).toBe("default"); + }); + }); +});