diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2022-07-08 10:13:41 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-07-08 20:02:47 +0000 |
commit | 231b27662db5d7525d24704e6c53981662e3403f (patch) | |
tree | fa2cabaa4e740e84759e1f57f2c12d1dac431bd5 /server | |
parent | bcf7cdd7569465198d9d0252b6aedcfc51c83474 (diff) | |
download | sonarqube-231b27662db5d7525d24704e6c53981662e3403f.tar.gz sonarqube-231b27662db5d7525d24704e6c53981662e3403f.zip |
SONAR-16565 Move max token lifetime logic to its own function
Diffstat (limited to 'server')
5 files changed, 199 insertions, 186 deletions
diff --git a/server/sonar-web/src/main/js/api/settings.ts b/server/sonar-web/src/main/js/api/settings.ts index e322511bce8..c00cc81078b 100644 --- a/server/sonar-web/src/main/js/api/settings.ts +++ b/server/sonar-web/src/main/js/api/settings.ts @@ -45,6 +45,15 @@ export function getValues( ]); } +export function getAllValues( + data: { component?: string } & BranchParameters = {} +): Promise<SettingValue[]> { + return getJSON('/api/settings/values', data).then((r: SettingValueResponse) => [ + ...r.settings, + ...r.setSecuredSettings.map(key => ({ key })) + ]); +} + export function setSettingValue( definition: SettingDefinition, value: any, diff --git a/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx b/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx index 3afaf428006..7abb80758a5 100644 --- a/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx +++ b/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx @@ -20,19 +20,15 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup'; -import { format } from 'date-fns'; import selectEvent from 'react-select-event'; import { getMyProjects, getScannableProjects } from '../../../api/components'; import NotificationsMock from '../../../api/mocks/NotificationsMock'; import UserTokensMock from '../../../api/mocks/UserTokensMock'; -import { getValues } from '../../../api/settings'; -import { generateToken } from '../../../api/user-tokens'; import { mockUserToken } from '../../../helpers/mocks/token'; import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks'; import { renderApp } from '../../../helpers/testReactTestingUtils'; import { Permissions } from '../../../types/permissions'; -import { SettingsKey } from '../../../types/settings'; -import { TokenExpiration, TokenType } from '../../../types/token'; +import { TokenType } from '../../../types/token'; import { CurrentUser } from '../../../types/users'; import routes from '../routes'; @@ -50,7 +46,7 @@ jest.mock('../../../api/settings', () => { const { SettingsKey } = jest.requireActual('../../../types/settings'); return { ...jest.requireActual('../../../api/settings'), - getValues: jest.fn().mockResolvedValue([ + getAllValues: jest.fn().mockResolvedValue([ { key: SettingsKey.TokenMaxAllowedLifetime, value: 'No expiration' @@ -234,7 +230,6 @@ describe('security page', () => { beforeAll(() => { tokenMock = new UserTokensMock(); - (getValues as jest.Mock).mockClear(); }); afterEach(() => { @@ -244,133 +239,6 @@ describe('security page', () => { const securityPagePath = 'account/security'; it.each([ - [ - '30 days', - [TokenExpiration.OneMonth], - [TokenExpiration.ThreeMonths, TokenExpiration.OneYear, TokenExpiration.NoExpiration] - ], - [ - '90 days', - [TokenExpiration.OneMonth, TokenExpiration.ThreeMonths], - [TokenExpiration.OneYear, TokenExpiration.NoExpiration] - ], - [ - '1 year', - [TokenExpiration.OneMonth, TokenExpiration.ThreeMonths, TokenExpiration.OneYear], - [TokenExpiration.NoExpiration] - ], - [ - 'No expiration', - [ - TokenExpiration.OneMonth, - TokenExpiration.ThreeMonths, - TokenExpiration.OneYear, - TokenExpiration.NoExpiration - ], - [] - ] - ])( - 'should display expiration date inferior or equal to the settings limit %s', - async (settingMaxLifetime, expectedTime, notExpectedTime) => { - (getValues as jest.Mock).mockImplementationOnce(() => - Promise.resolve([{ key: SettingsKey.TokenMaxAllowedLifetime, value: settingMaxLifetime }]) - ); - - renderAccountApp( - mockLoggedInUser({ permissions: { global: [Permissions.Scan] } }), - securityPagePath - ); - - await selectEvent.openMenu(screen.getAllByRole('textbox')[2]); - - expectedTime.forEach(time => { - // TokenExpiration.OneMonth is expected twice has it is the default value. - expect(screen.getAllByText(`users.tokens.expiration.${time}`).length).toBe( - time === TokenExpiration.OneMonth ? 2 : 1 - ); - }); - - notExpectedTime.forEach(time => { - expect(screen.queryByText(`users.tokens.expiration.${time}`)).not.toBeInTheDocument(); - }); - } - ); - - it('should handle absent setting', async () => { - (getValues as jest.Mock).mockImplementationOnce(() => Promise.resolve([])); - - renderAccountApp( - mockLoggedInUser({ permissions: { global: [Permissions.Scan] } }), - securityPagePath - ); - - await selectEvent.openMenu(screen.getAllByRole('textbox')[2]); - - [ - TokenExpiration.OneMonth, - TokenExpiration.ThreeMonths, - TokenExpiration.OneYear, - TokenExpiration.NoExpiration - ].forEach(time => { - // TokenExpiration.OneMonth is expected twice has it is the default value. - expect(screen.getAllByText(`users.tokens.expiration.${time}`).length).toBe( - time === TokenExpiration.OneMonth ? 2 : 1 - ); - }); - }); - - it.each([ - [TokenExpiration.OneMonth, '2022-07-01'], - [TokenExpiration.ThreeMonths, '2022-08-30'], - [TokenExpiration.OneYear, '2023-06-01'], - [TokenExpiration.NoExpiration, undefined] - ])( - 'should allow token creation with proper expiration date for %s days', - async (tokenExpirationTime, expectedTime) => { - const user = userEvent.setup(); - - renderAccountApp( - mockLoggedInUser({ permissions: { global: [Permissions.Scan] } }), - securityPagePath - ); - - // Add the token - const newTokenName = 'importantToken' + tokenExpirationTime; - const input = screen.getByPlaceholderText('users.tokens.enter_name'); - const generateButton = screen.getByRole('button', { name: 'users.generate' }); - expect(input).toBeInTheDocument(); - await user.click(input); - await user.keyboard(newTokenName); - - expect(generateButton).toBeDisabled(); - - const tokenTypeLabel = `users.tokens.${TokenType.User}`; - await selectEvent.select(screen.getAllByRole('textbox')[1], [tokenTypeLabel]); - - const tokenExpirationLabel = `users.tokens.expiration.${tokenExpirationTime}`; - await selectEvent.select(screen.getAllByRole('textbox')[2], [tokenExpirationLabel]); - - await user.click(generateButton); - - expect(generateToken).toHaveBeenLastCalledWith({ - name: newTokenName, - login: 'luke', - type: TokenType.User, - expirationDate: expectedTime - }); - - // ensure the list of tokens is updated - const rows = await screen.findAllByRole('row'); - expect(rows).toHaveLength(4); - expect(rows.pop()).toHaveTextContent( - `${newTokenName}users.tokens.USER_TOKEN.shortneverApril 4, 2022${ - expectedTime ? format(expectedTime, 'MMMM D, YYYY') : '–' - }users.tokens.revoke` - ); - } - ); - - it.each([ ['user', TokenType.User], ['global', TokenType.Global], ['project analysis', TokenType.Project] diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx index cff1307a7c2..ec07f19b5e4 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx @@ -20,17 +20,19 @@ import { isEmpty } from 'lodash'; import * as React from 'react'; import { getScannableProjects } from '../../../api/components'; -import { getValues } from '../../../api/settings'; import { generateToken, getTokens } from '../../../api/user-tokens'; import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; import { SubmitButton } from '../../../components/controls/buttons'; import Select, { BasicSelectOption } from '../../../components/controls/Select'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; -import { now, toShortNotSoISOString } from '../../../helpers/dates'; import { translate } from '../../../helpers/l10n'; +import { + computeTokenExpirationDate, + EXPIRATION_OPTIONS, + getAvailableExpirationOptions +} from '../../../helpers/tokens'; import { hasGlobalPermission } from '../../../helpers/users'; import { Permissions } from '../../../types/permissions'; -import { SettingsKey } from '../../../types/settings'; import { TokenExpiration, TokenType, UserToken } from '../../../types/token'; import { CurrentUser } from '../../../types/users'; import TokensFormItem, { TokenDeleteConfirmation } from './TokensFormItem'; @@ -57,25 +59,6 @@ interface State { tokenExpirationOptions: { value: TokenExpiration; label: string }[]; } -const EXPIRATION_OPTIONS = [ - TokenExpiration.OneMonth, - TokenExpiration.ThreeMonths, - TokenExpiration.OneYear, - TokenExpiration.NoExpiration -].map(value => { - return { - value, - label: translate('users.tokens.expiration', value.toString()) - }; -}); - -const SETTINGS_EXPIRATION_MAP: { [key: string]: TokenExpiration } = { - '30 days': TokenExpiration.OneMonth, - '90 days': TokenExpiration.ThreeMonths, - '1 year': TokenExpiration.OneYear, - 'No expiration': TokenExpiration.NoExpiration -}; - export class TokensForm extends React.PureComponent<Props, State> { mounted = false; state: State = { @@ -120,29 +103,9 @@ export class TokensForm extends React.PureComponent<Props, State> { }; fetchTokenSettings = async () => { - /* - * We intentionally fetch all settings, because fetching a specific setting will - * return it from the DB as a fallback, even if the setting is not defined at startup. - */ - const settings = await getValues({ keys: '' }); - const maxTokenLifetime = settings.find( - ({ key }) => key === SettingsKey.TokenMaxAllowedLifetime - ); - - if (maxTokenLifetime === undefined || maxTokenLifetime.value === undefined) { - return; - } - - const maxTokenExpirationOption = SETTINGS_EXPIRATION_MAP[maxTokenLifetime.value]; - - if (maxTokenExpirationOption !== TokenExpiration.NoExpiration) { - const tokenExpirationOptions = EXPIRATION_OPTIONS.filter( - option => - option.value <= maxTokenExpirationOption && option.value !== TokenExpiration.NoExpiration - ); - if (this.mounted) { - this.setState({ tokenExpirationOptions }); - } + const tokenExpirationOptions = await getAvailableExpirationOptions(); + if (this.mounted) { + this.setState({ tokenExpirationOptions }); } }; @@ -160,12 +123,6 @@ export class TokensForm extends React.PureComponent<Props, State> { } }; - getExpirationDate = (days: number): string => { - const expirationDate = now(); - expirationDate.setDate(expirationDate.getDate() + days); - return toShortNotSoISOString(expirationDate); - }; - handleGenerateToken = async (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); const { login } = this.props; @@ -184,7 +141,7 @@ export class TokensForm extends React.PureComponent<Props, State> { type: newTokenType, ...(newTokenType === TokenType.Project && { projectKey: selectedProject.key }), ...(newTokenExpiration !== TokenExpiration.NoExpiration && { - expirationDate: this.getExpirationDate(newTokenExpiration) + expirationDate: computeTokenExpirationDate(newTokenExpiration) }) }); diff --git a/server/sonar-web/src/main/js/helpers/__tests__/tokens-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/tokens-test.ts new file mode 100644 index 00000000000..7f35313ce4d --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/__tests__/tokens-test.ts @@ -0,0 +1,107 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { getAllValues } from '../../api/settings'; +import { SettingsKey } from '../../types/settings'; +import { TokenExpiration } from '../../types/token'; +import { mockSettingValue } from '../mocks/settings'; +import { + computeTokenExpirationDate, + EXPIRATION_OPTIONS, + getAvailableExpirationOptions +} from '../tokens'; + +jest.mock('../../api/settings', () => { + return { + getAllValues: jest.fn().mockResolvedValue([]) + }; +}); + +jest.mock('../dates', () => { + return { + ...jest.requireActual('../dates'), + now: jest.fn(() => new Date('2022-06-01T12:00:00Z')) + }; +}); + +describe('getAvailableExpirationOptions', () => { + it('should correctly return all options if no setting', async () => { + expect(await getAvailableExpirationOptions()).toEqual(EXPIRATION_OPTIONS); + }); + + it('should correctly return all options if the max setting is no expiration', async () => { + (getAllValues as jest.Mock).mockResolvedValueOnce([ + mockSettingValue({ key: SettingsKey.TokenMaxAllowedLifetime, value: 'No expiration' }) + ]); + expect(await getAvailableExpirationOptions()).toEqual(EXPIRATION_OPTIONS); + }); + + it('should correctly limit options if the max setting is 1 year', async () => { + (getAllValues as jest.Mock).mockResolvedValueOnce([ + mockSettingValue({ key: SettingsKey.TokenMaxAllowedLifetime, value: '1 year' }) + ]); + expect(await getAvailableExpirationOptions()).toEqual( + [TokenExpiration.OneMonth, TokenExpiration.ThreeMonths, TokenExpiration.OneYear].map( + value => { + return { + value, + label: `users.tokens.expiration.${value.toString()}` + }; + } + ) + ); + }); + + it('should correctly limit options if the max setting is 3 months', async () => { + (getAllValues as jest.Mock).mockResolvedValueOnce([ + mockSettingValue({ key: SettingsKey.TokenMaxAllowedLifetime, value: '90 days' }) + ]); + expect(await getAvailableExpirationOptions()).toEqual( + [TokenExpiration.OneMonth, TokenExpiration.ThreeMonths].map(value => { + return { + value, + label: `users.tokens.expiration.${value.toString()}` + }; + }) + ); + }); + + it('should correctly limit options if the max setting is 30 days', async () => { + (getAllValues as jest.Mock).mockResolvedValueOnce([ + mockSettingValue({ key: SettingsKey.TokenMaxAllowedLifetime, value: '30 days' }) + ]); + expect(await getAvailableExpirationOptions()).toEqual([ + { + value: TokenExpiration.OneMonth, + label: `users.tokens.expiration.${TokenExpiration.OneMonth.toString()}` + } + ]); + }); +}); + +describe('computeTokenExpirationDate', () => { + it.each([ + [TokenExpiration.OneMonth, '2022-07-01'], + [TokenExpiration.ThreeMonths, '2022-08-30'], + [TokenExpiration.OneYear, '2023-06-01'] + ])('should correctly compute the proper expiration date for %s days', (days, expected) => { + expect(computeTokenExpirationDate(days)).toBe(expected); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/tokens.ts b/server/sonar-web/src/main/js/helpers/tokens.ts new file mode 100644 index 00000000000..ab8f3c5c522 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/tokens.ts @@ -0,0 +1,72 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { getAllValues } from '../api/settings'; +import { SettingsKey } from '../types/settings'; +import { TokenExpiration } from '../types/token'; +import { now, toShortNotSoISOString } from './dates'; +import { translate } from './l10n'; + +export const EXPIRATION_OPTIONS = [ + TokenExpiration.OneMonth, + TokenExpiration.ThreeMonths, + TokenExpiration.OneYear, + TokenExpiration.NoExpiration +].map(value => { + return { + value, + label: translate('users.tokens.expiration', value.toString()) + }; +}); + +const SETTINGS_EXPIRATION_MAP: { [key: string]: TokenExpiration } = { + '30 days': TokenExpiration.OneMonth, + '90 days': TokenExpiration.ThreeMonths, + '1 year': TokenExpiration.OneYear, + 'No expiration': TokenExpiration.NoExpiration +}; + +export async function getAvailableExpirationOptions() { + /* + * We intentionally fetch all settings, because fetching a specific setting will + * return it from the DB as a fallback, even if the setting is not defined at startup. + */ + const setting = (await getAllValues()).find(v => v.key === SettingsKey.TokenMaxAllowedLifetime); + if (setting === undefined || setting.value === undefined) { + return EXPIRATION_OPTIONS; + } + + const maxTokenLifetime = setting.value; + if (SETTINGS_EXPIRATION_MAP[maxTokenLifetime] !== TokenExpiration.NoExpiration) { + return EXPIRATION_OPTIONS.filter( + option => + option.value <= SETTINGS_EXPIRATION_MAP[maxTokenLifetime] && + option.value !== TokenExpiration.NoExpiration + ); + } + + return EXPIRATION_OPTIONS; +} + +export function computeTokenExpirationDate(days: number) { + const expirationDate = now(); + expirationDate.setDate(expirationDate.getDate() + days); + return toShortNotSoISOString(expirationDate); +} |