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;