Merge pull request #8782 from marshmalien/setting-radius-tacacs-edit-forms

Add RADIUS and TACACS+ setting forms

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-01-20 22:55:51 +00:00 committed by GitHub
commit f37471c858
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 569 additions and 59 deletions

View File

@ -2,12 +2,18 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import RADIUS from './RADIUS';
import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import RADIUS from './RADIUS';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
RADIUS_SERVER: 'radius.example.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '$encrypted$',
},
});
describe('<RADIUS />', () => {
@ -23,9 +29,14 @@ describe('<RADIUS />', () => {
initialEntries: ['/settings/radius/details'],
});
await act(async () => {
wrapper = mountWithContexts(<RADIUS />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('RADIUSDetail').length).toBe(1);
});
@ -35,9 +46,14 @@ describe('<RADIUS />', () => {
initialEntries: ['/settings/radius/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<RADIUS />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('RADIUSEdit').length).toBe(1);
});

View File

@ -1,25 +1,113 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import { EncryptedField, InputField } from '../../shared/SharedFields';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function RADIUSEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchRadius, result: radius } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('radius');
const mergedData = {};
Object.keys(data).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchRadius();
}, [fetchRadius]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/radius/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm(form);
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(radius).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/radius/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
acc[key] = fields[key].value ?? '';
return acc;
}, {});
function RADIUSEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/radius/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && radius && (
<Formik initialValues={initialValues(radius)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="RADIUS_SERVER"
config={radius.RADIUS_SERVER}
/>
<InputField name="RADIUS_PORT" config={radius.RADIUS_PORT} />
<EncryptedField
name="RADIUS_SECRET"
config={radius.RADIUS_SECRET}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody>
);
}
export default withI18n()(RADIUSEdit);
export default RADIUSEdit;

View File

@ -1,16 +1,149 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import RADIUSEdit from './RADIUSEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
RADIUS_SERVER: 'radius.mock.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '$encrypted$',
},
});
describe('<RADIUSEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<RADIUSEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/radius/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('RADIUSEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="RADIUS Server"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="RADIUS Port"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="RADIUS Secret"]').length).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
RADIUS_SERVER: '',
RADIUS_PORT: 1812,
RADIUS_SECRET: '',
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('input#RADIUS_SERVER').simulate('change', {
target: { value: 'radius.new_mock.org', name: 'RADIUS_SERVER' },
});
wrapper
.find('FormGroup[fieldId="RADIUS_SECRET"] button[aria-label="Revert"]')
.invoke('onClick')();
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
RADIUS_SERVER: 'radius.new_mock.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '',
});
});
test('should navigate to radius detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/radius/details');
});
test('should navigate to radius detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/radius/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});

View File

@ -2,12 +2,20 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import TACACS from './TACACS';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
data: {
TACACSPLUS_HOST: 'mockhost',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
},
});
describe('<TACACS />', () => {
@ -23,9 +31,14 @@ describe('<TACACS />', () => {
initialEntries: ['/settings/tacacs/details'],
});
await act(async () => {
wrapper = mountWithContexts(<TACACS />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('TACACSDetail').length).toBe(1);
});
@ -35,9 +48,14 @@ describe('<TACACS />', () => {
initialEntries: ['/settings/tacacs/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<TACACS />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
expect(wrapper.find('TACACSEdit').length).toBe(1);
});

View File

@ -1,25 +1,130 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } from '../../../../components/Card';
import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
ChoiceField,
EncryptedField,
InputField,
} from '../../shared/SharedFields';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function TACACSEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchTACACS, result: tacacs } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('tacacsplus');
const mergedData = {};
Object.keys(data).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchTACACS();
}, [fetchTACACS]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/tacacs/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm(form);
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(tacacs).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/tacacs/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
acc[key] = fields[key].value ?? '';
return acc;
}, {});
function TACACSEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/tacacs/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && tacacs && (
<Formik initialValues={initialValues(tacacs)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="TACACSPLUS_HOST"
config={tacacs.TACACSPLUS_HOST}
/>
<InputField
name="TACACSPLUS_PORT"
config={tacacs.TACACSPLUS_PORT}
type="number"
/>
<EncryptedField
name="TACACSPLUS_SECRET"
config={tacacs.TACACSPLUS_SECRET}
/>
<InputField
name="TACACSPLUS_SESSION_TIMEOUT"
config={tacacs.TACACSPLUS_SESSION_TIMEOUT}
type="number"
/>
<ChoiceField
name="TACACSPLUS_AUTH_PROTOCOL"
config={tacacs.TACACSPLUS_AUTH_PROTOCOL}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody>
);
}
export default withI18n()(TACACSEdit);
export default TACACSEdit;

View File

@ -1,16 +1,166 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import TACACSEdit from './TACACSEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
TACACSPLUS_HOST: 'mockhost',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 123,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
},
});
describe('<TACACSEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<TACACSEdit />);
});
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/tacacs/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => {
expect(wrapper.find('TACACSEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="TACACS+ Server"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="TACACS+ Port"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="TACACS+ Secret"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="TACACS+ Auth Session Timeout"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="TACACS+ Authentication Protocol"]').length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
TACACSPLUS_HOST: '',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('input#TACACSPLUS_HOST').simulate('change', {
target: { value: 'new_host', name: 'TACACSPLUS_HOST' },
});
wrapper.find('input#TACACSPLUS_PORT').simulate('change', {
target: { value: 999, name: 'TACACSPLUS_PORT' },
});
wrapper
.find(
'FormGroup[fieldId="TACACSPLUS_SECRET"] button[aria-label="Revert"]'
)
.invoke('onClick')();
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
TACACSPLUS_HOST: 'new_host',
TACACSPLUS_PORT: 999,
TACACSPLUS_SECRET: '',
TACACSPLUS_SESSION_TIMEOUT: 123,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
});
});
test('should navigate to tacacs detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/tacacs/details');
});
test('should navigate to tacacs detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/tacacs/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
});