diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx index 86e9efb20d..37719121eb 100644 --- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx +++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx @@ -8,6 +8,7 @@ import { ipRangeActions } from "@/app/store/iprange"; import type { IPRange } from "@/app/store/iprange/types"; import { IPRangeType } from "@/app/store/iprange/types"; import type { RootState } from "@/app/store/root/types"; +import type { Subnet } from "@/app/store/subnet/types"; import * as factory from "@/testing/factories"; import { userEvent, @@ -22,18 +23,24 @@ const mockStore = configureStore(); describe("ReservedRangeForm", () => { let state: RootState; let ipRange: IPRange; + let subnet: Subnet; beforeEach(() => { ipRange = factory.ipRange({ comment: "what a beaut", - start_ip: "11.1.1.1", + start_ip: "10.10.0.1", + end_ip: "10.10.0.100", type: IPRangeType.Reserved, user: "wombat", }); + subnet = factory.subnet({ cidr: "10.10.0.0/24" }); state = factory.rootState({ iprange: factory.ipRangeState({ items: [ipRange], }), + subnet: factory.subnetState({ + items: [subnet], + }), }); }); @@ -48,6 +55,7 @@ describe("ReservedRangeForm", () => { @@ -62,7 +70,10 @@ describe("ReservedRangeForm", () => { - + ); @@ -79,16 +90,17 @@ describe("ReservedRangeForm", () => { ); expect( screen.getByRole("textbox", { name: Labels.StartIp }) - ).toHaveAttribute("value", ipRange.start_ip); + ).toHaveAttribute("value", ipRange.start_ip.split(".")[-1]); // value should only be the last octet of the address expect(screen.getByRole("textbox", { name: Labels.EndIp })).toHaveAttribute( "value", - ipRange.end_ip + ipRange.end_ip.split(".")[-1] // value should only be the last octet of the address ); expect( screen.getByRole("textbox", { name: Labels.Comment }) @@ -107,6 +119,7 @@ describe("ReservedRangeForm", () => { @@ -129,18 +142,18 @@ describe("ReservedRangeForm", () => { ); await userEvent.type( screen.getByRole("textbox", { name: Labels.StartIp }), - "1.1.1.1" + "1" ); await userEvent.type( screen.getByRole("textbox", { name: Labels.EndIp }), - "1.1.1.2" + "99" ); await userEvent.type( screen.getByRole("textbox", { name: Labels.Comment }), @@ -149,9 +162,9 @@ describe("ReservedRangeForm", () => { await userEvent.click(screen.getByRole("button", { name: "Reserve" })); const expected = ipRangeActions.create({ comment: "reserved", - end_ip: "1.1.1.2", - start_ip: "1.1.1.1", - subnet: 1, + start_ip: "10.10.0.1", + end_ip: "10.10.0.99", + subnet: subnet.id, type: IPRangeType.Reserved, }); await waitFor(() => @@ -171,19 +184,20 @@ describe("ReservedRangeForm", () => { ); const startIpField = screen.getByRole("textbox", { name: Labels.StartIp }); await userEvent.clear(startIpField); - await userEvent.type(startIpField, "1.2.3.4"); + await userEvent.type(startIpField, "20"); await userEvent.click(screen.getByRole("button", { name: "Save" })); const expected = ipRangeActions.update({ comment: ipRange.comment, end_ip: ipRange.end_ip, id: ipRange.id, - start_ip: "1.2.3.4", + start_ip: "10.10.0.20", }); await waitFor(() => expect( @@ -204,19 +218,20 @@ describe("ReservedRangeForm", () => { ); const startIpField = screen.getByRole("textbox", { name: Labels.StartIp }); await userEvent.clear(startIpField); - await userEvent.type(startIpField, "1.2.3.4"); + await userEvent.type(startIpField, "4"); await userEvent.click(screen.getByRole("button", { name: "Save" })); const expected = ipRangeActions.update({ comment: ipRange.comment, end_ip: ipRange.end_ip, id: ipRange.id, - start_ip: "1.2.3.4", + start_ip: "10.10.0.4", }); await waitFor(() => { const actual = store @@ -238,6 +253,7 @@ describe("ReservedRangeForm", () => { @@ -249,7 +265,7 @@ describe("ReservedRangeForm", () => { it("displays an error when start and end IP addresses are not provided", async () => { renderWithBrowserRouter( - , + , { state, route: "/machines", @@ -267,4 +283,58 @@ describe("ReservedRangeForm", () => { await screen.findByLabelText(Labels.EndIp) ).toHaveAccessibleErrorMessage(/End IP is required/); }); + + it("displays an error when an invalid IP address is entered", async () => { + renderWithBrowserRouter( + , + { + state, + route: "/machines", + } + ); + await userEvent.type( + screen.getByRole("textbox", { name: Labels.StartIp }), + "abc" + ); + await userEvent.type( + screen.getByRole("textbox", { name: Labels.EndIp }), + "abc" + ); + await userEvent.click(screen.getByRole("button", { name: "Reserve" })); + expect( + await screen.findByLabelText(Labels.StartIp) + ).toHaveAccessibleErrorMessage(/This is not a valid IP address/); + expect( + await screen.findByLabelText(Labels.EndIp) + ).toHaveAccessibleErrorMessage(/This is not a valid IP address/); + }); + + it("displays an error when an out-of-range IP address is entered", async () => { + renderWithBrowserRouter( + , + { + state, + route: "/machines", + } + ); + await userEvent.type( + screen.getByRole("textbox", { name: Labels.StartIp }), + "0" + ); + await userEvent.type( + screen.getByRole("textbox", { name: Labels.EndIp }), + "255" + ); + await userEvent.click(screen.getByRole("button", { name: "Reserve" })); + expect( + await screen.findByLabelText(Labels.StartIp) + ).toHaveAccessibleErrorMessage( + /The IP address is outside of the subnet's range/ + ); + expect( + await screen.findByLabelText(Labels.EndIp) + ).toHaveAccessibleErrorMessage( + /The IP address is outside of the subnet's range/ + ); + }); }); diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx index 7241ca8d59..760fb943bd 100644 --- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx +++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx @@ -1,11 +1,16 @@ import { useCallback } from "react"; import { Col, Row, Spinner } from "@canonical/react-components"; +import * as ipaddr from "ipaddr.js"; +import { isIP, isIPv4 } from "is-ip"; import { useDispatch, useSelector } from "react-redux"; import * as Yup from "yup"; import FormikField from "@/app/base/components/FormikField"; import FormikForm from "@/app/base/components/FormikForm"; +import PrefixedIpInput, { + formatIpAddress, +} from "@/app/base/components/PrefixedIpInput"; import { useSidePanel, type SetSidePanelContent, @@ -15,8 +20,14 @@ import ipRangeSelectors from "@/app/store/iprange/selectors"; import type { IPRange } from "@/app/store/iprange/types"; import { IPRangeType, IPRangeMeta } from "@/app/store/iprange/types"; import type { RootState } from "@/app/store/root/types"; +import subnetSelectors from "@/app/store/subnet/selectors"; import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; import { isId } from "@/app/utils"; +import { + getImmutableAndEditableOctets, + getIpRangeFromCidr, + isIpInSubnet, +} from "@/app/utils/subnetIpRange"; type Props = { createType?: IPRangeType; @@ -39,12 +50,6 @@ export enum Labels { StartIp = "Start IP address", } -const Schema = Yup.object().shape({ - comment: Yup.string(), - start_ip: Yup.string().required("Start IP is required"), - end_ip: Yup.string().required("End IP is required"), -}); - const ReservedRangeForm = ({ createType, ipRangeId, @@ -67,8 +72,15 @@ const ReservedRangeForm = ({ const saved = useSelector(ipRangeSelectors.saved); const saving = useSelector(ipRangeSelectors.saving); const errors = useSelector(ipRangeSelectors.errors); + const subnet = useSelector((state: RootState) => + subnetSelectors.getById(state, subnetId) + ); + const subnetLoading = useSelector(subnetSelectors.loading); + const cleanup = useCallback(() => ipRangeActions.cleanup(), []); + const isEditing = isId(computedIpRangeId); + const showDynamicComment = isEditing && ipRange?.type === IPRangeType.Dynamic; const onClose = () => setSidePanelContent(null); let computedCreateType = createType; if (!createType) { @@ -78,31 +90,141 @@ const ReservedRangeForm = ({ : undefined; } - if (isEditing && !ipRange) { + if ((isEditing && !ipRange) || subnetLoading) { return ( ); } - let initialComment = ""; - const showDynamicComment = isEditing && ipRange?.type === IPRangeType.Dynamic; - if (showDynamicComment) { - initialComment = "Dynamic"; - } else if (isEditing) { - initialComment = ipRange?.comment ?? ""; + + if (!subnet && subnetId) { + // Return null if subnet ID is provided but subnet has not been loaded yet + return null; } + const ReservedRangeSchema = Yup.object().shape({ + comment: Yup.string(), + start_ip: Yup.string() + .required("Start IP is required") + .test({ + name: "ip-is-valid", + message: "This is not a valid IP address", + test: (ip_address) => + subnet + ? isIP(formatIpAddress(ip_address, subnet.cidr)) + : isIP(`${ip_address}`), + }) + .test({ + name: "ip-is-in-subnet", + message: "The IP address is outside of the subnet's range.", + test: (ip_address) => { + if (subnet) { + const ip = formatIpAddress(ip_address, subnet.cidr); + const networkAddress = subnet.cidr.split("/")[0]; + const subnetIsIpv4 = isIPv4(networkAddress); + if (subnetIsIpv4) { + return isIpInSubnet(ip, subnet.cidr as string); + } else { + try { + const prefixLength = parseInt(subnet.cidr.split("/")[1]); + const addr = ipaddr.parse(ip); + const netAddr = ipaddr.parse(networkAddress); + return addr.match(netAddr, prefixLength); + } catch (e) { + return false; + } + } + } else { + // Return "true" if there is no subnet - we only need this validation when reserving a range for a subnet + return true; + } + }, + }), + end_ip: Yup.string() + .required("End IP is required") + .test({ + name: "ip-is-valid", + message: "This is not a valid IP address", + test: (ip_address) => + subnet + ? isIP(formatIpAddress(ip_address, subnet.cidr)) + : isIP(`${ip_address}`), + }) + .test({ + name: "ip-is-in-subnet", + message: "The IP address is outside of the subnet's range.", + test: (ip_address) => { + if (subnet) { + const ip = formatIpAddress(ip_address, subnet.cidr); + const networkAddress = subnet.cidr.split("/")[0]; + const subnetIsIpv4 = isIPv4(networkAddress); + if (subnetIsIpv4) { + return isIpInSubnet(ip, subnet.cidr as string); + } else { + try { + const prefixLength = parseInt(subnet.cidr.split("/")[1]); + const addr = ipaddr.parse(ip); + const netAddr = ipaddr.parse(networkAddress); + return addr.match(netAddr, prefixLength); + } catch (e) { + return false; + } + } + } else { + // Return "true" if there is no subnet - we only need this validation when reserving a range for a subnet + return true; + } + }, + }), + }); + + const getInitialValues = () => { + let initialComment = ""; + if (showDynamicComment) { + initialComment = "Dynamic"; + } else if (isEditing) { + initialComment = ipRange?.comment ?? ""; + } + + let startIp = ""; + let endIp = ""; + + if (isEditing && ipRange) { + if (subnet) { + const networkAddress = subnet.cidr.split("/")[0]; + const subnetIsIpv4 = isIPv4(networkAddress); + const [firstIP, lastIp] = getIpRangeFromCidr(subnet.cidr); + const [immutableOctets, _] = getImmutableAndEditableOctets( + firstIP, + lastIp + ); + + startIp = subnetIsIpv4 + ? ipRange?.start_ip.replace(`${immutableOctets}.`, "") + : ipRange?.start_ip.replace(`${networkAddress}`, ""); + endIp = subnetIsIpv4 + ? ipRange?.end_ip.replace(`${immutableOctets}.`, "") + : ipRange?.end_ip.replace(`${networkAddress}`, ""); + } else { + startIp = ipRange.start_ip; + endIp = ipRange.end_ip; + } + } + + return { + comment: initialComment, + end_ip: endIp, + start_ip: startIp, + }; + }; + return ( aria-label={isEditing ? Labels.EditRange : Labels.CreateRange} cleanup={cleanup} errors={errors} - initialValues={{ - comment: initialComment, - end_ip: ipRange?.end_ip ?? "", - start_ip: ipRange?.start_ip ?? "", - }} + initialValues={getInitialValues()} onCancel={onClose} onSaveAnalytics={{ action: "Save reserved range", @@ -110,6 +232,14 @@ const ReservedRangeForm = ({ label: `${isEditing ? "Edit" : "Create"} reserved range form`, }} onSubmit={(values) => { + // If a subnet is provided, PrefixedIpInput fields are used and the IP addresses need to be formatted + const startIp = subnet + ? formatIpAddress(values.start_ip, subnet.cidr) + : values.start_ip; + const endIp = subnet + ? formatIpAddress(values.end_ip, subnet.cidr) + : values.end_ip; + // Clear the errors from the previous submission. dispatch(cleanup()); if (!isEditing && computedCreateType) { @@ -117,14 +247,17 @@ const ReservedRangeForm = ({ ipRangeActions.create({ subnet: subnetId, type: computedCreateType, - ...values, + start_ip: startIp, + end_ip: endIp, + comment: values.comment, }) ); } else if (isEditing && ipRange) { dispatch( ipRangeActions.update({ [IPRangeMeta.PK]: ipRange[IPRangeMeta.PK], - ...values, + start_ip: startIp, + end_ip: endIp, // Reset the value of the comment field so that "Dynamic" isn't stored. comment: showDynamicComment ? ipRange?.comment : values.comment, }) @@ -136,26 +269,51 @@ const ReservedRangeForm = ({ saved={saved} saving={saving} submitLabel={isEditing ? "Save" : "Reserve"} - validationSchema={Schema} + validationSchema={ReservedRangeSchema} {...props} > - - - - - - + {subnet ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + )} {isEditing || computedCreateType === IPRangeType.Reserved ? (