diff --git a/ui/features/password_complexity_configuration/react/CustomForbiddenWordsSection.tsx b/ui/features/password_complexity_configuration/react/CustomForbiddenWordsSection.tsx index 62489f63a4b..f41f49d9390 100644 --- a/ui/features/password_complexity_configuration/react/CustomForbiddenWordsSection.tsx +++ b/ui/features/password_complexity_configuration/react/CustomForbiddenWordsSection.tsx @@ -28,16 +28,14 @@ import {IconTrashLine, IconUploadSolid} from '@instructure/ui-icons' import ForbiddenWordsFileUpload from './ForbiddenWordsFileUpload' import {showFlashAlert} from '@canvas/alerts/react/FlashAlert' import {useScope as useI18nScope} from '@canvas/i18n' -import type {GlobalEnv} from '@canvas/global/env/GlobalEnv' -import type {PasswordSettingsResponse} from './types' import {deleteForbiddenWordsFile} from './apiClient' import {executeApiRequest} from '@canvas/do-fetch-api-effect/apiRequest' -declare const ENV: GlobalEnv - const I18n = useI18nScope('password_complexity_configuration') interface Props { + currentAttachmentId: number | null + passwordPolicyHashExists: boolean setNewlyUploadedAttachmentId: (attachmentId: number | null) => void onCustomForbiddenWordsEnabledChange: (enabled: boolean) => void } @@ -60,6 +58,8 @@ export const fetchLatestForbiddenWords = async ( const CustomForbiddenWordsSection = ({ setNewlyUploadedAttachmentId, onCustomForbiddenWordsEnabledChange, + currentAttachmentId, + passwordPolicyHashExists, }: Props) => { const linkRef = useRef(null) const [forbiddenWordsUrl, setForbiddenWordsUrl] = useState(null) @@ -69,6 +69,7 @@ const CustomForbiddenWordsSection = ({ const [commonPasswordsAttachmentId, setCommonPasswordsAttachmentId] = useState( null ) + const [forbiddenWordsFileEnabled, setForbiddenWordsEnabled] = useState(false) const handleForbiddenWordsToggle = () => { const newEnabledState = !customForbiddenWordsEnabled @@ -77,22 +78,17 @@ const CustomForbiddenWordsSection = ({ } const fetchAndSetForbiddenWords = useCallback(async () => { - const {status, data: settingsResult} = await executeApiRequest({ - path: `/api/v1/accounts/${ENV.DOMAIN_ROOT_ACCOUNT_ID}/settings`, - method: 'GET', - }) - if (status !== 200) { - throw new Error('Failed to fetch current settings.') + if (passwordPolicyHashExists) { + setForbiddenWordsEnabled(true) } - const attachmentId = settingsResult.password_policy.common_passwords_attachment_id - if (!attachmentId) { + if (!currentAttachmentId) { return } - setCommonPasswordsAttachmentId(attachmentId) + setCommonPasswordsAttachmentId(currentAttachmentId) try { - const data = await fetchLatestForbiddenWords(attachmentId) + const data = await fetchLatestForbiddenWords(currentAttachmentId) if (data) { setForbiddenWordsUrl(data.url) setForbiddenWordsName(data.display_name) @@ -109,7 +105,7 @@ const CustomForbiddenWordsSection = ({ console.error('Failed to fetch forbidden words:', error) } } - }, []) + }, [currentAttachmentId, passwordPolicyHashExists]) // pre-fetch forbidden words as early as possible when the component mounts useEffect(() => { @@ -171,6 +167,7 @@ const CustomForbiddenWordsSection = ({ onChange={handleForbiddenWordsToggle} label={I18n.t('Customize forbidden words/terms list')} data-testid="customForbiddenWordsCheckbox" + disabled={!forbiddenWordsFileEnabled} /> diff --git a/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx b/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx index f8660feec6d..1a61b8fc764 100644 --- a/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx +++ b/ui/features/password_complexity_configuration/react/PasswordComplexityConfiguration.tsx @@ -39,11 +39,11 @@ const I18n = useI18nScope('password_complexity_configuration') declare const ENV: GlobalEnv -const MINIMUM_CHARACTER_LENGTH = 8 -const MAXIMUM_CHARACTER_LENGTH = 255 -const DEFAULT_MAX_LOGIN_ATTEMPTS = 10 -const MINIMUM_LOGIN_ATTEMPTS = 3 -const MAXIMUM_LOGIN_ATTEMPTS = 20 +export const MINIMUM_CHARACTER_LENGTH = 8 +export const MAXIMUM_CHARACTER_LENGTH = 255 +export const DEFAULT_MAX_LOGIN_ATTEMPTS = 10 +export const MINIMUM_LOGIN_ATTEMPTS = 3 +export const MAXIMUM_LOGIN_ATTEMPTS = 20 interface Account { settings: PasswordSettings @@ -63,59 +63,41 @@ const PasswordComplexityConfiguration = () => { const [customMaxLoginAttemptsEnabled, setCustomMaxLoginAttemptsEnabled] = useState(false) const [allowLoginSuspensionEnabled, setAllowLoginSuspensionEnabled] = useState(false) const [maxLoginAttempts, setMaxLoginAttempts] = useState(DEFAULT_MAX_LOGIN_ATTEMPTS) + const [currentAttachmentId, setCurrentAttachmentId] = useState(null) const [newlyUploadedAttachmentId, setNewlyUploadedAttachmentId] = useState(null) const [customForbiddenWordsEnabled, setCustomForbiddenWordsEnabled] = useState(false) + const [passwordPolicyHashExists, setPasswordPolicyHashExists] = useState(false) useEffect(() => { if (showTray) { const fetchCurrentSettings = async () => { try { - const {status, data} = await executeApiRequest({ + const response = await executeApiRequest({ path: `/api/v1/accounts/${ENV.DOMAIN_ROOT_ACCOUNT_ID}/settings`, method: 'GET', }) - if (status === 200) { - const passwordPolicy = data.password_policy + if (response?.status === 200 && response?.data) { + const passwordPolicy = response.data.password_policy || {} - if (passwordPolicy.require_number_characters === 'true') { - setRequireNumbersEnabled(true) - } else { - setRequireNumbersEnabled(false) + if (Object.keys(passwordPolicy).length > 0) { + setPasswordPolicyHashExists(true) } - - if (passwordPolicy.require_symbol_characters === 'true') { - setRequireSymbolsEnabled(true) - } else { - setRequireSymbolsEnabled(false) - } - - if (passwordPolicy.minimum_character_length) { - setMinimumCharacterLength(passwordPolicy.minimum_character_length) - setMinimumCharacterLengthEnabled(true) - } else { - setMinimumCharacterLengthEnabled(false) - } - - if (passwordPolicy.maximum_login_attempts) { - setMaxLoginAttempts(passwordPolicy.maximum_login_attempts) - setCustomMaxLoginAttemptsEnabled(true) - } else { - setCustomMaxLoginAttemptsEnabled(false) - } - - if (passwordPolicy.allow_login_suspension === 'true') { - setAllowLoginSuspensionEnabled(true) - } else { - setAllowLoginSuspensionEnabled(false) - } - - // ensure customForbiddenWordsEnabled is correctly set when the tray opens to prevent - // mistakenly deleting an existing custom list if “Apply” is clicked without changes + setRequireNumbersEnabled(passwordPolicy.require_number_characters === 'true') + setRequireSymbolsEnabled(passwordPolicy.require_symbol_characters === 'true') + setMinimumCharacterLength( + passwordPolicy.minimum_character_length || MINIMUM_CHARACTER_LENGTH + ) + setMinimumCharacterLengthEnabled(!!passwordPolicy.minimum_character_length) + setMaxLoginAttempts(passwordPolicy.maximum_login_attempts || DEFAULT_MAX_LOGIN_ATTEMPTS) + setCustomMaxLoginAttemptsEnabled(!!passwordPolicy.maximum_login_attempts) + setAllowLoginSuspensionEnabled(passwordPolicy.allow_login_suspension === 'true') setCustomForbiddenWordsEnabled(!!passwordPolicy.common_passwords_attachment_id) + setCurrentAttachmentId(passwordPolicy.common_passwords_attachment_id || null) + } else { + throw new Error('Failed to fetch current settings.') } } catch (err: any) { - // err type has to be any because the error object is not defined showFlashAlert({ message: I18n.t('An error occurred fetching password policy settings.'), err, @@ -195,20 +177,22 @@ const PasswordComplexityConfiguration = () => { allow_login_suspension: allowLoginSuspensionEnabled, } - if (!customForbiddenWordsEnabled) { - if (settingsResult.password_policy.common_passwords_attachment_id) { - await deleteForbiddenWordsFile( + if (settingsResult.password_policy) { + if (!customForbiddenWordsEnabled) { + if (settingsResult.password_policy.common_passwords_attachment_id) { + await deleteForbiddenWordsFile( + settingsResult.password_policy.common_passwords_attachment_id + ) + } + } else if (settingsResult.password_policy.common_passwords_attachment_id) { + passwordPolicy.common_passwords_attachment_id = settingsResult.password_policy.common_passwords_attachment_id - ) } - } else if (settingsResult.password_policy.common_passwords_attachment_id) { - passwordPolicy.common_passwords_attachment_id = - settingsResult.password_policy.common_passwords_attachment_id - } - if (settingsResult.password_policy.common_passwords_folder_id) { - passwordPolicy.common_passwords_folder_id = - settingsResult.password_policy.common_passwords_folder_id + if (settingsResult.password_policy.common_passwords_folder_id) { + passwordPolicy.common_passwords_folder_id = + settingsResult.password_policy.common_passwords_folder_id + } } if (customMaxLoginAttemptsEnabled) { @@ -358,6 +342,8 @@ const PasswordComplexityConfiguration = () => { diff --git a/ui/features/password_complexity_configuration/react/__tests__/CustomForbiddenWordsSection.test.tsx b/ui/features/password_complexity_configuration/react/__tests__/CustomForbiddenWordsSection.test.tsx new file mode 100644 index 00000000000..de8f10f823b --- /dev/null +++ b/ui/features/password_complexity_configuration/react/__tests__/CustomForbiddenWordsSection.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas 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, version 3 of the License. + * + * Canvas 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 {render, screen, cleanup} from '@testing-library/react' +import CustomForbiddenWordsSection from '../CustomForbiddenWordsSection' +import {executeApiRequest} from '@canvas/do-fetch-api-effect/apiRequest' + +jest.mock('@canvas/do-fetch-api-effect/apiRequest') +jest.mock('../apiClient') + +const mockedExecuteApiRequest = executeApiRequest as jest.MockedFunction + +describe('CustomForbiddenWordsSection Component', () => { + beforeAll(() => { + if (!window.ENV) { + window.ENV = {} + } + window.ENV.DOMAIN_ROOT_ACCOUNT_ID = '1' + }) + + afterAll(() => { + delete window.ENV.DOMAIN_ROOT_ACCOUNT_ID + }) + + afterEach(() => { + jest.clearAllMocks() + cleanup() + }) + + beforeEach(() => { + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: { + password_policy: { + common_passwords_attachment_id: null, + }, + }, + }) + }) + + describe('when no file is uploaded', () => { + beforeEach(() => { + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: { + password_policy: { + common_passwords_attachment_id: null, + }, + }, + }) + }) + + it('shows “Upload” button but not “Current Custom List”', async () => { + render( + {}} + onCustomForbiddenWordsEnabledChange={() => {}} + currentAttachmentId={123} + passwordPolicyHashExists={true} + /> + ) + const uploadButton = await screen.findByTestId('uploadButton') + expect(uploadButton).toBeInTheDocument() + expect(uploadButton).toBeDisabled() + expect(screen.queryByText('Current Custom List')).not.toBeInTheDocument() + }) + }) +}) diff --git a/ui/features/password_complexity_configuration/react/__tests__/ForbiddenWordsFileUpload.test.tsx b/ui/features/password_complexity_configuration/react/__tests__/ForbiddenWordsFileUpload.test.tsx new file mode 100644 index 00000000000..5b47ecb56a8 --- /dev/null +++ b/ui/features/password_complexity_configuration/react/__tests__/ForbiddenWordsFileUpload.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas 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, version 3 of the License. + * + * Canvas 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 {render, screen} from '@testing-library/react' +import ForbiddenWordsFileUpload from '../ForbiddenWordsFileUpload' +import userEvent from '@testing-library/user-event' + +jest.mock('@canvas/do-fetch-api-effect/apiRequest') +jest.mock('../apiClient') + +describe('ForbiddenWordsFileUpload Component', () => { + const defaultProps = { + open: true, + onDismiss: jest.fn(), + onSave: jest.fn(), + setForbiddenWordsUrl: jest.fn(), + setForbiddenWordsFilename: jest.fn(), + } + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders the modal with the correct heading', () => { + render() + expect(screen.getByText('Upload Forbidden Words/Terms List')).toBeInTheDocument() + expect(screen.getByText('Upload File')).toBeInTheDocument() + }) + + it('displays the FileDrop component when no file is uploaded', () => { + render() + expect(screen.getByText('Upload File')).toBeInTheDocument() + expect(screen.getByText('Drag and drop, or upload from your computer')).toBeInTheDocument() + }) + }) + + describe('Modal Interactions', () => { + it('resets state on cancel and does not call prop functions', async () => { + render() + const cancelButton = screen.getByText('Cancel').closest('button') + if (!cancelButton) { + throw new Error('Cancel button not found') + } + await userEvent.click(cancelButton) + expect(defaultProps.setForbiddenWordsFilename).not.toHaveBeenCalled() + expect(defaultProps.setForbiddenWordsUrl).not.toHaveBeenCalled() + expect(defaultProps.onDismiss).toHaveBeenCalled() + }) + }) +}) diff --git a/ui/features/password_complexity_configuration/react/__tests__/NumberInputControlled.test.tsx b/ui/features/password_complexity_configuration/react/__tests__/NumberInputControlled.test.tsx new file mode 100644 index 00000000000..3c51e4bd4df --- /dev/null +++ b/ui/features/password_complexity_configuration/react/__tests__/NumberInputControlled.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas 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, version 3 of the License. + * + * Canvas 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 {render, screen} from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' +import NumberInputControlled from '../NumberInputControlled' + +describe('NumberInputControlled', () => { + const defaultProps = { + minimum: 0, + maximum: 10, + currentValue: 5, + updateCurrentValue: jest.fn(), + disabled: false, + 'data-testid': 'number-input', + } + + const renderComponent = (props = {}) => { + render() + } + + it('renders correctly with initial props', () => { + renderComponent() + const input = screen.getByTestId('number-input') + expect(input).toHaveValue('5') + }) +}) diff --git a/ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx b/ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx new file mode 100644 index 00000000000..b3b6278c312 --- /dev/null +++ b/ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas 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, version 3 of the License. + * + * Canvas 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 {render, screen, waitFor, cleanup} from '@testing-library/react' +import '@testing-library/jest-dom' +import PasswordComplexityConfiguration from '../PasswordComplexityConfiguration' +import {executeApiRequest} from '@canvas/do-fetch-api-effect/apiRequest' +import userEvent from '@testing-library/user-event' + +jest.mock('@canvas/do-fetch-api-effect/apiRequest') +const mockedExecuteApiRequest = executeApiRequest as jest.MockedFunction + +const MOCK_MINIMUM_CHARACTER_LENGTH = '8' +const MOCK_MAXIMUM_LOGIN_ATTEMPTS = '10' + +const getViewOptionsButton = async () => { + const viewOptions = await waitFor(() => { + const button = screen.getByText('View Options') + return button.closest('button') + }) + if (!viewOptions) { + throw new Error('View Options button not found') + } + return viewOptions +} + +describe('PasswordComplexityConfiguration Component', () => { + beforeAll(() => { + if (!window.ENV) { + window.ENV = {} + } + window.ENV.DOMAIN_ROOT_ACCOUNT_ID = '1' + }) + + afterAll(() => { + delete window.ENV.DOMAIN_ROOT_ACCOUNT_ID + }) + + afterEach(() => { + jest.clearAllMocks() + cleanup() + }) + + beforeEach(() => { + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: { + password_policy: { + minimum_character_length: MOCK_MINIMUM_CHARACTER_LENGTH, + maximum_login_attempts: MOCK_MAXIMUM_LOGIN_ATTEMPTS, + }, + }, + }) + }) + + describe('tray Interaction', () => { + it('opens the tray when "View Options" button is clicked', async () => { + render() + await userEvent.click(await getViewOptionsButton()) + expect(screen.getByText('Current Password Configuration')).toBeInTheDocument() + }) + + it('closes the tray when "Cancel" button is clicked', async () => { + render() + await userEvent.click(await getViewOptionsButton()) + expect(screen.getByText('Current Password Configuration')).toBeInTheDocument() + const cancelButton = await screen.findByTestId('cancelButton') + await userEvent.click(cancelButton) + expect(screen.queryByText('Password Options Tray')).not.toBeInTheDocument() + }) + }) + + describe('form control UI Interaction Tests', () => { + describe('input field states', () => { + beforeEach(async () => { + render() + await userEvent.click(await getViewOptionsButton()) + }) + + it('enables minimum character length input by default', async () => { + const input = await screen.findByTestId('minimumCharacterLengthInput') + expect(input).toBeEnabled() + }) + + it('enables custom max login attempts input by default', async () => { + const input = await screen.findByTestId('customMaxLoginAttemptsInput') + expect(input).toBeEnabled() + }) + + it('enables allow login suspension checkbox when custom max login attempts is checked', async () => { + const checkbox = await screen.findByTestId('allowLoginSuspensionCheckbox') + expect(checkbox).toBeEnabled() + }) + + it('disables custom max login attempts input and allow suspension login checkbox when its checkbox is unchecked', async () => { + const checkbox = await screen.findByTestId('customMaxLoginAttemptsCheckbox') + await userEvent.click(checkbox) + const input = await screen.findByTestId('customMaxLoginAttemptsInput') + const allowLoginSuspensionCheckbox = await screen.findByTestId( + 'allowLoginSuspensionCheckbox' + ) + expect(input).toBeDisabled() + expect(allowLoginSuspensionCheckbox).toBeDisabled() + }) + + it('re-enables custom max login attempts input and allow login suspension checkbox when checkbox is checked again', async () => { + const checkbox = await screen.findByTestId('customMaxLoginAttemptsCheckbox') + await userEvent.click(checkbox) + await userEvent.click(checkbox) + const input = await screen.findByTestId('customMaxLoginAttemptsInput') + const allowLoginSuspensionCheckbox = await screen.findByTestId( + 'allowLoginSuspensionCheckbox' + ) + expect(input).toBeEnabled() + expect(allowLoginSuspensionCheckbox).toBeEnabled() + }) + }) + }) + + describe('API calls', () => { + it('should handle default password_policy settings gracefully', async () => { + render() + await userEvent.click(await getViewOptionsButton()) + await waitFor(() => expect(screen.getByTestId('saveButton')).toBeEnabled()) + expect(screen.getByTestId('minimumCharacterLengthInput')).toHaveValue( + MOCK_MINIMUM_CHARACTER_LENGTH + ) + expect(screen.getByTestId('customMaxLoginAttemptsCheckbox')).toBeChecked() + expect(screen.getByTestId('requireNumbersCheckbox')).not.toBeChecked() + expect(screen.getByTestId('requireSymbolsCheckbox')).not.toBeChecked() + }) + + it('should handle missing password_policy key gracefully', async () => { + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: {}, + }) + render() + await userEvent.click(await getViewOptionsButton()) + await waitFor(() => expect(screen.getByTestId('saveButton')).toBeEnabled()) + expect( + screen.queryByText('An error occurred fetching password policy settings.') + ).not.toBeInTheDocument() + expect(screen.getByTestId('minimumCharacterLengthInput')).toHaveValue( + MOCK_MINIMUM_CHARACTER_LENGTH + ) + expect(screen.getByTestId('requireNumbersCheckbox')).not.toBeChecked() + expect(screen.getByTestId('requireSymbolsCheckbox')).not.toBeChecked() + }) + + it('should handle undefined password_policy key gracefully', async () => { + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: { + password_policy: undefined, + }, + }) + render() + await userEvent.click(await getViewOptionsButton()) + await waitFor(() => expect(screen.getByTestId('saveButton')).toBeEnabled()) + expect( + screen.queryByText('An error occurred fetching password policy settings.') + ).not.toBeInTheDocument() + expect(screen.getByTestId('minimumCharacterLengthInput')).toHaveValue( + MOCK_MINIMUM_CHARACTER_LENGTH + ) + expect(screen.getByTestId('requireNumbersCheckbox')).not.toBeChecked() + expect(screen.getByTestId('requireSymbolsCheckbox')).not.toBeChecked() + }) + + it('should handle completely empty password_policy', async () => { + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: { + password_policy: {}, + }, + }) + render() + await userEvent.click(await getViewOptionsButton()) + await waitFor(() => expect(screen.getByTestId('saveButton')).toBeEnabled()) + expect( + screen.queryByText('An error occurred fetching password policy settings.') + ).not.toBeInTheDocument() + expect(screen.getByTestId('minimumCharacterLengthInput')).toHaveValue( + MOCK_MINIMUM_CHARACTER_LENGTH + ) + expect(screen.getByTestId('requireNumbersCheckbox')).not.toBeChecked() + expect(screen.getByTestId('requireSymbolsCheckbox')).not.toBeChecked() + }) + + it('should handle missing nested keys in password_policy', async () => { + const minimumCharacterLength = '12' + mockedExecuteApiRequest.mockResolvedValue({ + status: 200, + data: { + password_policy: { + require_number_characters: 'true', + allow_login_suspension: 'false', + minimum_character_length: minimumCharacterLength, + }, + }, + }) + render() + await userEvent.click(await getViewOptionsButton()) + await waitFor(() => expect(screen.getByTestId('saveButton')).toBeEnabled()) + expect(screen.getByTestId('requireSymbolsCheckbox')).not.toBeChecked() + expect(screen.getByTestId('customMaxLoginAttemptsCheckbox')).not.toBeChecked() + expect(screen.getByTestId('minimumCharacterLengthInput')).toHaveValue(minimumCharacterLength) + expect(screen.getByTestId('customForbiddenWordsCheckbox')).not.toBeChecked() + }) + }) + + describe('Saving settings', () => { + it('makes a PUT request with the correct method and path when saving all settings, including defaults', async () => { + render() + await userEvent.click(await getViewOptionsButton()) + const saveButton = await screen.findByTestId('saveButton') + await userEvent.click(saveButton) + const putCall = mockedExecuteApiRequest.mock.calls.find(call => call[0].method === 'PUT') + expect(putCall).toEqual([ + { + method: 'PUT', + body: { + account: { + settings: { + password_policy: { + require_number_characters: false, + require_symbol_characters: false, + allow_login_suspension: false, + maximum_login_attempts: MOCK_MAXIMUM_LOGIN_ATTEMPTS, + minimum_character_length: MOCK_MINIMUM_CHARACTER_LENGTH, + }, + }, + }, + }, + path: '/api/v1/accounts/1/', + }, + ]) + }) + }) +})