Skip to content

Commit

Permalink
feat(preferences): Migrate SSH key deletion to API v3 MAASENG-4449 (#…
Browse files Browse the repository at this point in the history
…5626)

- Migrated SSH key delete form to API v3
- Added missing tests for ssh key query hooks
- Delete form now fetches IDs from side panel context instead of URL parameters
- Created mock resolver for SSH key deletion
- Added generic type props to ModelActionForm to support v3 API errors

Resolves [MAASENG-4449](https://warthogs.atlassian.net/browse/MAASENG-4449)
  • Loading branch information
ndv99 authored Feb 27, 2025
1 parent 454b62c commit c15f8b6
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 126 deletions.
49 changes: 47 additions & 2 deletions src/app/api/query/sshKeys.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { useListSshKeys } from "./sshKeys";
import {
useCreateSshKeys,
useDeleteSshKey,
useImportSshKeys,
useListSshKeys,
} from "./sshKeys";

import type {
SshKeyImportFromSourceRequest,
SshKeyManualUploadRequest,
} from "@/app/apiclient";
import { mockSshKeys, sshKeyResolvers } from "@/testing/resolvers/sshKeys";
import {
renderHookWithProviders,
setupMockServer,
waitFor,
} from "@/testing/utils";

setupMockServer(sshKeyResolvers.listSshKeys.handler());
setupMockServer(
sshKeyResolvers.listSshKeys.handler(),
sshKeyResolvers.createSshKey.handler(),
sshKeyResolvers.importSshKey.handler(),
sshKeyResolvers.deleteSshKey.handler()
);

describe("useListSshKeys", () => {
it("should return SSH keys data", async () => {
Expand All @@ -16,3 +30,34 @@ describe("useListSshKeys", () => {
expect(result.current.data).toEqual(mockSshKeys);
});
});

describe("useCreateSshKeys", () => {
it("should create a new SSH key", async () => {
const newSshKey: SshKeyManualUploadRequest = {
key: "ssh-rsa aabb",
};
const { result } = renderHookWithProviders(() => useCreateSshKeys());
result.current.mutate({ body: newSshKey });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
});

describe("useImportSshKeys", () => {
it("should import a new SSH key", async () => {
const newSshKey: SshKeyImportFromSourceRequest = {
protocol: "lp",
auth_id: "coolUsername",
};
const { result } = renderHookWithProviders(() => useImportSshKeys());
result.current.mutate({ body: newSshKey });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
});

describe("useDeleteSshKey", () => {
it("should delete an SSH key", async () => {
const { result } = renderHookWithProviders(() => useDeleteSshKey());
result.current.mutate({ path: { id: 1 } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
});
22 changes: 22 additions & 0 deletions src/app/api/query/sshKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ import type {
ListUserSshkeysData,
ListUserSshkeysError,
ListUserSshkeysResponse,
DeleteUserSshkeyData,
DeleteUserSshkeyResponse,
DeleteUserSshkeyError,
} from "@/app/apiclient";
import {
createUserSshkeysMutation,
deleteUserSshkeyMutation,
importUserSshkeysMutation,
listUserSshkeysOptions,
listUserSshkeysQueryKey,
Expand Down Expand Up @@ -70,3 +74,21 @@ export const useImportSshKeys = (
},
});
};

export const useDeleteSshKey = (
mutationOptions?: Options<DeleteUserSshkeyData>
) => {
const queryClient = useQueryClient();
return useMutation<
DeleteUserSshkeyResponse,
DeleteUserSshkeyError,
Options<DeleteUserSshkeyData>
>({
...deleteUserSshkeyMutation(mutationOptions),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: listUserSshkeysQueryKey(),
});
},
});
};
13 changes: 6 additions & 7 deletions src/app/base/components/ModelActionForm/ModelActionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,22 @@ import { Col, Row } from "@canonical/react-components";

import type { Props as FormikFormProps } from "@/app/base/components/FormikForm/FormikForm";
import FormikForm from "@/app/base/components/FormikForm/FormikForm";
import type { EmptyObject } from "@/app/base/types";

type Props = {
type Props<V extends object, E = null> = {
modelType: string;
message?: ReactNode;
} & FormikFormProps<EmptyObject>;
} & FormikFormProps<V, E>;

const ModelActionForm = ({
const ModelActionForm = <V extends object, E = null>({
modelType,
message,
submitAppearance = "negative",
submitLabel = "Delete",
initialValues = {},
initialValues,
...props
}: Props) => {
}: Props<V, E>) => {
return (
<FormikForm
<FormikForm<V, E>
initialValues={initialValues}
submitAppearance={submitAppearance}
submitLabel={submitLabel}
Expand Down
2 changes: 1 addition & 1 deletion src/app/base/components/SSHKeyForm/SSHKeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const SSHKeyForm = ({ cols, ...props }: Props): JSX.Element => {
} else {
importSshKey.mutate({
body: {
auth_id: values.auth_id,
auth_id: values.auth_id as string,
protocol: values.protocol as SshKeysProtocolType,
},
});
Expand Down
8 changes: 4 additions & 4 deletions src/app/base/components/SSHKeyForm/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { KeySource, SSHKey } from "@/app/store/sshkey/types";
import type { SshKeyResponse } from "@/app/apiclient";

export type SSHKeyFormValues = {
protocol: KeySource["protocol"];
auth_id: KeySource["auth_id"];
key: SSHKey["key"];
protocol: SshKeyResponse["protocol"] | "upload" | "";
auth_id: SshKeyResponse["auth_id"];
key: SshKeyResponse["key"];
};
5 changes: 3 additions & 2 deletions src/app/preferences/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { ValueOf } from "@canonical/react-components";

import type { SshKeyResponse } from "../apiclient";

import type { PreferenceSidePanelViews } from "./constants";

import type { SidePanelContent, SetSidePanelContent } from "@/app/base/types";
import type { SSHKey } from "@/app/store/sshkey/types";

type SSHKeyGroup = {
keys: SSHKey[];
keys: SshKeyResponse[];
};
export type PreferenceSidePanelContent = SidePanelContent<
ValueOf<typeof PreferenceSidePanelViews>,
Expand Down
130 changes: 50 additions & 80 deletions src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.test.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,65 @@
import configureStore from "redux-mock-store";

import DeleteSSHKey from "./DeleteSSHKey";

import * as sidePanelHooks from "@/app/base/side-panel-context";
import { PreferenceSidePanelViews } from "@/app/preferences/constants";
import type { RootState } from "@/app/store/root/types";
import * as factory from "@/testing/factories";
import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils";
import { sshKeyResolvers } from "@/testing/resolvers/sshKeys";
import {
renderWithBrowserRouter,
screen,
setupMockServer,
userEvent,
waitFor,
} from "@/testing/utils";

let state: RootState;
const mockStore = configureStore<RootState>();
const mockServer = setupMockServer(sshKeyResolvers.deleteSshKey.handler());

beforeEach(() => {
const keys = [
factory.sshKey({
id: 1,
key: "ssh-rsa aabb",
keysource: { protocol: "lp", auth_id: "koalaparty" },
}),
factory.sshKey({
id: 2,
key: "ssh-rsa ccdd",
keysource: { protocol: "gh", auth_id: "koalaparty" },
}),
factory.sshKey({
id: 3,
key: "ssh-rsa eeff",
keysource: { protocol: "lp", auth_id: "maaate" },
}),
factory.sshKey({
id: 4,
key: "ssh-rsa gghh",
keysource: { protocol: "gh", auth_id: "koalaparty" },
}),
factory.sshKey({ id: 5, key: "ssh-rsa gghh" }),
];
vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({
setSidePanelContent: vi.fn(),
sidePanelContent: {
view: PreferenceSidePanelViews.DELETE_SSH_KEYS,
extras: { group: { keys } },
},
setSidePanelSize: vi.fn(),
sidePanelSize: "regular",
});
state = factory.rootState({
sshkey: factory.sshKeyState({
loading: false,
loaded: true,
items: keys,
}),
describe("DeleteSSHKey", () => {
beforeEach(() => {
vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({
setSidePanelContent: vi.fn(),
sidePanelContent: {
view: PreferenceSidePanelViews.DELETE_SSH_KEYS,
},
setSidePanelSize: vi.fn(),
sidePanelSize: "regular",
});
});
});

it("renders", () => {
renderWithBrowserRouter(<DeleteSSHKey />, {
state,
route: "/account/prefs/ssh-keys/delete?ids=2,3",
it("renders", () => {
renderWithBrowserRouter(<DeleteSSHKey />, {
route: "/account/prefs/ssh-keys/delete?ids=2,3",
});
expect(
screen.getByRole("form", { name: "Delete SSH key confirmation" })
).toBeInTheDocument();
expect(
screen.getByText("Are you sure you want to delete these SSH keys?")
).toBeInTheDocument();
});
expect(
screen.getByRole("form", { name: "Delete SSH key confirmation" })
).toBeInTheDocument();
expect(
screen.getByText("Are you sure you want to delete these SSH keys?")
).toBeInTheDocument();
});

it("can delete a group of SSH keys", async () => {
const store = mockStore(state);
renderWithBrowserRouter(<DeleteSSHKey />, {
route: "/account/prefs/ssh-keys/delete?ids=2,3",
store,
it("can delete a group of SSH keys", async () => {
renderWithBrowserRouter(<DeleteSSHKey />, {
route: "/account/prefs/ssh-keys/delete?ids=2,3",
});
await userEvent.click(screen.getByRole("button", { name: /delete/i }));

await waitFor(() => {
expect(sshKeyResolvers.deleteSshKey.resolved).toBeTruthy();
});
});
await userEvent.click(screen.getByRole("button", { name: /delete/i }));

expect(
store.getActions().some((action) => action.type === "sshkey/delete")
).toBe(true);
});
it("can show errors encountered when deleting SSH keys", async () => {
mockServer.use(
sshKeyResolvers.deleteSshKey.error({ message: "Uh oh!", code: 404 })
);
renderWithBrowserRouter(<DeleteSSHKey />, {
route: "/account/prefs/ssh-keys/delete?ids=2,3",
});

it("can add a message when a SSH key is deleted", async () => {
state.sshkey.saved = true;
const store = mockStore(state);
renderWithBrowserRouter(<DeleteSSHKey />, {
route: "/account/prefs/ssh-keys/delete?ids=2,3",
store,
});
// Click on the delete button:
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
await userEvent.click(screen.getByRole("button", { name: /delete/i }));

const actions = store.getActions();
expect(actions.some((action) => action.type === "sshkey/cleanup")).toBe(true);
expect(actions.some((action) => action.type === "message/add")).toBe(true);
await waitFor(() => {
expect(screen.getByText("Uh oh!")).toBeInTheDocument();
});
});
});
41 changes: 11 additions & 30 deletions src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,19 @@
import { useState } from "react";

import { useOnEscapePressed } from "@canonical/react-components";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useSearchParams } from "react-router-dom";

import { useDeleteSshKey } from "@/app/api/query/sshKeys";
import type { DeleteUserSshkeyError } from "@/app/apiclient";
import ModelActionForm from "@/app/base/components/ModelActionForm";
import { useAddMessage } from "@/app/base/hooks";
import type { EmptyObject } from "@/app/base/types";
import urls from "@/app/preferences/urls";
import { sshkeyActions } from "@/app/store/sshkey";
import sshkeySelectors from "@/app/store/sshkey/selectors";
import type { SSHKey, SSHKeyMeta } from "@/app/store/sshkey/types";

const DeleteSSHKey = () => {
const [deleting, setDeleting] = useState<SSHKey[SSHKeyMeta.PK][]>([]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const saved = useSelector(sshkeySelectors.saved);
const saving = useSelector(sshkeySelectors.saving);
const sshkeys = useSelector(sshkeySelectors.all);
const dispatch = useDispatch();
const onClose = () => navigate({ pathname: urls.sshKeys.index });
useOnEscapePressed(() => onClose());
const sshKeysDeleted =
deleting.length > 0 &&
!deleting.some((id) => !sshkeys.find((key) => key.id === id));
useAddMessage(
saved && sshKeysDeleted,
sshkeyActions.cleanup,
"SSH key removed successfully.",
() => setDeleting([])
);

const deleteSshKey = useDeleteSshKey();

const ids = searchParams
.get("ids")
Expand All @@ -41,8 +25,9 @@ const DeleteSSHKey = () => {
}

return (
<ModelActionForm
<ModelActionForm<EmptyObject, DeleteUserSshkeyError>
aria-label="Delete SSH key confirmation"
errors={deleteSshKey.error}
initialValues={{}}
message={`Are you sure you want to delete ${
ids.length > 1 ? "these SSH keys" : "this SSH key"
Expand All @@ -51,16 +36,12 @@ const DeleteSSHKey = () => {
onCancel={onClose}
onSubmit={() => {
ids.forEach((id) => {
dispatch(sshkeyActions.delete(id));
deleteSshKey.mutate({ path: { id } });
});
setDeleting(ids);
}}
onSuccess={() => {
dispatch(sshkeyActions.cleanup());
onClose();
}}
saved={saved}
saving={saving}
onSuccess={onClose}
saved={deleteSshKey.isSuccess}
saving={deleteSshKey.isPending}
/>
);
};
Expand Down
Loading

0 comments on commit c15f8b6

Please sign in to comment.