diff --git a/ui/src/pages/providers/ProviderCreate/ProviderCreate.test.tsx b/ui/src/pages/providers/ProviderCreate/ProviderCreate.test.tsx
new file mode 100644
index 000000000..3566337f8
--- /dev/null
+++ b/ui/src/pages/providers/ProviderCreate/ProviderCreate.test.tsx
@@ -0,0 +1,129 @@
+import { screen, waitFor } from "@testing-library/dom";
+import { faker } from "@faker-js/faker";
+import userEvent from "@testing-library/user-event";
+import { Location } from "react-router-dom";
+import MockAdapter from "axios-mock-adapter";
+import * as reactQuery from "@tanstack/react-query";
+
+import { renderComponent } from "test/utils";
+import { urls } from "urls";
+import { axiosInstance } from "api/axios";
+
+import ProviderCreate from "./ProviderCreate";
+import { ProviderFormLabel } from "../ProviderForm";
+import { Label } from "./types";
+import { initialValues } from "./ProviderCreate";
+import {
+ NotificationProvider,
+ NotificationConsumer,
+} from "@canonical/react-components";
+import { queryKeys } from "util/queryKeys";
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual("@tanstack/react-query");
+ return {
+ ...actual,
+ useQueryClient: vi.fn(),
+ };
+});
+
+const mock = new MockAdapter(axiosInstance);
+
+beforeEach(() => {
+ mock.reset();
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries: vi.fn(),
+ } as unknown as reactQuery.QueryClient);
+ mock.onPost("/idps").reply(200);
+});
+
+test("can cancel", async () => {
+ let location: Location | null = null;
+ renderComponent(, {
+ url: "/",
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ });
+ await userEvent.click(screen.getByRole("button", { name: Label.CANCEL }));
+ expect((location as Location | null)?.pathname).toBe(urls.providers.index);
+});
+
+test("calls the API on submit", async () => {
+ const values = {
+ id: faker.word.sample(),
+ };
+ renderComponent();
+ const input = screen.getByRole("textbox", { name: ProviderFormLabel.NAME });
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, values.id);
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(mock.history.post[0].url).toBe("/idps");
+ expect(JSON.parse(mock.history.post[0].data as string)).toMatchObject({
+ ...initialValues,
+ scope: initialValues.scope.split(","),
+ ...values,
+ });
+});
+
+test("handles API success", async () => {
+ let location: Location | null = null;
+ const invalidateQueries = vi.fn();
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries,
+ } as unknown as reactQuery.QueryClient);
+ mock.onPost("/idps").reply(200);
+ const values = {
+ id: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ {
+ url: "/",
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ },
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ProviderFormLabel.NAME }),
+ values.id,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ await waitFor(() =>
+ expect(invalidateQueries).toHaveBeenCalledWith({
+ queryKey: [queryKeys.providers],
+ }),
+ );
+ expect(document.querySelector(".p-notification--positive")).toHaveTextContent(
+ Label.SUCCESS,
+ ),
+ expect((location as Location | null)?.pathname).toBe(urls.providers.index);
+});
+
+test("handles API failure", async () => {
+ mock.onPost("/idps").reply(400, {
+ message: "oops",
+ });
+ const values = {
+ id: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ProviderFormLabel.NAME }),
+ values.id,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(document.querySelector(".p-notification--negative")).toHaveTextContent(
+ `${Label.ERROR}oops`,
+ );
+});
diff --git a/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx b/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx
index c3d83f8ee..06cc9773b 100644
--- a/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx
+++ b/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx
@@ -20,6 +20,17 @@ import SidePanel from "components/SidePanel";
import ScrollableContainer from "components/ScrollableContainer";
import { TestId } from "./test-types";
import { testId } from "test/utils";
+import { Label } from "./types";
+
+export const initialValues = {
+ provider: "generic",
+ id: "",
+ client_id: "",
+ client_secret: "",
+ mapper_url: "",
+ scope: "email",
+ subject_source: "userinfo",
+} as const;
const ProviderCreate: FC = () => {
const navigate = useNavigate();
@@ -31,15 +42,7 @@ const ProviderCreate: FC = () => {
});
const formik = useFormik({
- initialValues: {
- provider: "generic",
- id: "",
- client_id: "",
- client_secret: "",
- mapper_url: "",
- scope: "email",
- subject_source: "userinfo",
- },
+ initialValues,
validationSchema: ProviderCreateSchema,
onSubmit: (values) => {
createProvider(
@@ -49,12 +52,15 @@ const ProviderCreate: FC = () => {
void queryClient.invalidateQueries({
queryKey: [queryKeys.providers],
});
- const msg = `Provider created.`;
- navigate("/provider", notify.queue(notify.success(msg)));
+ navigate("/provider", notify.queue(notify.success(Label.SUCCESS)));
})
- .catch((e) => {
+ .catch((error: unknown) => {
formik.setSubmitting(false);
- notify.failure("Provider creation failed", e);
+ notify.failure(
+ Label.ERROR,
+ error instanceof Error ? error : null,
+ typeof error === "string" ? error : null,
+ );
});
},
});
@@ -85,7 +91,7 @@ const ProviderCreate: FC = () => {
{
disabled={!formik.isValid}
onClick={submitForm}
>
- Save
+ {Label.SUBMIT}
diff --git a/ui/src/pages/providers/ProviderCreate/types.ts b/ui/src/pages/providers/ProviderCreate/types.ts
new file mode 100644
index 000000000..e1bb7fc68
--- /dev/null
+++ b/ui/src/pages/providers/ProviderCreate/types.ts
@@ -0,0 +1,6 @@
+export enum Label {
+ CANCEL = "Cancel",
+ ERROR = "Provider creation failed",
+ SUBMIT = "Save",
+ SUCCESS = "Provider created.",
+}
diff --git a/ui/src/pages/providers/ProviderEdit/ProviderEdit.test.tsx b/ui/src/pages/providers/ProviderEdit/ProviderEdit.test.tsx
new file mode 100644
index 000000000..a602aa176
--- /dev/null
+++ b/ui/src/pages/providers/ProviderEdit/ProviderEdit.test.tsx
@@ -0,0 +1,166 @@
+import { screen, waitFor } from "@testing-library/dom";
+import { faker } from "@faker-js/faker";
+import userEvent from "@testing-library/user-event";
+import { Location } from "react-router-dom";
+import MockAdapter from "axios-mock-adapter";
+import * as reactQuery from "@tanstack/react-query";
+
+import { renderComponent } from "test/utils";
+import { axiosInstance } from "api/axios";
+
+import ProviderEdit from "./ProviderEdit";
+import { ProviderFormLabel } from "../ProviderForm";
+import { Label } from "./types";
+import {
+ NotificationProvider,
+ NotificationConsumer,
+} from "@canonical/react-components";
+import { queryKeys } from "util/queryKeys";
+import { mockIdentityProvider } from "test/mocks/providers";
+import { IdentityProvider } from "types/provider";
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual("@tanstack/react-query");
+ return {
+ ...actual,
+ useQueryClient: vi.fn(),
+ };
+});
+
+const mock = new MockAdapter(axiosInstance);
+
+let provider: IdentityProvider;
+
+beforeEach(() => {
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries: vi.fn(),
+ } as unknown as reactQuery.QueryClient);
+ mock.reset();
+ provider = mockIdentityProvider({
+ id: faker.word.sample(),
+ apple_private_key: faker.word.sample(),
+ apple_private_key_id: faker.word.sample(),
+ apple_team_id: faker.word.sample(),
+ auth_url: faker.word.sample(),
+ client_id: faker.word.sample(),
+ client_secret: faker.word.sample(),
+ issuer_url: faker.word.sample(),
+ mapper_url: faker.word.sample(),
+ microsoft_tenant: faker.word.sample(),
+ provider: faker.word.sample(),
+ requested_claims: faker.word.sample(),
+ subject_source: "userinfo",
+ token_url: faker.word.sample(),
+ scope: ["email"],
+ });
+ mock.onGet(`/idps/${provider.id}`).reply(200, { data: [provider] });
+ mock.onPatch(`/idps/${provider.id}`).reply(200);
+});
+
+test("can cancel", async () => {
+ let location: Location | null = null;
+ renderComponent(, {
+ url: `/?id=${provider.id}`,
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ });
+ await userEvent.click(screen.getByRole("button", { name: Label.CANCEL }));
+ expect((location as Location | null)?.pathname).toBe("/");
+ expect((location as Location | null)?.search).toBe("");
+});
+
+test("calls the API on submit", async () => {
+ const values = {
+ scope: faker.word.sample(),
+ };
+ renderComponent(, {
+ url: `/?id=${provider.id}`,
+ });
+ const input = screen.getByRole("textbox", { name: ProviderFormLabel.SCOPES });
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, values.scope);
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(mock.history.patch[0].url).toBe(`/idps/${provider.id}`);
+ expect(JSON.parse(mock.history.patch[0].data as string)).toMatchObject({
+ apple_private_key: provider.apple_private_key,
+ apple_private_key_id: provider.apple_private_key_id,
+ apple_team_id: provider.apple_team_id,
+ auth_url: provider.auth_url,
+ client_id: provider.client_id,
+ client_secret: provider.client_secret,
+ id: provider.id,
+ issuer_url: provider.issuer_url,
+ mapper_url: provider.mapper_url,
+ microsoft_tenant: provider.microsoft_tenant,
+ provider: provider.provider,
+ requested_claims: provider.requested_claims,
+ subject_source: "userinfo",
+ token_url: provider.token_url,
+ scope: [values.scope],
+ });
+});
+
+test("handles API success", async () => {
+ let location: Location | null = null;
+ const invalidateQueries = vi.fn();
+ vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({
+ invalidateQueries,
+ } as unknown as reactQuery.QueryClient);
+ mock.onPatch(`/idps/${provider.id}`).reply(200);
+ const values = {
+ scope: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ {
+ url: `/?id=${provider.id}`,
+ setLocation: (newLocation) => {
+ location = newLocation;
+ },
+ },
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ProviderFormLabel.SCOPES }),
+ values.scope,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ await waitFor(() =>
+ expect(invalidateQueries).toHaveBeenCalledWith({
+ queryKey: [queryKeys.providers],
+ }),
+ );
+ expect(document.querySelector(".p-notification--positive")).toHaveTextContent(
+ Label.SUCCESS,
+ );
+ expect((location as Location | null)?.pathname).toBe("/");
+ expect((location as Location | null)?.search).toBe("");
+});
+
+test("handles API failure", async () => {
+ mock.onPatch(`/idps/${provider.id}`).reply(400, {
+ message: "oops",
+ });
+ const values = {
+ scope: faker.word.sample(),
+ };
+ renderComponent(
+
+
+
+ ,
+ { url: `/?id=${provider.id}` },
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: ProviderFormLabel.SCOPES }),
+ values.scope,
+ );
+ await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT }));
+ expect(document.querySelector(".p-notification--negative")).toHaveTextContent(
+ `${Label.ERROR}oops`,
+ );
+});
diff --git a/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx b/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx
index 196e19a69..7a74d7f96 100644
--- a/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx
+++ b/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx
@@ -17,6 +17,7 @@ import usePanelParams from "util/usePanelParams";
import ScrollableContainer from "components/ScrollableContainer";
import { TestId } from "./test-types";
import { testId } from "test/utils";
+import { Label } from "./types";
const ProviderEdit: FC = () => {
const notify = useNotify();
@@ -24,13 +25,9 @@ const ProviderEdit: FC = () => {
const panelParams = usePanelParams();
const providerId = panelParams.id;
- if (!providerId) {
- return;
- }
-
const { data: provider } = useQuery({
queryKey: [queryKeys.providers, providerId],
- queryFn: () => fetchProvider(providerId),
+ queryFn: () => (providerId ? fetchProvider(providerId) : null),
});
const ProviderEditSchema = Yup.object().shape({
@@ -39,21 +36,21 @@ const ProviderEdit: FC = () => {
const formik = useFormik({
initialValues: {
- apple_private_key: provider?.apple_private_key,
- apple_private_key_id: provider?.apple_private_key_id,
- apple_team_id: provider?.apple_team_id,
- auth_url: provider?.auth_url,
- client_id: provider?.client_id,
- client_secret: provider?.client_secret,
- id: provider?.id,
- issuer_url: provider?.issuer_url,
- mapper_url: provider?.mapper_url,
- microsoft_tenant: provider?.microsoft_tenant,
- provider: provider?.provider,
- requested_claims: provider?.requested_claims,
- scope: provider?.scope?.join(","),
- subject_source: provider?.subject_source,
- token_url: provider?.token_url,
+ apple_private_key: provider?.apple_private_key || "",
+ apple_private_key_id: provider?.apple_private_key_id || "",
+ apple_team_id: provider?.apple_team_id || "",
+ auth_url: provider?.auth_url || "",
+ client_id: provider?.client_id || "",
+ client_secret: provider?.client_secret || "",
+ id: provider?.id || "",
+ issuer_url: provider?.issuer_url || "",
+ mapper_url: provider?.mapper_url || "",
+ microsoft_tenant: provider?.microsoft_tenant || "",
+ provider: provider?.provider || "",
+ requested_claims: provider?.requested_claims || "",
+ scope: provider?.scope?.join(",") || "",
+ subject_source: provider?.subject_source || "userinfo",
+ token_url: provider?.token_url || "",
},
enableReinitialize: true,
validationSchema: ProviderEditSchema,
@@ -66,12 +63,16 @@ const ProviderEdit: FC = () => {
void queryClient.invalidateQueries({
queryKey: [queryKeys.providers],
});
- notify.success("Provider updated");
+ notify.success(Label.SUCCESS);
panelParams.clear();
})
- .catch((e) => {
+ .catch((error: unknown) => {
formik.setSubmitting(false);
- notify.failure("Provider update failed", e);
+ notify.failure(
+ Label.ERROR,
+ error instanceof Error ? error : null,
+ typeof error === "string" ? error : null,
+ );
});
},
});
@@ -102,7 +103,7 @@ const ProviderEdit: FC = () => {
className="u-no-margin--bottom u-sv2"
onClick={panelParams.clear}
>
- Cancel
+ {Label.CANCEL}
{
disabled={!formik.isValid}
onClick={() => void formik.submitForm()}
>
- Update
+ {Label.SUBMIT}
diff --git a/ui/src/pages/providers/ProviderEdit/types.ts b/ui/src/pages/providers/ProviderEdit/types.ts
new file mode 100644
index 000000000..3a59e864f
--- /dev/null
+++ b/ui/src/pages/providers/ProviderEdit/types.ts
@@ -0,0 +1,6 @@
+export enum Label {
+ CANCEL = "Cancel",
+ ERROR = "Provider update failed",
+ SUBMIT = "Update",
+ SUCCESS = "Provider updated",
+}
diff --git a/ui/src/pages/providers/ProviderForm/index.ts b/ui/src/pages/providers/ProviderForm/index.ts
index 2fe31befb..2b5a42371 100644
--- a/ui/src/pages/providers/ProviderForm/index.ts
+++ b/ui/src/pages/providers/ProviderForm/index.ts
@@ -1 +1,2 @@
export { default, type ProviderFormTypes } from "./ProviderForm";
+export { Label as ProviderFormLabel } from "./types";