diff --git a/frontend/apps/console/src/features/applications/components/create-application/ConfigureDetails.tsx b/frontend/apps/console/src/features/applications/components/create-application/ConfigureDetails.tsx index 15fc329290..29b8966a31 100644 --- a/frontend/apps/console/src/features/applications/components/create-application/ConfigureDetails.tsx +++ b/frontend/apps/console/src/features/applications/components/create-application/ConfigureDetails.tsx @@ -36,6 +36,7 @@ import { } from '../../models/application-create-flow'; import type {PlatformApplicationTemplate, TechnologyApplicationTemplate} from '../../models/application-templates'; import getConfigurationTypeFromTemplate from '../../utils/getConfigurationTypeFromTemplate'; +import hasInvalidCorsRows from '../../utils/hasInvalidCorsRows'; import isRedirectCapableTemplate from '../../utils/isRedirectCapableTemplate'; /** @@ -292,6 +293,7 @@ export default function ConfigureDetails({ relyingPartyName: contextRelyingPartyName, setRelyingPartyName, appName, + corsOrigins, } = useApplicationCreate(); const { control, @@ -327,6 +329,11 @@ export default function ConfigureDetails({ : configurationType; const effectiveIsRedirectCapable = isEmbeddedApproach ? false : isRedirectCapable; + // Guarded on the same condition that renders the CORS editor, so rows left over from a previously + // selected template can't block a step that no longer shows them. + const showsCorsEditor: boolean = effectiveIsRedirectCapable && Boolean(selectedTemplateConfig?.capabilities?.cors); + const hasInvalidCorsOrigins: boolean = showsCorsEditor && hasInvalidCorsRows(corsOrigins); + const isWallet: boolean = selectedTemplateConfig?.id === 'wallet'; const [walletVendor, setWalletVendor] = useState(CUSTOM_WALLET_VENDOR); const [customClientId, setCustomClientId] = useState(''); @@ -455,38 +462,31 @@ export default function ConfigureDetails({ * Determine if step is ready based on validity and configuration type. */ useEffect((): void => { - // If Passkey is enabled, we MUST have valid relying party info - if (isPasskeyConfigEnabled) { - if (!relyingPartyId || !relyingPartyName) { - onReadyChange(false); - return; - } - } - - if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.NONE) { - // Even if no base config needed, if Passkey is enabled we need those fields valid - // The Passkey check block above handles returning false if invalid. - // If we are here, it means either Passkey is disabled OR Passkey fields are valid. - onReadyChange(true); - return; - } - - // For URL-based config, need valid hosting URL - if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.URL) { + let ready: boolean; + + if (isPasskeyConfigEnabled && (!relyingPartyId || !relyingPartyName)) { + // If Passkey is enabled, we MUST have valid relying party info + ready = false; + } else if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.NONE) { + // Even if no base config needed, if Passkey is enabled we need those fields valid, which the + // check above has already accounted for. + ready = true; + } else if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.URL) { + // For URL-based config, need valid hosting URL const hasValidHostingUrl: boolean = !!hostingUrl && !errors.hostingUrl; const hasValidCallbackUrl: boolean = callbackMode === 'same' || (!!callbackUrl && !errors.callbackUrl); - onReadyChange(!!hasValidHostingUrl && !!hasValidCallbackUrl); - return; - } - - // For deeplink config, need valid deeplink. For wallets, also block if the resolved client - // id is already taken by another application (would otherwise fail only on submit). - if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.DEEPLINK) { - onReadyChange(!!deeplink && !errors.deeplink && !isDuplicateWalletClientId); - return; + ready = !!hasValidHostingUrl && !!hasValidCallbackUrl; + } else if (effectiveConfigurationType === ApplicationCreateFlowConfiguration.DEEPLINK) { + // For deeplink config, need valid deeplink. For wallets, also block if the resolved client + // id is already taken by another application (would otherwise fail only on submit). + ready = !!deeplink && !errors.deeplink && !isDuplicateWalletClientId; + } else { + ready = isValid; } - onReadyChange(isValid); + // A malformed CORS origin used to be dropped on submit without a word, so block here instead. + // This reads the same rows that get merged into the allow-list, so the two cannot disagree. + onReadyChange(ready && !hasInvalidCorsOrigins); }, [ isValid, effectiveConfigurationType, @@ -501,6 +501,7 @@ export default function ConfigureDetails({ onReadyChange, selectedTemplateConfig, isDuplicateWalletClientId, + hasInvalidCorsOrigins, ]); // For platforms that don't require configuration AND no passkey configuration needed AND the diff --git a/frontend/apps/console/src/features/applications/components/create-application/ConfigureRedirectUris.tsx b/frontend/apps/console/src/features/applications/components/create-application/ConfigureRedirectUris.tsx index 43b72e1944..a9d7fb2d8e 100644 --- a/frontend/apps/console/src/features/applications/components/create-application/ConfigureRedirectUris.tsx +++ b/frontend/apps/console/src/features/applications/components/create-application/ConfigureRedirectUris.tsx @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {isValidOrigin} from '@thunderid/configure-settings'; +import {AllowedOriginTypes, createRow, rowKey, type AllowedOriginDraftRow} from '@thunderid/configure-settings'; import { Alert, Box, @@ -19,6 +19,7 @@ import {Plus, Trash} from '@wso2/oxygen-ui-icons-react'; import type {JSX} from 'react'; import {useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; +import CorsOriginsEditor from './CorsOriginsEditor'; import DevServerLogo from './DevServerLogo'; import useApplicationCreate from '../../contexts/ApplicationCreate/useApplicationCreate'; @@ -56,12 +57,6 @@ interface UriListEditorProps { onUrisChange: (uris: string[]) => void; /** Whether an empty row is an error (redirect URIs are required, post-logout ones are optional). */ required: boolean; - /** Value format validator. Defaults to the permissive URI-format check. */ - isValidValue?: (value: string) => boolean; - /** Already-translated "value must not be empty" error message. */ - emptyValueMessage?: string; - /** Already-translated "value is not a valid format" error message. */ - invalidValueMessage?: string; } function UriListEditor({ @@ -72,9 +67,6 @@ function UriListEditor({ uris, onUrisChange, required, - isValidValue = isValidUriFormat, - emptyValueMessage = '', - invalidValueMessage = '', }: UriListEditorProps): JSX.Element { const {t} = useTranslation(); const [errors, setErrors] = useState>({}); @@ -84,9 +76,7 @@ function UriListEditor({ if (required) { setErrors((prev) => ({ ...prev, - [index]: - emptyValueMessage || - t('applications:edit.general.redirectUris.error.empty', 'Invalid Redirect: URI must not be empty.'), + [index]: t('applications:edit.general.redirectUris.error.empty', 'Invalid Redirect: URI must not be empty.'), })); return false; } @@ -97,12 +87,13 @@ function UriListEditor({ }); return false; } - if (!isValidValue(uri)) { + if (!isValidUriFormat(uri)) { setErrors((prev) => ({ ...prev, - [index]: - invalidValueMessage || - t('applications:edit.general.redirectUris.error.invalid', 'Invalid Redirect: Please enter a valid URL.'), + [index]: t( + 'applications:edit.general.redirectUris.error.invalid', + 'Invalid Redirect: Please enter a valid URL.', + ), })); return false; } @@ -198,8 +189,8 @@ interface DevServerBannerProps { showCors: boolean; redirectUris: string[]; onRedirectUrisChange: (uris: string[]) => void; - corsOrigins: string[]; - onCorsOriginsChange: (origins: string[]) => void; + corsOrigins: AllowedOriginDraftRow[]; + onCorsOriginsChange: (origins: AllowedOriginDraftRow[]) => void; } /** @@ -221,8 +212,10 @@ function DevServerBanner({ if (!redirectUris.includes(devServer.url)) { onRedirectUrisChange([...redirectUris, devServer.url]); } - if (showCors && !corsOrigins.includes(devServer.url)) { - onCorsOriginsChange([...corsOrigins, devServer.url]); + // The dev server URL is always an exact origin, never a pattern. + const devServerRow = createRow(AllowedOriginTypes.ORIGIN, devServer.url); + if (showCors && !corsOrigins.some((row) => rowKey(row) === rowKey(devServerRow))) { + onCorsOriginsChange([...corsOrigins, devServerRow]); } }; @@ -347,25 +340,7 @@ export default function ConfigureRedirectUris(): JSX.Element { /> )} - {showCors && ( - - )} + {showCors && } ); } diff --git a/frontend/apps/console/src/features/applications/components/create-application/CorsOriginsEditor.tsx b/frontend/apps/console/src/features/applications/components/create-application/CorsOriginsEditor.tsx new file mode 100644 index 0000000000..42b680b883 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/create-application/CorsOriginsEditor.tsx @@ -0,0 +1,132 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + AllowedOriginRow, + AllowedOriginRowIssueFallbacks, + AllowedOriginTypes, + createRow, + validateAllowedOriginRows, + type AllowedOriginDraftRow, + type AllowedOriginRowError, + type AllowedOriginRowWarning, +} from '@thunderid/configure-settings'; +import {Box, Button, FormControl, FormLabel, Stack, Typography} from '@wso2/oxygen-ui'; +import {Plus} from '@wso2/oxygen-ui-icons-react'; +import type {JSX} from 'react'; +import {useMemo, useState} from 'react'; +import {useTranslation} from 'react-i18next'; + +interface CorsOriginsEditorProps { + rows: AllowedOriginDraftRow[]; + onRowsChange: (rows: AllowedOriginDraftRow[]) => void; +} + +/** + * The Configuration step's CORS Allowed Origins editor. Each row states whether it is an exact + * origin or a regular expression, so nothing is reclassified from its text on the way to the + * deployment's allow-list. + * + * Validation messages are resolved from the `settings` namespace, which the Settings page's CORS + * card uses too, so both surfaces phrase the same rule identically. + */ +export default function CorsOriginsEditor({rows, onRowsChange}: CorsOriginsEditorProps): JSX.Element { + const {t} = useTranslation(); + // Messages stay hidden until a row has been blurred, so a half-typed origin doesn't shout. + const [touched, setTouched] = useState>({}); + + // An empty list renders as a bare "Add Origin" button with no field to type into, which reads as + // broken rather than "nothing added yet". Show one empty row instead; editing it commits the real + // (currently empty) list upward. The placeholder row is created once, so typing into it doesn't + // remount the field on every keystroke. + const placeholderRow = useMemo(() => createRow(AllowedOriginTypes.ORIGIN), []); + const displayRows = rows.length > 0 ? rows : [placeholderRow]; + + const issues = validateAllowedOriginRows(displayRows); + + /** + * Resolves a row's issue message, or `undefined` while the row has no issue or has not been + * blurred yet. + * + * @param codes - The issue codes for every row, keyed by row id + * @param id - The row to resolve a message for + * @returns The localized message, or `undefined` when the row has nothing to say + */ + const messageFor = ( + codes: Record, + id: string, + ): string | undefined => { + const code = codes[id]; + if (!touched[id] || !code) { + return undefined; + } + return t(`settings:cors.validation.${code}`, AllowedOriginRowIssueFallbacks[code]); + }; + + /** + * Commits a change to one row upward, leaving every other row as it is. Editing the placeholder row + * is what turns it into the first real entry. + * + * @param id - The row to change + * @param patch - The fields to overwrite on that row + */ + const updateRow = (id: string, patch: Partial): void => { + onRowsChange(displayRows.map((row) => (row.id === id ? {...row, ...patch} : row))); + }; + + return ( + + {t('applications:onboarding.configure.details.corsOrigins.title', 'CORS Allowed Origins')} + + {t( + 'applications:onboarding.configure.details.corsOrigins.description', + 'Origins allowed to make cross-origin requests to the token and userinfo endpoints. Each entry is either an exact origin or a regular expression.', + )} + + + + {displayRows.map((row) => ( + { + setTouched((prev) => ({...prev, [row.id]: true})); + updateRow(row.id, {type}); + }} + onChange={(value) => updateRow(row.id, {value})} + onBlur={() => setTouched((prev) => ({...prev, [row.id]: true}))} + onRemove={() => onRowsChange(displayRows.filter((candidate) => candidate.id !== row.id))} + /> + ))} + + + + + + + ); +} diff --git a/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureDetails.test.tsx b/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureDetails.test.tsx index 23f64a5a68..fa540e301c 100644 --- a/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureDetails.test.tsx +++ b/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureDetails.test.tsx @@ -5,6 +5,7 @@ import {render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {TokenEndpointAuthMethods} from '@thunderid/configure-applications'; import {AuthenticatorTypes} from '@thunderid/configure-connections'; +import {AllowedOriginTypes, createRow} from '@thunderid/configure-settings'; import {LoggerProvider, LogLevel} from '@thunderid/logger'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import ApplicationCreateContext, { @@ -577,6 +578,91 @@ describe('ConfigureDetails', () => { }); }); + describe('CORS readiness guard', () => { + // A malformed origin used to be dropped on submit without a word, so the step has to block on it. + const corsTemplate = (): ApplicationTemplate => ({ + ...createTemplate('Browser App', []), + capabilities: {cors: true}, + }); + + it('blocks the step while a CORS row is invalid, and releases it once the row is corrected', async () => { + const onReadyChange = vi.fn(); + renderWithContext( + { + technology: TechnologyApplicationTemplate.REACT, + platform: PlatformApplicationTemplate.BROWSER, + onReadyChange, + }, + { + selectedTemplateConfig: corsTemplate(), + corsOrigins: [createRow(AllowedOriginTypes.ORIGIN, 'https://example.com/path')], + }, + ); + + const user = userEvent.setup(); + await user.type( + screen.getByPlaceholderText('applications:onboarding.configure.details.hostingUrl.placeholder'), + 'https://example.com', + ); + + // The URL config is valid, so only the malformed origin can be holding readiness back. + await waitFor(() => { + expect(onReadyChange).toHaveBeenLastCalledWith(false); + }); + }); + + it('does not block the step when every CORS row is valid', async () => { + const onReadyChange = vi.fn(); + renderWithContext( + { + technology: TechnologyApplicationTemplate.REACT, + platform: PlatformApplicationTemplate.BROWSER, + onReadyChange, + }, + { + selectedTemplateConfig: corsTemplate(), + corsOrigins: [createRow(AllowedOriginTypes.REGEX, '^https://[a-z]+\\.example\\.com$')], + }, + ); + + const user = userEvent.setup(); + await user.type( + screen.getByPlaceholderText('applications:onboarding.configure.details.hostingUrl.placeholder'), + 'https://example.com', + ); + + await waitFor(() => { + expect(onReadyChange).toHaveBeenLastCalledWith(true); + }); + }); + + it('ignores rows left over from a template that no longer shows the editor', async () => { + const onReadyChange = vi.fn(); + renderWithContext( + { + technology: TechnologyApplicationTemplate.REACT, + platform: PlatformApplicationTemplate.BROWSER, + onReadyChange, + }, + { + // No cors capability, so the editor is hidden and its stale rows must not block the step. + selectedTemplateConfig: createTemplate('Browser App', []), + corsOrigins: [createRow(AllowedOriginTypes.ORIGIN, 'https://example.com/path')], + }, + ); + + const user = userEvent.setup(); + await user.type( + screen.getByPlaceholderText('applications:onboarding.configure.details.hostingUrl.placeholder'), + 'https://example.com', + ); + + await waitFor(() => { + expect(onReadyChange).toHaveBeenLastCalledWith(true); + }); + }); + }); + it('handles server applications configuration correctly', () => { const template = createTemplate('Server Application', []); diff --git a/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureRedirectUris.test.tsx b/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureRedirectUris.test.tsx index f7ddb917d9..3f90b15810 100644 --- a/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureRedirectUris.test.tsx +++ b/frontend/apps/console/src/features/applications/components/create-application/__tests__/ConfigureRedirectUris.test.tsx @@ -3,6 +3,7 @@ import {render, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import {AllowedOriginTypes, createRow} from '@thunderid/configure-settings'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import ApplicationCreateContext, { type ApplicationCreateContextType, @@ -175,7 +176,10 @@ describe('ConfigureRedirectUris', () => { await user.click(screen.getByText('applications:onboarding.configure.details.devServer.addToRedirectAndCors')); expect(setRedirectUris).toHaveBeenCalledWith(['http://localhost:5173']); - expect(setCorsOrigins).toHaveBeenCalledWith(['http://localhost:5173']); + // Quick-add always produces an exact origin, never a pattern. + expect(setCorsOrigins).toHaveBeenCalledWith([ + expect.objectContaining({type: AllowedOriginTypes.ORIGIN, value: 'http://localhost:5173'}), + ]); }); it('only adds the dev server URL to redirect URIs, not CORS origins, for a non-CORS template', async () => { @@ -204,7 +208,7 @@ describe('ConfigureRedirectUris', () => { selectedTemplateConfig: reactTemplate, redirectUris: ['http://localhost:5173'], setRedirectUris, - corsOrigins: ['http://localhost:5173'], + corsOrigins: [createRow(AllowedOriginTypes.ORIGIN, 'http://localhost:5173')], setCorsOrigins, }); @@ -268,12 +272,12 @@ describe('ConfigureRedirectUris', () => { expect(setRedirectUris).toHaveBeenCalledWith(['', '']); }); - it('flags an invalid CORS origin (a path is not a bare origin)', async () => { + it('flags an invalid CORS origin (a path is not a bare origin) instead of accepting it as a pattern', async () => { const setCorsOrigins = vi.fn(); renderWithContext({ selectedTemplateConfig: reactTemplate, - corsOrigins: ['https://example.com/some/path'], + corsOrigins: [createRow(AllowedOriginTypes.ORIGIN, 'https://example.com/some/path')], setCorsOrigins, }); @@ -281,8 +285,6 @@ describe('ConfigureRedirectUris', () => { await user.click(corsInput); await user.tab(); - expect( - await screen.findByText('applications:onboarding.configure.details.corsOrigins.error.invalid'), - ).toBeInTheDocument(); + expect(await screen.findByText('settings:cors.validation.invalidOrigin')).toBeInTheDocument(); }); }); diff --git a/frontend/apps/console/src/features/applications/components/create-application/__tests__/CorsOriginsEditor.test.tsx b/frontend/apps/console/src/features/applications/components/create-application/__tests__/CorsOriginsEditor.test.tsx new file mode 100644 index 0000000000..44f12732d3 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/create-application/__tests__/CorsOriginsEditor.test.tsx @@ -0,0 +1,122 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {AllowedOriginTypes, createRow} from '@thunderid/configure-settings'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import CorsOriginsEditor from '../CorsOriginsEditor'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const ORIGIN_PLACEHOLDER = 'applications:onboarding.configure.details.corsOrigins.placeholder'; +const REGEX_PLACEHOLDER = 'applications:onboarding.configure.details.corsOrigins.regexPlaceholder'; + +const origin = (value: string) => createRow(AllowedOriginTypes.ORIGIN, value); +const regex = (value: string) => createRow(AllowedOriginTypes.REGEX, value); + +describe('CorsOriginsEditor', () => { + let user: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + user = userEvent.setup(); + }); + + it('shows one empty origin row when the list is empty', () => { + render(); + + expect(screen.getByPlaceholderText(ORIGIN_PLACEHOLDER)).toHaveValue(''); + expect(screen.getByRole('combobox', {name: 'settings:cors.type.label'})).toHaveTextContent( + 'settings:cors.type.origin', + ); + }); + + it('renders a loaded regex row with its type preselected and its delimiters', () => { + render(); + + expect(screen.getByPlaceholderText(REGEX_PLACEHOLDER)).toHaveValue('^https://x\\.io$'); + expect(screen.getAllByText('/')).toHaveLength(2); + }); + + it('appends an origin row on Add Origin', async () => { + const onRowsChange = vi.fn(); + render(); + + await user.click( + screen.getByRole('button', {name: 'applications:onboarding.configure.details.corsOrigins.addOrigin'}), + ); + + expect(onRowsChange).toHaveBeenCalledWith([ + expect.objectContaining({type: AllowedOriginTypes.ORIGIN, value: 'https://a.example.com'}), + expect.objectContaining({type: AllowedOriginTypes.ORIGIN, value: ''}), + ]); + }); + + it('keeps the text a row already holds when its type changes', async () => { + const onRowsChange = vi.fn(); + render(); + + await user.click(screen.getByRole('combobox', {name: 'settings:cors.type.label'})); + await user.click(screen.getByRole('option', {name: 'settings:cors.type.regex'})); + + expect(onRowsChange).toHaveBeenCalledWith([ + expect.objectContaining({type: AllowedOriginTypes.REGEX, value: 'https://app.example.com'}), + ]); + }); + + it('removes a row', async () => { + const onRowsChange = vi.fn(); + render(); + + await user.click( + screen.getByRole('button', {name: 'applications:onboarding.configure.details.corsOrigins.removeOrigin'}), + ); + + expect(onRowsChange).toHaveBeenCalledWith([]); + }); + + it('reports a typed value for the edited row only, leaving its siblings as they are', async () => { + const onRowsChange = vi.fn(); + const untouched = origin('https://a.example.com'); + render(); + + await user.type(screen.getAllByPlaceholderText(ORIGIN_PLACEHOLDER)[1], 'h'); + + expect(onRowsChange).toHaveBeenCalledWith([untouched, expect.objectContaining({value: 'h'})]); + }); + + it('stays quiet until the row is blurred, then reports an invalid origin', async () => { + render(); + + expect(screen.queryByText('settings:cors.validation.invalidOrigin')).toBeNull(); + + await user.click(screen.getByPlaceholderText(ORIGIN_PLACEHOLDER)); + await user.tab(); + + expect(await screen.findByText('settings:cors.validation.invalidOrigin')).toBeInTheDocument(); + }); + + it('reports a regex that does not compile', async () => { + render(); + + await user.click(screen.getByPlaceholderText(REGEX_PLACEHOLDER)); + await user.tab(); + + expect(await screen.findByText('settings:cors.validation.invalidRegex')).toBeInTheDocument(); + }); + + it('warns about an unanchored pattern without reporting it as an error', async () => { + render(); + + await user.click(screen.getByPlaceholderText(REGEX_PLACEHOLDER)); + await user.tab(); + + expect(await screen.findByText('settings:cors.validation.unanchoredRegex')).toBeInTheDocument(); + expect(screen.getByPlaceholderText(REGEX_PLACEHOLDER)).toHaveAttribute('aria-invalid', 'false'); + }); +}); diff --git a/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateContext.tsx b/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateContext.tsx index fdd77e343f..ed8e22b934 100644 --- a/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateContext.tsx +++ b/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateContext.tsx @@ -1,6 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import type {AllowedOriginDraftRow} from '@thunderid/configure-settings'; import type {LayoutConfig, Theme} from '@thunderid/design'; import type {Context} from 'react'; import {createContext} from 'react'; @@ -309,16 +310,17 @@ export interface ApplicationCreateContextType { /** * CORS allowed origins entered in the Configuration step, for templates whose Configuration - * step offers a CORS editor. These are merged into the deployment's CORS allow-list on submit. + * step offers a CORS editor. Each row carries the type the admin chose (exact origin or regex), + * and they are merged into the deployment's CORS allow-list on submit. * @remark Needed for the Configuration step. */ - corsOrigins: string[]; + corsOrigins: AllowedOriginDraftRow[]; /** * Sets the CORS allowed origins. * @remark Needed for the Configuration step. */ - setCorsOrigins: (origins: string[]) => void; + setCorsOrigins: (origins: AllowedOriginDraftRow[]) => void; /** * Per-item "use the organization unit's default" selection, backing the Details step's diff --git a/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateProvider.tsx b/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateProvider.tsx index d889c381c7..2a27b2e767 100644 --- a/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateProvider.tsx +++ b/frontend/apps/console/src/features/applications/contexts/ApplicationCreate/ApplicationCreateProvider.tsx @@ -3,6 +3,7 @@ import {useGetApplications} from '@thunderid/configure-applications'; import {AuthenticatorTypes} from '@thunderid/configure-connections'; +import type {AllowedOriginDraftRow} from '@thunderid/configure-settings'; import type {LayoutConfig, Theme} from '@thunderid/design'; import type {PropsWithChildren} from 'react'; import {useState, useMemo, useCallback} from 'react'; @@ -72,7 +73,7 @@ const INITIAL_STATE: { isSignOutFlowEnabled: boolean; redirectUris: string[]; postLogoutRedirectUris: string[]; - corsOrigins: string[]; + corsOrigins: AllowedOriginDraftRow[]; ouDefaults: OrganizationUnitDefaultsSelection; selectedTechnology: TechnologyApplicationTemplate | null; selectedPlatform: PlatformApplicationTemplate | null; @@ -185,7 +186,7 @@ export default function ApplicationCreateProvider({children}: ApplicationCreateP const [isSignOutFlowEnabled, setIsSignOutFlowEnabled] = useState(INITIAL_STATE.isSignOutFlowEnabled); const [redirectUris, setRedirectUris] = useState(INITIAL_STATE.redirectUris); const [postLogoutRedirectUris, setPostLogoutRedirectUris] = useState(INITIAL_STATE.postLogoutRedirectUris); - const [corsOrigins, setCorsOrigins] = useState(INITIAL_STATE.corsOrigins); + const [corsOrigins, setCorsOrigins] = useState(INITIAL_STATE.corsOrigins); const [ouDefaults, setOuDefaults] = useState(INITIAL_STATE.ouDefaults); const [selectedTechnology, setSelectedTechnology] = useState( INITIAL_STATE.selectedTechnology, diff --git a/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx b/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx index 6546b9de91..4c5b9e524b 100644 --- a/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx +++ b/frontend/apps/console/src/features/applications/pages/ApplicationCreatePage.tsx @@ -11,8 +11,9 @@ import { useGetOrganizationUnit, useHasMultipleOUs, } from '@thunderid/configure-organization-units'; -import {useGetCorsConfig, useUpdateCorsConfig} from '@thunderid/configure-settings'; +import {isRowEmpty, useGetCorsConfig, useUpdateCorsConfig} from '@thunderid/configure-settings'; import {useGetUserTypes} from '@thunderid/configure-user-types'; +import {useToast} from '@thunderid/contexts'; import {DefaultTheme, useGetTheme, type Theme} from '@thunderid/design'; import {useTemplateLiteralResolver} from '@thunderid/hooks'; import {useLogger} from '@thunderid/logger/react'; @@ -59,6 +60,7 @@ import resolveCreationFlow from '../utils/resolveCreationFlow'; export default function ApplicationCreatePage(): JSX.Element { const {t} = useTranslation(); + const {showToast} = useToast(); const {resolveAll} = useTemplateLiteralResolver(); const { @@ -662,15 +664,36 @@ export default function ApplicationCreatePage(): JSX.Element { // Configuration step are merged into the deployment's allow-list here rather than sent as // part of the application payload above. This is independent of application creation // succeeding, so it neither blocks nor is blocked by the navigation below. - const validCorsAdditions = corsOrigins.map((origin) => origin.trim()).filter(Boolean); - if (selectedTemplateConfig?.capabilities?.cors && validCorsAdditions.length > 0) { - updateCorsConfig.mutate({ - data: mergeCorsOrigins( - corsConfigData?.writable.allowedOrigins ?? [], - corsConfigData?.readOnly.allowedOrigins ?? [], - validCorsAdditions, - ), - }); + const corsAdditions = corsOrigins.filter((row) => !isRowEmpty(row)); + if (selectedTemplateConfig?.capabilities?.cors && corsAdditions.length > 0) { + updateCorsConfig.mutate( + { + data: mergeCorsOrigins( + corsConfigData?.writable.allowedOrigins ?? [], + corsConfigData?.readOnly.allowedOrigins ?? [], + corsAdditions, + ), + }, + { + // The application itself is already created and the wizard navigates away below, so + // there is no form left to attach an inline error to and retrying here would risk a + // second application. Name the one thing the admin has to redo instead, otherwise the + // origins they entered are silently missing from the deployment's allow-list. + onError: (err: Error) => { + logger.error('Failed to merge the wizard CORS origins into the deployment allow-list', { + applicationId: createdApp.id, + error: err, + }); + showToast( + t( + 'applications:onboarding.configure.details.corsOrigins.saveError', + 'The application was created, but its allowed origins were not saved. Add them under Settings, CORS.', + ), + 'error', + ); + }, + }, + ); } // The mcp-client template has no completion step of its own, so it always goes straight diff --git a/frontend/apps/console/src/features/applications/utils/__tests__/hasInvalidCorsRows.test.ts b/frontend/apps/console/src/features/applications/utils/__tests__/hasInvalidCorsRows.test.ts new file mode 100644 index 0000000000..77a77b4a8e --- /dev/null +++ b/frontend/apps/console/src/features/applications/utils/__tests__/hasInvalidCorsRows.test.ts @@ -0,0 +1,35 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {AllowedOriginTypes, createRow} from '@thunderid/configure-settings'; +import {describe, expect, it} from 'vitest'; +import hasInvalidCorsRows from '../hasInvalidCorsRows'; + +const origin = (value: string) => createRow(AllowedOriginTypes.ORIGIN, value); +const regex = (value: string) => createRow(AllowedOriginTypes.REGEX, value); + +describe('hasInvalidCorsRows', () => { + it('is false for an empty list', () => { + expect(hasInvalidCorsRows([])).toBe(false); + }); + + it('is false for the untouched placeholder row', () => { + expect(hasInvalidCorsRows([origin('')])).toBe(false); + }); + + it('is false for valid origin and regex rows', () => { + expect(hasInvalidCorsRows([origin('https://app.example.com'), regex('^https://x\\.io$')])).toBe(false); + }); + + it('is true for an origin row carrying a path', () => { + expect(hasInvalidCorsRows([origin('https://example.com/path')])).toBe(true); + }); + + it('is true for a regex row that does not compile', () => { + expect(hasInvalidCorsRows([regex('(bad')])).toBe(true); + }); + + it('is false for an unanchored regex, which only warns', () => { + expect(hasInvalidCorsRows([regex('acme\\.io')])).toBe(false); + }); +}); diff --git a/frontend/apps/console/src/features/applications/utils/__tests__/mergeCorsOrigins.test.ts b/frontend/apps/console/src/features/applications/utils/__tests__/mergeCorsOrigins.test.ts index b74b7f044c..23c808a88f 100644 --- a/frontend/apps/console/src/features/applications/utils/__tests__/mergeCorsOrigins.test.ts +++ b/frontend/apps/console/src/features/applications/utils/__tests__/mergeCorsOrigins.test.ts @@ -1,48 +1,70 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import {AllowedOriginTypes, createRow} from '@thunderid/configure-settings'; import {describe, expect, it} from 'vitest'; import mergeCorsOrigins from '../mergeCorsOrigins'; +const origin = (value: string) => createRow(AllowedOriginTypes.ORIGIN, value); +const regex = (value: string) => createRow(AllowedOriginTypes.REGEX, value); + describe('mergeCorsOrigins', () => { it('appends new valid origins to the existing writable list', () => { - const result = mergeCorsOrigins(['https://existing.example.com'], [], ['https://new.example.com']); + const result = mergeCorsOrigins(['https://existing.example.com'], [], [origin('https://new.example.com')]); expect(result).toEqual({allowedOrigins: ['https://existing.example.com', 'https://new.example.com']}); }); + it('appends a regex addition as a {regex} entry', () => { + const result = mergeCorsOrigins([], [], [regex('^https://.*\\.acme\\.io$')]); + + expect(result).toEqual({allowedOrigins: [{regex: '^https://.*\\.acme\\.io$'}]}); + }); + it('skips additions that already exist in the writable list', () => { - const result = mergeCorsOrigins(['https://existing.example.com'], [], ['https://existing.example.com']); + const result = mergeCorsOrigins(['https://existing.example.com'], [], [origin('https://existing.example.com')]); expect(result).toEqual({allowedOrigins: ['https://existing.example.com']}); }); it('skips additions that already exist in the read-only list', () => { - const result = mergeCorsOrigins([], ['https://readonly.example.com'], ['https://readonly.example.com']); + const result = mergeCorsOrigins([], ['https://readonly.example.com'], [origin('https://readonly.example.com')]); expect(result).toEqual({allowedOrigins: []}); }); - it('normalizes additions before comparing and storing (trailing slash, casing)', () => { - const result = mergeCorsOrigins(['https://existing.example.com'], [], ['HTTPS://EXISTING.example.com/']); + it('normalizes origin additions before comparing and storing (trailing slash, casing)', () => { + const result = mergeCorsOrigins(['https://existing.example.com'], [], [origin('HTTPS://EXISTING.example.com/')]); expect(result).toEqual({allowedOrigins: ['https://existing.example.com']}); }); - it('skips blank and invalid additions', () => { - const result = mergeCorsOrigins([], [], ['', ' ', 'not-a-valid-origin', 'https://valid.example.com/path']); + it('does not treat a regex addition as a duplicate of a literal with the same text', () => { + const result = mergeCorsOrigins(['https://app.example.com'], [], [regex('https://app.example.com')]); + + expect(result).toEqual({allowedOrigins: ['https://app.example.com', {regex: 'https://app.example.com'}]}); + }); + + it('skips blank additions', () => { + const result = mergeCorsOrigins([], [], [origin(''), origin(' '), regex('')]); + + expect(result).toEqual({allowedOrigins: []}); + }); + + it('skips invalid additions as a backstop, since the wizard now blocks them up front', () => { + const result = mergeCorsOrigins([], [], [origin('not-a-valid-origin'), origin('https://valid.example.com/path')]); expect(result).toEqual({allowedOrigins: []}); }); it('dedupes multiple identical additions in the same call', () => { - const result = mergeCorsOrigins([], [], ['http://localhost:5173', 'http://localhost:5173']); + const result = mergeCorsOrigins([], [], [origin('http://localhost:5173'), origin('http://localhost:5173')]); expect(result).toEqual({allowedOrigins: ['http://localhost:5173']}); }); it('leaves regex read-only/writable entries untouched and still compares against their text', () => { - const result = mergeCorsOrigins([{regex: '^https://.*\\.example\\.com$'}], [], ['http://localhost:3000']); + const result = mergeCorsOrigins([{regex: '^https://.*\\.example\\.com$'}], [], [origin('http://localhost:3000')]); expect(result).toEqual({ allowedOrigins: [{regex: '^https://.*\\.example\\.com$'}, 'http://localhost:3000'], diff --git a/frontend/apps/console/src/features/applications/utils/hasInvalidCorsRows.ts b/frontend/apps/console/src/features/applications/utils/hasInvalidCorsRows.ts new file mode 100644 index 0000000000..b727aa2ec9 --- /dev/null +++ b/frontend/apps/console/src/features/applications/utils/hasInvalidCorsRows.ts @@ -0,0 +1,15 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {validateAllowedOriginRows, type AllowedOriginDraftRow} from '@thunderid/configure-settings'; + +/** + * Reports whether any Configuration step CORS row would be rejected, so the wizard can block Create + * rather than dropping the entry on the way to the deployment's allow-list. + * + * No existing-entry set is passed: a row that repeats an origin the deployment already allows is a + * no-op for `mergeCorsOrigins`, not a mistake the admin has to correct before continuing. + */ +export default function hasInvalidCorsRows(rows: AllowedOriginDraftRow[]): boolean { + return Object.keys(validateAllowedOriginRows(rows).errors).length > 0; +} diff --git a/frontend/apps/console/src/features/applications/utils/mergeCorsOrigins.ts b/frontend/apps/console/src/features/applications/utils/mergeCorsOrigins.ts index c704d8e28a..058e6bf4c0 100644 --- a/frontend/apps/console/src/features/applications/utils/mergeCorsOrigins.ts +++ b/frontend/apps/console/src/features/applications/utils/mergeCorsOrigins.ts @@ -2,31 +2,40 @@ // SPDX-License-Identifier: Apache-2.0 import { - isValidOrigin, - normalizeOrigin, - originValueText, + isRowEmpty, + rowKey, + toAllowedOrigins, + toRows, + validateAllowedOriginRows, type AllowedOrigin, + type AllowedOriginDraftRow, type CorsValue, } from '@thunderid/configure-settings'; /** * Builds the CORS PUT payload for adding the Configuration step's origins to the deployment's - * writable allow-list, without disturbing existing entries. Additions that are invalid, blank, or - * already present (writable or read-only) are skipped rather than duplicated or overwritten. + * writable allow-list, without disturbing existing entries. Additions that are blank or already + * present (writable or read-only) are skipped rather than duplicated or overwritten. Entries are + * compared by type as well as value, so an added pattern never collides with a literal of the same + * text. + * + * Invalid additions are also skipped, but only as a backstop: the Configuration step blocks Create + * while any row is invalid, so nothing the admin typed should reach here and disappear. */ export default function mergeCorsOrigins( writable: AllowedOrigin[], readOnly: AllowedOrigin[], - additions: string[], + additions: AllowedOriginDraftRow[], ): CorsValue { - const existing = new Set([...writable, ...readOnly].map(originValueText).map(normalizeOrigin)); + const existing = new Set([...toRows(writable), ...toRows(readOnly)].map(rowKey)); const merged: AllowedOrigin[] = [...writable]; - additions.forEach((raw) => { - const normalized = normalizeOrigin(raw); - if (normalized === '' || !isValidOrigin(normalized) || existing.has(normalized)) return; - existing.add(normalized); - merged.push(normalized); + additions.forEach((row) => { + const key = rowKey(row); + if (isRowEmpty(row) || existing.has(key)) return; + if (Object.keys(validateAllowedOriginRows([row]).errors).length > 0) return; + existing.add(key); + merged.push(...toAllowedOrigins([row])); }); return {allowedOrigins: merged}; diff --git a/frontend/packages/configure-settings/src/components/cors/AllowedOriginRow.tsx b/frontend/packages/configure-settings/src/components/cors/AllowedOriginRow.tsx new file mode 100644 index 0000000000..12a974c6c2 --- /dev/null +++ b/frontend/packages/configure-settings/src/components/cors/AllowedOriginRow.tsx @@ -0,0 +1,180 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Box, IconButton, InputAdornment, MenuItem, Select, Stack, TextField, Tooltip} from '@wso2/oxygen-ui'; +import {Lock, Trash} from '@wso2/oxygen-ui-icons-react'; +import type {JSX} from 'react'; +import {AllowedOriginTypes, type AllowedOriginType} from '../../models/allowedOriginRow'; + +/** Keeps the lock on a read-only row aligned with the remove button on an editable one. */ +const ROW_ACTION_WIDTH = 40; + +const TYPE_SELECT_WIDTH = 116; + +/** + * Props for {@link AllowedOriginRow}. + * + * @public + */ +export interface AllowedOriginRowProps { + /** Whether the row holds a literal origin or a regex pattern. */ + type: AllowedOriginType; + + /** The origin or pattern text, without the decorative delimiters. */ + value: string; + + /** Already-translated blocking message. */ + error?: string; + + /** Already-translated non-blocking caution, shown only when there is no error. */ + warning?: string; + + /** Whether the entry is managed declaratively and cannot be edited or removed. */ + locked?: boolean; + + /** Already-translated placeholder for a literal origin. */ + originPlaceholder: string; + + /** Already-translated placeholder for a regex pattern. */ + regexPlaceholder: string; + + /** Already-translated accessible name for the type selector. */ + typeLabel: string; + + /** Already-translated label for the literal origin option. */ + originOptionLabel: string; + + /** Already-translated label for the regex option. */ + regexOptionLabel: string; + + /** Already-translated accessible name for the remove action. */ + removeLabel: string; + + /** Already-translated tooltip explaining why a locked row cannot be edited. */ + lockedLabel?: string; + + /** Test id applied to the row, so a row's controls can be located together. */ + testId?: string; + + onTypeChange?: (type: AllowedOriginType) => void; + onChange?: (value: string) => void; + onBlur?: () => void; + onRemove?: () => void; +} + +/** + * A single allowed-origin row: an explicit Origin/Regex selector, the value field, and a remove + * action. Regex rows render monospaced between `/` delimiters so a pattern is never mistaken for a + * URL. The delimiters are decoration and are not part of the value. + * + * Every string is injected already translated, so the Settings page and the application creation + * wizard can render the same row from their own i18n namespaces. + * + * @public + */ +export default function AllowedOriginRow({ + type, + value, + error = undefined, + warning = undefined, + locked = false, + originPlaceholder, + regexPlaceholder, + typeLabel, + originOptionLabel, + regexOptionLabel, + removeLabel, + lockedLabel = undefined, + testId = undefined, + onTypeChange = undefined, + onChange = undefined, + onBlur = undefined, + onRemove = undefined, +}: AllowedOriginRowProps): JSX.Element { + const isRegex = type === AllowedOriginTypes.REGEX; + const helperText = error ?? warning; + + /** + * One of the `/` delimiters that frame a regex row. They are hidden from assistive technology and + * live outside the input value, so the field still reports the raw pattern (which contains slashes + * of its own). + * + * @param position - Which side of the field to render + * @returns The decorative adornment + */ + const delimiter = (position: 'start' | 'end'): JSX.Element => ( + + / + + ); + + return ( + + + + onChange?.(event.target.value)} + onBlur={onBlur} + slotProps={{ + input: { + readOnly: locked, + startAdornment: isRegex ? delimiter('start') : undefined, + endAdornment: isRegex ? delimiter('end') : undefined, + sx: isRegex ? {fontFamily: 'monospace'} : undefined, + }, + }} + sx={{ + flex: 1, + ...(locked ? {opacity: 0.65} : {}), + ...(!error && warning ? {'& .MuiFormHelperText-root': {color: 'warning.main'}} : {}), + }} + /> + + {locked ? ( + + + + + + ) : ( + + + + + + )} + + ); +} diff --git a/frontend/packages/configure-settings/src/components/cors/CorsSection.tsx b/frontend/packages/configure-settings/src/components/cors/CorsSection.tsx index aec80e21c9..06bcbdca4d 100644 --- a/frontend/packages/configure-settings/src/components/cors/CorsSection.tsx +++ b/frontend/packages/configure-settings/src/components/cors/CorsSection.tsx @@ -3,39 +3,16 @@ import {QueryErrorNotice, SettingsCard, UnsavedChangesBar} from '@thunderid/components'; import {getErrorMessage} from '@thunderid/utils'; -import {Box, Button, Divider, Skeleton, Stack, TextField, Typography} from '@wso2/oxygen-ui'; +import {Box, Button, Divider, Skeleton, Stack, Typography} from '@wso2/oxygen-ui'; import {InfoIcon, Plus} from '@wso2/oxygen-ui-icons-react'; import type {JSX} from 'react'; -import {useCallback} from 'react'; +import {useCallback, useMemo} from 'react'; import {useTranslation} from 'react-i18next'; -import OriginRow from './OriginRow'; +import AllowedOriginRow from './AllowedOriginRow'; import useGetCorsConfig from '../../api/useGetCorsConfig'; import useUpdateCorsConfig from '../../api/useUpdateCorsConfig'; import useAllowedOriginsDraft from '../../hooks/useAllowedOriginsDraft'; -import type {AllowedOrigin} from '../../models/responses'; - -const ROW_ACTION_WIDTH = 40; - -/** Renders an allowed origin for display: a literal string as-is, a regex entry as its pattern. */ -function originText(entry: AllowedOrigin): string { - return typeof entry === 'string' ? entry : entry.regex; -} - -/** A single non-editable origin row: a muted read-only field plus a spacer that aligns with editable rows. */ -function OriginDisplayRow({value}: {value: string}): JSX.Element { - return ( - - - - - ); -} +import {toRows} from '../../utils/allowedOriginRows'; export default function CorsSection(): JSX.Element { const {t} = useTranslation(); @@ -51,8 +28,22 @@ export default function CorsSection(): JSX.Element { [t], ); - const readOnlyOrigins: AllowedOrigin[] = data?.readOnly.allowedOrigins ?? []; - const hasReadOnlyOrigins: boolean = readOnlyOrigins.length > 0; + const readOnlyEntriesKey = JSON.stringify(data?.readOnly.allowedOrigins ?? []); + const readOnlyRows = useMemo( + () => toRows(data?.readOnly.allowedOrigins ?? []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [readOnlyEntriesKey], + ); + const hasReadOnlyOrigins: boolean = readOnlyRows.length > 0; + + const rowLabels = { + originPlaceholder: t('settings:cors.originPlaceholder', 'https://app.example.com'), + regexPlaceholder: t('settings:cors.regexPlaceholder', '^https://[a-z0-9-]+\\.example\\.com$'), + typeLabel: t('settings:cors.type.label', 'Entry type'), + originOptionLabel: t('settings:cors.type.origin', 'Origin'), + regexOptionLabel: t('settings:cors.type.regex', 'Regex'), + removeLabel: t('settings:cors.removeOrigin', 'Remove origin'), + }; // A previous save error is stale once the draft changes again. const clearSaveError = (): void => { @@ -98,26 +89,37 @@ export default function CorsSection(): JSX.Element { body = ( <> - {readOnlyOrigins.map((entry, index) => ( - // eslint-disable-next-line react/no-array-index-key - + {readOnlyRows.map((row) => ( + ))} - {origins.draft.map((value, index) => ( - ( + { + clearSaveError(); + origins.changeRowType(row.id, type); + }} onChange={(next) => { clearSaveError(); - origins.changeRow(index, next); + origins.changeRow(row.id, next); }} - onBlur={() => origins.blurRow(index)} + onBlur={() => origins.blurRow(row.id)} onRemove={() => { clearSaveError(); - origins.removeRow(index); + origins.removeRow(row.id); }} /> ))} diff --git a/frontend/packages/configure-settings/src/components/cors/OriginRow.tsx b/frontend/packages/configure-settings/src/components/cors/OriginRow.tsx deleted file mode 100644 index b32687badc..0000000000 --- a/frontend/packages/configure-settings/src/components/cors/OriginRow.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {IconButton, Stack, TextField, Tooltip} from '@wso2/oxygen-ui'; -import {Trash} from '@wso2/oxygen-ui-icons-react'; -import type {JSX} from 'react'; - -interface OriginRowProps { - value: string; - error?: string; - placeholder: string; - removeLabel: string; - onChange: (value: string) => void; - onBlur: () => void; - onRemove: () => void; -} - -/** A single editable allowed-origin row: a validated text field plus a remove action. */ -export default function OriginRow({ - value, - error = undefined, - placeholder, - removeLabel, - onChange, - onBlur, - onRemove, -}: OriginRowProps): JSX.Element { - return ( - - onChange(event.target.value)} - onBlur={onBlur} - sx={{flex: 1}} - /> - - - - - - - ); -} diff --git a/frontend/packages/configure-settings/src/components/cors/__tests__/AllowedOriginRow.test.tsx b/frontend/packages/configure-settings/src/components/cors/__tests__/AllowedOriginRow.test.tsx new file mode 100644 index 0000000000..d064eda3dd --- /dev/null +++ b/frontend/packages/configure-settings/src/components/cors/__tests__/AllowedOriginRow.test.tsx @@ -0,0 +1,101 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {renderWithProviders} from '@thunderid/test-utils'; +import {describe, it, expect, vi} from 'vitest'; +import {AllowedOriginTypes} from '../../../models/allowedOriginRow'; +import AllowedOriginRow, {type AllowedOriginRowProps} from '../AllowedOriginRow'; + +const LABELS = { + originPlaceholder: 'https://app.example.com', + regexPlaceholder: '^https://example\\.com$', + typeLabel: 'Entry type', + originOptionLabel: 'Origin', + regexOptionLabel: 'Regex', + removeLabel: 'Remove origin', +}; + +function renderRow(props: Partial = {}) { + return renderWithProviders(); +} + +describe('AllowedOriginRow', () => { + it('shows the origin placeholder and no delimiters for an origin row', () => { + renderRow({type: AllowedOriginTypes.ORIGIN}); + expect(screen.getByPlaceholderText('https://app.example.com')).toBeInTheDocument(); + expect(screen.queryByText('/')).toBeNull(); + }); + + it('wraps a regex row in delimiters and keeps them out of the field value', () => { + renderRow({type: AllowedOriginTypes.REGEX, value: '^https://x\\.io$'}); + expect(screen.getByPlaceholderText('^https://example\\.com$')).toBeInTheDocument(); + expect(screen.getAllByText('/')).toHaveLength(2); + // The delimiters are decoration, so the field still reports the raw pattern. + expect(screen.getByDisplayValue('^https://x\\.io$')).toBeInTheDocument(); + }); + + // The type selector renders its own hidden native input ahead of the value field, so anything that + // reaches for "the input in this row" by position gets the selector instead. Callers that address + // the value field by role, as the end-to-end page object does, must keep finding exactly one. + it('exposes the value field as the only textbox in the row', () => { + renderRow({value: 'https://app.example.com'}); + expect(screen.getAllByRole('textbox')).toHaveLength(1); + expect(screen.getByRole('textbox')).toHaveValue('https://app.example.com'); + }); + + it('reports a type change', async () => { + const user = userEvent.setup(); + const onTypeChange = vi.fn(); + renderRow({onTypeChange}); + + await user.click(screen.getByRole('combobox', {name: 'Entry type'})); + await user.click(screen.getByRole('option', {name: 'Regex'})); + + expect(onTypeChange).toHaveBeenCalledWith(AllowedOriginTypes.REGEX); + }); + + it('renders an error as helper text and puts the field in the error state', () => { + renderRow({value: 'bad', error: 'Enter a valid origin.'}); + expect(screen.getByText('Enter a valid origin.')).toBeInTheDocument(); + expect(screen.getByDisplayValue('bad')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders a warning as helper text without the error state', () => { + renderRow({type: AllowedOriginTypes.REGEX, value: 'acme\\.io', warning: 'Not anchored.'}); + expect(screen.getByText('Not anchored.')).toBeInTheDocument(); + expect(screen.getByDisplayValue('acme\\.io')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('prefers the error over the warning when both apply', () => { + renderRow({value: 'x', error: 'Blocking.', warning: 'Advisory.'}); + expect(screen.getByText('Blocking.')).toBeInTheDocument(); + expect(screen.queryByText('Advisory.')).toBeNull(); + }); + + it('locks the row: read-only field, disabled type, and a lock in place of the remove action', () => { + renderRow({value: 'https://console.example.com', locked: true, lockedLabel: 'Managed declaratively.'}); + expect(screen.getByDisplayValue('https://console.example.com')).toHaveAttribute('readonly'); + // No placeholder, so a locked row is never mistaken for an empty editable one. + expect(screen.queryByPlaceholderText('https://app.example.com')).toBeNull(); + expect(screen.getByRole('combobox', {name: 'Entry type'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.queryByRole('button', {name: 'Remove origin'})).toBeNull(); + }); + + it('locks the row even when no lock explanation was supplied', () => { + renderRow({value: 'https://console.example.com', locked: true}); + expect(screen.getByDisplayValue('https://console.example.com')).toHaveAttribute('readonly'); + expect(screen.queryByRole('button', {name: 'Remove origin'})).toBeNull(); + }); + + it('reports a remove request', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + renderRow({onRemove}); + + await user.click(screen.getByRole('button', {name: 'Remove origin'})); + + expect(onRemove).toHaveBeenCalled(); + }); +}); diff --git a/frontend/packages/configure-settings/src/components/cors/__tests__/CorsSection.test.tsx b/frontend/packages/configure-settings/src/components/cors/__tests__/CorsSection.test.tsx index 834ca9f640..6270347475 100644 --- a/frontend/packages/configure-settings/src/components/cors/__tests__/CorsSection.test.tsx +++ b/frontend/packages/configure-settings/src/components/cors/__tests__/CorsSection.test.tsx @@ -7,15 +7,19 @@ import {renderWithProviders} from '@thunderid/test-utils'; import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import type {CorsConfigResponse} from '../../../models/responses'; +const mockRefetch = vi.fn(); const mockUseGetCorsConfig = - vi.fn<() => {data: CorsConfigResponse | undefined; isLoading: boolean; error: Error | null}>(); + vi.fn<() => {data: CorsConfigResponse | undefined; isLoading: boolean; error: Error | null; refetch?: () => void}>(); vi.mock('../../../api/useGetCorsConfig', () => ({ - default: () => mockUseGetCorsConfig(), + default: () => ({refetch: mockRefetch, ...mockUseGetCorsConfig()}), })); const mockMutate = vi.fn(); +const mockReset = vi.fn(); +// The save-failure surface reads the mutation's own error state, so each test declares it. +let updateState: {isError: boolean; error: Error | null} = {isError: false, error: null}; vi.mock('../../../api/useUpdateCorsConfig', () => ({ - default: () => ({mutate: mockMutate, isPending: false}), + default: () => ({mutate: mockMutate, isPending: false, reset: mockReset, ...updateState}), })); const {default: CorsSection} = await import('../CorsSection'); @@ -33,6 +37,9 @@ describe('CorsSection', () => { beforeEach(() => { mockUseGetCorsConfig.mockReset(); mockMutate.mockReset(); + mockReset.mockReset(); + mockRefetch.mockReset(); + updateState = {isError: false, error: null}; }); afterEach(() => { @@ -51,6 +58,16 @@ describe('CorsSection', () => { expect(screen.getByRole('alert')).toBeInTheDocument(); }); + it('refetches when the load error is retried', async () => { + const user = userEvent.setup(); + mockUseGetCorsConfig.mockReturnValue({data: undefined, isLoading: false, error: new Error('load failed')}); + renderWithProviders(); + + await user.click(screen.getByRole('button', {name: /refresh/i})); + + expect(mockRefetch).toHaveBeenCalled(); + }); + it('renders read-only origins (incl. regex patterns), editable origins, and the Add control', () => { mockUseGetCorsConfig.mockReturnValue({ data: makeData({readOnly: {allowedOrigins: ['https://console.example.com', {regex: '^https://x$'}]}}), @@ -67,6 +84,75 @@ describe('CorsSection', () => { expect(screen.getByRole('button', {name: 'Remove origin'})).toBeInTheDocument(); }); + it('preselects each row type from the saved entry shape', () => { + mockUseGetCorsConfig.mockReturnValue({ + data: makeData({ + readOnly: {allowedOrigins: [{regex: '^https://ro\\.io$'}]}, + writable: {allowedOrigins: ['https://app.acme.com', {regex: '^https://rw\\.io$'}]}, + }), + isLoading: false, + error: null, + }); + renderWithProviders(); + + const [readOnlyType, originType, regexType] = screen.getAllByRole('combobox', {name: 'Entry type'}); + expect(readOnlyType).toHaveTextContent('Regex'); + expect(originType).toHaveTextContent('Origin'); + expect(regexType).toHaveTextContent('Regex'); + }); + + it('locks the type selector on read-only rows and offers no remove action for them', () => { + mockUseGetCorsConfig.mockReturnValue({ + data: makeData({readOnly: {allowedOrigins: ['https://console.example.com']}, writable: {allowedOrigins: []}}), + isLoading: false, + error: null, + }); + renderWithProviders(); + + expect(screen.getByRole('combobox', {name: 'Entry type'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.queryByRole('button', {name: 'Remove origin'})).toBeNull(); + }); + + it('saves a row switched to Regex as a {regex} entry, even when its text is a valid origin', async () => { + const user = userEvent.setup(); + mockUseGetCorsConfig.mockReturnValue({ + data: makeData({readOnly: {allowedOrigins: []}, writable: {allowedOrigins: ['https://app.example.com']}}), + isLoading: false, + error: null, + }); + renderWithProviders(); + + await user.click(screen.getByRole('combobox', {name: 'Entry type'})); + await user.click(screen.getByRole('option', {name: 'Regex'})); + + await user.click(await screen.findByRole('button', {name: 'Save changes'})); + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({data: {allowedOrigins: [{regex: 'https://app.example.com'}]}}), + expect.anything(), + ); + }); + + it('does not save an origin row that carries a path', async () => { + const user = userEvent.setup(); + mockUseGetCorsConfig.mockReturnValue({ + data: makeData({readOnly: {allowedOrigins: []}, writable: {allowedOrigins: []}}), + isLoading: false, + error: null, + }); + renderWithProviders(); + + await user.click(screen.getByRole('button', {name: 'Add origin'})); + // Changed without blurring, so the row error is not visible yet and Save stays enabled. This + // used to be silently promoted to a regex rather than rejected. + fireEvent.change(screen.getByPlaceholderText('https://app.example.com'), { + target: {value: 'https://example.com/path'}, + }); + fireEvent.click(screen.getByRole('button', {name: 'Save changes'})); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + it('removes an editable origin when its delete button is clicked', async () => { const user = userEvent.setup(); mockUseGetCorsConfig.mockReturnValue({ @@ -142,6 +228,50 @@ describe('CorsSection', () => { expect(mockMutate).not.toHaveBeenCalled(); }); + it('reverts the draft when the unsaved bar is reset', async () => { + const user = userEvent.setup(); + mockUseGetCorsConfig.mockReturnValue({ + data: makeData({readOnly: {allowedOrigins: []}, writable: {allowedOrigins: ['https://app.acme.com']}}), + isLoading: false, + error: null, + }); + renderWithProviders(); + + await user.click(screen.getByRole('button', {name: 'Add origin'})); + // Every editable row carries the placeholder, so address the row that was just added. + const addedField = screen.getAllByPlaceholderText('https://app.example.com').at(-1)!; + await user.type(addedField, 'https://new.example.com'); + await user.click(await screen.findByRole('button', {name: 'Reset'})); + + expect(screen.queryByDisplayValue('https://new.example.com')).toBeNull(); + expect(screen.getByDisplayValue('https://app.acme.com')).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Save changes'})).toBeNull(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it('surfaces a save failure on the unsaved bar and clears it once the draft changes again', async () => { + const user = userEvent.setup(); + updateState = {isError: true, error: new Error('save failed')}; + mockUseGetCorsConfig.mockReturnValue({ + data: makeData({readOnly: {allowedOrigins: []}, writable: {allowedOrigins: []}}), + isLoading: false, + error: null, + }); + renderWithProviders(); + + await user.click(screen.getByRole('button', {name: 'Add origin'})); + await user.type(screen.getByPlaceholderText('https://app.example.com'), 'https://new.example.com'); + + // The bar renders the resolved catalog message, never the server's own error text. + const bar = await screen.findByText('Failed to update allowed origins.'); + expect(bar).toBeInTheDocument(); + expect(screen.queryByText('save failed')).toBeNull(); + + // Editing again invalidates the stale failure, so the mutation state is reset. + await user.type(screen.getByDisplayValue('https://new.example.com'), 'x'); + expect(mockReset).toHaveBeenCalled(); + }); + it('blocks Save when a row is a duplicate', async () => { const user = userEvent.setup(); mockUseGetCorsConfig.mockReturnValue({ diff --git a/frontend/packages/configure-settings/src/hooks/__tests__/useAllowedOriginsDraft.test.ts b/frontend/packages/configure-settings/src/hooks/__tests__/useAllowedOriginsDraft.test.ts index 3bd1eadbd4..4e3ed492c3 100644 --- a/frontend/packages/configure-settings/src/hooks/__tests__/useAllowedOriginsDraft.test.ts +++ b/frontend/packages/configure-settings/src/hooks/__tests__/useAllowedOriginsDraft.test.ts @@ -4,6 +4,7 @@ import {act} from '@testing-library/react'; import {renderHook} from '@thunderid/test-utils'; import {describe, it, expect} from 'vitest'; +import {AllowedOriginTypes} from '../../models/allowedOriginRow'; import type {AllowedOrigin, CorsConfigResponse} from '../../models/responses'; import useAllowedOriginsDraft from '../useAllowedOriginsDraft'; @@ -15,12 +16,20 @@ function makeData(readOnly: AllowedOrigin[], writable: AllowedOrigin[]): CorsCon }; } +/** The typed shape of each row, with the generated id dropped so assertions stay readable. */ +function shapeOf(draft: {type: string; value: string}[]): {type: string; value: string}[] { + return draft.map(({type, value}) => ({type, value})); +} + describe('useAllowedOriginsDraft', () => { - it('loads writable entries as editable rows (strings as-is, regex as its pattern)', () => { + it('loads each writable entry with the type its wire shape declares', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.acme.com', {regex: '^https://[a-z]+\\.acme\\.io$'}])), ); - expect(result.current.draft).toEqual(['https://app.acme.com', '^https://[a-z]+\\.acme\\.io$']); + expect(shapeOf(result.current.draft)).toEqual([ + {type: AllowedOriginTypes.ORIGIN, value: 'https://app.acme.com'}, + {type: AllowedOriginTypes.REGEX, value: '^https://[a-z]+\\.acme\\.io$'}, + ]); expect(result.current.dirty).toBe(false); expect(result.current.hasErrors).toBe(false); }); @@ -29,44 +38,67 @@ describe('useAllowedOriginsDraft', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.acme.com']))); act(() => result.current.addRow()); expect(result.current.dirty).toBe(false); - act(() => result.current.changeRow(1, 'https://new.example.com')); + act(() => result.current.changeRow(result.current.draft[1].id, 'https://new.example.com')); expect(result.current.dirty).toBe(true); }); + it('adds a literal origin row, not a regex one', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], []))); + act(() => result.current.addRow()); + expect(result.current.draft[0].type).toBe(AllowedOriginTypes.ORIGIN); + }); + it('removing a row marks the draft dirty', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.acme.com', 'https://other.acme.com'])), ); - act(() => result.current.removeRow(0)); + act(() => result.current.removeRow(result.current.draft[0].id)); expect(result.current.dirty).toBe(true); - expect(result.current.draft).toEqual(['https://other.acme.com']); + expect(shapeOf(result.current.draft)).toEqual([{type: AllowedOriginTypes.ORIGIN, value: 'https://other.acme.com'}]); }); it('reset clears local edits and reverts to the saved value', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.acme.com']))); - act(() => result.current.changeRow(0, 'https://changed.example.com')); + act(() => result.current.changeRow(result.current.draft[0].id, 'https://changed.example.com')); expect(result.current.dirty).toBe(true); act(() => result.current.reset()); expect(result.current.dirty).toBe(false); - expect(result.current.draft).toEqual(['https://app.acme.com']); + expect(shapeOf(result.current.draft)).toEqual([{type: AllowedOriginTypes.ORIGIN, value: 'https://app.acme.com'}]); }); - it('normalizes a row on blur (lowercase + trailing slash), preserving an explicit port', () => { + it('normalizes an origin row on blur (lowercase + trailing slash), preserving an explicit port', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.acme.com']))); - act(() => result.current.changeRow(0, 'HTTPS://Example.COM:443/')); - act(() => result.current.blurRow(0)); - expect(result.current.draft[0]).toBe('https://example.com:443'); + act(() => result.current.changeRow(result.current.draft[0].id, 'HTTPS://Example.COM:443/')); + act(() => result.current.blurRow(result.current.draft[0].id)); + expect(result.current.draft[0].value).toBe('https://example.com:443'); expect(result.current.hasErrors).toBe(false); }); - it('flags a row that is neither a valid origin nor a compilable regex', () => { - const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['(bad']))); + it('leaves a regex row untouched on blur, even when its text parses as an origin', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: 'https://APP.example.com/'}]))); + act(() => result.current.blurRow(result.current.draft[0].id)); + // Casing is significant to the matcher and `/` is an ordinary regex character, so neither may be rewritten. + expect(result.current.draft[0].value).toBe('https://APP.example.com/'); + }); + + it('flags an origin row whose value is not a valid origin, instead of treating it as a pattern', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://example.com/path']))); let ok = true; act(() => { ok = result.current.validateAll(); }); expect(ok).toBe(false); - expect(result.current.errors[0]).toBeTruthy(); + expect(result.current.errors[result.current.draft[0].id]).toBeTruthy(); + }); + + it('flags a regex row whose pattern does not compile', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: '(bad'}]))); + let ok = true; + act(() => { + ok = result.current.validateAll(); + }); + expect(ok).toBe(false); + expect(result.current.errors[result.current.draft[0].id]).toBeTruthy(); }); it('accepts both a valid origin and a valid regex row', () => { @@ -85,27 +117,29 @@ describe('useAllowedOriginsDraft', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://dup.example.com', 'https://dup.example.com'])), ); + const [first, second] = result.current.draft; act(() => { result.current.validateAll(); }); - expect(result.current.errors[0]).toBeTruthy(); - expect(result.current.errors[1]).toBeTruthy(); + expect(result.current.errors[first.id]).toBeTruthy(); + expect(result.current.errors[second.id]).toBeTruthy(); - act(() => result.current.removeRow(1)); - expect(result.current.errors[0]).toBeUndefined(); + act(() => result.current.removeRow(second.id)); + expect(result.current.errors[first.id]).toBeUndefined(); }); it('clears a duplicate error when the counterpart is edited to a unique value', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://dup.example.com', 'https://dup.example.com'])), ); + const [first, second] = result.current.draft; act(() => { result.current.validateAll(); }); - expect(result.current.errors[0]).toBeTruthy(); + expect(result.current.errors[first.id]).toBeTruthy(); - act(() => result.current.changeRow(1, 'https://unique.example.com')); - expect(result.current.errors[0]).toBeUndefined(); + act(() => result.current.changeRow(second.id, 'https://unique.example.com')); + expect(result.current.errors[first.id]).toBeUndefined(); }); it('treats a default port as distinct from the port-less origin (no false duplicate)', () => { @@ -125,7 +159,7 @@ describe('useAllowedOriginsDraft', () => { act(() => { result.current.validateAll(); }); - expect(result.current.errors[0]).toBeTruthy(); + expect(result.current.errors[result.current.draft[0].id]).toBeTruthy(); }); it('flags a custom regex that duplicates a read-only regex', () => { @@ -137,11 +171,83 @@ describe('useAllowedOriginsDraft', () => { act(() => { result.current.validateAll(); }); - expect(result.current.errors[0]).toBeTruthy(); + expect(result.current.errors[result.current.draft[0].id]).toBeTruthy(); + }); + + it('does not treat a read-only regex as a duplicate of a writable origin with the same text', () => { + const {result} = renderHook(() => + useAllowedOriginsDraft(makeData([{regex: 'https://shared.example.com'}], ['https://shared.example.com'])), + ); + act(() => { + result.current.validateAll(); + }); + expect(result.current.hasErrors).toBe(false); + }); + + describe('changeRowType', () => { + it('keeps the text the row already holds', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.example.com']))); + act(() => result.current.changeRowType(result.current.draft[0].id, AllowedOriginTypes.REGEX)); + expect(shapeOf(result.current.draft)).toEqual([ + {type: AllowedOriginTypes.REGEX, value: 'https://app.example.com'}, + ]); + }); + + it('re-canonicalizes for the new type, so a pattern-cased value becomes a valid origin', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: 'HTTPS://X.COM/'}]))); + act(() => result.current.changeRowType(result.current.draft[0].id, AllowedOriginTypes.ORIGIN)); + expect(result.current.draft[0].value).toBe('https://x.com'); + expect(result.current.hasErrors).toBe(false); + }); + + it('validates immediately, without waiting for a blur', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: '^https://x\\.io$'}]))); + act(() => result.current.changeRowType(result.current.draft[0].id, AllowedOriginTypes.ORIGIN)); + expect(result.current.errors[result.current.draft[0].id]).toBeTruthy(); + }); + + it('marks the draft dirty even when only the type changed', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.example.com']))); + act(() => result.current.changeRowType(result.current.draft[0].id, AllowedOriginTypes.REGEX)); + expect(result.current.dirty).toBe(true); + }); + + it('retypes only the named row, leaving its siblings as they are', () => { + const {result} = renderHook(() => + useAllowedOriginsDraft(makeData([], ['HTTPS://First.example.com/', 'https://second.example.com'])), + ); + act(() => result.current.changeRowType(result.current.draft[1].id, AllowedOriginTypes.REGEX)); + // The untouched row keeps the text it was loaded with, rather than being re-canonicalized too. + expect(shapeOf(result.current.draft)).toEqual([ + {type: AllowedOriginTypes.ORIGIN, value: 'HTTPS://First.example.com/'}, + {type: AllowedOriginTypes.REGEX, value: 'https://second.example.com'}, + ]); + }); + }); + + describe('unanchored regex warning', () => { + it('warns without blocking the save', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: 'acme\\.io'}]))); + let ok = false; + act(() => { + ok = result.current.validateAll(); + }); + expect(ok).toBe(true); + expect(result.current.hasErrors).toBe(false); + expect(result.current.warnings[result.current.draft[0].id]).toBeTruthy(); + }); + + it('is absent for an anchored pattern', () => { + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: '^https://x\\.io$'}]))); + act(() => { + result.current.validateAll(); + }); + expect(result.current.warnings[result.current.draft[0].id]).toBeUndefined(); + }); }); describe('buildPayload', () => { - it('classifies origins as strings and non-origins as regex entries, dropping empty rows', () => { + it('emits the wire shape each row declares, dropping empty rows', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['https://app.example.com', {regex: '^https://[a-z]+\\.example\\.com$'}])), ); @@ -160,10 +266,16 @@ describe('useAllowedOriginsDraft', () => { it('round-trips a loaded regex entry back to a {regex} entry', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: '^https://x\\.io$'}]))); - expect(result.current.draft).toEqual(['^https://x\\.io$']); + expect(result.current.draft[0].type).toBe(AllowedOriginTypes.REGEX); expect(result.current.buildPayload()).toEqual({allowedOrigins: [{regex: '^https://x\\.io$'}]}); }); + it('keeps a regex whose pattern is itself a valid origin as a {regex} entry', () => { + // A no-op save used to rewrite this to a literal, narrowing a substring match to an exact one. + const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], [{regex: 'https://example.com'}]))); + expect(result.current.buildPayload()).toEqual({allowedOrigins: [{regex: 'https://example.com'}]}); + }); + it('keeps the "null" literal as a string entry', () => { const {result} = renderHook(() => useAllowedOriginsDraft(makeData([], ['null']))); expect(result.current.buildPayload()).toEqual({allowedOrigins: ['null']}); diff --git a/frontend/packages/configure-settings/src/hooks/useAllowedOriginsDraft.ts b/frontend/packages/configure-settings/src/hooks/useAllowedOriginsDraft.ts index 62688967e2..7f4f612d97 100644 --- a/frontend/packages/configure-settings/src/hooks/useAllowedOriginsDraft.ts +++ b/frontend/packages/configure-settings/src/hooks/useAllowedOriginsDraft.ts @@ -3,10 +3,15 @@ import {useCallback, useMemo, useState} from 'react'; import {useTranslation} from 'react-i18next'; -import type {AllowedOrigin, CorsConfigResponse, CorsValue} from '../models/responses'; +import {AllowedOriginTypes, type AllowedOriginDraftRow, type AllowedOriginType} from '../models/allowedOriginRow'; +import type {CorsConfigResponse, CorsValue} from '../models/responses'; +import {createRow, normalizeRowValue, rowKey, toAllowedOrigins, toRows} from '../utils/allowedOriginRows'; import baselineKey from '../utils/baselineKey'; -import {isValidOrigin, isValidRegex, normalizeOrigin} from '../utils/origin'; -import originValueText from '../utils/originValueText'; +import validateAllowedOriginRows, { + AllowedOriginRowIssueFallbacks, + type AllowedOriginRowError, + type AllowedOriginRowWarning, +} from '../utils/validateAllowedOriginRows'; /** * The editable draft of writable CORS origins returned by {@link useAllowedOriginsDraft}. @@ -14,34 +19,38 @@ import originValueText from '../utils/originValueText'; * @public */ export interface AllowedOriginsDraft { - /** Editable rows. Each is a literal origin (incl. `"null"`) or a regex pattern, kept as text. */ - draft: string[]; - /** Per-row validation/duplicate error messages, keyed by draft index. */ - errors: Record; - /** Whether the normalized draft differs from the saved baseline. */ + /** Editable rows, each carrying the type the admin chose. */ + draft: AllowedOriginDraftRow[]; + /** Blocking validation and duplicate messages, keyed by row id. */ + errors: Record; + /** Non-blocking cautions, keyed by row id. */ + warnings: Record; + /** Whether the draft differs from the saved baseline. */ dirty: boolean; - /** Whether any row currently has a validation/duplicate error. */ + /** Whether any row currently has a blocking error. */ hasErrors: boolean; - /** Appends an empty editable row. */ + /** Appends an empty literal-origin row. */ addRow: () => void; - /** Removes the row at the given index and re-validates the remaining rows. */ - removeRow: (index: number) => void; - /** Updates the row at the given index; its own error stays hidden until blur. */ - changeRow: (index: number, value: string) => void; - /** Normalizes and validates the row at the given index. */ - blurRow: (index: number) => void; + /** Removes the row with the given id and re-validates the remaining rows. */ + removeRow: (id: string) => void; + /** Updates a row's value; its own messages stay hidden until blur. */ + changeRow: (id: string, value: string) => void; + /** Switches a row between a literal origin and a regex, keeping the text it already holds. */ + changeRowType: (id: string, type: AllowedOriginType) => void; + /** Normalizes and validates the row with the given id. */ + blurRow: (id: string) => void; /** Clears local edits, reverting to the saved server value (used by Reset and after a save). */ reset: () => void; - /** Validates every row, sets errors, and returns whether the draft is savable. */ + /** Validates every row, sets messages, and returns whether the draft is savable. */ validateAll: () => boolean; - /** Builds the PUT body, classifying each row as a literal origin or a `{regex}` entry. */ + /** Builds the PUT body from each row's declared type. */ buildPayload: () => CorsValue; } /** * Manages the editable draft of writable CORS origins as a local overlay over the server value, so a - * background refetch does not clobber in-progress edits. On save, each row is classified as a literal - * origin or a `{regex}` entry. + * background refetch does not clobber in-progress edits. Each row's type comes from the saved entry's + * shape and is then the admin's to change, so nothing is ever reclassified from its text. * * @param data - The fetched CORS config, or `undefined` while loading * @returns The draft state and operations: add/remove/edit, validation, dirty tracking, and payload building @@ -50,114 +59,144 @@ export interface AllowedOriginsDraft { */ export default function useAllowedOriginsDraft(data: CorsConfigResponse | undefined): AllowedOriginsDraft { const {t} = useTranslation(); - const [editedDraft, setEditedDraft] = useState(undefined); - const [errors, setErrors] = useState>({}); + const [editedDraft, setEditedDraft] = useState(undefined); + const [errors, setErrors] = useState>({}); + const [warnings, setWarnings] = useState>({}); - const savedValues = useMemo(() => (data?.writable.allowedOrigins ?? []).map(originValueText), [data]); - const readOnlyNormalized = useMemo( - () => (data?.readOnly.allowedOrigins ?? []).map(originValueText).map(normalizeOrigin), - [data], + // Both memos key on the serialized entries rather than on `data`: building rows mints new ids, so + // a refetch returning an equal value would otherwise remount every untouched field. + const savedEntriesKey = JSON.stringify(data?.writable.allowedOrigins ?? []); + const readOnlyEntriesKey = JSON.stringify(data?.readOnly.allowedOrigins ?? []); + + const savedRows = useMemo( + () => toRows(data?.writable.allowedOrigins ?? []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [savedEntriesKey], + ); + const readOnlyKeys = useMemo>( + () => new Set(toRows(data?.readOnly.allowedOrigins ?? []).map(rowKey)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [readOnlyEntriesKey], ); - const draft = editedDraft ?? savedValues; + const draft = editedDraft ?? savedRows; - const computeErrors = useCallback( - (rows: string[]): Record => { - const normalized = rows.map(normalizeOrigin); - const counts = new Map(); - normalized.forEach((value) => { - if (value !== '') { - counts.set(value, (counts.get(value) ?? 0) + 1); - } - }); - const readOnlySet = new Set(readOnlyNormalized); - const nextErrors: Record = {}; - normalized.forEach((value, index) => { - if (value === '') { - return; - } - if (!isValidOrigin(value) && !isValidRegex(value)) { - nextErrors[index] = t('settings:cors.validation.invalid'); - } else if ((counts.get(value) ?? 0) > 1 || readOnlySet.has(value)) { - nextErrors[index] = t('settings:cors.validation.duplicate'); - } - }); - return nextErrors; + /** + * Validates rows and resolves each issue code to a localized message. Codes are resolved + * dynamically, so every lookup carries the code's own default copy as its fallback. + */ + const computeIssues = useCallback( + (rows: AllowedOriginDraftRow[]): {errors: Record; warnings: Record} => { + const issues = validateAllowedOriginRows(rows, readOnlyKeys); + const resolve = ( + codes: Record, + ): Record => + Object.fromEntries( + Object.entries(codes).map(([id, code]) => [ + id, + t(`settings:cors.validation.${code}`, AllowedOriginRowIssueFallbacks[code]), + ]), + ); + return {errors: resolve(issues.errors), warnings: resolve(issues.warnings)}; + }, + [readOnlyKeys, t], + ); + + /** + * Publishes the messages for a set of rows, optionally keeping one row quiet. The row being typed + * into is silenced until it is blurred, so a half-typed origin is not reported as invalid. + */ + const applyIssues = useCallback( + (rows: AllowedOriginDraftRow[], quietRowId?: string): void => { + const issues = computeIssues(rows); + if (quietRowId !== undefined) { + delete issues.errors[quietRowId]; + delete issues.warnings[quietRowId]; + } + setErrors(issues.errors); + setWarnings(issues.warnings); }, - [readOnlyNormalized, t], + [computeIssues], ); const addRow = useCallback((): void => { - setEditedDraft([...draft, '']); + setEditedDraft([...draft, createRow(AllowedOriginTypes.ORIGIN)]); }, [draft]); const removeRow = useCallback( - (index: number): void => { - const next = draft.filter((_, i) => i !== index); + (id: string): void => { + const next = draft.filter((row) => row.id !== id); setEditedDraft(next); // Removing a row can clear duplicate errors on the remaining rows. - setErrors(computeErrors(next)); + applyIssues(next); }, - [draft, computeErrors], + [draft, applyIssues], ); const changeRow = useCallback( - (index: number, value: string): void => { - const next = [...draft]; - next[index] = value; + (id: string, value: string): void => { + const next = draft.map((row) => (row.id === id ? {...row, value} : row)); + setEditedDraft(next); + // Keep the active row quiet until blur, while clearing stale messages on other rows. + applyIssues(next, id); + }, + [draft, applyIssues], + ); + + const changeRowType = useCallback( + (id: string, type: AllowedOriginType): void => { + // Switching type is a deliberate, discrete action rather than mid-typing, so the text carries + // over untouched, is re-canonicalized for the new type, and is validated straight away. + const next = draft.map((row) => { + if (row.id !== id) { + return row; + } + const retyped = {...row, type}; + return {...retyped, value: normalizeRowValue(retyped)}; + }); setEditedDraft(next); - // Keep the active row quiet until blur, while clearing stale errors on other rows. - const recomputed = computeErrors(next); - delete recomputed[index]; - setErrors(recomputed); + applyIssues(next); }, - [draft, computeErrors], + [draft, applyIssues], ); const blurRow = useCallback( - (index: number): void => { - const next = [...draft]; - next[index] = normalizeOrigin(next[index] ?? ''); + (id: string): void => { + const next = draft.map((row) => (row.id === id ? {...row, value: normalizeRowValue(row)} : row)); setEditedDraft(next); - setErrors(computeErrors(next)); + applyIssues(next); }, - [draft, computeErrors], + [draft, applyIssues], ); const reset = useCallback((): void => { setEditedDraft(undefined); setErrors({}); + setWarnings({}); }, []); const validateAll = useCallback((): boolean => { - const nextErrors = computeErrors(draft); - setErrors(nextErrors); - return Object.keys(nextErrors).length === 0; - }, [draft, computeErrors]); - - const buildPayload = useCallback((): CorsValue => { - const entries: AllowedOrigin[] = []; - draft.forEach((raw) => { - const value = normalizeOrigin(raw); - if (value === '') { - return; - } - entries.push(isValidOrigin(value) ? value : {regex: value}); - }); - return {allowedOrigins: entries}; - }, [draft]); + const issues = computeIssues(draft); + setErrors(issues.errors); + setWarnings(issues.warnings); + return Object.keys(issues.errors).length === 0; + }, [draft, computeIssues]); + + const buildPayload = useCallback((): CorsValue => ({allowedOrigins: toAllowedOrigins(draft)}), [draft]); - const dirty = useMemo(() => baselineKey(draft) !== baselineKey(savedValues), [draft, savedValues]); + const dirty = useMemo(() => baselineKey(draft) !== baselineKey(savedRows), [draft, savedRows]); const hasErrors: boolean = Object.keys(errors).length > 0; return { draft, errors, + warnings, dirty, hasErrors, addRow, removeRow, changeRow, + changeRowType, blurRow, reset, validateAll, diff --git a/frontend/packages/configure-settings/src/index.ts b/frontend/packages/configure-settings/src/index.ts index 3ef03d8e94..bb4d4f3177 100644 --- a/frontend/packages/configure-settings/src/index.ts +++ b/frontend/packages/configure-settings/src/index.ts @@ -7,17 +7,36 @@ export {default as useUpdateCorsConfig} from './api/useUpdateCorsConfig'; export type {UpdateCorsConfigVariables} from './api/useUpdateCorsConfig'; // Components +export {default as AllowedOriginRow} from './components/cors/AllowedOriginRow'; +export type {AllowedOriginRowProps} from './components/cors/AllowedOriginRow'; export {default as CorsSection} from './components/cors/CorsSection'; // Constants export {default as SettingsQueryKeys} from './constants/settings-query-keys'; // Models +export * from './models/allowedOriginRow'; export * from './models/responses'; // Pages export {default as SettingsPage} from './pages/SettingsPage'; // Utils +export { + createRow, + createRowId, + isRowEmpty, + normalizeRowValue, + rowKey, + toAllowedOrigins, + toRows, +} from './utils/allowedOriginRows'; +export {default as isRegexAnchored} from './utils/isRegexAnchored'; export {isValidOrigin, isValidRegex, normalizeOrigin} from './utils/origin'; export {default as originValueText} from './utils/originValueText'; +export {default as validateAllowedOriginRows, AllowedOriginRowIssueFallbacks} from './utils/validateAllowedOriginRows'; +export type { + AllowedOriginRowError, + AllowedOriginRowIssues, + AllowedOriginRowWarning, +} from './utils/validateAllowedOriginRows'; diff --git a/frontend/packages/configure-settings/src/models/allowedOriginRow.ts b/frontend/packages/configure-settings/src/models/allowedOriginRow.ts new file mode 100644 index 0000000000..b6d4ec37aa --- /dev/null +++ b/frontend/packages/configure-settings/src/models/allowedOriginRow.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The two shapes an allowed origin can take on the wire: a literal origin string or a `{regex}` + * entry. The admin picks this per row; it is never inferred from the text. + * + * @public + */ +export const AllowedOriginTypes = { + ORIGIN: 'origin', + REGEX: 'regex', +} as const; + +/** + * The type of a single allowed-origin row. + * + * @public + */ +export type AllowedOriginType = (typeof AllowedOriginTypes)[keyof typeof AllowedOriginTypes]; + +/** + * One editable allowed-origin row. + * + * @public + */ +export interface AllowedOriginDraftRow { + /** Stable client-only key for React and per-row error lookup. Never sent to the server. */ + id: string; + /** Whether this row is a literal origin or a regex pattern. */ + type: AllowedOriginType; + /** The origin or pattern text, without the decorative `/` delimiters shown for regex rows. */ + value: string; +} diff --git a/frontend/packages/configure-settings/src/utils/__tests__/allowedOriginRows.test.ts b/frontend/packages/configure-settings/src/utils/__tests__/allowedOriginRows.test.ts new file mode 100644 index 0000000000..d4f5190ee6 --- /dev/null +++ b/frontend/packages/configure-settings/src/utils/__tests__/allowedOriginRows.test.ts @@ -0,0 +1,107 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import {AllowedOriginTypes} from '../../models/allowedOriginRow'; +import {createRow, isRowEmpty, normalizeRowValue, rowKey, toAllowedOrigins, toRows} from '../allowedOriginRows'; + +describe('createRowId', () => { + it('gives every row a distinct id', () => { + expect(createRow().id).not.toBe(createRow().id); + }); +}); + +describe('toRows', () => { + it('takes each row type from the entry shape rather than the text', () => { + const rows = toRows(['https://app.example.com', {regex: '^https://x\\.io$'}]); + expect(rows.map(({type, value}) => ({type, value}))).toEqual([ + {type: AllowedOriginTypes.ORIGIN, value: 'https://app.example.com'}, + {type: AllowedOriginTypes.REGEX, value: '^https://x\\.io$'}, + ]); + }); + + it('keeps a regex whose pattern is a valid origin typed as a regex', () => { + expect(toRows([{regex: 'https://example.com'}])[0].type).toBe(AllowedOriginTypes.REGEX); + }); +}); + +describe('toAllowedOrigins', () => { + it('emits the wire shape each row declares', () => { + expect( + toAllowedOrigins([ + createRow(AllowedOriginTypes.ORIGIN, 'https://x.com'), + createRow(AllowedOriginTypes.REGEX, '^https://x\\.com$'), + ]), + ).toEqual(['https://x.com', {regex: '^https://x\\.com$'}]); + }); + + it('emits a regex entry for a regex row whose text is itself a valid origin', () => { + expect(toAllowedOrigins([createRow(AllowedOriginTypes.REGEX, 'https://x.com')])).toEqual([ + {regex: 'https://x.com'}, + ]); + }); + + it('emits a string entry for an origin row with the same text', () => { + expect(toAllowedOrigins([createRow(AllowedOriginTypes.ORIGIN, 'https://x.com')])).toEqual(['https://x.com']); + }); + + it('drops empty rows', () => { + expect(toAllowedOrigins([createRow(AllowedOriginTypes.ORIGIN, ' '), createRow(AllowedOriginTypes.REGEX)])).toEqual( + [], + ); + }); + + it('keeps the "null" literal as a string entry', () => { + expect(toAllowedOrigins([createRow(AllowedOriginTypes.ORIGIN, 'null')])).toEqual(['null']); + }); +}); + +describe('normalizeRowValue', () => { + it('lowercases an origin and strips its trailing slash', () => { + expect(normalizeRowValue(createRow(AllowedOriginTypes.ORIGIN, ' HTTPS://Example.COM/ '))).toBe( + 'https://example.com', + ); + }); + + it('preserves an explicit default port', () => { + expect(normalizeRowValue(createRow(AllowedOriginTypes.ORIGIN, 'https://example.com:443'))).toBe( + 'https://example.com:443', + ); + }); + + it('only trims a regex, leaving casing and slashes alone', () => { + expect(normalizeRowValue(createRow(AllowedOriginTypes.REGEX, ' https://APP.example.com/ '))).toBe( + 'https://APP.example.com/', + ); + }); +}); + +describe('rowKey', () => { + it('separates an origin from a regex with the same text', () => { + expect(rowKey(createRow(AllowedOriginTypes.ORIGIN, 'https://x.com'))).not.toBe( + rowKey(createRow(AllowedOriginTypes.REGEX, 'https://x.com')), + ); + }); + + it('matches two origins that differ only in case or a trailing slash', () => { + expect(rowKey(createRow(AllowedOriginTypes.ORIGIN, 'HTTPS://X.com/'))).toBe( + rowKey(createRow(AllowedOriginTypes.ORIGIN, 'https://x.com')), + ); + }); + + it('separates two regexes that differ only in case', () => { + expect(rowKey(createRow(AllowedOriginTypes.REGEX, 'https://X.com'))).not.toBe( + rowKey(createRow(AllowedOriginTypes.REGEX, 'https://x.com')), + ); + }); +}); + +describe('isRowEmpty', () => { + it.each([ + ['', true], + [' ', true], + ['https://x.com', false], + ])('reports %j as %s', (value, expected) => { + expect(isRowEmpty(createRow(AllowedOriginTypes.ORIGIN, value))).toBe(expected); + }); +}); diff --git a/frontend/packages/configure-settings/src/utils/__tests__/baselineKey.test.ts b/frontend/packages/configure-settings/src/utils/__tests__/baselineKey.test.ts index 9c8339dc0e..96b5b74564 100644 --- a/frontend/packages/configure-settings/src/utils/__tests__/baselineKey.test.ts +++ b/frontend/packages/configure-settings/src/utils/__tests__/baselineKey.test.ts @@ -2,18 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 import {describe, it, expect} from 'vitest'; +import {AllowedOriginTypes} from '../../models/allowedOriginRow'; +import {createRow} from '../allowedOriginRows'; import baselineKey from '../baselineKey'; +const origin = (value: string) => createRow(AllowedOriginTypes.ORIGIN, value); +const regex = (value: string) => createRow(AllowedOriginTypes.REGEX, value); + describe('baselineKey', () => { - it('is equal for inputs that normalize to the same origins', () => { - expect(baselineKey(['HTTPS://App.IO/', ''])).toBe(baselineKey(['https://app.io'])); + it('is equal for rows that normalize to the same entries', () => { + expect(baselineKey([origin('HTTPS://App.IO/'), origin('')])).toBe(baselineKey([origin('https://app.io')])); + }); + + it('ignores row ids, which are client-only', () => { + expect(baselineKey([origin('https://a.io')])).toBe(baselineKey([origin('https://a.io')])); + }); + + it('differs when the values differ', () => { + expect(baselineKey([origin('https://a.io')])).not.toBe(baselineKey([origin('https://b.io')])); }); - it('differs when the origins differ', () => { - expect(baselineKey(['https://a.io'])).not.toBe(baselineKey(['https://b.io'])); + it('differs when only the type changed', () => { + expect(baselineKey([origin('https://a.io')])).not.toBe(baselineKey([regex('https://a.io')])); }); it('is order-sensitive', () => { - expect(baselineKey(['https://a.io', 'https://b.io'])).not.toBe(baselineKey(['https://b.io', 'https://a.io'])); + expect(baselineKey([origin('https://a.io'), origin('https://b.io')])).not.toBe( + baselineKey([origin('https://b.io'), origin('https://a.io')]), + ); }); }); diff --git a/frontend/packages/configure-settings/src/utils/__tests__/isRegexAnchored.test.ts b/frontend/packages/configure-settings/src/utils/__tests__/isRegexAnchored.test.ts new file mode 100644 index 0000000000..e634c951d9 --- /dev/null +++ b/frontend/packages/configure-settings/src/utils/__tests__/isRegexAnchored.test.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import isRegexAnchored from '../isRegexAnchored'; + +describe('isRegexAnchored', () => { + // Mirrors the backend's own anchor check, which decides whether it logs a warning for an entry. + it.each([ + ['^https://x\\.io$', true], + ['\\Ahttps://x\\.io\\z', true], + ['^https://x\\.io\\z', true], + ['^https://x\\.io', false], + ['https://x\\.io$', false], + ['https://x\\.io', false], + ['', false], + ])('reports %j as %s', (pattern, expected) => { + expect(isRegexAnchored(pattern)).toBe(expected); + }); + + it('ignores surrounding whitespace', () => { + expect(isRegexAnchored(' ^https://x\\.io$ ')).toBe(true); + }); + + // An escaped anchor is literal text, so the pattern still searches rather than matching in full. + it.each([ + ['^https://x\\.io\\$', false], + ['^https://x\\.io\\\\$', true], + ['^https://x\\.io\\\\\\$', false], + ['\\Ahttps://x\\.io\\\\z', false], + ['^\\$', false], + ['^\\\\$', true], + ])('reports %j as %s, reading the backslashes before the anchor', (pattern, expected) => { + expect(isRegexAnchored(pattern)).toBe(expected); + }); +}); diff --git a/frontend/packages/configure-settings/src/utils/__tests__/normalizedNonEmpty.test.ts b/frontend/packages/configure-settings/src/utils/__tests__/normalizedNonEmpty.test.ts deleted file mode 100644 index 8a162bdc00..0000000000 --- a/frontend/packages/configure-settings/src/utils/__tests__/normalizedNonEmpty.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {describe, it, expect} from 'vitest'; -import normalizedNonEmpty from '../normalizedNonEmpty'; - -describe('normalizedNonEmpty', () => { - it('normalizes each value (lowercase + trailing slash) and preserves order', () => { - expect(normalizedNonEmpty(['HTTPS://Example.COM/', 'https://app.io'])).toEqual([ - 'https://example.com', - 'https://app.io', - ]); - }); - - it('drops empty and whitespace-only entries', () => { - expect(normalizedNonEmpty(['', ' ', 'https://app.io'])).toEqual(['https://app.io']); - }); - - it('returns an empty array when there are no non-empty values', () => { - expect(normalizedNonEmpty(['', ' '])).toEqual([]); - }); -}); diff --git a/frontend/packages/configure-settings/src/utils/__tests__/validateAllowedOriginRows.test.ts b/frontend/packages/configure-settings/src/utils/__tests__/validateAllowedOriginRows.test.ts new file mode 100644 index 0000000000..1edfb306f9 --- /dev/null +++ b/frontend/packages/configure-settings/src/utils/__tests__/validateAllowedOriginRows.test.ts @@ -0,0 +1,89 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import {AllowedOriginTypes} from '../../models/allowedOriginRow'; +import {createRow, rowKey} from '../allowedOriginRows'; +import validateAllowedOriginRows from '../validateAllowedOriginRows'; + +describe('validateAllowedOriginRows', () => { + it('accepts a valid origin and a valid anchored regex', () => { + const issues = validateAllowedOriginRows([ + createRow(AllowedOriginTypes.ORIGIN, 'https://app.example.com'), + createRow(AllowedOriginTypes.REGEX, '^https://x\\.io$'), + ]); + expect(issues.errors).toEqual({}); + expect(issues.warnings).toEqual({}); + }); + + it.each([ + ['a path', 'https://example.com/path'], + ['a query string', 'https://example.com?x=1'], + ['a fragment', 'https://example.com#frag'], + ['a wildcard host', 'https://*.example.com'], + ['no scheme', 'example.com'], + ])('rejects an origin row with %s instead of accepting it as a pattern', (_label, value) => { + const row = createRow(AllowedOriginTypes.ORIGIN, value); + expect(validateAllowedOriginRows([row]).errors[row.id]).toBe('invalidOrigin'); + }); + + it('rejects a regex row whose pattern does not compile', () => { + const row = createRow(AllowedOriginTypes.REGEX, '(bad'); + expect(validateAllowedOriginRows([row]).errors[row.id]).toBe('invalidRegex'); + }); + + it('accepts a regex row whose pattern would be an invalid origin', () => { + const row = createRow(AllowedOriginTypes.REGEX, '^https://.*\\.example\\.com/path$'); + expect(validateAllowedOriginRows([row]).errors[row.id]).toBeUndefined(); + }); + + it('flags rows that repeat the same entry', () => { + const rows = [ + createRow(AllowedOriginTypes.ORIGIN, 'https://dup.example.com'), + createRow(AllowedOriginTypes.ORIGIN, 'HTTPS://Dup.example.com/'), + ]; + const {errors} = validateAllowedOriginRows(rows); + expect(errors[rows[0].id]).toBe('duplicate'); + expect(errors[rows[1].id]).toBe('duplicate'); + }); + + it('does not flag an origin and a regex that merely share the same text', () => { + const rows = [ + createRow(AllowedOriginTypes.ORIGIN, 'https://x.com'), + createRow(AllowedOriginTypes.REGEX, 'https://x.com'), + ]; + expect(validateAllowedOriginRows(rows).errors).toEqual({}); + }); + + it('flags a row that collides with an existing entry', () => { + const existing = new Set([rowKey(createRow(AllowedOriginTypes.ORIGIN, 'https://console.example.com'))]); + const row = createRow(AllowedOriginTypes.ORIGIN, 'https://console.example.com'); + expect(validateAllowedOriginRows([row], existing).errors[row.id]).toBe('duplicate'); + }); + + it('ignores empty rows entirely', () => { + const rows = [createRow(AllowedOriginTypes.ORIGIN, ' '), createRow(AllowedOriginTypes.REGEX, '')]; + const issues = validateAllowedOriginRows(rows); + expect(issues.errors).toEqual({}); + expect(issues.warnings).toEqual({}); + }); + + describe('unanchored regex warning', () => { + it('warns without producing an error', () => { + const row = createRow(AllowedOriginTypes.REGEX, 'acme\\.io'); + const issues = validateAllowedOriginRows([row]); + expect(issues.errors[row.id]).toBeUndefined(); + expect(issues.warnings[row.id]).toBe('unanchoredRegex'); + }); + + it('is not raised for a pattern that fails to compile', () => { + const row = createRow(AllowedOriginTypes.REGEX, '(bad'); + expect(validateAllowedOriginRows([row]).warnings[row.id]).toBeUndefined(); + }); + + it('is not raised for a literal origin row', () => { + const row = createRow(AllowedOriginTypes.ORIGIN, 'https://x.com'); + expect(validateAllowedOriginRows([row]).warnings[row.id]).toBeUndefined(); + }); + }); +}); diff --git a/frontend/packages/configure-settings/src/utils/allowedOriginRows.ts b/frontend/packages/configure-settings/src/utils/allowedOriginRows.ts new file mode 100644 index 0000000000..3e109d7161 Binary files /dev/null and b/frontend/packages/configure-settings/src/utils/allowedOriginRows.ts differ diff --git a/frontend/packages/configure-settings/src/utils/baselineKey.ts b/frontend/packages/configure-settings/src/utils/baselineKey.ts index 098d5f491c..384d35c6ce 100644 --- a/frontend/packages/configure-settings/src/utils/baselineKey.ts +++ b/frontend/packages/configure-settings/src/utils/baselineKey.ts @@ -1,14 +1,16 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import normalizedNonEmpty from './normalizedNonEmpty'; +import nonEmptyRowKeys from './nonEmptyRowKeys'; +import type {AllowedOriginDraftRow} from '../models/allowedOriginRow'; /** - * Builds a stable key for a set of origins, used to compare a draft against its saved baseline. + * Builds a stable key for a set of rows, used to compare a draft against its saved baseline. The key + * covers each row's type as well as its value, so changing only a row's type counts as a change. * - * @param values - The origin values to key - * @returns A stable string key derived from the normalized, non-empty origins + * @param rows - The rows to key + * @returns A stable string key derived from the non-empty rows */ -export default function baselineKey(values: string[]): string { - return JSON.stringify(normalizedNonEmpty(values)); +export default function baselineKey(rows: AllowedOriginDraftRow[]): string { + return JSON.stringify(nonEmptyRowKeys(rows)); } diff --git a/frontend/packages/configure-settings/src/utils/isRegexAnchored.ts b/frontend/packages/configure-settings/src/utils/isRegexAnchored.ts new file mode 100644 index 0000000000..6a599e2c11 --- /dev/null +++ b/frontend/packages/configure-settings/src/utils/isRegexAnchored.ts @@ -0,0 +1,42 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Reports whether a pattern is anchored at both ends. Mirrors the backend's `isRegexAnchored`, which + * logs a warning for unanchored entries: the matcher searches the raw `Origin` header rather than + * matching it in full, so an unanchored pattern also allows any origin that merely contains it. + * + * This is a textual check rather than a parse: `\A` and `\z` are RE2 anchors with no JavaScript + * equivalent, but a pattern using them is still anchored for the server that runs it. An anchor the + * pattern escapes into literal text does not count, so `^foo\$` is reported as unanchored. + * + * @param pattern - The regex pattern to test + * @returns Whether the pattern starts and ends with an anchor + * + * @public + */ +export default function isRegexAnchored(pattern: string): boolean { + const trimmed = pattern.trim(); + const starts = trimmed.startsWith('^') || trimmed.startsWith('\\A'); + return starts && (endsWithAnchor(trimmed, '$') || endsWithAnchor(trimmed, '\\z')); +} + +/** + * Reports whether a pattern ends with the given anchor as an anchor rather than as literal text. An + * odd number of backslashes in front of it escapes it, so `^foo\$` ends with a literal `$` and is not + * anchored, while `^foo\\$` ends with an escaped backslash followed by a real anchor. + * + * @param pattern - The trimmed pattern to test + * @param anchor - The end anchor to look for + * @returns Whether the pattern ends with an unescaped occurrence of the anchor + */ +function endsWithAnchor(pattern: string, anchor: '$' | '\\z'): boolean { + if (!pattern.endsWith(anchor)) { + return false; + } + let backslashes = 0; + for (let index = pattern.length - anchor.length - 1; pattern[index] === '\\'; index -= 1) { + backslashes += 1; + } + return backslashes % 2 === 0; +} diff --git a/frontend/packages/configure-settings/src/utils/nonEmptyRowKeys.ts b/frontend/packages/configure-settings/src/utils/nonEmptyRowKeys.ts new file mode 100644 index 0000000000..356acaaf62 --- /dev/null +++ b/frontend/packages/configure-settings/src/utils/nonEmptyRowKeys.ts @@ -0,0 +1,16 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {isRowEmpty, rowKey} from './allowedOriginRows'; +import type {AllowedOriginDraftRow} from '../models/allowedOriginRow'; + +/** + * Keys the non-empty rows for comparison. Row ids are deliberately excluded, so two lists holding + * the same entries compare equal even though their rows were minted separately. + * + * @param rows - The rows to key + * @returns One key per non-empty row, in order + */ +export default function nonEmptyRowKeys(rows: AllowedOriginDraftRow[]): string[] { + return rows.filter((row) => !isRowEmpty(row)).map(rowKey); +} diff --git a/frontend/packages/configure-settings/src/utils/normalizedNonEmpty.ts b/frontend/packages/configure-settings/src/utils/normalizedNonEmpty.ts deleted file mode 100644 index 15fba7fce0..0000000000 --- a/frontend/packages/configure-settings/src/utils/normalizedNonEmpty.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {normalizeOrigin} from './origin'; - -/** - * Normalizes each value and drops the empty ones. - * - * @param values - The raw origin values to normalize - * @returns The normalized origins with empty entries removed - */ -export default function normalizedNonEmpty(values: string[]): string[] { - return values.map(normalizeOrigin).filter((value) => value !== ''); -} diff --git a/frontend/packages/configure-settings/src/utils/validateAllowedOriginRows.ts b/frontend/packages/configure-settings/src/utils/validateAllowedOriginRows.ts new file mode 100644 index 0000000000..7dc6283739 --- /dev/null +++ b/frontend/packages/configure-settings/src/utils/validateAllowedOriginRows.ts @@ -0,0 +1,106 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {isRowEmpty, normalizeRowValue, rowKey} from './allowedOriginRows'; +import isRegexAnchored from './isRegexAnchored'; +import {isValidOrigin, isValidRegex} from './origin'; +import {AllowedOriginTypes, type AllowedOriginDraftRow} from '../models/allowedOriginRow'; + +/** + * A blocking problem with a row. + * + * @public + */ +export type AllowedOriginRowError = 'invalidOrigin' | 'invalidRegex' | 'duplicate'; + +/** + * A non-blocking caution about a row. + * + * @public + */ +export type AllowedOriginRowWarning = 'unanchoredRegex'; + +/** + * The English copy for each issue code, used as the positional fallback when resolving + * `settings:cors.validation.`. Codes are resolved dynamically, so a catalog that is missing one + * would otherwise render the raw key as helper text. Kept beside the codes so a new code cannot be + * added without its default copy. + * + * @public + */ +export const AllowedOriginRowIssueFallbacks: Record = { + invalidOrigin: + 'Enter a valid origin, e.g. https://app.example.com. Paths, query strings, and fragments are not allowed.', + invalidRegex: 'Enter a valid regular expression.', + duplicate: 'This entry is already in the list.', + unanchoredRegex: 'This pattern is not anchored with ^ and $, so it also matches any origin that merely contains it.', +}; + +/** + * Per-row problems, keyed by row id. + * + * @public + */ +export interface AllowedOriginRowIssues { + /** Problems that must be fixed before saving. */ + errors: Record; + /** Cautions that do not prevent saving. */ + warnings: Record; +} + +/** + * Validates rows against the rules for their declared type, and flags patterns the matcher would + * apply more broadly than they look. Returns codes rather than messages so each surface can resolve + * them in its own namespace. + * + * @param rows - The rows to validate + * @param existingKeys - Keys of entries the rows must not collide with, such as the read-only layer + * @returns The blocking errors and non-blocking warnings, keyed by row id + * + * @public + */ +export default function validateAllowedOriginRows( + rows: AllowedOriginDraftRow[], + existingKeys?: ReadonlySet, +): AllowedOriginRowIssues { + const seen = new Set(); + const repeated = new Set(); + rows.forEach((row) => { + if (!isRowEmpty(row)) { + const key = rowKey(row); + if (seen.has(key)) { + repeated.add(key); + } + seen.add(key); + } + }); + + const errors: Record = {}; + const warnings: Record = {}; + + rows.forEach((row) => { + if (isRowEmpty(row)) { + return; + } + const value = normalizeRowValue(row); + const key = rowKey(row); + const isRegex = row.type === AllowedOriginTypes.REGEX; + const compiles = isRegex && isValidRegex(value); + + if (isRegex && !compiles) { + errors[row.id] = 'invalidRegex'; + } else if (!isRegex && !isValidOrigin(value)) { + errors[row.id] = 'invalidOrigin'; + } else if (repeated.has(key) || existingKeys?.has(key)) { + errors[row.id] = 'duplicate'; + } + + // Anchoring is advisory and independent of the duplicate check, but there is nothing useful to + // say about the anchors of a pattern that does not compile. + if (compiles && !isRegexAnchored(value)) { + warnings[row.id] = 'unanchoredRegex'; + } + }); + + return {errors, warnings}; +} diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 3a0f36b6f7..2bd2227e35 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -2879,10 +2879,13 @@ const translations = { 'onboarding.configure.details.devServer.addToRedirect': 'Add it to redirect URIs', 'onboarding.configure.details.corsOrigins.title': 'CORS Allowed Origins', 'onboarding.configure.details.corsOrigins.description': - 'Origins allowed to make cross-origin requests to the token and userinfo endpoints.', + 'Origins allowed to make cross-origin requests to the token and userinfo endpoints. Each entry is either an exact origin or a regular expression.', 'onboarding.configure.details.corsOrigins.placeholder': 'https://example.com', + 'onboarding.configure.details.corsOrigins.regexPlaceholder': '^https://[a-z0-9-]+\\.example\\.com$', 'onboarding.configure.details.corsOrigins.addOrigin': 'Add Origin', - 'onboarding.configure.details.corsOrigins.error.invalid': 'Enter a valid origin, e.g. https://app.example.com.', + 'onboarding.configure.details.corsOrigins.removeOrigin': 'Remove Origin', + 'onboarding.configure.details.corsOrigins.saveError': + 'The application was created, but its allowed origins were not saved. Add them under Settings, CORS.', 'edit.general.allowedUserTypes.placeholder': 'Select user types', 'edit.general.allowedUserTypes.hint': 'Users of these types can sign up through this application', 'edit.general.applicationUrl.hint': 'The homepage URL of your application', @@ -5166,13 +5169,23 @@ const translations = { 'tabs.ariaLabel': 'Settings sections', 'tabs.cors': 'CORS', 'cors.card.title': 'Allowed origins', - 'cors.card.description': 'Manage which origins are allowed to access your APIs.', + 'cors.card.description': + 'Manage which origins are allowed to access your APIs. Each entry is either an exact origin or a regular expression.', 'cors.readOnlyHint': "Some origins are read-only because they're managed declaratively.", 'cors.addOrigin': 'Add origin', 'cors.originPlaceholder': 'https://app.example.com', + 'cors.regexPlaceholder': '^https://[a-z0-9-]+\\.example\\.com$', 'cors.removeOrigin': 'Remove origin', - 'cors.validation.invalid': 'Enter a valid origin (e.g. https://app.example.com) or a valid regular expression.', - 'cors.validation.duplicate': 'This origin is already in the list.', + 'cors.lockedOrigin': "Managed declaratively and can't be edited here.", + 'cors.type.label': 'Entry type', + 'cors.type.origin': 'Origin', + 'cors.type.regex': 'Regex', + 'cors.validation.invalidOrigin': + 'Enter a valid origin, e.g. https://app.example.com. Paths, query strings, and fragments are not allowed.', + 'cors.validation.invalidRegex': 'Enter a valid regular expression.', + 'cors.validation.unanchoredRegex': + 'This pattern is not anchored with ^ and $, so it also matches any origin that merely contains it.', + 'cors.validation.duplicate': 'This entry is already in the list.', 'cors.unsavedChanges': 'You have unsaved changes', 'cors.reset': 'Reset', 'cors.save': 'Save changes', diff --git a/tests/e2e/pages/settings/settings.page.ts b/tests/e2e/pages/settings/settings.page.ts index 2df69d44ca..a7beec1e79 100644 --- a/tests/e2e/pages/settings/settings.page.ts +++ b/tests/e2e/pages/settings/settings.page.ts @@ -4,7 +4,8 @@ /** * Settings Page Object Model (CORS allowed origins) * - * Encapsulates the Settings > CORS panel: adding/removing custom allowed origins and saving. + * Encapsulates the Settings > CORS panel: adding/removing custom allowed origins (as exact origins + * or as regex patterns) and saving. * * @example * const settingsPage = new SettingsPage(page, baseUrl); @@ -18,18 +19,17 @@ import { BasePage } from "../base.page"; import { UnsavedChangesBar } from "../components/unsaved-changes-bar"; import { Timeouts } from "../../constants/timeouts"; -// Matches the placeholder rendered on each editable (custom) origin input. -const ORIGIN_PLACEHOLDER = "https://app.example.com"; +// Marks each editable (custom) origin row. Read-only rows carry no test id. +const ROW_TEST_ID = "cors-origin-row"; export class SettingsPage extends BasePage { readonly baseUrl: string; readonly corsTab: Locator; readonly addOriginButton: Locator; - // Editable (custom) origin inputs and their delete buttons render in the same row order, - // so the Nth input aligns with the Nth remove button. Read-only rows carry neither. - readonly originInputs: Locator; - readonly removeButtons: Locator; + // Editable (custom) origin rows. Each row owns its own value field, type selector, and remove + // button, so controls are always resolved within a row rather than by position across the page. + readonly originRows: Locator; readonly unsavedChangesBar: UnsavedChangesBar; constructor(page: Page, baseUrl: string) { @@ -38,8 +38,7 @@ export class SettingsPage extends BasePage { this.corsTab = page.getByRole("tab", { name: /cors/i }); this.addOriginButton = page.getByRole("button", { name: /add origin/i }); - this.originInputs = page.getByPlaceholder(ORIGIN_PLACEHOLDER); - this.removeButtons = page.getByRole("button", { name: /remove origin/i }); + this.originRows = page.locator(`[data-componentid="${ROW_TEST_ID}"]`); // CorsSection passes saveLabel={t('settings:cors.save', 'Save changes')} and // resetLabel={t('settings:cors.reset', 'Reset')}. this.unsavedChangesBar = new UnsavedChangesBar(page, "Save changes", "Reset"); @@ -54,38 +53,62 @@ export class SettingsPage extends BasePage { await this.corsTab.first().waitFor({ state: "visible", timeout: Timeouts.ELEMENT_VISIBILITY }); } - /** Index of the editable row holding the given origin, or -1 if absent. */ - private async indexOfOrigin(origin: string): Promise { - const count = await this.originInputs.count(); - for (let i = 0; i < count; i++) { - if ((await this.originInputs.nth(i).inputValue()) === origin) { - return i; + /** + * The value field of the given editable row. Resolved by role rather than by position: the row's + * type selector renders its own `aria-hidden` native input ahead of this one, so the first `input` + * in the row is the selector's, not the field the test means to type into. + */ + private valueField(row: Locator): Locator { + return row.getByRole("textbox"); + } + + /** + * The editable row holding the given value, whether it is an exact origin or a pattern, or + * `undefined` when no row holds it. Values are compared as strings rather than interpolated into a + * selector, so a pattern's backslashes stay literal instead of being read as selector escapes. + */ + private async rowFor(value: string): Promise { + for (const row of await this.originRows.all()) { + if ((await this.valueField(row).inputValue()) === value) { + return row; } } - return -1; + return undefined; } - /** Whether a custom (editable) origin with the given value is currently listed. */ - async hasCustomOrigin(origin: string): Promise { - return (await this.indexOfOrigin(origin)) !== -1; + /** Whether a custom (editable) entry with the given value is currently listed. */ + async hasCustomOrigin(value: string): Promise { + return (await this.rowFor(value)) !== undefined; } /** Add a custom allowed origin and persist it. */ async addAllowedOrigin(origin: string) { await this.addOriginButton.click(); - const input = this.originInputs.last(); - await input.fill(origin); - await input.blur(); + const field = this.valueField(this.originRows.last()); + await field.fill(origin); + await field.blur(); + await this.unsavedChangesBar.save(); + } + + /** Add a custom allowed origin as a regex pattern and persist it. */ + async addAllowedOriginRegex(pattern: string) { + await this.addOriginButton.click(); + const row = this.originRows.last(); + await row.getByRole("combobox", { name: /entry type/i }).click(); + await this.page.getByRole("option", { name: /^regex$/i }).click(); + const field = this.valueField(row); + await field.fill(pattern); + await field.blur(); await this.unsavedChangesBar.save(); } - /** Remove a custom allowed origin (no-op if absent) and persist. */ - async removeAllowedOrigin(origin: string) { - const index = await this.indexOfOrigin(origin); - if (index === -1) { + /** Remove a custom allowed entry (no-op if absent) and persist. */ + async removeAllowedOrigin(value: string) { + const row = await this.rowFor(value); + if (row === undefined) { return; } - await this.removeButtons.nth(index).click(); + await row.getByRole("button", { name: /remove origin/i }).click(); await this.unsavedChangesBar.save(); } } diff --git a/tests/e2e/tests/settings/cors-allowed-origins.spec.ts b/tests/e2e/tests/settings/cors-allowed-origins.spec.ts index 875898e2bf..af084c44c4 100644 --- a/tests/e2e/tests/settings/cors-allowed-origins.spec.ts +++ b/tests/e2e/tests/settings/cors-allowed-origins.spec.ts @@ -23,6 +23,8 @@ const BASE_URL = process.env.BASE_URL || "https://localhost:8090"; const DISCOVERY_PATH = "/.well-known/openid-configuration"; // A fake origin dedicated to this test - it only needs to be a valid origin string const TEST_ORIGIN = "https://e2e-cors-probe.invalid"; +// The same origin expressed as an anchored pattern, for the regex entry type. +const TEST_ORIGIN_PATTERN = "^https://e2e-cors-probe\\.invalid$"; // The sample app's origin, configured by the imported deployment config. Editing allowed origins // through the console must never drop it, otherwise every later sample-app test loses its ability to // read cross-origin responses. @@ -43,15 +45,17 @@ async function corsAllowOriginHeader(page: Page, origin: string = TEST_ORIGIN): test.describe("Settings — CORS allowed origins", { tag: [TestTags.SMOKE] }, () => { test.beforeEach(async ({ settingsPage }) => { - // Ensure a clean starting state (origin not yet configured). + // Ensure a clean starting state (neither entry yet configured). await settingsPage.goto(); await settingsPage.removeAllowedOrigin(TEST_ORIGIN); + await settingsPage.removeAllowedOrigin(TEST_ORIGIN_PATTERN); }); test.afterEach(async ({ settingsPage }) => { - // Remove the origin added by the test so the shared deployment config stays clean. + // Remove the entries added by the test so the shared deployment config stays clean. await settingsPage.goto(); await settingsPage.removeAllowedOrigin(TEST_ORIGIN); + await settingsPage.removeAllowedOrigin(TEST_ORIGIN_PATTERN); // Editing origins here must leave the pre-configured ones intact and still enforced at runtime. // Without this, a regression that drops them would surface far away, as unexplained cross-origin @@ -79,6 +83,16 @@ test.describe("Settings — CORS allowed origins", { tag: [TestTags.SMOKE] }, () expect(acao, "a configured origin must be echoed in Access-Control-Allow-Origin").toBe(TEST_ORIGIN); }); + test("allows a cross-origin request matched by an entry saved as a regex", async ({ settingsPage }) => { + await settingsPage.addAllowedOriginRegex(TEST_ORIGIN_PATTERN); + + expect(await settingsPage.hasCustomOrigin(TEST_ORIGIN_PATTERN)).toBe(true); + const acao = await corsAllowOriginHeader(settingsPage.page); + expect(acao, "an origin matched by a configured pattern must be echoed in Access-Control-Allow-Origin").toBe( + TEST_ORIGIN + ); + }); + test("denies the origin again after it is removed through the console", async ({ settingsPage }) => { await settingsPage.addAllowedOrigin(TEST_ORIGIN); await settingsPage.removeAllowedOrigin(TEST_ORIGIN);