From 5e5029a92055c054de179c0fc58d8e5333e24a3f Mon Sep 17 00:00:00 2001 From: Michael Hulse Date: Tue, 3 Sep 2024 13:39:01 -0700 Subject: [PATCH] fix error when fetching password policy settings when opening password complexity tray on an account with no custom password settings, an error would occur because we tried to fetch non-existant settings. this change accounts for that scenario along with house cleaning and not allowing file uploads if they don't have the password complexity key. also added test coverage. closes FOO-4731 closes FOO-4712 closes FOO-4711 flag=password_complexity [skip-crystalball] test plan: - enable password_complexity feature flag - remove password_policy key from account settings - Account.default.settings.delete(:password_policy) - Account.default.save! - go to root domain site admin authentication page - select "View Options" under "Canvas" - expect no errors Change-Id: I026a6bfa868a65120e031633dd65b15c0fd02323 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/356505 Reviewed-by: August Thornton Reviewed-by: AJ Esa QA-Review: August Thornton QA-Review: AJ Esa Product-Review: AJ Esa Tested-by: Service Cloud Jenkins --- .../react/CustomForbiddenWordsSection.tsx | 27 +- .../react/PasswordComplexityConfiguration.tsx | 92 +++---- .../CustomForbiddenWordsSection.test.tsx | 84 ++++++ .../ForbiddenWordsFileUpload.test.tsx | 67 +++++ .../__tests__/NumberInputControlled.test.tsx | 43 +++ .../PasswordComplexityConfiguration.test.tsx | 257 ++++++++++++++++++ 6 files changed, 502 insertions(+), 68 deletions(-) create mode 100644 ui/features/password_complexity_configuration/react/__tests__/CustomForbiddenWordsSection.test.tsx create mode 100644 ui/features/password_complexity_configuration/react/__tests__/ForbiddenWordsFileUpload.test.tsx create mode 100644 ui/features/password_complexity_configuration/react/__tests__/NumberInputControlled.test.tsx create mode 100644 ui/features/password_complexity_configuration/react/__tests__/PasswordComplexityConfiguration.test.tsx 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/', + }, + ]) + }) + }) +})