Merge pull request #8781 from marshmalien/setting-ldap-edit-forms

Add all LDAP (Default, 1-5) setting forms

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-01-14 12:49:25 +00:00 committed by GitHub
commit d88ed19edf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 529 additions and 32 deletions

View File

@ -6,6 +6,7 @@ import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/yaml/yaml';
import 'codemirror/mode/jinja2/jinja2';
import 'codemirror/lib/codemirror.css';
import 'codemirror/addon/display/placeholder';
const LINE_HEIGHT = 24;
const PADDING = 12;
@ -55,6 +56,17 @@ const CodeMirror = styled(ReactCodeMirror)`
background-color: var(--pf-c-form-control--disabled--BackgroundColor);
}
`}
${props =>
props.options &&
props.options.placeholder &&
`
.CodeMirror-empty {
pre.CodeMirror-placeholder {
color: var(--pf-c-form-control--placeholder--Color);
height: 100% !important;
}
}
`}
`;
function CodeMirrorInput({
@ -66,6 +78,7 @@ function CodeMirrorInput({
rows,
fullHeight,
className,
placeholder,
}) {
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the
// modal's initial render, it appears as an empty box due to mis-calculated
@ -92,6 +105,7 @@ function CodeMirrorInput({
smartIndent: false,
lineNumbers: true,
lineWrapping: true,
placeholder,
readOnly,
}}
fullHeight={fullHeight}

View File

@ -6,12 +6,13 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
import { SettingsProvider } from '../../../contexts/Settings';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import mockLDAP from '../shared/data.ldapSettings.json';
import LDAP from './LDAP';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
data: {},
});
SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP });
describe('<LDAP />', () => {
let wrapper;
@ -39,9 +40,14 @@ describe('<LDAP />', () => {
initialEntries: ['/settings/ldap/default/edit'],
});
await act(async () => {
wrapper = mountWithContexts(<LDAP />, {
context: { router: { history } },
});
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAP />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('LDAPEdit').length).toBe(1);

View File

@ -1,25 +1,250 @@
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, useRouteMatch } 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,
FormFullWidthLayout,
} from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
BooleanField,
ChoiceField,
EncryptedField,
InputField,
ObjectField,
} from '../../shared/SharedFields';
import { formatJson } from '../../shared/settingUtils';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function filterByPrefix(data, prefix) {
return Object.keys(data)
.filter(key => key.includes(prefix))
.reduce((obj, key) => {
obj[key] = data[key];
return obj;
}, {});
}
function LDAPEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const {
params: { category },
} = useRouteMatch('/settings/ldap/:category/edit');
const ldapCategory =
category === 'default' ? 'AUTH_LDAP_' : `AUTH_LDAP_${category}_`;
const { isLoading, error, request: fetchLDAP, result: ldap } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('ldap');
const mergedData = {};
Object.keys(data).forEach(key => {
if (!options[key]) {
return;
}
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
const allCategories = {
AUTH_LDAP_1_: filterByPrefix(mergedData, 'AUTH_LDAP_1_'),
AUTH_LDAP_2_: filterByPrefix(mergedData, 'AUTH_LDAP_2_'),
AUTH_LDAP_3_: filterByPrefix(mergedData, 'AUTH_LDAP_3_'),
AUTH_LDAP_4_: filterByPrefix(mergedData, 'AUTH_LDAP_4_'),
AUTH_LDAP_5_: filterByPrefix(mergedData, 'AUTH_LDAP_5_'),
AUTH_LDAP_: Object.assign({}, mergedData),
};
Object.keys({
...allCategories.AUTH_LDAP_1_,
...allCategories.AUTH_LDAP_2_,
...allCategories.AUTH_LDAP_3_,
...allCategories.AUTH_LDAP_4_,
...allCategories.AUTH_LDAP_5_,
}).forEach(keyToOmit => {
delete allCategories.AUTH_LDAP_[keyToOmit];
});
return allCategories[ldapCategory];
}, [options, ldapCategory]),
null
);
useEffect(() => {
fetchLDAP();
}, [fetchLDAP]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push(`/settings/ldap/${category}/details`);
},
[history, category]
),
null
);
const handleSubmit = async form => {
await submitForm({
[`${ldapCategory}BIND_DN`]: form[`${ldapCategory}BIND_DN`],
[`${ldapCategory}BIND_PASSWORD`]: form[`${ldapCategory}BIND_PASSWORD`],
[`${ldapCategory}DENY_GROUP`]: form[`${ldapCategory}DENY_GROUP`],
[`${ldapCategory}GROUP_TYPE`]: form[`${ldapCategory}GROUP_TYPE`],
[`${ldapCategory}REQUIRE_GROUP`]: form[`${ldapCategory}REQUIRE_GROUP`],
[`${ldapCategory}SERVER_URI`]: form[`${ldapCategory}SERVER_URI`],
[`${ldapCategory}START_TLS`]: form[`${ldapCategory}START_TLS`],
[`${ldapCategory}USER_DN_TEMPLATE`]: form[
`${ldapCategory}USER_DN_TEMPLATE`
],
[`${ldapCategory}GROUP_SEARCH`]: formatJson(
form[`${ldapCategory}GROUP_SEARCH`]
),
[`${ldapCategory}GROUP_TYPE_PARAMS`]: formatJson(
form[`${ldapCategory}GROUP_TYPE_PARAMS`]
),
[`${ldapCategory}ORGANIZATION_MAP`]: formatJson(
form[`${ldapCategory}ORGANIZATION_MAP`]
),
[`${ldapCategory}TEAM_MAP`]: formatJson(form[`${ldapCategory}TEAM_MAP`]),
[`${ldapCategory}USER_ATTR_MAP`]: formatJson(
form[`${ldapCategory}USER_ATTR_MAP`]
),
[`${ldapCategory}USER_FLAGS_BY_GROUP`]: formatJson(
form[`${ldapCategory}USER_FLAGS_BY_GROUP`]
),
[`${ldapCategory}USER_SEARCH`]: formatJson(
form[`${ldapCategory}USER_SEARCH`]
),
});
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(ldap).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push(`/settings/ldap/${category}/details`);
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
: emptyDefault;
} else {
acc[key] = fields[key].value ?? '';
}
return acc;
}, {});
function LDAPEdit({ i18n }) {
return (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/ldap/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && ldap && (
<Formik initialValues={initialValues(ldap)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name={`${ldapCategory}SERVER_URI`}
config={ldap[`${ldapCategory}SERVER_URI`]}
/>
<EncryptedField
name={`${ldapCategory}BIND_PASSWORD`}
config={ldap[`${ldapCategory}BIND_PASSWORD`]}
/>
<ChoiceField
name={`${ldapCategory}GROUP_TYPE`}
config={ldap[`${ldapCategory}GROUP_TYPE`]}
/>
<BooleanField
name={`${ldapCategory}START_TLS`}
config={ldap[`${ldapCategory}START_TLS`]}
/>
<FormFullWidthLayout>
<InputField
name={`${ldapCategory}BIND_DN`}
config={ldap[`${ldapCategory}BIND_DN`]}
/>
<InputField
name={`${ldapCategory}USER_DN_TEMPLATE`}
config={ldap[`${ldapCategory}USER_DN_TEMPLATE`]}
/>
<InputField
name={`${ldapCategory}REQUIRE_GROUP`}
config={ldap[`${ldapCategory}REQUIRE_GROUP`]}
/>
<InputField
name={`${ldapCategory}DENY_GROUP`}
config={ldap[`${ldapCategory}DENY_GROUP`]}
/>
</FormFullWidthLayout>
<ObjectField
name={`${ldapCategory}USER_SEARCH`}
config={ldap[`${ldapCategory}USER_SEARCH`]}
/>
<ObjectField
name={`${ldapCategory}GROUP_SEARCH`}
config={ldap[`${ldapCategory}GROUP_SEARCH`]}
/>
<ObjectField
name={`${ldapCategory}USER_ATTR_MAP`}
config={ldap[`${ldapCategory}USER_ATTR_MAP`]}
/>
<ObjectField
name={`${ldapCategory}GROUP_TYPE_PARAMS`}
config={ldap[`${ldapCategory}GROUP_TYPE_PARAMS`]}
/>
<ObjectField
name={`${ldapCategory}USER_FLAGS_BY_GROUP`}
config={ldap[`${ldapCategory}USER_FLAGS_BY_GROUP`]}
/>
<ObjectField
name={`${ldapCategory}ORGANIZATION_MAP`}
config={ldap[`${ldapCategory}ORGANIZATION_MAP`]}
/>
<ObjectField
name={`${ldapCategory}TEAM_MAP`}
config={ldap[`${ldapCategory}TEAM_MAP`]}
/>
{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()(LDAPEdit);
export default LDAPEdit;

View File

@ -1,16 +1,265 @@
import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { useRouteMatch } from 'react-router-dom';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import mockLDAP from '../../shared/data.ldapSettings.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import LDAPEdit from './LDAPEdit';
jest.mock('../../../../api/models/Settings');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: jest.fn(),
}));
SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP });
describe('<LDAPEdit />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<LDAPEdit />);
let history;
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/ldap/default/edit'],
});
useRouteMatch.mockImplementation(() => ({
url: '/settings/ldap/default/edit',
path: '/settings/ldap/:category/edit',
params: { category: 'default' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAPEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
expect(wrapper.find('LDAPEdit').length).toBe(1);
});
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="LDAP Server URI"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Bind DN"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Bind Password"]').length).toBe(
1
);
expect(wrapper.find('FormGroup[label="LDAP User Search"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="LDAP User DN Template"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="LDAP User Attribute Map"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Group Search"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Group Type"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="LDAP Group Type Parameters"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Require Group"]').length).toBe(
1
);
expect(wrapper.find('FormGroup[label="LDAP Deny Group"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Start TLS"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="LDAP User Flags By Group"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="LDAP Organization Map"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="LDAP Team Map"]').length).toBe(1);
expect(
wrapper.find('FormGroup[fieldId="AUTH_LDAP_SERVER_URI"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"]').length
).toBe(0);
});
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({
AUTH_LDAP_BIND_DN: '',
AUTH_LDAP_BIND_PASSWORD: '',
AUTH_LDAP_CONNECTION_OPTIONS: {
OPT_NETWORK_TIMEOUT: 30,
OPT_REFERRALS: 0,
},
AUTH_LDAP_DENY_GROUP: null,
AUTH_LDAP_GROUP_SEARCH: [],
AUTH_LDAP_GROUP_TYPE: 'MemberDNGroupType',
AUTH_LDAP_GROUP_TYPE_PARAMS: {
member_attr: 'member',
name_attr: 'cn',
},
AUTH_LDAP_ORGANIZATION_MAP: {},
AUTH_LDAP_REQUIRE_GROUP: null,
AUTH_LDAP_SERVER_URI: '',
AUTH_LDAP_START_TLS: false,
AUTH_LDAP_TEAM_MAP: {},
AUTH_LDAP_USER_ATTR_MAP: {},
AUTH_LDAP_USER_DN_TEMPLATE: null,
AUTH_LDAP_USER_FLAGS_BY_GROUP: {},
AUTH_LDAP_USER_SEARCH: [],
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper
.find(
'FormGroup[fieldId="AUTH_LDAP_BIND_PASSWORD"] button[aria-label="Revert"]'
)
.invoke('onClick')();
wrapper
.find(
'FormGroup[fieldId="AUTH_LDAP_BIND_DN"] button[aria-label="Revert"]'
)
.invoke('onClick')();
wrapper.find('input#AUTH_LDAP_SERVER_URI').simulate('change', {
target: {
value: 'ldap://mock.example.com',
name: 'AUTH_LDAP_SERVER_URI',
},
});
wrapper.find('CodeMirrorInput#AUTH_LDAP_TEAM_MAP').invoke('onChange')(
'{\n"LDAP Sales":{\n"organization":\n"mock org"\n}\n}'
);
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
AUTH_LDAP_BIND_DN: '',
AUTH_LDAP_BIND_PASSWORD: '',
AUTH_LDAP_DENY_GROUP: '',
AUTH_LDAP_GROUP_SEARCH: [],
AUTH_LDAP_GROUP_TYPE: 'MemberDNGroupType',
AUTH_LDAP_GROUP_TYPE_PARAMS: { name_attr: 'cn', member_attr: 'member' },
AUTH_LDAP_ORGANIZATION_MAP: {},
AUTH_LDAP_REQUIRE_GROUP: 'CN=Tower Users,OU=Users,DC=example,DC=com',
AUTH_LDAP_SERVER_URI: 'ldap://mock.example.com',
AUTH_LDAP_START_TLS: false,
AUTH_LDAP_USER_ATTR_MAP: {},
AUTH_LDAP_USER_DN_TEMPLATE: 'uid=%(user)s,OU=Users,DC=example,DC=com',
AUTH_LDAP_USER_FLAGS_BY_GROUP: {},
AUTH_LDAP_USER_SEARCH: [],
AUTH_LDAP_TEAM_MAP: {
'LDAP Sales': {
organization: 'mock org',
},
},
});
});
test('should navigate to ldap default detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/ldap/default/details');
});
test('should navigate to ldap default detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/ldap/default/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}>
<LDAPEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
test('should display ldap category 5 edit form', async () => {
history = createMemoryHistory({
initialEntries: ['/settings/ldap/5/edit'],
});
useRouteMatch.mockImplementation(() => ({
url: '/settings/ldap/5/edit',
path: '/settings/ldap/:category/edit',
params: { category: '5' },
}));
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<LDAPEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(
wrapper.find('FormGroup[fieldId="AUTH_LDAP_SERVER_URI"]').length
).toBe(0);
expect(
wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"] input').props()
.value
).toEqual('ldap://ldap5.example.com');
});
});

View File

@ -17,10 +17,10 @@ import { FormFullWidthLayout } from '../../../components/FormLayout';
import Popover from '../../../components/Popover';
import {
combine,
required,
url,
integer,
minMaxValue,
required,
url,
} from '../../../util/validators';
import RevertButton from './RevertButton';
@ -51,6 +51,7 @@ const SettingGroup = withI18n()(
isRequired={isRequired}
label={label}
validated={validated}
id={fieldId}
labelIcon={
<>
<Popover
@ -84,13 +85,13 @@ const BooleanField = withI18n()(
>
<Switch
id={name}
ouiaId={name}
isChecked={field.value}
isDisabled={disabled}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
onChange={checked => helpers.setValue(checked)}
aria-label={ariaLabel || config.label}
ouiaId={ariaLabel || config.label}
/>
</SettingGroup>
) : null;
@ -242,11 +243,13 @@ const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
>
<CodeMirrorInput
{...field}
fullHeight
id={name}
mode="javascript"
onChange={value => {
helpers.setValue(value);
}}
mode="javascript"
placeholder={JSON.stringify(config?.placeholder, null, 2)}
/>
</SettingGroup>
</FormFullWidthLayout>

View File

@ -109,7 +109,7 @@
"AUTH_LDAP_4_USER_FLAGS_BY_GROUP": {},
"AUTH_LDAP_4_ORGANIZATION_MAP": {},
"AUTH_LDAP_4_TEAM_MAP": {},
"AUTH_LDAP_5_SERVER_URI": "",
"AUTH_LDAP_5_SERVER_URI": "ldap://ldap5.example.com",
"AUTH_LDAP_5_BIND_DN": "",
"AUTH_LDAP_5_BIND_PASSWORD": "",
"AUTH_LDAP_5_START_TLS": false,