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 <august@instructure.com>
Reviewed-by: AJ Esa <ajmal.esa@instructure.com>
QA-Review: August Thornton <august@instructure.com>
QA-Review: AJ Esa <ajmal.esa@instructure.com>
Product-Review: AJ Esa <ajmal.esa@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Michael Hulse 2024-09-03 13:39:01 -07:00 committed by SaltNPepa
parent 5285761de4
commit 5e5029a920
6 changed files with 502 additions and 68 deletions

View File

@ -28,16 +28,14 @@ import {IconTrashLine, IconUploadSolid} from '@instructure/ui-icons'
import ForbiddenWordsFileUpload from './ForbiddenWordsFileUpload' import ForbiddenWordsFileUpload from './ForbiddenWordsFileUpload'
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert' import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
import {useScope as useI18nScope} from '@canvas/i18n' 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 {deleteForbiddenWordsFile} from './apiClient'
import {executeApiRequest} from '@canvas/do-fetch-api-effect/apiRequest' import {executeApiRequest} from '@canvas/do-fetch-api-effect/apiRequest'
declare const ENV: GlobalEnv
const I18n = useI18nScope('password_complexity_configuration') const I18n = useI18nScope('password_complexity_configuration')
interface Props { interface Props {
currentAttachmentId: number | null
passwordPolicyHashExists: boolean
setNewlyUploadedAttachmentId: (attachmentId: number | null) => void setNewlyUploadedAttachmentId: (attachmentId: number | null) => void
onCustomForbiddenWordsEnabledChange: (enabled: boolean) => void onCustomForbiddenWordsEnabledChange: (enabled: boolean) => void
} }
@ -60,6 +58,8 @@ export const fetchLatestForbiddenWords = async (
const CustomForbiddenWordsSection = ({ const CustomForbiddenWordsSection = ({
setNewlyUploadedAttachmentId, setNewlyUploadedAttachmentId,
onCustomForbiddenWordsEnabledChange, onCustomForbiddenWordsEnabledChange,
currentAttachmentId,
passwordPolicyHashExists,
}: Props) => { }: Props) => {
const linkRef = useRef<HTMLAnchorElement | null>(null) const linkRef = useRef<HTMLAnchorElement | null>(null)
const [forbiddenWordsUrl, setForbiddenWordsUrl] = useState<string | null>(null) const [forbiddenWordsUrl, setForbiddenWordsUrl] = useState<string | null>(null)
@ -69,6 +69,7 @@ const CustomForbiddenWordsSection = ({
const [commonPasswordsAttachmentId, setCommonPasswordsAttachmentId] = useState<number | null>( const [commonPasswordsAttachmentId, setCommonPasswordsAttachmentId] = useState<number | null>(
null null
) )
const [forbiddenWordsFileEnabled, setForbiddenWordsEnabled] = useState(false)
const handleForbiddenWordsToggle = () => { const handleForbiddenWordsToggle = () => {
const newEnabledState = !customForbiddenWordsEnabled const newEnabledState = !customForbiddenWordsEnabled
@ -77,22 +78,17 @@ const CustomForbiddenWordsSection = ({
} }
const fetchAndSetForbiddenWords = useCallback(async () => { const fetchAndSetForbiddenWords = useCallback(async () => {
const {status, data: settingsResult} = await executeApiRequest<PasswordSettingsResponse>({ if (passwordPolicyHashExists) {
path: `/api/v1/accounts/${ENV.DOMAIN_ROOT_ACCOUNT_ID}/settings`, setForbiddenWordsEnabled(true)
method: 'GET',
})
if (status !== 200) {
throw new Error('Failed to fetch current settings.')
} }
const attachmentId = settingsResult.password_policy.common_passwords_attachment_id if (!currentAttachmentId) {
if (!attachmentId) {
return return
} }
setCommonPasswordsAttachmentId(attachmentId) setCommonPasswordsAttachmentId(currentAttachmentId)
try { try {
const data = await fetchLatestForbiddenWords(attachmentId) const data = await fetchLatestForbiddenWords(currentAttachmentId)
if (data) { if (data) {
setForbiddenWordsUrl(data.url) setForbiddenWordsUrl(data.url)
setForbiddenWordsName(data.display_name) setForbiddenWordsName(data.display_name)
@ -109,7 +105,7 @@ const CustomForbiddenWordsSection = ({
console.error('Failed to fetch forbidden words:', error) console.error('Failed to fetch forbidden words:', error)
} }
} }
}, []) }, [currentAttachmentId, passwordPolicyHashExists])
// pre-fetch forbidden words as early as possible when the component mounts // pre-fetch forbidden words as early as possible when the component mounts
useEffect(() => { useEffect(() => {
@ -171,6 +167,7 @@ const CustomForbiddenWordsSection = ({
onChange={handleForbiddenWordsToggle} onChange={handleForbiddenWordsToggle}
label={I18n.t('Customize forbidden words/terms list')} label={I18n.t('Customize forbidden words/terms list')}
data-testid="customForbiddenWordsCheckbox" data-testid="customForbiddenWordsCheckbox"
disabled={!forbiddenWordsFileEnabled}
/> />
</Flex.Item> </Flex.Item>
<Flex.Item> <Flex.Item>

View File

@ -39,11 +39,11 @@ const I18n = useI18nScope('password_complexity_configuration')
declare const ENV: GlobalEnv declare const ENV: GlobalEnv
const MINIMUM_CHARACTER_LENGTH = 8 export const MINIMUM_CHARACTER_LENGTH = 8
const MAXIMUM_CHARACTER_LENGTH = 255 export const MAXIMUM_CHARACTER_LENGTH = 255
const DEFAULT_MAX_LOGIN_ATTEMPTS = 10 export const DEFAULT_MAX_LOGIN_ATTEMPTS = 10
const MINIMUM_LOGIN_ATTEMPTS = 3 export const MINIMUM_LOGIN_ATTEMPTS = 3
const MAXIMUM_LOGIN_ATTEMPTS = 20 export const MAXIMUM_LOGIN_ATTEMPTS = 20
interface Account { interface Account {
settings: PasswordSettings settings: PasswordSettings
@ -63,59 +63,41 @@ const PasswordComplexityConfiguration = () => {
const [customMaxLoginAttemptsEnabled, setCustomMaxLoginAttemptsEnabled] = useState(false) const [customMaxLoginAttemptsEnabled, setCustomMaxLoginAttemptsEnabled] = useState(false)
const [allowLoginSuspensionEnabled, setAllowLoginSuspensionEnabled] = useState(false) const [allowLoginSuspensionEnabled, setAllowLoginSuspensionEnabled] = useState(false)
const [maxLoginAttempts, setMaxLoginAttempts] = useState(DEFAULT_MAX_LOGIN_ATTEMPTS) const [maxLoginAttempts, setMaxLoginAttempts] = useState(DEFAULT_MAX_LOGIN_ATTEMPTS)
const [currentAttachmentId, setCurrentAttachmentId] = useState<number | null>(null)
const [newlyUploadedAttachmentId, setNewlyUploadedAttachmentId] = useState<number | null>(null) const [newlyUploadedAttachmentId, setNewlyUploadedAttachmentId] = useState<number | null>(null)
const [customForbiddenWordsEnabled, setCustomForbiddenWordsEnabled] = useState(false) const [customForbiddenWordsEnabled, setCustomForbiddenWordsEnabled] = useState(false)
const [passwordPolicyHashExists, setPasswordPolicyHashExists] = useState(false)
useEffect(() => { useEffect(() => {
if (showTray) { if (showTray) {
const fetchCurrentSettings = async () => { const fetchCurrentSettings = async () => {
try { try {
const {status, data} = await executeApiRequest<PasswordSettingsResponse>({ const response = await executeApiRequest<PasswordSettingsResponse>({
path: `/api/v1/accounts/${ENV.DOMAIN_ROOT_ACCOUNT_ID}/settings`, path: `/api/v1/accounts/${ENV.DOMAIN_ROOT_ACCOUNT_ID}/settings`,
method: 'GET', method: 'GET',
}) })
if (status === 200) { if (response?.status === 200 && response?.data) {
const passwordPolicy = data.password_policy const passwordPolicy = response.data.password_policy || {}
if (passwordPolicy.require_number_characters === 'true') { if (Object.keys(passwordPolicy).length > 0) {
setRequireNumbersEnabled(true) setPasswordPolicyHashExists(true)
} else {
setRequireNumbersEnabled(false)
} }
setRequireNumbersEnabled(passwordPolicy.require_number_characters === 'true')
if (passwordPolicy.require_symbol_characters === 'true') { setRequireSymbolsEnabled(passwordPolicy.require_symbol_characters === 'true')
setRequireSymbolsEnabled(true) setMinimumCharacterLength(
} else { passwordPolicy.minimum_character_length || MINIMUM_CHARACTER_LENGTH
setRequireSymbolsEnabled(false) )
} setMinimumCharacterLengthEnabled(!!passwordPolicy.minimum_character_length)
setMaxLoginAttempts(passwordPolicy.maximum_login_attempts || DEFAULT_MAX_LOGIN_ATTEMPTS)
if (passwordPolicy.minimum_character_length) { setCustomMaxLoginAttemptsEnabled(!!passwordPolicy.maximum_login_attempts)
setMinimumCharacterLength(passwordPolicy.minimum_character_length) setAllowLoginSuspensionEnabled(passwordPolicy.allow_login_suspension === 'true')
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
setCustomForbiddenWordsEnabled(!!passwordPolicy.common_passwords_attachment_id) 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) { } catch (err: any) {
// err type has to be any because the error object is not defined
showFlashAlert({ showFlashAlert({
message: I18n.t('An error occurred fetching password policy settings.'), message: I18n.t('An error occurred fetching password policy settings.'),
err, err,
@ -195,20 +177,22 @@ const PasswordComplexityConfiguration = () => {
allow_login_suspension: allowLoginSuspensionEnabled, allow_login_suspension: allowLoginSuspensionEnabled,
} }
if (!customForbiddenWordsEnabled) { if (settingsResult.password_policy) {
if (settingsResult.password_policy.common_passwords_attachment_id) { if (!customForbiddenWordsEnabled) {
await deleteForbiddenWordsFile( 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 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) { if (settingsResult.password_policy.common_passwords_folder_id) {
passwordPolicy.common_passwords_folder_id = passwordPolicy.common_passwords_folder_id =
settingsResult.password_policy.common_passwords_folder_id settingsResult.password_policy.common_passwords_folder_id
}
} }
if (customMaxLoginAttemptsEnabled) { if (customMaxLoginAttemptsEnabled) {
@ -358,6 +342,8 @@ const PasswordComplexityConfiguration = () => {
<CustomForbiddenWordsSection <CustomForbiddenWordsSection
setNewlyUploadedAttachmentId={setNewlyUploadedAttachmentId} setNewlyUploadedAttachmentId={setNewlyUploadedAttachmentId}
onCustomForbiddenWordsEnabledChange={handleCustomForbiddenWordsEnabledChange} onCustomForbiddenWordsEnabledChange={handleCustomForbiddenWordsEnabledChange}
currentAttachmentId={currentAttachmentId}
passwordPolicyHashExists={passwordPolicyHashExists}
/> />
<View as="div" margin="medium medium small medium"> <View as="div" margin="medium medium small medium">

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<typeof executeApiRequest>
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(
<CustomForbiddenWordsSection
setNewlyUploadedAttachmentId={() => {}}
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()
})
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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(<ForbiddenWordsFileUpload {...defaultProps} />)
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(<ForbiddenWordsFileUpload {...defaultProps} />)
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(<ForbiddenWordsFileUpload {...defaultProps} />)
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()
})
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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(<NumberInputControlled {...defaultProps} {...props} />)
}
it('renders correctly with initial props', () => {
renderComponent()
const input = screen.getByTestId('number-input')
expect(input).toHaveValue('5')
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<typeof executeApiRequest>
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(<PasswordComplexityConfiguration />)
await userEvent.click(await getViewOptionsButton())
expect(screen.getByText('Current Password Configuration')).toBeInTheDocument()
})
it('closes the tray when "Cancel" button is clicked', async () => {
render(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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(<PasswordComplexityConfiguration />)
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/',
},
])
})
})
})