From 5e85d43a2748a4088a1d748acc9c4b5f363c46b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 22 Jul 2024 13:03:48 +0100 Subject: [PATCH 1/5] refactor(web): use queries to handle the first user --- web/src/components/users/FirstUser.jsx | 58 ++------- web/src/components/users/FirstUserForm.jsx | 38 ++---- web/src/queries/users.ts | 144 +++++++++++++++++++++ web/src/types/users.ts | 34 +++++ 4 files changed, 205 insertions(+), 69 deletions(-) create mode 100644 web/src/queries/users.ts create mode 100644 web/src/types/users.ts diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index d0677714c9..8a81b04cf2 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -20,13 +20,17 @@ */ import React, { useState, useEffect } from "react"; -import { Skeleton, Split, Stack } from "@patternfly/react-core"; +import { Split, Stack } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; import { useNavigate } from "react-router-dom"; import { RowActions, ButtonLink } from "~/components/core"; import { _ } from "~/i18n"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "~/context/installer"; +import { + useFirstUser, + useFirstUserChanges, + useFirstUserMutation, + useRemoveFirstUserMutation, +} from "~/queries/users"; const UserNotDefined = ({ actionCb }) => { return ( @@ -73,48 +77,14 @@ const UserData = ({ user, actions }) => { ); }; -const initialUser = { - userName: "", - fullName: "", - autologin: false, - password: "", -}; - export default function FirstUser() { - const client = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [user, setUser] = useState({}); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - cancellablePromise(client.users.getUser()).then((userValues) => { - setUser(userValues); - setIsLoading(false); - }); - }, [client.users, cancellablePromise]); - - useEffect(() => { - return client.users.onUsersChange((changes) => { - if (changes.firstUser !== undefined) { - setUser(changes.firstUser); - } - }); - }, [client.users]); - - const remove = async () => { - setIsLoading(true); - - const result = await client.users.removeUser(); + const { data: user } = useFirstUser(); + const removeUser = useRemoveFirstUserMutation(); + const navigate = useNavigate(); - if (result) { - setUser(initialUser); - setIsLoading(false); - } - }; + useFirstUserChanges(); const isUserDefined = user?.userName && user?.userName !== ""; - const navigate = useNavigate(); - const actions = [ { title: _("Edit"), @@ -122,14 +92,12 @@ export default function FirstUser() { }, { title: _("Discard"), - onClick: remove, + onClick: () => removeUser.mutate(), isDanger: true, }, ]; - if (isLoading) { - return ; - } else if (isUserDefined) { + if (isUserDefined) { return ; } else { return ; diff --git a/web/src/components/users/FirstUserForm.jsx b/web/src/components/users/FirstUserForm.jsx index 9915c2d980..d2f752a221 100644 --- a/web/src/components/users/FirstUserForm.jsx +++ b/web/src/components/users/FirstUserForm.jsx @@ -42,6 +42,7 @@ import { _ } from "~/i18n"; import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; import { suggestUsernames } from "~/components/users/utils"; +import { useFirstUser, useFirstUserMutation } from "~/queries/users"; const UsernameSuggestions = ({ isOpen = false, @@ -82,6 +83,8 @@ const UsernameSuggestions = ({ // close to the related input. // TODO: extract the suggestions logic. export default function FirstUserForm() { + const { data: firstUser } = useFirstUser(); + const setFirstUser = useFirstUserMutation(); const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); const [state, setState] = useState({}); @@ -96,24 +99,14 @@ export default function FirstUserForm() { const passwordRef = useRef(); useEffect(() => { - cancellablePromise(client.users.getUser()).then((userValues) => { - const editing = userValues.userName !== ""; - setState({ - load: true, - user: userValues, - isEditing: editing, - }); - setChangePassword(!editing); + const editing = firstUser.userName !== ""; + setState({ + load: true, + user: firstUser, + isEditing: editing, }); - }, [client.users, cancellablePromise]); - - useEffect(() => { - return client.users.onUsersChange(({ firstUser }) => { - if (firstUser !== undefined) { - setState({ ...state, user: firstUser }); - } - }); - }, [client.users, state]); + setChangePassword(!editing); + }, [firstUser]); useEffect(() => { if (showSuggestions) { @@ -152,13 +145,10 @@ export default function FirstUserForm() { return; } - const { result, issues = [] } = await client.users.setUser({ ...state.user, ...user }); - if (!result || issues.length) { - // FIXME: improve error handling. See client. - setErrors(issues.length ? issues : [_("Please, try again.")]); - } else { - navigate(".."); - } + setFirstUser + .mutateAsync({ ...state.user, ...user }) + .catch((e) => setErrors(e)) + .then(() => navigate("..")); }; const onSuggestionSelected = (suggestion) => { diff --git a/web/src/queries/users.ts b/web/src/queries/users.ts new file mode 100644 index 0000000000..4f5da5a4b2 --- /dev/null +++ b/web/src/queries/users.ts @@ -0,0 +1,144 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { QueryClient, useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { _ } from "~/i18n"; +import { FirstUser, RootUser } from "~/types/users"; + +/** + * Returns a query for retrieving the first user configuration + */ +const firstUserQuery = () => ({ + queryKey: ["users", "firstUser"], + queryFn: () => fetch("/api/users/first").then((res) => res.json()), +}); + +/** + * Hook that returns the first user. + */ +const useFirstUser = () => useSuspenseQuery(firstUserQuery()); + +/* + * Hook that returns a mutation to change the first user. + */ +const useFirstUserMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: (user: FirstUser) => + fetch("/api/users/first", { + method: "PUT", + body: JSON.stringify({ ...user, data: {} }), + headers: { + "Content-Type": "application/json", + }, + }).then((response) => { + if (response.ok) { + return response.json(); + } else { + throw new Error(_("Please, try again")); + } + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users", "firstUser"] }), + }; + return useMutation(query); +}; + +const useRemoveFirstUserMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: () => + fetch("/api/users/first", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users", "firstUser"] }); + }, + }; + return useMutation(query); +}; + +/** + * Listens for first user changes. + */ +const useFirstUserChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + if (event.type === "FirstUserChanged") { + const { fullName, userName, password, autologin, data } = event; + queryClient.setQueryData(["users", "firstUser"], { + fullName, + userName, + password, + autologin, + data, + }); + } + }); + }); +}; + +/** + * Returns a query for retrieving the root user configuration. + */ +const rootUserQuery = () => ({ + queryKey: ["users", "root"], + queryFn: () => fetch("/api/users/root").then((res) => res.json()), +}); + +const useRootUser = () => useSuspenseQuery(rootUserQuery()); + +/* + * Hook that returns a mutation to change the root user configuration. + */ +const useRootUserMutation = () => { + const queryClient = new QueryClient(); + const query = { + mutationFn: (root: RootUser) => + fetch("/api/users/root", { + method: "PATCH", + body: JSON.stringify(root), + headers: { + "Content-Type": "application/json", + }, + }), + success: queryClient.invalidateQueries({ queryKey: ["users", "firstUser"] }), + }; + return useMutation(query); +}; + +export { + useFirstUser, + useFirstUserMutation, + useRemoveFirstUserMutation, + useRootUser, + useRootUserMutation, + useFirstUserChanges, +}; diff --git a/web/src/types/users.ts b/web/src/types/users.ts new file mode 100644 index 0000000000..7fce70b720 --- /dev/null +++ b/web/src/types/users.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +type FirstUser = { + fullName: string; + userName: string; + password: string; + autologin: boolean; +}; + +type RootUser = { + password: string | null; + sshkey: string | null; +}; + +export type { FirstUser, RootUser }; From e5b5cc3df58af41cf5cde6b84c7b9c782465d9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 22 Jul 2024 14:10:24 +0100 Subject: [PATCH 2/5] refactor(web): use queries to handle root auth --- web/src/components/users/RootAuthMethods.jsx | 53 ++++--------------- .../components/users/RootPasswordPopup.jsx | 5 +- web/src/components/users/RootSSHKeyPopup.jsx | 6 +-- web/src/queries/users.ts | 44 ++++++++++++--- web/src/types/users.ts | 10 +++- 5 files changed, 62 insertions(+), 56 deletions(-) diff --git a/web/src/components/users/RootAuthMethods.jsx b/web/src/components/users/RootAuthMethods.jsx index c34cf4b5fe..ff24fa5076 100644 --- a/web/src/components/users/RootAuthMethods.jsx +++ b/web/src/components/users/RootAuthMethods.jsx @@ -28,6 +28,7 @@ import { RootPasswordPopup, RootSSHKeyPopup } from "~/components/users"; import { _ } from "~/i18n"; import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; +import { useRootUser, useRootUserChanges, useRootUserMutation } from "~/queries/users"; const MethodsNotDefined = ({ setPassword, setSSHKey }) => { return ( @@ -54,42 +55,17 @@ const MethodsNotDefined = ({ setPassword, setSSHKey }) => { ); }; export default function RootAuthMethods() { - const { users: client } = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [sshKey, setSSHKey] = useState(""); - const [isPasswordDefined, setIsPasswordDefined] = useState(false); + const setRootUser = useRootUserMutation(); const [isSSHKeyFormOpen, setIsSSHKeyFormOpen] = useState(false); const [isPasswordFormOpen, setIsPasswordFormOpen] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const loadData = async () => { - try { - const isPasswordSet = await cancellablePromise(client.isRootPasswordSet()); - const sshKey = await cancellablePromise(client.getRootSSHKey()); - - setIsPasswordDefined(isPasswordSet); - setSSHKey(sshKey); - } catch (error) { - // TODO: handle/display errors - console.log(error); - } finally { - setIsLoading(false); - } - }; - - loadData(); - }, [client, cancellablePromise]); - - useEffect(() => { - return client.onUsersChange((changes) => { - if (changes.rootPasswordSet !== undefined) setIsPasswordDefined(changes.rootPasswordSet); - if (changes.rootSSHKey !== undefined) setSSHKey(changes.rootSSHKey); - }); - }, [client]); - const isSSHKeyDefined = sshKey !== ""; + const { + data: { password: isPasswordDefined, sshkey: sshKey }, + } = useRootUser(); + + useRootUserChanges(); + const isSSHKeyDefined = sshKey !== ""; const openPasswordForm = () => setIsPasswordFormOpen(true); const openSSHKeyForm = () => setIsSSHKeyFormOpen(true); const closePasswordForm = () => setIsPasswordFormOpen(false); @@ -102,7 +78,7 @@ export default function RootAuthMethods() { }, isPasswordDefined && { title: _("Discard"), - onClick: () => client.removeRootPassword(), + onClick: () => setRootUser.mutate({ password: "" }), isDanger: true, }, ].filter(Boolean); @@ -114,20 +90,11 @@ export default function RootAuthMethods() { }, sshKey && { title: _("Discard"), - onClick: () => client.setRootSSHKey(""), + onClick: () => setRootUser.mutate({ sshkey: "" }), isDanger: true, }, ].filter(Boolean); - if (isLoading) { - return ( - <> - - - - ); - } - const PasswordLabel = () => { return isPasswordDefined ? _("Already set") : _("Not set"); }; diff --git a/web/src/components/users/RootPasswordPopup.jsx b/web/src/components/users/RootPasswordPopup.jsx index ea7cdd12b9..b696ae4acf 100644 --- a/web/src/components/users/RootPasswordPopup.jsx +++ b/web/src/components/users/RootPasswordPopup.jsx @@ -27,6 +27,7 @@ import { PasswordAndConfirmationInput, Popup } from "~/components/core"; import { _ } from "~/i18n"; import { useInstallerClient } from "~/context/installer"; +import { useRootUser, useRootUserMutation } from "~/queries/users"; /** * A dialog holding the form to change the root password @@ -41,7 +42,7 @@ import { useInstallerClient } from "~/context/installer"; * @param {function} props.onClose - the function to be called when the dialog is closed */ export default function RootPasswordPopup({ title = _("Root password"), isOpen, onClose }) { - const { users: client } = useInstallerClient(); + const setRootUser = useRootUserMutation(); const [password, setPassword] = useState(""); const [isValidPassword, setIsValidPassword] = useState(true); const passwordRef = useRef(); @@ -54,7 +55,7 @@ export default function RootPasswordPopup({ title = _("Root password"), isOpen, const accept = async (e) => { e.preventDefault(); // TODO: handle errors - if (password !== "") await client.setRootPassword(password); + if (password !== "") await setRootUser.mutateAsync({ password }); close(); }; diff --git a/web/src/components/users/RootSSHKeyPopup.jsx b/web/src/components/users/RootSSHKeyPopup.jsx index cd444f5bf4..8e8b9b48b6 100644 --- a/web/src/components/users/RootSSHKeyPopup.jsx +++ b/web/src/components/users/RootSSHKeyPopup.jsx @@ -24,7 +24,7 @@ import { Form, FormGroup, FileUpload } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { Popup } from "~/components/core"; -import { useInstallerClient } from "~/context/installer"; +import { useRootUserMutation } from "~/queries/users"; /** * A dialog holding the form to set the SSH Public key for root @@ -45,7 +45,7 @@ export default function RootSSHKeyPopup({ isOpen, onClose, }) { - const client = useInstallerClient(); + const setRootUser = useRootUserMutation(); const [isLoading, setIsLoading] = useState(false); const [sshKey, setSSHKey] = useState(currentKey); @@ -60,7 +60,7 @@ export default function RootSSHKeyPopup({ const accept = async (e) => { e.preventDefault(); - client.users.setRootSSHKey(sshKey); + await setRootUser.mutateAsync({ sshkey: sshKey }); // TODO: handle/display errors close(); }; diff --git a/web/src/queries/users.ts b/web/src/queries/users.ts index 4f5da5a4b2..6ec88957a0 100644 --- a/web/src/queries/users.ts +++ b/web/src/queries/users.ts @@ -23,7 +23,7 @@ import React from "react"; import { QueryClient, useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; import { _ } from "~/i18n"; -import { FirstUser, RootUser } from "~/types/users"; +import { FirstUser, RootUser, RootUserChanges } from "~/types/users"; /** * Returns a query for retrieving the first user configuration @@ -119,26 +119,58 @@ const useRootUser = () => useSuspenseQuery(rootUserQuery()); * Hook that returns a mutation to change the root user configuration. */ const useRootUserMutation = () => { - const queryClient = new QueryClient(); + const queryClient = useQueryClient(); const query = { - mutationFn: (root: RootUser) => + mutationFn: (changes: Partial) => fetch("/api/users/root", { method: "PATCH", - body: JSON.stringify(root), + body: JSON.stringify({ ...changes, passwordEncrypted: false }), headers: { "Content-Type": "application/json", }, }), - success: queryClient.invalidateQueries({ queryKey: ["users", "firstUser"] }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users", "root"] }), }; return useMutation(query); }; +/** + * Listens for first user changes. + */ +const useRootUserChanges = () => { + const client = useInstallerClient(); + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent((event) => { + console.log("event.type", event.type); + if (event.type === "RootChanged") { + const { password, sshkey } = event; + queryClient.setQueryData(["users", "root"], (oldRoot: RootUser) => { + const newRoot = { ...oldRoot }; + if (password !== undefined) { + newRoot.password = password; + } + + if (sshkey) { + newRoot.sshkey = sshkey; + } + + return newRoot; + }); + } + }); + }); +}; + export { useFirstUser, + useFirstUserChanges, useFirstUserMutation, useRemoveFirstUserMutation, useRootUser, + useRootUserChanges, useRootUserMutation, - useFirstUserChanges, }; diff --git a/web/src/types/users.ts b/web/src/types/users.ts index 7fce70b720..bd88452d4c 100644 --- a/web/src/types/users.ts +++ b/web/src/types/users.ts @@ -27,8 +27,14 @@ type FirstUser = { }; type RootUser = { - password: string | null; + password: boolean; sshkey: string | null; }; -export type { FirstUser, RootUser }; +type RootUserChanges = { + password: string; + passwordEncrypted: boolean; + sshkey: string; +}; + +export type { FirstUser, RootUserChanges, RootUser }; From 24c414442a6e8e0b1dc8cdd60f272cb2568be6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 23 Jul 2024 12:19:34 +0100 Subject: [PATCH 3/5] test(web): fix root auth tests --- .../components/users/RootAuthMethods.test.jsx | 400 +++++++----------- .../users/RootPasswordPopup.test.jsx | 48 +-- .../components/users/RootSSHKeyPopup.test.jsx | 55 ++- 3 files changed, 211 insertions(+), 292 deletions(-) diff --git a/web/src/components/users/RootAuthMethods.test.jsx b/web/src/components/users/RootAuthMethods.test.jsx index bc41d28d5c..ea1c5f22dd 100644 --- a/web/src/components/users/RootAuthMethods.test.jsx +++ b/web/src/components/users/RootAuthMethods.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,299 +20,225 @@ */ import React from "react"; - import { act, screen, within } from "@testing-library/react"; -import { installerRender, createCallbackMock } from "~/test-utils"; +import { plainRender, installerRender, createCallbackMock } from "~/test-utils"; import { noop } from "~/utils"; -import { createClient } from "~/client"; import { RootAuthMethods } from "~/components/users"; +import { useRootUser, useRootUserMutation } from "~/queries/users"; -jest.mock("~/client"); -jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); +const mockRootUserMutation = { mutate: jest.fn(), mutateAsync: jest.fn() }; +let mockPassword; +let mockSSHKey; - return { - ...original, - Skeleton: () =>
PFSkeleton
, - }; -}); +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useRootUser: () => ({ data: { password: mockPassword, sshkey: mockSSHKey } }), + useRootUserMutation: () => mockRootUserMutation, + useRootUserChanges: () => jest.fn(), +})); -let onUsersChangeFn = noop; const isRootPasswordSetFn = jest.fn(); -const getRootSSHKeyFn = jest.fn(); -const setRootSSHKeyFn = jest.fn(); const removeRootPasswordFn = jest.fn(); const testKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; beforeEach(() => { - isRootPasswordSetFn.mockResolvedValue(false); - getRootSSHKeyFn.mockResolvedValue(""); - - createClient.mockImplementation(() => { - return { - users: { - isRootPasswordSet: isRootPasswordSetFn, - getRootSSHKey: getRootSSHKeyFn, - setRootSSHKey: setRootSSHKeyFn, - onUsersChange: onUsersChangeFn, - removeRootPassword: removeRootPasswordFn, - }, - }; - }); + mockPassword = false; + mockSSHKey = ""; }); -describe("when loading initial data", () => { - it("renders a loading component", async () => { - installerRender(); - await screen.findAllByText("PFSkeleton"); +describe("when no method is defined", () => { + it("renders a text inviting the user to define at least one", () => { + plainRender(); + + screen.getByText("No root authentication method defined yet."); + screen.getByText(/at least one/); }); }); -describe("when ready", () => { - describe("and no method is defined", () => { - it("renders a text inviting the user to define at least one", async () => { - installerRender(); - - await screen.findByText("No root authentication method defined yet."); - screen.getByText(/at least one/); - }); - - it("renders buttons for setting either, a password or a SSH Public Key", async () => { - installerRender(); +describe("and the password has been set", () => { + beforeEach(() => { + mockPassword = true; + }); - await screen.findByRole("button", { name: "Set a password" }); - screen.getByRole("button", { name: "Upload a SSH Public Key" }); - }); + it("renders the 'Already set' status", async () => { + plainRender(); - it("allows setting the password", async () => { - const { user } = installerRender(); + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + within(passwordRow).getByText("Already set"); + }); - const button = await screen.findByRole("button", { name: "Set a password" }); - await user.click(button); + it("does not renders the 'Set' action", async () => { + const { user } = plainRender(); - screen.getByRole("dialog", { name: "Set a root password" }); - }); + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const setAction = within(passwordRow).queryByRole("menuitem", { name: "Set" }); + expect(setAction).toBeNull(); + }); - it("allows setting the SSH Public Key", async () => { - const { user } = installerRender(); + it("allows the user to change the already set password", async () => { + const { user } = plainRender(); - const button = await screen.findByRole("button", { name: "Upload a SSH Public Key" }); - await user.click(button); + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const changeAction = within(passwordRow).queryByRole("menuitem", { name: "Change" }); + await user.click(changeAction); - screen.getByRole("dialog", { name: "Add a SSH Public Key for root" }); - }); + screen.getByRole("dialog", { name: "Change the root password" }); }); - describe("and at least one method is already defined", () => { - beforeEach(() => isRootPasswordSetFn.mockResolvedValue(true)); + it("allows the user to discard the chosen password", async () => { + const { user } = plainRender(); - it("renders a table with available methods", async () => { - installerRender(); + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const discardAction = within(passwordRow).queryByRole("menuitem", { name: "Discard" }); + await user.click(discardAction); + + expect(mockRootUserMutation.mutate).toHaveBeenCalledWith({ password: "" }); + }); +}); - const table = await screen.findByRole("grid"); - within(table).getByText("Password"); - within(table).getByText("SSH Key"); - }); +describe("the password is not set yet", () => { + // Mock another auth method for reaching the table + beforeEach(() => { + mockSSHKey = "Fake"; }); - describe("and the password has been set", () => { - beforeEach(() => isRootPasswordSetFn.mockResolvedValue(true)); - - it("renders the 'Already set' status", async () => { - installerRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - within(passwordRow).getByText("Already set"); - }); - - it("does not renders the 'Set' action", async () => { - const { user } = installerRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const setAction = within(passwordRow).queryByRole("menuitem", { name: "Set" }); - expect(setAction).toBeNull(); - }); - - it("allows the user to change the already set password", async () => { - const { user } = installerRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const changeAction = await within(passwordRow).queryByRole("menuitem", { name: "Change" }); - await user.click(changeAction); - - screen.getByRole("dialog", { name: "Change the root password" }); - }); - - it("allows the user to discard the chosen password", async () => { - const { user } = installerRender(); - - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const discardAction = await within(passwordRow).queryByRole("menuitem", { name: "Discard" }); - await user.click(discardAction); - - expect(removeRootPasswordFn).toHaveBeenCalled(); - }); + it("renders the 'Not set' status", async () => { + plainRender(); + + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + within(passwordRow).getByText("Not set"); }); - describe("but the password is not set yet", () => { - // Mock another auth method for reaching the table - beforeEach(() => getRootSSHKeyFn.mockResolvedValue("Fake")); + it("allows the user to set a password", async () => { + const { user } = plainRender(); - it("renders the 'Not set' status", async () => { - installerRender(); + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const setAction = within(passwordRow).getByRole("menuitem", { name: "Set" }); + await user.click(setAction); + screen.getByRole("dialog", { name: "Set a root password" }); + }); - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - within(passwordRow).getByText("Not set"); - }); + it("does not render the 'Change' nor the 'Discard' actions", async () => { + const { user } = plainRender(); - it("allows the user to set a password", async () => { - const { user } = installerRender(); + const table = await screen.findByRole("grid"); + const passwordRow = within(table).getByText("Password").closest("tr"); + const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const setAction = within(passwordRow).getByRole("menuitem", { name: "Set" }); - await user.click(setAction); - screen.getByRole("dialog", { name: "Set a root password" }); - }); + const changeAction = within(passwordRow).queryByRole("menuitem", { name: "Change" }); + const discardAction = within(passwordRow).queryByRole("menuitem", { name: "Discard" }); - it("does not render the 'Change' nor the 'Discard' actions", async () => { - const { user } = installerRender(); + expect(changeAction).toBeNull(); + expect(discardAction).toBeNull(); + }); +}); - const table = await screen.findByRole("grid"); - const passwordRow = within(table).getByText("Password").closest("tr"); - const actionsToggler = within(passwordRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); +describe("and the SSH Key has been set", () => { + beforeEach(() => { + mockSSHKey = testKey; + }); - const changeAction = await within(passwordRow).queryByRole("menuitem", { name: "Change" }); - const discardAction = await within(passwordRow).queryByRole("menuitem", { name: "Discard" }); + it("renders its truncated content keeping the comment visible when possible", async () => { + plainRender(); - expect(changeAction).toBeNull(); - expect(discardAction).toBeNull(); - }); + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + within(sshKeyRow).getByText("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+"); + within(sshKeyRow).getByText("test@example"); }); - describe("and the SSH Key has been set", () => { - beforeEach(() => getRootSSHKeyFn.mockResolvedValue(testKey)); - - it("renders its truncated content keeping the comment visible when possible", async () => { - installerRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - within(sshKeyRow).getByText("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+"); - within(sshKeyRow).getByText("test@example"); - }); - - it("does not renders the 'Set' action", async () => { - const { user } = installerRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const setAction = within(sshKeyRow).queryByRole("menuitem", { name: "Set" }); - expect(setAction).toBeNull(); - }); - - it("allows the user to change it", async () => { - const { user } = installerRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const changeAction = await within(sshKeyRow).queryByRole("menuitem", { name: "Change" }); - await user.click(changeAction); - - screen.getByRole("dialog", { name: "Edit the SSH Public Key for root" }); - }); - - it("allows the user to discard it", async () => { - const { user } = installerRender(); - - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const discardAction = await within(sshKeyRow).queryByRole("menuitem", { name: "Discard" }); - await user.click(discardAction); - - expect(setRootSSHKeyFn).toHaveBeenCalledWith(""); - }); + it("does not renders the 'Set' action", async () => { + const { user } = plainRender(); + + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const setAction = within(sshKeyRow).queryByRole("menuitem", { name: "Set" }); + expect(setAction).toBeNull(); }); - describe("but the SSH Key is not set yet", () => { - // Mock another auth method for reaching the table - beforeEach(() => isRootPasswordSetFn.mockResolvedValue(true)); + it("allows the user to change it", async () => { + const { user } = plainRender(); - it("renders the 'Not set' status", async () => { - installerRender(); + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const changeAction = within(sshKeyRow).queryByRole("menuitem", { name: "Change" }); + await user.click(changeAction); - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - within(sshKeyRow).getByText("Not set"); - }); + screen.getByRole("dialog", { name: "Edit the SSH Public Key for root" }); + }); - it("allows the user to set a key", async () => { - const { user } = installerRender(); + it("allows the user to discard it", async () => { + const { user } = plainRender(); - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); - const setAction = within(sshKeyRow).getByRole("menuitem", { name: "Set" }); - await user.click(setAction); - screen.getByRole("dialog", { name: "Add a SSH Public Key for root" }); - }); + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const discardAction = within(sshKeyRow).queryByRole("menuitem", { name: "Discard" }); + await user.click(discardAction); - it("does not render the 'Change' nor the 'Discard' actions", async () => { - const { user } = installerRender(); + expect(mockRootUserMutation.mutate).toHaveBeenCalledWith({ sshkey: "" }); + }); +}); - const table = await screen.findByRole("grid"); - const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); - const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); - await user.click(actionsToggler); +describe("but the SSH Key is not set yet", () => { + // Mock another auth method for reaching the table + beforeEach(() => { + mockPassword = true; + }); - const changeAction = await within(sshKeyRow).queryByRole("menuitem", { name: "Change" }); - const discardAction = await within(sshKeyRow).queryByRole("menuitem", { name: "Discard" }); + it("renders the 'Not set' status", async () => { + plainRender(); - expect(changeAction).toBeNull(); - expect(discardAction).toBeNull(); - }); + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + within(sshKeyRow).getByText("Not set"); }); - describe("and user settings changes", () => { - // Mock an auth method for reaching the table - beforeEach(() => isRootPasswordSetFn.mockResolvedValue(true)); + it("allows the user to set a key", async () => { + const { user } = plainRender(); + + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); + const setAction = within(sshKeyRow).getByRole("menuitem", { name: "Set" }); + await user.click(setAction); + screen.getByRole("dialog", { name: "Add a SSH Public Key for root" }); + }); - it("updates the UI accordingly", async () => { - const [mockFunction, callbacks] = createCallbackMock(); - onUsersChangeFn = mockFunction; + it("does not render the 'Change' nor the 'Discard' actions", async () => { + const { user } = plainRender(); - installerRender(); - await screen.findAllByText("Not set"); + const table = await screen.findByRole("grid"); + const sshKeyRow = within(table).getByText("SSH Key").closest("tr"); + const actionsToggler = within(sshKeyRow).getByRole("button", { name: "Actions" }); + await user.click(actionsToggler); - const [cb] = callbacks; - act(() => { - cb({ rootPasswordSet: true, rootSSHKey: testKey }); - }); + const changeAction = within(sshKeyRow).queryByRole("menuitem", { name: "Change" }); + const discardAction = within(sshKeyRow).queryByRole("menuitem", { name: "Discard" }); - await screen.findByText("Already set"); - await screen.findByText("test@example"); - }); + expect(changeAction).toBeNull(); + expect(discardAction).toBeNull(); }); }); diff --git a/web/src/components/users/RootPasswordPopup.test.jsx b/web/src/components/users/RootPasswordPopup.test.jsx index 4340e75475..833e388019 100644 --- a/web/src/components/users/RootPasswordPopup.test.jsx +++ b/web/src/components/users/RootPasswordPopup.test.jsx @@ -22,49 +22,45 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; - +import { plainRender } from "~/test-utils"; import { RootPasswordPopup } from "~/components/users"; -jest.mock("~/client"); +const mockRootUserMutation = { mutateAsync: jest.fn() }; +let mockPassword; +let mockSSHKey; + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useRootUser: () => ({ data: { password: mockPassword, sshkey: "" } }), + useRootUserMutation: () => mockRootUserMutation, + useRootUserChanges: () => jest.fn(), +})); const onCloseCallback = jest.fn(); -const setRootPasswordFn = jest.fn(); const password = "nots3cr3t"; -beforeEach(() => { - createClient.mockImplementation(() => { - return { - users: { - setRootPassword: setRootPasswordFn, - }, - }; - }); -}); - describe("when it is closed", () => { it("renders nothing", async () => { - const { container } = installerRender(); + const { container } = plainRender(); await waitFor(() => expect(container).toBeEmptyDOMElement()); }); }); describe("when it is open", () => { - it("renders default title when none if given", async () => { - installerRender(); - const dialog = await screen.findByRole("dialog"); + it("renders default title when none if given", () => { + plainRender(); + const dialog = screen.queryByRole("dialog"); within(dialog).getByText("Root password"); }); - it("renders the given title", async () => { - installerRender(); - const dialog = await screen.findByRole("dialog"); + it("renders the given title", () => { + plainRender(); + const dialog = screen.getByRole("dialog"); within(dialog).getByText("Change The Root Password"); }); it("allows changing the password", async () => { - const { user } = installerRender(); + const { user } = plainRender(); await screen.findByRole("dialog"); @@ -79,17 +75,17 @@ describe("when it is open", () => { expect(confirmButton).toBeEnabled(); await user.click(confirmButton); - expect(setRootPasswordFn).toHaveBeenCalledWith(password); + expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ password }); expect(onCloseCallback).toHaveBeenCalled(); }); it("allows dismissing the dialog without changing the password", async () => { - const { user } = installerRender(); + const { user } = plainRender(); await screen.findByRole("dialog"); const cancelButton = await screen.findByRole("button", { name: /Cancel/i }); await user.click(cancelButton); - expect(setRootPasswordFn).not.toHaveBeenCalled(); + expect(mockRootUserMutation.mutateAsync).not.toHaveBeenCalled(); expect(onCloseCallback).toHaveBeenCalled(); }); }); diff --git a/web/src/components/users/RootSSHKeyPopup.test.jsx b/web/src/components/users/RootSSHKeyPopup.test.jsx index 64ed1fd177..e68bcc8bcb 100644 --- a/web/src/components/users/RootSSHKeyPopup.test.jsx +++ b/web/src/components/users/RootSSHKeyPopup.test.jsx @@ -22,54 +22,51 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; +import { plainRender } from "~/test-utils"; import { RootSSHKeyPopup } from "~/components/users"; -jest.mock("~/client"); +const mockRootUserMutation = { mutateAsync: jest.fn() }; +let mockSSHKey; + +jest.mock("~/queries/users", () => ({ + ...jest.requireActual("~/queries/users"), + useRootUser: () => ({ data: { sshkey: mockSSHKey } }), + useRootUserMutation: () => mockRootUserMutation, + useRootUserChanges: () => jest.fn(), +})); const onCloseCallback = jest.fn(); const setRootSSHKeyFn = jest.fn(); const testKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDM+ test@example"; -beforeEach(() => { - createClient.mockImplementation(() => { - return { - users: { - setRootSSHKey: setRootSSHKeyFn, - }, - }; - }); -}); - describe("when it is closed", () => { - it("renders nothing", async () => { - const { container } = installerRender(); - await waitFor(() => expect(container).toBeEmptyDOMElement()); + it("renders nothing", () => { + const { container } = plainRender(); + expect(container).toBeEmptyDOMElement(); }); }); describe("when it is open", () => { - it("renders default title when none if given", async () => { - installerRender(); - const dialog = await screen.findByRole("dialog"); + it("renders default title when none if given", () => { + plainRender(); + const dialog = screen.getByRole("dialog"); within(dialog).getByText("Set root SSH public key"); }); - it("renders the given title", async () => { - installerRender(); - const dialog = await screen.findByRole("dialog"); + it("renders the given title", () => { + plainRender(); + const dialog = screen.getByRole("dialog"); within(dialog).getByText("Root SSHKey"); }); - it("contains the given key, if any", async () => { - installerRender(); - const dialog = await screen.findByRole("dialog"); + it("contains the given key, if any", () => { + plainRender(); + const dialog = screen.getByRole("dialog"); within(dialog).getByText(testKey); }); it("allows defining a new root SSH public key", async () => { - const { user } = installerRender(); + const { user } = plainRender(); const dialog = await screen.findByRole("dialog"); const sshKeyInput = within(dialog).getByLabelText("Root SSH public key"); @@ -80,12 +77,12 @@ describe("when it is open", () => { expect(confirmButton).toBeEnabled(); await user.click(confirmButton); - expect(setRootSSHKeyFn).toHaveBeenCalledWith(testKey); + expect(mockRootUserMutation.mutateAsync).toHaveBeenCalledWith({ sshkey: testKey }); expect(onCloseCallback).toHaveBeenCalled(); }); it("does not change anything if the user cancels", async () => { - const { user } = installerRender(); + const { user } = plainRender(); const dialog = await screen.findByRole("dialog"); const sshKeyInput = within(dialog).getByLabelText("Root SSH public key"); const cancelButton = within(dialog).getByRole("button", { name: /Cancel/i }); @@ -93,7 +90,7 @@ describe("when it is open", () => { await user.type(sshKeyInput, testKey); await user.click(cancelButton); - expect(setRootSSHKeyFn).not.toHaveBeenCalled(); + expect(mockRootUserMutation.mutateAsync).not.toHaveBeenCalled(); expect(onCloseCallback).toHaveBeenCalled(); }); }); From 8658920eb01abe5e387a809cd5df82aaabfab2cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 23 Jul 2024 12:26:00 +0100 Subject: [PATCH 4/5] refactor(web): drop the UsersClient --- web/src/client/index.js | 4 - web/src/client/users.js | 184 -------------------------- web/src/client/users.test.js | 245 ----------------------------------- 3 files changed, 433 deletions(-) delete mode 100644 web/src/client/users.js delete mode 100644 web/src/client/users.test.js diff --git a/web/src/client/index.js b/web/src/client/index.js index 24282045ad..b1920f1f18 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -26,7 +26,6 @@ import { ManagerClient } from "./manager"; import { Monitor } from "./monitor"; import { ProductClient, SoftwareClient } from "./software"; import { StorageClient } from "./storage"; -import { UsersClient } from "./users"; import phase from "./phase"; import { QuestionsClient } from "./questions"; import { NetworkClient } from "./network"; @@ -41,7 +40,6 @@ import { HTTPClient, WSClient } from "./http"; * @property {ProductClient} product - product client. * @property {SoftwareClient} software - software client. * @property {StorageClient} storage - storage client. - * @property {UsersClient} users - users client. * @property {QuestionsClient} questions - questions client. * @property {() => WSClient} ws - Agama WebSocket client. * @property {() => boolean} isConnected - determines whether the client is connected @@ -68,7 +66,6 @@ const createClient = (url) => { const network = new NetworkClient(client); const software = new SoftwareClient(client); const storage = new StorageClient(client); - const users = new UsersClient(client); const questions = new QuestionsClient(client); const isConnected = () => client.ws().isConnected() || false; @@ -82,7 +79,6 @@ const createClient = (url) => { network, software, storage, - users, questions, isConnected, isRecoverable, diff --git a/web/src/client/users.js b/web/src/client/users.js deleted file mode 100644 index 304c7eec78..0000000000 --- a/web/src/client/users.js +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -const SERVICE_NAME = "org.opensuse.Agama.Manager1"; - -/** - * @typedef {object} UserResult - * @property {boolean} result - whether the action succeeded or not - * @property {string[]} issues - issues found when applying the action - */ - -/** - * @typedef {object} User - * @property {string} fullName - User full name - * @property {string} userName - userName - * @property {string} [password] - user password - * @property {boolean} autologin - Whether autologin is enabled - * @property {object} data - additional user data - */ - -/** - * @typedef {object} UserSettings - * @property {User} [firstUser] - first user - * @property {boolean} [rootPasswordSet] - whether the root password is set - * @property {string} [rootSSHKey] - root SSH public key - */ - -/** - * Client to interact with the Agama users service - * - * @ignore - */ -class UsersClient { - /** - * @param {import("./http").HTTPClient} client - HTTP client. - */ - constructor(client) { - this.client = client; - } - - /** - * Returns the first user structure - * - * @return {Promise} - */ - async getUser() { - const response = await this.client.get("/users/first"); - if (!response.ok) { - console.log("Failed to get first user config: ", response); - return { fullName: "", userName: "", password: "", autologin: false, data: {} }; - } - return response.json(); - } - - /** - * Returns true if the root password is set - * - * @return {Promise} - */ - async isRootPasswordSet() { - const response = await this.client.get("/users/root"); - if (!response.ok) { - console.log("Failed to get root config: ", response); - return false; - } - const config = await response.json(); - return config.password; - } - - /** - * Sets the first user - * - * @param {User} user - object with full name, user name, password and boolean for autologin - * @return {Promise} returns an object with the result and the issues found if error - */ - async setUser(user) { - const result = await this.client.put("/users/first", user); - - return { result: result.ok, issues: [] }; // TODO: check how to handle issues and result. Maybe separate call to validate? - } - - /** - * Removes the first user - * - * @return {Promise} whether the operation was successful or not - */ - async removeUser() { - return (await this.client.delete("/users/first")).ok; - } - - /** - * Sets the root password - * - * @param {String} password - plain text root password ( maybe allow client side encryption?) - * @return {Promise} whether the operation was successful or not - */ - async setRootPassword(password) { - const response = await this.client.patch("/users/root", { password, passwordEncrypted: false }); - return response.ok; - } - - /** - * Clears the root password - * - * @return {Promise} whether the operation was successful or not - */ - async removeRootPassword() { - return this.setRootPassword(""); - } - - /** - * Returns the root's public SSH key - * - * @return {Promise} SSH public key or an empty string if it is not set - */ - async getRootSSHKey() { - const response = await this.client.get("/users/root"); - if (!response.ok) { - console.log("Failed to get root config: ", response); - return ""; - } - const config = await response.json(); - return config.sshkey; - } - - /** - * Sets root's public SSH Key - * - * @param {String} key - plain text root ssh key. Empty string means disabled - * @return {Promise} whether the operation was successful or not - */ - async setRootSSHKey(key) { - const response = await this.client.patch("/users/root", { sshkey: key }); - return response.ok; - } - - /** - * Registers a callback to run when user properties change - * - * @param {(userSettings: UserSettings) => void} handler - callback function - * @return {import ("./dbus").RemoveFn} function to disable the callback - */ - onUsersChange(handler) { - return this.client.ws().onEvent((event) => { - if (event.type === "RootChanged") { - const res = {}; - if (event.password !== null) { - res.rootPasswordSet = event.password; - } - if (event.sshkey !== null) { - res.rootSSHKey = event.sshkey; - } - // @ts-ignore - return handler(res); - } else if (event.type === "FirstUserChanged") { - // @ts-ignore - const { fullName, userName, password, autologin, data } = event; - return handler({ firstUser: { fullName, userName, password, autologin, data } }); - } - }); - } -} - -export { UsersClient }; diff --git a/web/src/client/users.test.js b/web/src/client/users.test.js deleted file mode 100644 index 405d797b81..0000000000 --- a/web/src/client/users.test.js +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import { HTTPClient } from "./http"; -import { UsersClient } from "./users"; - -const mockJsonFn = jest.fn(); -const mockGetFn = jest.fn().mockImplementation(() => { - return { ok: true, json: mockJsonFn }; -}); -const mockPatchFn = jest.fn().mockImplementation(() => { - return { ok: true }; -}); -const mockPutFn = jest.fn().mockImplementation(() => { - return { ok: true }; -}); -const mockDeleteFn = jest.fn().mockImplementation(() => { - return { ok: true }; -}); - -jest.mock("./http", () => { - return { - HTTPClient: jest.fn().mockImplementation(() => { - return { - get: mockGetFn, - patch: mockPatchFn, - put: mockPutFn, - delete: mockDeleteFn, - }; - }), - }; -}); - -let client; - -const firstUser = { - fullName: "Jane Doe", - userName: "jane", - password: "12345", - autologin: false, -}; - -beforeEach(() => { - client = new UsersClient(new HTTPClient(new URL("http://localhost"))); -}); - -describe("#getUser", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(firstUser); - }); - - it("returns the defined first user", async () => { - const user = await client.getUser(); - expect(user).toEqual(firstUser); - expect(mockGetFn).toHaveBeenCalledWith("/users/first"); - }); -}); - -describe("#isRootPasswordSet", () => { - describe("when the root password is set", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ password: true, sshkey: "" }); - }); - - it("returns true", async () => { - expect(await client.isRootPasswordSet()).toEqual(true); - expect(mockGetFn).toHaveBeenCalledWith("/users/root"); - }); - }); - - describe("when the root password is not set", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ password: false, sshkey: "" }); - }); - - it("returns false", async () => { - expect(await client.isRootPasswordSet()).toEqual(false); - expect(mockGetFn).toHaveBeenCalledWith("/users/root"); - }); - }); -}); - -describe("#getRootSSHKey", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue({ password: "", sshkey: "ssh-key" }); - }); - - it("returns the SSH key for the root user", async () => { - const result = expect(await client.getRootSSHKey()).toEqual("ssh-key"); - expect(mockGetFn).toHaveBeenCalledWith("/users/root"); - }); -}); - -describe("#setUser", () => { - it("sets the values of the first user and returns whether succeeded or not an errors found", async () => { - const user = { - fullName: "Jane Doe", - userName: "jane", - password: "12345", - autologin: false, - }; - const result = await client.setUser(user); - expect(mockPutFn).toHaveBeenCalledWith("/users/first", user); - expect(result); - }); - - describe("when setting the user fails because some issue", () => { - beforeEach(() => { - mockPutFn.mockResolvedValue({ ok: false }); - }); - - // issues are not included in the response - it.skip("returns an object with the result as false and the issues found", async () => { - const result = await client.setUser({ - fullName: "Jane Doe", - userName: "jane", - password: "12345", - autologin: false, - }); - - expect(mockPutFn).toHaveBeenCalledWith("/users/first"); - expect(result).toEqual({ result: false, issues: ["There is an error"] }); - }); - }); -}); - -describe("#removeUser", () => { - it("removes the first user and returns true", async () => { - const result = await client.removeUser(); - expect(result).toEqual(true); - expect(mockDeleteFn).toHaveBeenCalledWith("/users/first"); - }); - - describe("when removing the user fails", () => { - beforeEach(() => { - mockDeleteFn.mockResolvedValue({ ok: false }); - }); - - it("returns false", async () => { - const result = await client.removeUser(); - expect(result).toEqual(false); - expect(mockDeleteFn).toHaveBeenCalledWith("/users/first"); - }); - }); -}); - -describe("#setRootPassword", () => { - it("sets the root password and returns true", async () => { - const result = await client.setRootPassword("12345"); - expect(mockPatchFn).toHaveBeenCalledWith("/users/root", { - password: "12345", - passwordEncrypted: false, - }); - expect(result).toEqual(true); - }); - - describe("when setting the password fails", () => { - beforeEach(() => { - mockPatchFn.mockResolvedValue({ ok: false }); - }); - - it("returns false", async () => { - const result = await client.setRootPassword("12345"); - expect(mockPatchFn).toHaveBeenCalledWith("/users/root", { - password: "12345", - passwordEncrypted: false, - }); - expect(result).toEqual(false); - }); - }); -}); - -describe("#removeRootPassword", () => { - beforeEach(() => { - mockPatchFn.mockResolvedValue({ ok: true }); - }); - - it("removes the root password", async () => { - const result = await client.removeRootPassword(); - expect(mockPatchFn).toHaveBeenCalledWith("/users/root", { - password: "", - passwordEncrypted: false, - }); - expect(result).toEqual(true); - }); - - describe("when setting the user fails", () => { - beforeEach(() => { - mockPatchFn.mockResolvedValue({ ok: false }); - }); - - it("returns false", async () => { - const result = await client.removeRootPassword(); - expect(mockPatchFn).toHaveBeenCalledWith("/users/root", { - password: "", - passwordEncrypted: false, - }); - expect(result).toEqual(false); - }); - }); -}); - -describe("#setRootSSHKey", () => { - beforeEach(() => { - mockPatchFn.mockResolvedValue({ ok: true }); - }); - - it("sets the root password and returns true", async () => { - const result = await client.setRootSSHKey("ssh-key"); - expect(mockPatchFn).toHaveBeenCalledWith("/users/root", { sshkey: "ssh-key" }); - expect(result).toEqual(true); - }); - - describe("when setting the user fails", () => { - beforeEach(() => { - mockPatchFn.mockResolvedValue({ ok: false }); - }); - - it("returns false", async () => { - const result = await client.setRootSSHKey("ssh-key"); - expect(mockPatchFn).toHaveBeenCalledWith("/users/root", { sshkey: "ssh-key" }); - expect(result).toEqual(false); - }); - }); -}); From 2ed229fd761f5da5f88ffac79da64ecc65a5ed3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 23 Jul 2024 12:45:55 +0100 Subject: [PATCH 5/5] refactor(web): simplify users hooks API --- web/src/components/users/FirstUser.jsx | 9 ++------- web/src/components/users/FirstUserForm.jsx | 2 +- web/src/components/users/RootAuthMethods.jsx | 4 +--- web/src/queries/users.ts | 10 ++++++++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index 8a81b04cf2..19c5c17f03 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -25,12 +25,7 @@ import { Table, Thead, Tr, Th, Tbody, Td } from "@patternfly/react-table"; import { useNavigate } from "react-router-dom"; import { RowActions, ButtonLink } from "~/components/core"; import { _ } from "~/i18n"; -import { - useFirstUser, - useFirstUserChanges, - useFirstUserMutation, - useRemoveFirstUserMutation, -} from "~/queries/users"; +import { useFirstUser, useFirstUserChanges, useRemoveFirstUserMutation } from "~/queries/users"; const UserNotDefined = ({ actionCb }) => { return ( @@ -78,7 +73,7 @@ const UserData = ({ user, actions }) => { }; export default function FirstUser() { - const { data: user } = useFirstUser(); + const user = useFirstUser(); const removeUser = useRemoveFirstUserMutation(); const navigate = useNavigate(); diff --git a/web/src/components/users/FirstUserForm.jsx b/web/src/components/users/FirstUserForm.jsx index d2f752a221..ad7322baa1 100644 --- a/web/src/components/users/FirstUserForm.jsx +++ b/web/src/components/users/FirstUserForm.jsx @@ -83,7 +83,7 @@ const UsernameSuggestions = ({ // close to the related input. // TODO: extract the suggestions logic. export default function FirstUserForm() { - const { data: firstUser } = useFirstUser(); + const firstUser = useFirstUser(); const setFirstUser = useFirstUserMutation(); const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); diff --git a/web/src/components/users/RootAuthMethods.jsx b/web/src/components/users/RootAuthMethods.jsx index ff24fa5076..b8b5143573 100644 --- a/web/src/components/users/RootAuthMethods.jsx +++ b/web/src/components/users/RootAuthMethods.jsx @@ -59,9 +59,7 @@ export default function RootAuthMethods() { const [isSSHKeyFormOpen, setIsSSHKeyFormOpen] = useState(false); const [isPasswordFormOpen, setIsPasswordFormOpen] = useState(false); - const { - data: { password: isPasswordDefined, sshkey: sshKey }, - } = useRootUser(); + const { password: isPasswordDefined, sshkey: sshKey } = useRootUser(); useRootUserChanges(); diff --git a/web/src/queries/users.ts b/web/src/queries/users.ts index 6ec88957a0..2404810de8 100644 --- a/web/src/queries/users.ts +++ b/web/src/queries/users.ts @@ -36,7 +36,10 @@ const firstUserQuery = () => ({ /** * Hook that returns the first user. */ -const useFirstUser = () => useSuspenseQuery(firstUserQuery()); +const useFirstUser = () => { + const { data: firstUser } = useSuspenseQuery(firstUserQuery()); + return firstUser; +}; /* * Hook that returns a mutation to change the first user. @@ -113,7 +116,10 @@ const rootUserQuery = () => ({ queryFn: () => fetch("/api/users/root").then((res) => res.json()), }); -const useRootUser = () => useSuspenseQuery(rootUserQuery()); +const useRootUser = () => { + const { data: rootUser } = useSuspenseQuery(rootUserQuery()); + return rootUser; +}; /* * Hook that returns a mutation to change the root user configuration.