diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.spec.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.spec.tsx new file mode 100644 index 00000000..bf7c68ca --- /dev/null +++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.spec.tsx @@ -0,0 +1,97 @@ +import type { Scope } from "nock"; +import { RenderResult, waitFor } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; +import mockApp from "~/testing/data/mock_app"; +import mockEnvironments from "~/testing/data/mock_environments"; +import { + mockFetchAPIKeys, + mockDeleteAPIKey, +} from "~/testing/nocks/nock_api_keys"; +import TabAPIKey from "./TabAPIKey"; + +interface WrapperProps { + app?: App; + environments?: Environment[]; + hash?: string; + setRefreshToken?: () => void; +} + +describe("~/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx", () => { + let wrapper: RenderResult; + let fetchScope: Scope; + let currentApp: App; + let currentEnv: Environment; + let currentEnvs: Environment[]; + + const createWrapper = ({ app, environments }: WrapperProps) => { + currentApp = app || mockApp(); + currentApp.id = "1644802351"; // Same as api key id + + currentEnvs = environments || mockEnvironments({ app: currentApp }); + currentEnv = currentEnvs[0]; + + fetchScope = mockFetchAPIKeys({ + appId: currentApp.id, + envId: currentEnv.id!, + }); + + wrapper = render(); + }; + + test("should fetch api keys", async () => { + createWrapper({}); + + await waitFor(() => { + expect(fetchScope.isDone()).toBe(true); + }); + + const subheader = + "This key will allow you to interact with our API and modify this environment."; + + // Header + expect(wrapper.getByText("API Key")).toBeTruthy(); + expect(wrapper.getByText(subheader)).toBeTruthy(); + + // API Keys + expect(wrapper.getByText("Default")).toBeTruthy(); + expect(wrapper.getByText("CI")).toBeTruthy(); + }); + + test("should delete api key", async () => { + createWrapper({}); + + await waitFor(() => { + expect(wrapper.getByText("CI")).toBeTruthy(); + }); + + fireEvent.click(wrapper.getAllByLabelText("Remove API Key").at(1)!); + + await waitFor(() => { + expect(wrapper.getByText("Confirm action")).toBeTruthy(); + }); + + const scope = mockDeleteAPIKey({ + appId: currentApp.id, + envId: currentEnv.id!, + keyId: "9868814106", + }); + + // Should refetch api keys + const fetchScope2 = mockFetchAPIKeys({ + appId: currentApp.id, + envId: currentEnv.id!, + response: { keys: [] }, + }); + + fireEvent.click(wrapper.getByText("Yes, continue")); + + await waitFor(() => { + expect(scope.isDone()).toBe(true); + expect(fetchScope2.isDone()).toBe(true); + // Should close modal + expect(() => wrapper.getByText("Confirm action")).toThrow(); + // Should no longer have API keys + expect(() => wrapper.getByText("Default")).toThrow(); + }); + }); +}); diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx index 31ec41e5..89af2d21 100644 --- a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx +++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx @@ -8,15 +8,17 @@ import Button from "@mui/lab/LoadingButton"; import TextInput from "@mui/material/TextField"; import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import DeleteIcon from "@mui/icons-material/Delete"; import Modal from "~/components/ModalV2"; +import ConfirmModal from "~/components/ConfirmModal"; import Spinner from "~/components/Spinner"; import InputDescription from "~/components/InputDescription"; -import { useFetchAPIKeys, generateNewAPIKey } from "../actions"; +import { useFetchAPIKeys, generateNewAPIKey, deleteAPIKey } from "../actions"; interface Props { app: App; environment: Environment; - setRefreshToken: (v: number) => void; + setRefreshToken?: (v: number) => void; // This is there just to accomodate the Tab signature. It's not really used. } interface APIKeyModalProps { @@ -72,13 +74,16 @@ function APIKeyModal({ onSubmit, onClose, loading, error }: APIKeyModalProps) { export default function TabAPIKey({ app, environment: env }: Props) { const [isVisible, setIsVisible] = useState(""); + const [refreshToken, setRefreshToken] = useState(); const [success, setSuccess] = useState(false); const [modalError, setModalError] = useState(""); const [modalLoading, setModalLoading] = useState(false); + const [apiKeyToDelete, setApiKeyToDelete] = useState(); const [isModalOpen, setIsModalOpen] = useState(false); const { loading, error, keys, setKeys } = useFetchAPIKeys({ appId: app.id, envId: env.id!, + refreshToken, }); const handleNewKey = (name: string) => { @@ -146,9 +151,12 @@ export default function TabAPIKey({ app, environment: env }: Props) { {keys.map(apiKey => ( - {apiKey.name} - - {isVisible === apiKey.id ? apiKey.token : "*".repeat(32)} + + {apiKey.name} + + {isVisible === apiKey.id ? apiKey.token : "*".repeat(32)} + + + + { + setIsVisible(isVisible === apiKey.id ? "" : apiKey.id); + }} + > + {isVisible === apiKey.id ? ( + + ) : ( + + )} + + { + setApiKeyToDelete(apiKey); + }} + > + + - { - setIsVisible(isVisible === apiKey.id ? "" : apiKey.id); - }} - > - {isVisible ? : } - ))} @@ -201,6 +239,38 @@ export default function TabAPIKey({ app, environment: env }: Props) { onSubmit={handleNewKey} /> )} + {apiKeyToDelete && ( + { + setApiKeyToDelete(undefined); + }} + onConfirm={({ setLoading, setError }) => { + setLoading(true); + setError(null); + setSuccess(false); + + deleteAPIKey(apiKeyToDelete) + .then(() => { + setRefreshToken(Date.now()); + }) + .catch(() => { + setError("Something went wrong while deleting the API key."); + }) + .finally(() => { + setLoading(false); + setApiKeyToDelete(undefined); + }); + + return ""; + }} + > + + This will delete the API key. +
If you have any integration that uses this API key, it will + stop working. +
+
+ )}
); } diff --git a/src/pages/apps/[id]/environments/[env-id]/config/actions.ts b/src/pages/apps/[id]/environments/[env-id]/config/actions.ts index 74b6f9bc..cd6310ea 100644 --- a/src/pages/apps/[id]/environments/[env-id]/config/actions.ts +++ b/src/pages/apps/[id]/environments/[env-id]/config/actions.ts @@ -324,9 +324,14 @@ export const deleteEnvironment = ({ interface FetchAPIKeyProps { appId: string; envId: string; + refreshToken?: number; } -export const useFetchAPIKeys = ({ appId, envId }: FetchAPIKeyProps) => { +export const useFetchAPIKeys = ({ + appId, + envId, + refreshToken, +}: FetchAPIKeyProps) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [keys, setKeys] = useState([]); @@ -346,11 +351,17 @@ export const useFetchAPIKeys = ({ appId, envId }: FetchAPIKeyProps) => { .finally(() => { setLoading(false); }); - }, [appId, envId]); + }, [appId, envId, refreshToken]); return { loading, error, keys, setKeys }; }; +export const deleteAPIKey = (apiKey: APIKey) => { + return api.delete<{ keys: APIKey[] }>( + `/app/${apiKey.appId}/env/${apiKey.envId}/api-key?keyId=${apiKey.id}` + ); +}; + interface GenerateNewAPIKeyProps { appId: string; envId?: string; diff --git a/src/types/api_key.d.ts b/src/types/api_key.d.ts index 84e43164..210f1417 100644 --- a/src/types/api_key.d.ts +++ b/src/types/api_key.d.ts @@ -4,4 +4,5 @@ declare interface APIKey { appId: string; token: string; envId?: string; + scope: "env"; } diff --git a/testing/data/index.ts b/testing/data/index.ts index 100cbf31..4cda9a37 100644 --- a/testing/data/index.ts +++ b/testing/data/index.ts @@ -10,3 +10,4 @@ export { default as mockAppResponse } from "./mock_app_response"; export { default as mockUserResponse } from "./mock_user_response"; export { default as mockRemoteConfigResponse } from "./mock_remote_config_response"; export { default as mockAdditionalSettingsResponse } from "./mock_settings_response"; +export { default as mockAPIKeysResponse } from "./mock_api_keys_response"; diff --git a/testing/data/mock_api_keys_response.ts b/testing/data/mock_api_keys_response.ts new file mode 100644 index 00000000..fe4b629a --- /dev/null +++ b/testing/data/mock_api_keys_response.ts @@ -0,0 +1,26 @@ +interface APIKeyResponse { + keys: APIKey[]; +} + +export default (): APIKeyResponse => ({ + keys: [ + { + appId: "1644802351", + envId: "1429333243019", + id: "8224011755", + scope: "env", + name: "Default", + token: + "SK_fkopNcPhEizN5sV7cZx69tQ1VJZSV6ZiYBdpt6qiojkOB6xbUkCfGbBjR9IxqK", + }, + { + appId: "1644802351", + envId: "1429333243019", + id: "9868814106", + scope: "env", + name: "CI", + token: + "SK_m9nvOqzHFXiNrGEzvEQfdJK6isRCLnHpUulb5URvaSMme8fDeNUzPdhzVQLczu", + }, + ], +}); diff --git a/testing/nocks/nock_api_keys.ts b/testing/nocks/nock_api_keys.ts new file mode 100644 index 00000000..f8eec038 --- /dev/null +++ b/testing/nocks/nock_api_keys.ts @@ -0,0 +1,40 @@ +import nock from "nock"; +import * as data from "../data"; + +const endpoint = process.env.API_DOMAIN || ""; + +interface MockFetchAPIKeysProps { + appId: string; + envId: string; + status?: number; + response?: { keys: APIKey[] }; +} + +export const mockFetchAPIKeys = ({ + appId, + envId, + status = 200, + response = data.mockAPIKeysResponse(), +}: MockFetchAPIKeysProps) => { + return nock(endpoint) + .get(`/app/${appId}/env/${envId}/api-keys`) + .reply(status, response); +}; + +interface MockDeleteAPIKeyProps { + appId: string; + envId: string; + keyId: string; + status?: number; +} + +export const mockDeleteAPIKey = ({ + appId, + envId, + keyId, + status = 200, +}: MockDeleteAPIKeyProps) => { + return nock(endpoint) + .delete(`/app/${appId}/env/${envId}/api-key?keyId=${keyId}`) + .reply(status, { ok: true }); +};