diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c7a19a09..cbddd7d07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,7 @@ name: Build and Test on: + workflow_dispatch: pull_request: branches: [main] diff --git a/cypress/e2e/endpoints/new-endpoint.cy.ts b/cypress/e2e/endpoints/new-endpoint.cy.ts index 6201d3823..af93701a0 100644 --- a/cypress/e2e/endpoints/new-endpoint.cy.ts +++ b/cypress/e2e/endpoints/new-endpoint.cy.ts @@ -116,15 +116,41 @@ describe("New endpoint", () => { }); }); - it("saves the endpoint", () => { + const fillOpenstackForm = () => { + cy.get("input[placeholder='Name']").type("new openstack"); + cy.get("input[placeholder='Username']").type("username"); + cy.get("input[placeholder='Password']").type("password"); + cy.get("input[placeholder='Authentication URL']").type("auth url"); + cy.get("input[placeholder='Project Name']").type("project name"); + }; + + const interceptSave = () => { + cy.intercept("POST", routeSelectors.SECRETS, { + fixture: "endpoints/secret-ref", + }).as("secrets-post"); + cy.intercept("POST", routeSelectors.ENDPOINTS, { + fixture: "endpoints/endpoint", + }).as("endpoints-post"); + cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1`, { + body: { status: "ACTIVE" }, + }).as("secrets-active"); + cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1/payload`, { + body: { username: "username", password: "password" }, + }).as("secrets-payload"); + }; + + it("saves the endpoint after it passes validation", () => { clickOpenstack().then(() => { - cy.get("input[placeholder='Name']").type("new openstack"); - cy.get("input[placeholder='Username']").type("username"); - cy.get("input[placeholder='Password']").type("password"); - cy.get("input[placeholder='Authentication URL']").type("auth url"); - cy.get("input[placeholder='Project Name']").type("project name"); + fillOpenstackForm(); + + let validated = false; + cy.intercept("POST", `${routeSelectors.ENDPOINTS}/actions`, req => { + validated = true; + req.reply({ fixture: "endpoints/validation-success" }); + }).as("endpoints-validate"); cy.intercept("POST", routeSelectors.SECRETS, req => { + expect(validated, "validated before saving").to.equal(true); expect(req.body).to.have.property("algorithm", "aes"); expect(req.body).to.have.property("payload"); expect(JSON.parse(req.body.payload)).to.have.property( @@ -135,6 +161,7 @@ describe("New endpoint", () => { }).as("secrets-post"); cy.intercept("POST", routeSelectors.ENDPOINTS, req => { + expect(validated, "validated before saving").to.equal(true); expect(req.body).to.have.property("endpoint"); expect(req.body.endpoint).to.have.property("name", "new openstack"); expect(req.body.endpoint).to.have.property("type", "openstack"); @@ -152,53 +179,33 @@ describe("New endpoint", () => { cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1/payload`, { body: { username: "username", password: "password" }, }).as("secrets-payload"); - cy.intercept("POST", `${routeSelectors.ENDPOINTS}/**/actions`, { - fixture: "endpoints/validation-fail", - }).as("endpoints-validate"); cy.get("button").contains("Validate and save").click(); cy.wait([ + "@endpoints-validate", "@secrets-post", "@endpoints-post", "@secrets-active", "@secrets-payload", - "@endpoints-validate", ]); + + cy.get("div[class^=EndpointModal__StatusMessage]").should( + "contain.text", + "Endpoint is Valid", + ); }); }); - it("fails validation", () => { + it("doesn't save the endpoint when validation fails", () => { clickOpenstack().then(() => { - cy.get("input[placeholder='Name']").type("new openstack"); - cy.get("input[placeholder='Username']").type("username"); - cy.get("input[placeholder='Password']").type("password"); - cy.get("input[placeholder='Authentication URL']").type("auth url"); - cy.get("input[placeholder='Project Name']").type("project name"); - - cy.intercept("POST", routeSelectors.SECRETS, { - fixture: "endpoints/secret-ref", - }).as("secrets-post"); - cy.intercept("POST", routeSelectors.ENDPOINTS, { - fixture: "endpoints/endpoint", - }).as("endpoints-post"); - cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1`, { - body: { status: "ACTIVE" }, - }).as("secrets-active"); - cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1/payload`, { - body: { username: "username", password: "password" }, - }).as("secrets-payload"); - cy.intercept("POST", `${routeSelectors.ENDPOINTS}/**/actions`, { + fillOpenstackForm(); + interceptSave(); + cy.intercept("POST", `${routeSelectors.ENDPOINTS}/actions`, { fixture: "endpoints/validation-fail", }).as("endpoints-validate"); cy.get("button").contains("Validate and save").click(); - cy.wait([ - "@secrets-post", - "@endpoints-post", - "@secrets-active", - "@secrets-payload", - "@endpoints-validate", - ]); + cy.wait("@endpoints-validate"); cy.get("div[class^=EndpointModal__StatusMessage]").should( "contain.text", @@ -213,48 +220,37 @@ describe("New endpoint", () => { validationFailFixture["validate-connection"].message, ); }); + + cy.get("@secrets-post.all").should("have.length", 0); + cy.get("@endpoints-post.all").should("have.length", 0); + cy.get("input[placeholder='Name']").should("have.value", "new openstack"); + cy.get("button").contains("Validate and save").should("exist"); }); }); - it("validates successfully", () => { + it("validates the platform and connection info from the form", () => { clickOpenstack().then(() => { - cy.get("input[placeholder='Name']").type("new openstack"); - cy.get("input[placeholder='Username']").type("username"); - cy.get("input[placeholder='Password']").type("password"); - cy.get("input[placeholder='Authentication URL']").type("auth url"); - cy.get("input[placeholder='Project Name']").type("project name"); - - cy.intercept("POST", routeSelectors.SECRETS, { - fixture: "endpoints/secret-ref", - }).as("secrets-post"); - cy.intercept("POST", routeSelectors.ENDPOINTS, { - fixture: "endpoints/endpoint", - }).as("endpoints-post"); - cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1`, { - body: { status: "ACTIVE" }, - }).as("secrets-active"); - cy.intercept(`${routeSelectors.SECRETS}/secret-ref-1/payload`, { - body: { username: "username", password: "password" }, - }).as("secrets-payload"); - - cy.intercept("POST", `${routeSelectors.ENDPOINTS}/**/actions`, req => { - expect(req.body).to.have.property("validate-connection", null); + fillOpenstackForm(); + interceptSave(); + + cy.intercept("POST", `${routeSelectors.ENDPOINTS}/actions`, req => { + const validateConnection = req.body["validate-connection"]; + expect(validateConnection).to.have.property("platform", "openstack"); + expect(validateConnection).to.have.property("mapped_regions"); + expect(validateConnection.connection_info).to.include({ + auth_url: "auth url", + username: "username", + password: "password", + project_name: "project name", + }); + expect(validateConnection.connection_info).not.to.have.property( + "secret_ref", + ); req.reply({ fixture: "endpoints/validation-success" }); }).as("endpoints-validate"); cy.get("button").contains("Validate and save").click(); - cy.wait([ - "@secrets-post", - "@endpoints-post", - "@secrets-active", - "@secrets-payload", - "@endpoints-validate", - ]); - - cy.get("div[class^=EndpointModal__StatusMessage]").should( - "contain.text", - "Endpoint is Valid", - ); + cy.wait("@endpoints-validate"); }); }); }); diff --git a/src/components/modules/EndpointModule/EndpointModal/EndpointModal.spec.tsx b/src/components/modules/EndpointModule/EndpointModal/EndpointModal.spec.tsx new file mode 100644 index 000000000..155568dd0 --- /dev/null +++ b/src/components/modules/EndpointModule/EndpointModal/EndpointModal.spec.tsx @@ -0,0 +1,314 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +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 Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import React from "react"; +import { runInAction } from "mobx"; + +import type { Endpoint, Validation } from "@src/@types/Endpoint"; +import endpointStore from "@src/stores/EndpointStore"; +import notificationStore from "@src/stores/NotificationStore"; +import providerStore from "@src/stores/ProviderStore"; +import { act, fireEvent, render, waitFor } from "@testing-library/react"; + +import EndpointModal from "./EndpointModal"; + +const ORIGINAL_HOST = "192.0.2.10"; +const FAILING_HOST = "192.0.2.20"; +const WORKING_HOST = "192.0.2.30"; + +const VALID: Validation = { valid: true, message: "Endpoint is valid" }; +const INVALID: Validation = { + valid: false, + message: `Connection to ${FAILING_HOST} timed out`, +}; + +const ENDPOINT: Endpoint = { + id: "endpoint-1", + name: "Endpoint", + description: "Endpoint description", + type: "openstack", + created_at: new Date().toISOString(), + mapped_regions: [], + connection_info: {}, +}; + +let mockInvalidFields: string[] = []; + +jest.mock("@src/components/modules/EndpointModule/EndpointLogos", () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock("@src/stores/NotificationStore", () => ({ + alert: jest.fn(), +})); + +jest.mock("@src/plugins", () => { + const MockReact = require("react"); + class MockContentPlugin extends MockReact.Component { + componentDidMount() { + this.props.onRef(this); + } + + findInvalidFields() { + return mockInvalidFields; + } + + render() { + const field = { name: "host" }; + return MockReact.createElement( + "div", + null, + MockReact.createElement("input", { + "data-testid": "host", + defaultValue: this.props.getFieldValue(field), + disabled: this.props.disabled, + onChange: (e: any) => + this.props.handleFieldChange(field, e.target.value), + }), + MockReact.createElement( + "button", + { onClick: () => this.props.handleValidateClick() }, + "Plugin validate", + ), + ); + } + } + return { ContentPlugin: { for: () => MockContentPlugin } }; +}); + +const setValidation = (validation: Validation) => { + runInAction(() => { + endpointStore.validation = validation; + }); +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +}; + +describe("EndpointModal", () => { + let validateAndSave: jest.SpyInstance; + + const renderModal = async (element: React.ReactElement) => { + const result = render(element); + await act(async () => {}); + return result; + }; + + const renderEditModal = (onCancelClick = jest.fn()) => + renderModal( + , + ); + + const renderCreateModal = () => + renderModal( + , + ); + + const submit = (getByText: (text: string) => HTMLElement) => { + fireEvent.click(getByText("Validate and save")); + }; + + beforeEach(() => { + mockInvalidFields = []; + runInAction(() => { + endpointStore.endpoints = [{ ...ENDPOINT }]; + endpointStore.connectionInfo = { host: ORIGINAL_HOST }; + endpointStore.validation = null; + endpointStore.adding = false; + endpointStore.updating = false; + }); + jest + .spyOn(providerStore, "getConnectionInfoSchema") + .mockResolvedValue(undefined); + validateAndSave = jest.spyOn(endpointStore, "validateAndSave"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + (notificationStore.alert as jest.Mock).mockClear(); + }); + + describe("editing an endpoint", () => { + it("submits the current form values", async () => { + validateAndSave.mockImplementation(async () => { + setValidation(VALID); + return VALID; + }); + const { getByTestId, getByText } = await renderEditModal(); + + fireEvent.change(getByTestId("host"), { + target: { value: WORKING_HOST }, + }); + submit(getByText); + + await waitFor(() => expect(getByText("Endpoint is Valid")).toBeTruthy()); + expect(validateAndSave).toHaveBeenCalledTimes(1); + expect(validateAndSave).toHaveBeenCalledWith({ + endpoint: expect.objectContaining({ + id: ENDPOINT.id, + host: WORKING_HOST, + }), + isNew: false, + }); + }); + + it("stays open with the user's values and the error when validation fails", async () => { + validateAndSave.mockImplementation(async () => { + runInAction(() => { + endpointStore.connectionInfo = { host: ORIGINAL_HOST }; + }); + setValidation(INVALID); + return INVALID; + }); + const onCancelClick = jest.fn(); + const { getByTestId, getByText } = await renderEditModal(onCancelClick); + + fireEvent.change(getByTestId("host"), { + target: { value: FAILING_HOST }, + }); + submit(getByText); + + await waitFor(() => expect(getByText("Validation failed")).toBeTruthy()); + fireEvent.click(getByText("Show Error")); + expect(getByText(INVALID.message)).toBeTruthy(); + expect(getByText("Validate and save")).toBeTruthy(); + expect((getByTestId("host") as HTMLInputElement).disabled).toBe(false); + expect(onCancelClick).not.toHaveBeenCalled(); + + submit(getByText); + await waitFor(() => expect(validateAndSave).toHaveBeenCalledTimes(2)); + expect(validateAndSave).toHaveBeenLastCalledWith( + expect.objectContaining({ + endpoint: expect.objectContaining({ host: FAILING_HOST }), + }), + ); + }); + + it("re-enables the form without another alert when saving fails", async () => { + validateAndSave.mockRejectedValue({ status: 500 }); + const { getByTestId, getByText } = await renderEditModal(); + + submit(getByText); + + await waitFor(() => expect(getByText("Validate and save")).toBeTruthy()); + expect((getByTestId("host") as HTMLInputElement).disabled).toBe(false); + expect(notificationStore.alert).toHaveBeenCalledTimes(1); + expect(notificationStore.alert).toHaveBeenCalledWith( + "Validating endpoint ...", + ); + }); + }); + + describe("creating an endpoint", () => { + it("submits a new endpoint", async () => { + validateAndSave.mockImplementation(async () => { + setValidation(VALID); + return VALID; + }); + const { getByTestId, getByText } = await renderCreateModal(); + + fireEvent.change(getByTestId("host"), { + target: { value: WORKING_HOST }, + }); + submit(getByText); + + await waitFor(() => expect(getByText("Endpoint is Valid")).toBeTruthy()); + expect(validateAndSave).toHaveBeenCalledWith({ + endpoint: expect.objectContaining({ + type: "openstack", + host: WORKING_HOST, + }), + isNew: true, + }); + }); + + it("submits a retry as a new endpoint when validation fails", async () => { + validateAndSave.mockImplementation(async () => { + setValidation(INVALID); + return INVALID; + }); + const { getByText } = await renderCreateModal(); + + submit(getByText); + await waitFor(() => expect(getByText("Validation failed")).toBeTruthy()); + + submit(getByText); + await waitFor(() => expect(validateAndSave).toHaveBeenCalledTimes(2)); + expect(validateAndSave).toHaveBeenLastCalledWith( + expect.objectContaining({ isNew: true }), + ); + }); + }); + + it("doesn't submit when required fields are missing", async () => { + mockInvalidFields = ["host"]; + const { getByText } = await renderEditModal(); + + submit(getByText); + + expect(validateAndSave).not.toHaveBeenCalled(); + expect(notificationStore.alert).toHaveBeenCalledWith( + "Please fill all the required fields", + "error", + ); + }); + + it("shows that the endpoint is being saved once it's valid", async () => { + const attempt = deferred(); + validateAndSave.mockImplementation(() => { + runInAction(() => { + endpointStore.updating = true; + }); + return attempt.promise; + }); + const { getByText, queryByText } = await renderEditModal(); + + submit(getByText); + + await waitFor(() => expect(getByText("Saving ...")).toBeTruthy()); + expect(queryByText("Validate and save")).toBeNull(); + + act(() => { + runInAction(() => { + endpointStore.updating = false; + }); + }); + attempt.resolve(INVALID); + await waitFor(() => expect(getByText("Validate and save")).toBeTruthy()); + }); + + it("ignores submits while an attempt is in flight", async () => { + const attempt = deferred(); + validateAndSave.mockReturnValue(attempt.promise); + const { getByText } = await renderEditModal(); + + fireEvent.click(getByText("Plugin validate")); + fireEvent.click(getByText("Plugin validate")); + + expect(validateAndSave).toHaveBeenCalledTimes(1); + + attempt.resolve(INVALID); + await waitFor(() => expect(getByText("Validate and save")).toBeTruthy()); + }); +}); diff --git a/src/components/modules/EndpointModule/EndpointModal/EndpointModal.tsx b/src/components/modules/EndpointModule/EndpointModal/EndpointModal.tsx index c76e1aa38..51164c849 100644 --- a/src/components/modules/EndpointModule/EndpointModal/EndpointModal.tsx +++ b/src/components/modules/EndpointModule/EndpointModal/EndpointModal.tsx @@ -143,6 +143,8 @@ class EndpointModal extends React.Component { isValidateButtonEnabled = false; + isEndpointLoaded = false; + providerStoreObserver!: () => void; endpointValidationObserver!: () => void; @@ -185,29 +187,34 @@ class EndpointModal extends React.Component { } UNSAFE_componentWillReceiveProps(props: Props) { - if (this.state.validating) { - if (endpointStore.validation && !endpointStore.validation.valid) { - this.setState({ validating: false }); - } - } - if (props.endpoint && endpointStore.connectionInfo) { const plugin: any = ContentPlugin.for(props.endpoint.type); + const loadedEndpoint: EndpointType = { + ...ObjectUtils.flatten( + props.endpoint || {}, + plugin.REQUIRES_PARENT_OBJECT_PATH, + ), + ...ObjectUtils.flatten( + endpointStore.connectionInfo || {}, + plugin.REQUIRES_PARENT_OBJECT_PATH, + ), + }; + + // Saving updates the store's connection info, which shouldn't replace + // the values the user entered. + const isLoaded = this.isEndpointLoaded; + this.isEndpointLoaded = true; + this.setState(prevState => ({ isNew: this.props.isNewEndpoint ? prevState.isNew === null || prevState.isNew : prevState.isNew, - endpoint: { - ...prevState.endpoint, - ...ObjectUtils.flatten( - props.endpoint || {}, - plugin.REQUIRES_PARENT_OBJECT_PATH, - ), - ...ObjectUtils.flatten( - endpointStore.connectionInfo || {}, - plugin.REQUIRES_PARENT_OBJECT_PATH, - ), - }, + endpoint: isLoaded + ? prevState.endpoint + : { + ...prevState.endpoint, + ...loadedEndpoint, + }, })); } else { this.setState(prevState => ({ @@ -292,20 +299,36 @@ class EndpointModal extends React.Component { }); } - handleValidateClick() { - if (!this.highlightRequired()) { - this.setState({ validating: true }); + async handleValidateClick() { + if (this.state.validating) { + return; + } - notificationStore.alert("Saving endpoint ..."); - endpointStore.clearValidation(); + if (this.highlightRequired()) { + notificationStore.alert("Please fill all the required fields", "error"); + return; + } - if (this.state.isNew) { - this.add(); - } else { - this.update(); + const endpoint = this.state.endpoint; + if (!endpoint) { + return; + } + + this.setState({ validating: true }); + notificationStore.alert("Validating endpoint ..."); + + try { + const validation = await endpointStore.validateAndSave({ + endpoint, + isNew: Boolean(this.state.isNew), + }); + + if (validation && !validation.valid) { + this.setState({ validating: false }); } - } else { - notificationStore.alert("Please fill all the required fields", "error"); + } catch { + // The API caller has already shown the error. + this.setState({ validating: false }); } } @@ -343,36 +366,6 @@ class EndpointModal extends React.Component { return invalidFields.length > 0; } - async update() { - const stateEndpoint = this.state.endpoint; - if (!stateEndpoint) { - return; - } - const endpoint = endpointStore.endpoints.find( - e => e.id === stateEndpoint.id, - ); - if (!endpoint) { - throw new Error("Endpoint not found in store"); - } - await endpointStore.update(stateEndpoint); - - this.setState({ endpoint: ObjectUtils.flatten(endpoint) }); - notificationStore.alert("Validating endpoint ..."); - endpointStore.validate(endpoint); - } - - async add() { - if (!this.state.endpoint) { - return; - } - - await endpointStore.add(this.state.endpoint); - const endpoint = endpointStore.endpoints[0]; - this.setState({ isNew: false, endpoint: ObjectUtils.flatten(endpoint) }); - notificationStore.alert("Validating endpoint ..."); - endpointStore.validate(endpoint); - } - renderEndpointStatus() { const validation = endpointStore.validation; if (!this.state.validating && !validation) { @@ -442,7 +435,11 @@ class EndpointModal extends React.Component { this.state.validating || (endpointStore.validation && endpointStore.validation.valid) ) { - if (endpointStore.validation && endpointStore.validation.valid) { + if ( + endpointStore.adding || + endpointStore.updating || + (endpointStore.validation && endpointStore.validation.valid) + ) { message = "Saving ..."; } diff --git a/src/sources/EndpointSource.ts b/src/sources/EndpointSource.ts index c8ae5457e..0093965cc 100644 --- a/src/sources/EndpointSource.ts +++ b/src/sources/EndpointSource.ts @@ -151,11 +151,25 @@ class EndpointSource { return result; } - async validate(endpoint: Endpoint): Promise { + async validate( + endpoint: Endpoint, + useSchemaParser = false, + ): Promise { + const connectionInfo = useSchemaParser + ? ObjectUtils.skipFields(SchemaParser.connectionInfoToPayload(endpoint), [ + "secret_ref", + ]) || {} + : endpoint.connection_info; const response = await Api.send({ - url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/endpoints/${endpoint.id}/actions`, + url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/endpoints/actions`, method: "POST", - data: { "validate-connection": null }, + data: { + "validate-connection": { + platform: endpoint.type, + connection_info: connectionInfo, + mapped_regions: endpoint.mapped_regions || [], + }, + }, }); return response.data["validate-connection"]; } diff --git a/src/stores/EndpointStore.spec.tsx b/src/stores/EndpointStore.spec.tsx new file mode 100644 index 000000000..8a54777c2 --- /dev/null +++ b/src/stores/EndpointStore.spec.tsx @@ -0,0 +1,397 @@ +/* +Copyright (C) 2026 Cloudbase Solutions SRL +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. +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 Affero General Public License for more details. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +import type { Endpoint, Validation } from "@src/@types/Endpoint"; +import EndpointSource from "@src/sources/EndpointSource"; +import endpointStore from "@src/stores/EndpointStore"; +import { waitFor } from "@testing-library/react"; +import { OPENSTACK_ENDPOINT_MOCK } from "@tests/mocks/EndpointsMock"; + +const ORIGINAL_HOST = "192.0.2.10"; +const FAILING_HOST = "192.0.2.20"; +const WORKING_HOST = "192.0.2.30"; + +const VALID: Validation = { valid: true, message: "Endpoint is valid" }; +const invalid = (host: string): Validation => ({ + valid: false, + message: `Connection to ${host} timed out`, +}); + +const formValues = (values: { [prop: string]: any }): Endpoint => ({ + id: OPENSTACK_ENDPOINT_MOCK.id, + name: OPENSTACK_ENDPOINT_MOCK.name, + description: OPENSTACK_ENDPOINT_MOCK.description, + type: OPENSTACK_ENDPOINT_MOCK.type, + created_at: OPENSTACK_ENDPOINT_MOCK.created_at, + mapped_regions: OPENSTACK_ENDPOINT_MOCK.mapped_regions, + connection_info: {}, + username: "admin", + secret_ref: "http://barbican/v1/secrets/original", + ...values, +}); + +const savedEndpoint = (host: string): Endpoint => ({ + ...OPENSTACK_ENDPOINT_MOCK, + connection_info: { ...OPENSTACK_ENDPOINT_MOCK.connection_info, host }, +}); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe("EndpointStore", () => { + let sourceSpies: { + add: jest.SpyInstance; + update: jest.SpyInstance; + validate: jest.SpyInstance; + }; + + beforeEach(() => { + endpointStore.endpoints = []; + endpointStore.connectionInfo = null; + endpointStore.clearValidation(); + + sourceSpies = { + add: jest.spyOn(EndpointSource, "add"), + update: jest.spyOn(EndpointSource, "update"), + validate: jest.spyOn(EndpointSource, "validate"), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("validateAndSave, editing an endpoint", () => { + beforeEach(() => { + endpointStore.endpoints = [savedEndpoint(ORIGINAL_HOST)]; + }); + + it("validates the proposed values before updating the endpoint", async () => { + const edited = formValues({ host: WORKING_HOST }); + sourceSpies.validate.mockResolvedValue(VALID); + sourceSpies.update.mockResolvedValue(savedEndpoint(WORKING_HOST)); + + const result = await endpointStore.validateAndSave({ + endpoint: edited, + isNew: false, + }); + + expect(sourceSpies.validate).toHaveBeenCalledWith(edited, true); + expect(sourceSpies.update).toHaveBeenCalledWith(edited); + expect(sourceSpies.validate.mock.invocationCallOrder[0]).toBeLessThan( + sourceSpies.update.mock.invocationCallOrder[0], + ); + expect(result).toEqual(VALID); + expect(endpointStore.validation).toEqual(VALID); + expect(endpointStore.endpoints[0].connection_info.host).toBe( + WORKING_HOST, + ); + }); + + it("doesn't update the endpoint when the validation fails", async () => { + sourceSpies.validate.mockResolvedValue(invalid(FAILING_HOST)); + + const result = await endpointStore.validateAndSave({ + endpoint: formValues({ host: FAILING_HOST }), + isNew: false, + }); + + expect(result).toEqual(invalid(FAILING_HOST)); + expect(endpointStore.validation).toEqual(invalid(FAILING_HOST)); + expect(endpointStore.validating).toBe(false); + expect(sourceSpies.update).not.toHaveBeenCalled(); + expect(endpointStore.endpoints[0].connection_info.host).toBe( + ORIGINAL_HOST, + ); + }); + + it("doesn't update the endpoint when the validation request fails", async () => { + sourceSpies.validate.mockRejectedValue({ status: 500 }); + + await expect( + endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }), + ).rejects.toEqual({ status: 500 }); + + expect(sourceSpies.update).not.toHaveBeenCalled(); + expect(endpointStore.validation?.valid).toBe(false); + expect(endpointStore.validating).toBe(false); + }); + + it("doesn't report a valid endpoint and restores the store when the update fails", async () => { + const connectionInfo = { host: ORIGINAL_HOST }; + endpointStore.connectionInfo = connectionInfo; + sourceSpies.validate.mockResolvedValue(VALID); + sourceSpies.update.mockRejectedValue({ status: 500 }); + + await expect( + endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }), + ).rejects.toEqual({ status: 500 }); + + expect(endpointStore.validation).toBeNull(); + expect(endpointStore.validating).toBe(false); + expect(endpointStore.updating).toBe(false); + expect(endpointStore.endpoints[0].connection_info.host).toBe( + ORIGINAL_HOST, + ); + expect(endpointStore.connectionInfo).toEqual(connectionInfo); + }); + + it("reports the endpoint as valid only once it's saved", async () => { + const update = deferred(); + sourceSpies.validate.mockResolvedValue(VALID); + sourceSpies.update.mockReturnValue(update.promise); + + const attempt = endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + await waitFor(() => expect(sourceSpies.update).toHaveBeenCalled()); + + expect(endpointStore.validation).toBeNull(); + expect(endpointStore.validating).toBe(true); + expect(endpointStore.updating).toBe(true); + + update.resolve(savedEndpoint(WORKING_HOST)); + expect(await attempt).toEqual(VALID); + expect(endpointStore.validation).toEqual(VALID); + expect(endpointStore.validating).toBe(false); + }); + + it("passes missing optional values through without turning them into empty strings", async () => { + const edited = formValues({ host: WORKING_HOST, description: null }); + sourceSpies.validate.mockResolvedValue(VALID); + sourceSpies.update.mockResolvedValue(savedEndpoint(WORKING_HOST)); + + await endpointStore.validateAndSave({ endpoint: edited, isNew: false }); + + const submitted = sourceSpies.update.mock.calls[0][0]; + expect(submitted.description).toBeNull(); + expect(Object.values(submitted)).not.toContain(""); + }); + + it("saves the corrected values after a failed validation", async () => { + sourceSpies.validate.mockResolvedValueOnce(invalid(FAILING_HOST)); + await endpointStore.validateAndSave({ + endpoint: formValues({ host: FAILING_HOST }), + isNew: false, + }); + + sourceSpies.validate.mockResolvedValueOnce(VALID); + sourceSpies.update.mockResolvedValue(savedEndpoint(WORKING_HOST)); + const result = await endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + + expect(result).toEqual(VALID); + expect(sourceSpies.update).toHaveBeenCalledTimes(1); + expect(sourceSpies.update).toHaveBeenCalledWith( + expect.objectContaining({ host: WORKING_HOST }), + ); + }); + }); + + describe("validateAndSave, creating an endpoint", () => { + it("validates the proposed values before creating the endpoint", async () => { + const form = formValues({ host: WORKING_HOST }); + const added = savedEndpoint(WORKING_HOST); + sourceSpies.validate.mockResolvedValue(VALID); + sourceSpies.add.mockResolvedValue(added); + + const result = await endpointStore.validateAndSave({ + endpoint: form, + isNew: true, + }); + + expect(sourceSpies.validate).toHaveBeenCalledWith(form, true); + expect(sourceSpies.add).toHaveBeenCalledWith(form); + expect(sourceSpies.validate.mock.invocationCallOrder[0]).toBeLessThan( + sourceSpies.add.mock.invocationCallOrder[0], + ); + expect(result).toEqual(VALID); + expect(endpointStore.validation).toEqual(VALID); + expect(endpointStore.endpoints).toEqual([added]); + }); + + it("doesn't create the endpoint when the validation fails", async () => { + sourceSpies.validate.mockResolvedValue(invalid(FAILING_HOST)); + + const result = await endpointStore.validateAndSave({ + endpoint: formValues({ host: FAILING_HOST }), + isNew: true, + }); + + expect(result).toEqual(invalid(FAILING_HOST)); + expect(endpointStore.validation).toEqual(invalid(FAILING_HOST)); + expect(sourceSpies.add).not.toHaveBeenCalled(); + expect(endpointStore.endpoints).toEqual([]); + }); + + it("doesn't create the endpoint when the validation request fails", async () => { + sourceSpies.validate.mockRejectedValue({ status: 500 }); + + await expect( + endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: true, + }), + ).rejects.toEqual({ status: 500 }); + + expect(sourceSpies.add).not.toHaveBeenCalled(); + expect(endpointStore.endpoints).toEqual([]); + expect(endpointStore.validating).toBe(false); + }); + + it("doesn't report a valid endpoint when it can't be created", async () => { + sourceSpies.validate.mockResolvedValue(VALID); + sourceSpies.add.mockRejectedValue({ status: 500 }); + + await expect( + endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: true, + }), + ).rejects.toEqual({ status: 500 }); + + expect(endpointStore.validation).toBeNull(); + expect(endpointStore.validating).toBe(false); + expect(endpointStore.adding).toBe(false); + expect(endpointStore.endpoints).toEqual([]); + }); + }); + + describe("stale validation responses", () => { + beforeEach(() => { + endpointStore.endpoints = [savedEndpoint(ORIGINAL_HOST)]; + }); + + it("doesn't let an older attempt overwrite the state of a newer one", async () => { + const failingValidation = deferred(); + const workingValidation = deferred(); + sourceSpies.validate + .mockReturnValueOnce(failingValidation.promise) + .mockReturnValueOnce(workingValidation.promise); + sourceSpies.update.mockResolvedValue(savedEndpoint(WORKING_HOST)); + + const failingAttempt = endpointStore.validateAndSave({ + endpoint: formValues({ host: FAILING_HOST }), + isNew: false, + }); + const workingAttempt = endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + + workingValidation.resolve(VALID); + expect(await workingAttempt).toEqual(VALID); + + failingValidation.resolve(invalid(FAILING_HOST)); + expect(await failingAttempt).toBeNull(); + + expect(endpointStore.validation).toEqual(VALID); + expect(sourceSpies.update).toHaveBeenCalledTimes(1); + expect(sourceSpies.update).toHaveBeenCalledWith( + expect.objectContaining({ host: WORKING_HOST }), + ); + }); + + it("doesn't save the values of an older attempt which is valid", async () => { + const olderValidation = deferred(); + sourceSpies.validate + .mockReturnValueOnce(olderValidation.promise) + .mockResolvedValueOnce(invalid(FAILING_HOST)); + + const olderAttempt = endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + await endpointStore.validateAndSave({ + endpoint: formValues({ host: FAILING_HOST }), + isNew: false, + }); + + olderValidation.resolve(VALID); + expect(await olderAttempt).toBeNull(); + + expect(sourceSpies.update).not.toHaveBeenCalled(); + expect(endpointStore.validation).toEqual(invalid(FAILING_HOST)); + }); + + it("displays the error which belongs to the values of the current attempt", async () => { + sourceSpies.validate.mockResolvedValueOnce(invalid(FAILING_HOST)); + await endpointStore.validateAndSave({ + endpoint: formValues({ host: FAILING_HOST }), + isNew: false, + }); + expect(endpointStore.validation?.message).toContain(FAILING_HOST); + + sourceSpies.validate.mockResolvedValueOnce(invalid(WORKING_HOST)); + await endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + expect(endpointStore.validation?.message).toContain(WORKING_HOST); + expect(endpointStore.validation?.message).not.toContain(FAILING_HOST); + }); + + it("doesn't save an attempt whose validation has been cleared", async () => { + const validation = deferred(); + sourceSpies.validate.mockReturnValueOnce(validation.promise); + + const attempt = endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + expect(endpointStore.validating).toBe(true); + + endpointStore.clearValidation(); + validation.resolve(VALID); + expect(await attempt).toBeNull(); + + expect(sourceSpies.update).not.toHaveBeenCalled(); + expect(endpointStore.validation).toBeNull(); + expect(endpointStore.validating).toBe(false); + }); + + it("ignores the failure of a validation which has been cleared", async () => { + const validation = deferred(); + sourceSpies.validate.mockReturnValueOnce(validation.promise); + + const attempt = endpointStore.validateAndSave({ + endpoint: formValues({ host: WORKING_HOST }), + isNew: false, + }); + + endpointStore.clearValidation(); + validation.reject({ status: 500 }); + await expect(attempt).rejects.toEqual({ status: 500 }); + + expect(endpointStore.validation).toBeNull(); + expect(endpointStore.validating).toBe(false); + }); + }); +}); diff --git a/src/stores/EndpointStore.ts b/src/stores/EndpointStore.ts index 612c97316..9ba32de99 100644 --- a/src/stores/EndpointStore.ts +++ b/src/stores/EndpointStore.ts @@ -307,6 +307,8 @@ class EndpointStore { ); } + private validationId = 0; + @action async validate(endpoint: Endpoint) { this.validating = true; @@ -331,11 +333,64 @@ class EndpointStore { } @action clearValidation() { + this.validationId += 1; this.validating = false; this.validation = null; } - @action async update(endpoint: Endpoint) { + @action async validateAndSave(opts: { + endpoint: Endpoint; + isNew: boolean; + }): Promise { + const { endpoint, isNew } = opts; + this.validationId += 1; + const id = this.validationId; + const isCurrent = () => id === this.validationId; + this.validating = true; + this.validation = null; + + let validation: Validation; + try { + validation = await EndpointSource.validate(endpoint, true); + } catch (ex) { + if (isCurrent()) this.validateFailed(); + throw ex; + } + + if (!isCurrent()) { + return null; + } + if (!validation.valid) { + this.validateSuccess(validation); + return validation; + } + + try { + if (isNew) { + await this.add(endpoint); + } else { + await this.update(endpoint); + } + } catch (ex) { + if (isCurrent()) { + runInAction(() => { + this.validating = false; + this.validation = null; + }); + } + throw ex; + } + + if (!isCurrent()) { + return null; + } + this.validateSuccess(validation); + return validation; + } + + @action async update(endpoint: Endpoint): Promise { + const previousEndpoints = this.endpoints; + const previousConnectionInfo = this.connectionInfo; this.endpoints = updateEndpoint(endpoint, this.endpoints); this.connectionInfo = { ...endpoint.connection_info }; this.updating = true; @@ -343,8 +398,11 @@ class EndpointStore { try { const updatedEndpoint = await EndpointSource.update(endpoint); this.updateSuccess(updatedEndpoint); + return updatedEndpoint; } catch (e) { runInAction(() => { + this.endpoints = previousEndpoints; + this.connectionInfo = previousConnectionInfo; this.updating = false; }); throw e;