diff --git a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx index 0c662fd927..70d7ae5bb2 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAML.test.jsx @@ -3,11 +3,31 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { SettingsAPI } from '../../../api'; +import { SettingsProvider } from '../../../contexts/Settings'; +import mockAllOptions from '../shared/data.allSettingOptions.json'; import SAML from './SAML'; jest.mock('../../../api/models/Settings'); SettingsAPI.readCategory.mockResolvedValue({ - data: {}, + data: { + SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/', + SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/', + SOCIAL_AUTH_SAML_SP_ENTITY_ID: '', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '', + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '', + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: {}, + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SAML_AUTO_CREATE_OBJECTS: false, + }, }); describe('', () => { @@ -23,9 +43,14 @@ describe('', () => { initialEntries: ['/settings/saml/details'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('SAMLDetail').length).toBe(1); }); @@ -35,9 +60,14 @@ describe('', () => { initialEntries: ['/settings/saml/edit'], }); await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); }); expect(wrapper.find('SAMLEdit').length).toBe(1); }); diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx index 1afaee4e24..85fd5aa0d8 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.test.jsx @@ -32,6 +32,7 @@ SettingsAPI.readCategory.mockResolvedValue({ SOCIAL_AUTH_SAML_TEAM_MAP: {}, SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SAML_AUTO_CREATE_OBJECTS: false, }, }); @@ -59,6 +60,11 @@ describe('', () => { }); test('should render expected details', () => { + assertDetail( + wrapper, + 'Automatically Create Organizations and Teams on SAML Login', + 'Off' + ); assertDetail( wrapper, 'SAML Assertion Consumer Service (ACS) URL', diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx index fc9740b16c..93010d1ee5 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.jsx @@ -1,25 +1,208 @@ -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 { + BooleanField, + FileUploadField, + 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 SAMLEdit() { + const history = useHistory(); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const { PUT: options } = useSettings(); + + const { isLoading, error, request: fetchSAML, result: saml } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('saml'); + const mergedData = {}; + Object.keys(data).forEach(key => { + if (!options[key]) { + return; + } + mergedData[key] = options[key]; + mergedData[key].value = data[key]; + }); + return mergedData; + }, [options]), + null + ); + + useEffect(() => { + fetchSAML(); + }, [fetchSAML]); + + const { error: submitError, request: submitForm } = useRequest( + useCallback( + async values => { + await SettingsAPI.updateAll(values); + history.push('/settings/saml/details'); + }, + [history] + ), + null + ); + + const handleSubmit = async form => { + await submitForm({ + ...form, + SOCIAL_AUTH_SAML_ORG_INFO: formatJson(form.SOCIAL_AUTH_SAML_ORG_INFO), + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: formatJson( + form.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT + ), + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: formatJson( + form.SOCIAL_AUTH_SAML_SUPPORT_CONTACT + ), + SOCIAL_AUTH_SAML_ENABLED_IDPS: formatJson( + form.SOCIAL_AUTH_SAML_ENABLED_IDPS + ), + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: formatJson( + form.SOCIAL_AUTH_SAML_ORGANIZATION_MAP + ), + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: formatJson( + form.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR + ), + SOCIAL_AUTH_SAML_TEAM_MAP: formatJson(form.SOCIAL_AUTH_SAML_TEAM_MAP), + SOCIAL_AUTH_SAML_TEAM_ATTR: formatJson(form.SOCIAL_AUTH_SAML_TEAM_ATTR), + SOCIAL_AUTH_SAML_SECURITY_CONFIG: formatJson( + form.SOCIAL_AUTH_SAML_SECURITY_CONFIG + ), + SOCIAL_AUTH_SAML_SP_EXTRA: formatJson(form.SOCIAL_AUTH_SAML_SP_EXTRA), + SOCIAL_AUTH_SAML_EXTRA_DATA: formatJson(form.SOCIAL_AUTH_SAML_EXTRA_DATA), + }); + }; + + const handleRevertAll = async () => { + const defaultValues = Object.assign( + ...Object.entries(saml).map(([key, value]) => ({ + [key]: value.default, + })) + ); + await submitForm(defaultValues); + closeModal(); + }; + + const handleCancel = () => { + history.push('/settings/saml/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 SAMLEdit({ i18n }) { return ( - {i18n._(t`Edit form coming soon :)`)} - - - + {isLoading && } + {!isLoading && error && } + {!isLoading && saml && ( + + {formik => ( +
+ + + + + + + + + + + + + + + + + {submitError && } + + + {isModalOpen && ( + + )} + + )} +
+ )}
); } -export default withI18n()(SAMLEdit); +export default SAMLEdit; diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx index d6319d9b2e..858bf814f7 100644 --- a/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx +++ b/awx/ui_next/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.test.jsx @@ -1,16 +1,251 @@ 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 SAMLEdit from './SAMLEdit'; +jest.mock('../../../../api/models/Settings'); +SettingsAPI.updateAll.mockResolvedValue({}); +SettingsAPI.readCategory.mockResolvedValue({ + data: { + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_CALLBACK_URL: 'https://towerhost/sso/complete/saml/', + SOCIAL_AUTH_SAML_METADATA_URL: 'https://towerhost/sso/metadata/saml/', + SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'mock_id', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert', + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$', + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: { + givenName: 'Mock User', + emailAddress: 'mockuser@example.com', + }, + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }, +}); + describe('', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts(); - }); + let history; + afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/settings/saml/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + test('initially renders without crashing', () => { expect(wrapper.find('SAMLEdit').length).toBe(1); }); + + test('should display expected form fields', async () => { + expect( + wrapper.find('FormGroup[label="SAML Service Provider Entity ID"]').length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="Automatically Create Organizations and Teams on SAML Login"]' + ).length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="SAML Service Provider Public Certificate"]' + ).length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Private Key"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Organization Info"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Technical Contact"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Service Provider Support Contact"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Enabled Identity Providers"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Organization Map"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SAML Team Map"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Organization Attribute Mapping"]') + .length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="SAML Team Attribute Mapping"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SAML Security Config"]').length).toBe( + 1 + ); + expect( + wrapper.find( + 'FormGroup[label="SAML Service Provider extra configuration data"]' + ).length + ).toBe(1); + expect( + wrapper.find( + 'FormGroup[label="SAML IDP to extra_data attribute mapping"]' + ).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({ + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: null, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: null, + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_SP_ENTITY_ID: '', + SOCIAL_AUTH_SAML_SP_EXTRA: null, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '', + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: null, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }); + }); + + test('should successfully send request to api on form submission', async () => { + act(() => { + wrapper.find('input#SOCIAL_AUTH_SAML_SP_ENTITY_ID').simulate('change', { + target: { value: 'new_id', name: 'SOCIAL_AUTH_SAML_SP_ENTITY_ID' }, + }); + wrapper + .find( + 'FormGroup[fieldId="SOCIAL_AUTH_SAML_TECHNICAL_CONTACT"] 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({ + SAML_AUTO_CREATE_OBJECTS: true, + SOCIAL_AUTH_SAML_ENABLED_IDPS: {}, + SOCIAL_AUTH_SAML_EXTRA_DATA: [], + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {}, + SOCIAL_AUTH_SAML_ORGANIZATION_MAP: {}, + SOCIAL_AUTH_SAML_ORG_INFO: {}, + SOCIAL_AUTH_SAML_SP_ENTITY_ID: 'new_id', + SOCIAL_AUTH_SAML_SP_EXTRA: {}, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '$encrypted$', + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert', + SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {}, + SOCIAL_AUTH_SAML_TEAM_ATTR: {}, + SOCIAL_AUTH_SAML_TEAM_MAP: {}, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {}, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + requestedAuthnContext: false, + }, + }); + }); + + test('should navigate to saml detail on successful submission', async () => { + await act(async () => { + wrapper.find('Form').invoke('onSubmit')(); + }); + expect(history.location.pathname).toEqual('/settings/saml/details'); + }); + + test('should navigate to saml detail when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/settings/saml/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( + + + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx index f0fe7e8b72..a997388395 100644 --- a/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx +++ b/awx/ui_next/src/screens/Setting/shared/RevertButton.jsx @@ -13,7 +13,13 @@ const ButtonWrapper = styled.div` } `; -function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { +function RevertButton({ + i18n, + id, + defaultValue, + isDisabled = false, + onRevertCallback = () => null, +}) { const [field, meta, helpers] = useField(id); const initialValue = meta.initialValue ?? ''; const currentValue = field.value; @@ -30,6 +36,7 @@ function RevertButton({ i18n, id, defaultValue, isDisabled = false }) { function handleConfirm() { helpers.setValue(isRevertable ? defaultValue : initialValue); + onRevertCallback(); } const revertTooltipContent = isRevertable diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx index 877aa2be35..f668289976 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import { bool, oneOf, shape, string } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; import { + FileUpload, FormGroup as PFFormGroup, InputGroup, TextInput, @@ -42,6 +43,7 @@ const SettingGroup = withI18n()( isDisabled, isRequired, label, + onRevertCallback, popoverContent, validated, }) => ( @@ -62,6 +64,7 @@ const SettingGroup = withI18n()( id={fieldId} defaultValue={defaultValue} isDisabled={isDisabled} + onRevertCallback={onRevertCallback} /> } @@ -261,4 +264,52 @@ ObjectField.propTypes = { isRequired: bool, }; -export { BooleanField, ChoiceField, EncryptedField, InputField, ObjectField }; +const FileUploadField = withI18n()( + ({ i18n, name, config, isRequired = false }) => { + const validate = isRequired ? required(null, i18n) : null; + const [filename, setFilename] = useState(''); + const [fileIsUploading, setFileIsUploading] = useState(false); + const [field, meta, helpers] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return config ? ( + + setFilename('')} + > + { + helpers.setValue(value); + setFilename(title); + }} + onReadStarted={() => setFileIsUploading(true)} + onReadFinished={() => setFileIsUploading(false)} + isLoading={fileIsUploading} + allowEditingUploadedText + validated={isValid ? 'default' : 'error'} + /> + + + ) : null; + } +); + +export { + BooleanField, + ChoiceField, + EncryptedField, + FileUploadField, + InputField, + ObjectField, +}; diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx index d0c8a1437a..39b49f9428 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx @@ -8,6 +8,7 @@ import { BooleanField, ChoiceField, EncryptedField, + FileUploadField, InputField, ObjectField, } from './SharedFields'; @@ -161,4 +162,46 @@ describe('Setting form fields', () => { wrapper.update(); expect(wrapper.find('CodeMirrorInput').prop('value')).toBe('[]'); }); + + test('FileUploadField renders the expected content', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('FileUploadField')).toHaveLength(1); + expect(wrapper.find('label').text()).toEqual('mock file label'); + expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(''); + await act(async () => { + wrapper.find('FileUpload').invoke('onChange')( + { + text: () => + '-----BEGIN PRIVATE KEY-----\\nAAAAAAAAAAAAAA\\n-----END PRIVATE KEY-----\\n', + }, + 'new file name' + ); + }); + wrapper.update(); + expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual( + 'new file name' + ); + await act(async () => { + wrapper.find('button[aria-label="Revert"]').invoke('onClick')(); + }); + wrapper.update(); + expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(''); + }); });