diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json index e8735ea2713c0..fc4f94675ba81 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/admin.json @@ -30,6 +30,7 @@ "firstConfirmMessage_other": "You are about to delete the following connections:", "title": "Delete $t(connections.connection_one)" }, + "duplicate": "Duplicate $t(connections.connection_one)", "edit": "Edit $t(connections.connection_one)", "form": { "connectionIdRequired": "Connection ID is required", @@ -38,6 +39,7 @@ "connectionTypeRequired": "Connection Type is required", "extraFieldsJson": "Extra Fields JSON", "helperText": "Connection type missing? Make sure you have installed the corresponding Airflow Providers Package.", + "helperTextForDuplicatedRedactedFields": "Redacted fields ('***') are not copied. Fill them in for the new connection.", "helperTextForRedactedFields": "Redacted fields ('***') will remain unchanged if not modified.", "selectConnectionType": "Select Connection Type", "standardFields": "Standard Fields" diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx index 3d0f199c575ad..ee19f8c0001d6 100644 --- a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx @@ -52,6 +52,7 @@ import { formatNumber, useDocumentTitle } from "src/utils"; import AddConnectionButton from "./AddConnectionButton"; import DeleteConnectionButton from "./DeleteConnectionButton"; import DeleteConnectionsButton from "./DeleteConnectionsButton"; +import DuplicateConnectionButton from "./DuplicateConnectionButton"; import EditConnectionButton from "./EditConnectionButton"; import { NothingFoundInfo } from "./NothingFoundInfo"; import TestConnectionButton from "./TestConnectionButton"; @@ -125,6 +126,7 @@ const getColumns = ({ + ), diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx new file mode 100644 index 0000000000000..61ca4e0c75f33 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.test.tsx @@ -0,0 +1,114 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ConnectionResponse } from "openapi/requests/types.gen"; + +import i18n from "src/i18n/config"; +import { Wrapper } from "src/utils/Wrapper"; + +import adminLocale from "../../../public/i18n/locales/en/admin.json"; +import DuplicateConnectionButton from "./DuplicateConnectionButton"; + +const { mockAddConnection } = vi.hoisted(() => ({ mockAddConnection: vi.fn() })); + +vi.mock("src/queries/useAddConnection", () => ({ + useAddConnection: () => ({ addConnection: mockAddConnection, error: undefined, isPending: false }), +})); + +vi.mock("src/queries/useConnectionTypeMeta", () => ({ + useConnectionTypeMeta: () => ({ + formattedData: {}, + hookNames: { postgres: "Postgres" }, + isPending: false, + keysList: ["postgres"], + }), +})); + +vi.mock("src/queries/useConfig.tsx", () => ({ + useConfig: () => false, +})); + +const baseConnection: ConnectionResponse = { + conn_type: "postgres", + connection_id: "prod_db", + description: "Production", + extra: null, + host: "db.example.com", + login: "admin", + password: null, + port: 5432, + schema: "public", + team_name: null, +}; + +const duplicate = async (connection: ConnectionResponse) => { + render(, { wrapper: Wrapper }); + fireEvent.click(screen.getByRole("button", { name: "Duplicate Connection" })); + + const connectionIdInput = await screen.findByRole("textbox", { name: /Connection ID/u }); + + fireEvent.change(connectionIdInput, { target: { value: "staging_db" } }); + fireEvent.blur(connectionIdInput); + const save = screen.getByRole("button", { name: /Save/u }); + + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + await waitFor(() => expect(mockAddConnection).toHaveBeenCalledOnce()); + + return mockAddConnection.mock.calls[0]?.[0] as Record; +}; + +describe("DuplicateConnectionButton", () => { + beforeEach(() => { + i18n.addResourceBundle("en", "admin", adminLocale, true, true); + mockAddConnection.mockClear(); + }); + + it("creates a connection under the new id with the copied values", async () => { + const body = await duplicate(baseConnection); + + expect(body).toMatchObject({ + conn_type: "postgres", + connection_id: "staging_db", + description: "Production", + host: "db.example.com", + login: "admin", + password: "", + port: "5432", + schema: "public", + }); + expect(screen.queryByText(/are not copied/u)).not.toBeInTheDocument(); + }); + + it("does not copy redacted values", async () => { + const body = await duplicate({ + ...baseConnection, + extra: JSON.stringify({ nested: { api_key: "***", keep: "x" }, region: "eu", token: "***" }), + password: "***", + }); + + expect(body.password).toBe(""); + expect(JSON.parse(body.extra ?? "")).toEqual({ nested: { keep: "x" }, region: "eu" }); + expect(screen.getByText(/are not copied/u)).toBeInTheDocument(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx new file mode 100644 index 0000000000000..e490acecd02d2 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Connections/DuplicateConnectionButton.tsx @@ -0,0 +1,101 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useDisclosure } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; +import { FiCopy } from "react-icons/fi"; + +import type { ConnectionResponse } from "openapi/requests/types.gen"; + +import { Alert, IconButton, Modal } from "src/system-components"; + +import { useAddConnection } from "src/queries/useAddConnection"; + +import ConnectionForm from "./ConnectionForm"; +import type { ConnectionBody } from "./Connections"; + +// The API returns sensitive values as this placeholder. Editing merges it back into the stored secret, +// but a new connection has no stored secret, so the placeholder must not be submitted as a value. +const REDACTED = "***"; + +type Props = { + readonly connection: ConnectionResponse; + readonly disabled: boolean; +}; + +const DuplicateConnectionButton = ({ connection, disabled }: Props) => { + const { t: translate } = useTranslation("admin"); + const { onClose, onOpen, open } = useDisclosure(); + const { addConnection, error, isPending } = useAddConnection({ onSuccessConfirm: onClose }); + + let hasRedactedValues = connection.password === REDACTED; + const extra = + connection.extra === "" || connection.extra === null + ? "{}" + : JSON.stringify( + JSON.parse(connection.extra, (_key, value: unknown) => { + if (value === REDACTED) { + hasRedactedValues = true; + + return undefined; + } + + return value; + }), + ); + + const initialConnection: ConnectionBody = { + conn_type: connection.conn_type, + connection_id: "", + description: connection.description ?? "", + extra, + host: connection.host ?? "", + login: connection.login ?? "", + password: connection.password === REDACTED ? "" : (connection.password ?? ""), + port: connection.port?.toString() ?? "", + schema: connection.schema ?? "", + team_name: connection.team_name ?? "", + }; + + return ( + <> + + + + + {hasRedactedValues ? ( + {translate("connections.form.helperTextForDuplicatedRedactedFields")} + ) : undefined} + + + + ); +}; + +export default DuplicateConnectionButton;