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:
parent
5285761de4
commit
5e5029a920
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
|
@ -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/',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue