From c2f1799028bb9973ca515cdd744e8e5d966e4d25 Mon Sep 17 00:00:00 2001 From: Oleksii Kurinnyi Date: Wed, 24 Jan 2024 15:47:42 +0200 Subject: [PATCH] Prevent starting a Git+SSH factory if no SSH keys configured (#1043) * feat: prevent starting a Git+SSH factory if no SSH keys configured Signed-off-by: Oleksii Kurinnyi * fix: set public SSH key max length 4096 Signed-off-by: Oleksii Kurinnyi --------- Signed-off-by: Oleksii Kurinnyi --- .../dashboard-frontend/src/Routes/routes.ts | 1 + .../workspaceCreationTimeCheck.check.tsx | 20 ++- .../CheckExistingWorkspaces/index.tsx | 2 +- .../Initialize/__tests__/index.spec.tsx | 66 ++++++- .../CreatingSteps/Initialize/index.tsx | 66 +++++-- .../components/WorkspaceProgress/index.tsx | 2 +- .../ImportFromGit/GitRepoLocationInput.tsx | 131 -------------- .../ImportFromGit/__mocks__/index.tsx | 26 +++ .../__snapshots__/index.spec.tsx.snap | 109 ++++++++++++ .../ImportFromGit/__tests__/index.spec.tsx | 127 ++++++++++++++ .../GetStartedTab/ImportFromGit/index.tsx | 162 +++++++++++++----- .../__tests__/GetStartedTab.spec.tsx | 8 + .../pages/GetStarted/GetStartedTab/index.tsx | 82 ++++----- .../src/pages/GetStarted/index.tsx | 1 + .../AddModal/Form/SshPublicKey/index.tsx | 2 +- .../__snapshots__/index.spec.tsx.snap | 138 +++++++++++++++ .../UserPreferences/__tests__/index.spec.tsx | 29 ++-- .../src/pages/UserPreferences/index.tsx | 28 ++- .../bootstrap/__tests__/index.spec.tsx | 9 +- .../src/services/bootstrap/index.ts | 7 + .../helpers/factoryFlow/buildFactoryParams.ts | 4 +- .../src/services/helpers/location.ts | 11 ++ .../src/services/helpers/types.ts | 13 +- 23 files changed, 761 insertions(+), 283 deletions(-) delete mode 100644 packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/GitRepoLocationInput.tsx create mode 100644 packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap diff --git a/packages/dashboard-frontend/src/Routes/routes.ts b/packages/dashboard-frontend/src/Routes/routes.ts index d4e733490..24c8db13b 100644 --- a/packages/dashboard-frontend/src/Routes/routes.ts +++ b/packages/dashboard-frontend/src/Routes/routes.ts @@ -22,6 +22,7 @@ export enum ROUTE { FACTORY_LOADER = '/load-factory', FACTORY_LOADER_URL = '/load-factory?url=:url', USER_PREFERENCES = '/user-preferences', + USER_PREFERENCES_TAB = '/user-preferences?tab=:tabId', USER_ACCOUNT = '/user-account', } diff --git a/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx b/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx index d79c24779..f3ab817c4 100644 --- a/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx +++ b/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx @@ -86,12 +86,22 @@ describe('Workspace creation time', () => { const { rerender } = render( getComponent( `/load-factory?url=${url}`, - new FakeStoreBuilder().withInfrastructureNamespace([namespace]).build(), + new FakeStoreBuilder() + .withInfrastructureNamespace([namespace]) + .withSshKeys({ + keys: [ + { + name: 'test', + keyPub: 'test', + }, + ], + }) + .build(), ), ); await waitFor( - () => expect(mockPost).toBeCalledWith('/api/factory/resolver', expect.anything()), + () => expect(mockPost).toHaveBeenCalledWith('/api/factory/resolver', expect.anything()), { timeout: 8000 }, ); expect(mockPost).toHaveBeenCalledTimes(1); @@ -205,7 +215,7 @@ describe('Workspace creation time', () => { ]), { timeout: 1500 }, ); - expect(mockPost).toBeCalledTimes(3); + expect(mockPost).toHaveBeenCalledTimes(3); await waitFor( () => @@ -216,8 +226,8 @@ describe('Workspace creation time', () => { ]), { timeout: 1500 }, ); - expect(mockPatch).toBeCalledTimes(1); - expect(mockGet).not.toBeCalled(); + expect(mockPatch).toHaveBeenCalledTimes(1); + expect(mockGet).not.toHaveBeenCalled(); expect(screen.queryByTestId('fallback-spinner')).not.toBeInTheDocument(); expect(execTime).toBeLessThan(TIME_LIMIT); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx index a2f25208b..f83becf73 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/CheckExistingWorkspaces/index.tsx @@ -147,7 +147,7 @@ class CreatingStepCheckExistingWorkspaces extends ProgressStep { } let newWorkspaceName: string; - if (factoryParams.useDevworkspaceResources === true) { + if (factoryParams.useDevWorkspaceResources === true) { const resources = devWorkspaceResources[factoryParams.sourceUrl]?.resources; if (resources === undefined) { // going to use the default devfile in the next step diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx index f17ff8eeb..5713f7568 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/__tests__/index.spec.tsx @@ -23,7 +23,7 @@ import { FACTORY_URL_ATTR, POLICIES_CREATE_ATTR, } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { AlertItem } from '@/services/helpers/types'; +import { AlertItem, UserPreferencesTab } from '@/services/helpers/types'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; @@ -36,20 +36,35 @@ const mockOnRestart = jest.fn(); const mockOnError = jest.fn(); const mockOnHideError = jest.fn(); +jest.mock('@/services/helpers/location', () => ({ + toHref: (_: unknown, location: string) => 'http://localhost/' + location, + buildUserPreferencesLocation: (tab: UserPreferencesTab) => 'user-preferences?tab=' + tab, +})); + describe('Creating steps, initializing', () => { const factoryUrl = 'https://factory-url'; + const { reload } = window.location; let store: Store; beforeEach(() => { store = new FakeStoreBuilder() .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) + .withSshKeys({ + keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], + }) .build(); + delete (window as any).location; + (window.location as any) = { reload: jest.fn() }; + window.open = jest.fn(); + jest.useFakeTimers(); }); afterEach(() => { + window.location.reload = reload; + jest.clearAllMocks(); jest.clearAllTimers(); jest.useRealTimers(); @@ -83,7 +98,7 @@ describe('Creating steps, initializing', () => { }); await waitFor(() => expect(mockOnError).toHaveBeenCalledWith(expectAlertItem)); - expect(mockOnRestart).toHaveBeenCalled(); + expect(window.location.reload).toHaveBeenCalled(); expect(mockOnNextStep).not.toHaveBeenCalled(); }); @@ -99,7 +114,7 @@ describe('Creating steps, initializing', () => { const expectAlertItem = expect.objectContaining({ title: 'Failed to create the workspace', - children: expect.stringContaining('Devworkspace resources URL is missing.'), + children: expect.stringContaining('DevWorkspace resources URL is missing.'), actionCallbacks: [ expect.objectContaining({ title: 'Click to try again', @@ -213,6 +228,9 @@ describe('Creating steps, initializing', () => { .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) .withClusterConfig({ allWorkspacesLimit: 1 }) .withDevWorkspaces({ workspaces: [new DevWorkspaceBuilder().build()] }) + .withSshKeys({ + keys: [{ name: 'key1', keyPub: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD' }], + }) .build(); const searchParams = new URLSearchParams({ [FACTORY_URL_ATTR]: factoryUrl, @@ -236,6 +254,48 @@ describe('Creating steps, initializing', () => { expect(mockOnNextStep).not.toHaveBeenCalled(); }); + + test('no SSH keys with Git+SSH factory URL', async () => { + const store = new FakeStoreBuilder() + .withInfrastructureNamespace([{ name: 'user-che', attributes: { phase: 'Active' } }]) + .build(); + const searchParams = new URLSearchParams({ + [FACTORY_URL_ATTR]: factoryUrl, + }); + + // this will help test the case when the user clicks on the "Add SSH Keys" button + mockOnError.mockImplementation((alertItem: AlertItem) => { + if (alertItem.actionCallbacks) { + alertItem.actionCallbacks[1].callback(); + } + }); + + renderComponent(store, searchParams); + + await jest.advanceTimersByTimeAsync(MIN_STEP_DURATION_MS); + + const expectAlertItem = expect.objectContaining({ + title: 'No SSH keys found', + children: 'No SSH keys found. Please add your SSH keys and then try again.', + actionCallbacks: [ + expect.objectContaining({ + title: 'Click to try again', + callback: expect.any(Function), + }), + expect.objectContaining({ + title: 'Add SSH Keys', + callback: expect.any(Function), + }), + ], + }); + await waitFor(() => expect(mockOnError).toHaveBeenCalledWith(expectAlertItem)); + + expect(window.open).toHaveBeenCalledWith( + 'http://localhost/user-preferences?tab=SshKeys', + '_blank', + ); + expect(mockOnNextStep).not.toHaveBeenCalled(); + }); }); function getComponent(store: Store, searchParams: URLSearchParams): React.ReactElement { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx index a7ed6ae71..734381bd3 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/CreatingSteps/Initialize/index.tsx @@ -29,10 +29,12 @@ import { FactoryParams, PoliciesCreate, } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import { AlertItem } from '@/services/helpers/types'; +import { buildUserPreferencesLocation, toHref } from '@/services/helpers/location'; +import { AlertItem, UserPreferencesTab } from '@/services/helpers/types'; import { AppState } from '@/store'; import { selectAllWorkspacesLimit } from '@/store/ClusterConfig/selectors'; import { selectInfrastructureNamespaces } from '@/store/InfrastructureNamespaces/selectors'; +import { selectSshKeys } from '@/store/SshKeys/selectors'; import { selectAllWorkspaces } from '@/store/Workspaces/selectors'; export type Props = MappedProps & @@ -90,12 +92,12 @@ class CreatingStepInitialize extends ProgressStep { } protected async runStep(): Promise { - const { useDevworkspaceResources, sourceUrl, errorCode, policiesCreate, remotes } = + const { useDevWorkspaceResources, sourceUrl, errorCode, policiesCreate, remotes } = this.state.factoryParams; - if (useDevworkspaceResources === true && sourceUrl === '') { - throw new Error('Devworkspace resources URL is missing.'); - } else if (useDevworkspaceResources === false && sourceUrl === '' && !remotes) { + if (useDevWorkspaceResources === true && sourceUrl === '') { + throw new Error('DevWorkspace resources URL is missing.'); + } else if (useDevWorkspaceResources === false && sourceUrl === '' && !remotes) { const factoryPath = generatePath(ROUTE.FACTORY_LOADER_URL, { url: 'your-repository-url', }); @@ -126,6 +128,11 @@ class CreatingStepInitialize extends ProgressStep { ); } + // check for SSH keys availability + if (this.props.sshKeys.length === 0) { + throw new NoSshKeysError('No SSH keys found.'); + } + this.checkAllWorkspacesLimitExceeded(); return true; @@ -135,13 +142,38 @@ class CreatingStepInitialize extends ProgressStep { return (val && (val as PoliciesCreate) === 'perclick') || (val as PoliciesCreate) === 'peruser'; } - private handleRestart(alertKey: string): void { - this.props.onHideError(alertKey); - this.props.onRestart(); + private handleRestart(): void { + window.location.reload(); + } + + private handleOpenUserPreferences(): void { + const location = buildUserPreferencesLocation(UserPreferencesTab.SSH_KEYS); + const link = toHref(this.props.history, location); + window.open(link, '_blank'); } protected buildAlertItem(error: Error): AlertItem { const key = this.name; + + if (error instanceof NoSshKeysError) { + return { + key, + title: 'No SSH keys found', + variant: AlertVariant.warning, + children: 'No SSH keys found. Please add your SSH keys and then try again.', + actionCallbacks: [ + { + title: 'Click to try again', + callback: () => this.handleRestart(), + }, + { + title: 'Add SSH Keys', + callback: () => this.handleOpenUserPreferences(), + }, + ], + }; + } + return { key, title: 'Failed to create the workspace', @@ -150,7 +182,7 @@ class CreatingStepInitialize extends ProgressStep { actionCallbacks: [ { title: 'Click to try again', - callback: () => this.handleRestart(key), + callback: () => this.handleRestart(), }, ], }; @@ -171,8 +203,12 @@ class CreatingStepInitialize extends ProgressStep { const { distance, hasChildren } = this.props; const { name, lastError } = this.state; - const isError = lastError !== undefined; - const isWarning = false; + let isError = lastError !== undefined; + let isWarning = false; + if (lastError instanceof NoSshKeysError) { + isWarning = true; + isError = false; + } return ( @@ -196,10 +232,18 @@ export class AllWorkspacesExceededError extends Error { } } +export class NoSshKeysError extends Error { + constructor(message: string) { + super(message); + this.name = 'NoSshKeysError'; + } +} + const mapStateToProps = (state: AppState) => ({ infrastructureNamespaces: selectInfrastructureNamespaces(state), allWorkspaces: selectAllWorkspaces(state), allWorkspacesLimit: selectAllWorkspacesLimit(state), + sshKeys: selectSshKeys(state), }); const connector = connect(mapStateToProps, {}, null, { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx index dc1957162..db6aed574 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx @@ -372,7 +372,7 @@ class Progress extends React.Component { const { history, searchParams } = this.props; const { factoryParams } = this.state; - const usePrebuiltResources = factoryParams.useDevworkspaceResources; + const usePrebuiltResources = factoryParams.useDevWorkspaceResources; const steps = [ usePrebuiltResources ? this.getFactoryFetchResources() : this.getFactoryFetchDevfile(), this.getCheckExistingWorkspacesStep(), diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/GitRepoLocationInput.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/GitRepoLocationInput.tsx deleted file mode 100644 index 5c70425ad..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/GitRepoLocationInput.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2018-2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { - Button, - ButtonVariant, - Flex, - FlexItem, - Form, - FormGroup, - Text, - TextContent, - TextInput, - TextVariants, - ValidatedOptions, -} from '@patternfly/react-core'; -import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import React from 'react'; - -import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; - -const ERROR_PATTERN_MISMATCH = 'The URL or SSHLocation is not valid.'; - -type Props = { - onChange: (location: string) => void; - isLoading?: boolean; -}; -type State = { - validated: ValidatedOptions; - location: string; - errorMessage?: string; -}; - -export class GitRepoLocationInput extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - validated: ValidatedOptions.default, - location: '', - }; - } - - private handleChange(location: string): void { - const isValid = - FactoryLocationAdapter.isHttpLocation(location) || - FactoryLocationAdapter.isSshLocation(location); - - if (isValid) { - this.setState({ - validated: ValidatedOptions.default, - errorMessage: undefined, - }); - } else { - this.setState({ - validated: ValidatedOptions.error, - errorMessage: ERROR_PATTERN_MISMATCH, - }); - } - - this.setState({ - location, - }); - } - - private handleClick(): void { - this.props.onChange(this.state.location); - } - - public render() { - const { location, validated, errorMessage } = this.state; - const fieldId = 'git-repo-url'; - const buttonDisabled = - location === '' || validated === ValidatedOptions.error || this.props.isLoading; - - return ( -
{ - e.preventDefault(); - if (!buttonDisabled) { - this.handleClick(); - } - }} - > - } - > - - - - this.handleChange(value)} - value={location} - /> - - Import from a Git repository to create your first workspace. - - - - - - - - -
- ); - } -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx new file mode 100644 index 000000000..cb1735eab --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Props } from '@/pages/GetStarted/GetStartedTab/ImportFromGit'; + +export class ImportFromGit extends React.PureComponent { + public render() { + const { hasSshKeys } = this.props; + return ( +
+ {hasSshKeys ? 'true' : 'false'} +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..5c9d3242c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitRepoLocationInput snapshot 1`] = ` +
+
+

+ Import from Git +

+
+
+ + +
+
+
+
+ +
+
+ +
+
+
+ Import from a Git repository to create your first workspace. +
+
+
+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx new file mode 100644 index 000000000..73c38a0ee --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2018-2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { ImportFromGit } from '@/pages/GetStarted/GetStartedTab/ImportFromGit'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const history = createMemoryHistory({ + initialEntries: ['/'], +}); + +global.window.open = jest.fn(); + +describe('GitRepoLocationInput', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const component = createSnapshot(true); + expect(component).toMatchSnapshot(); + }); + + test('valid http:// location', () => { + renderComponent(true); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/'); + + expect(input).toHaveValue('http://test-location/'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + + userEvent.type(input, '{enter}'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + test('invalid location', () => { + renderComponent(true); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'invalid-test-location'); + + expect(input).toHaveValue('invalid-test-location'); + expect(input).toBeInvalid(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + + userEvent.type(input, '{enter}'); + expect(window.open).not.toHaveBeenCalled(); + }); + + test('valid Git+SSH location with SSH keys', () => { + renderComponent(true); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'git@github.com:user/repo.git'); + + expect(input).toHaveValue('git@github.com:user/repo.git'); + expect(input).toBeValid(); + + const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); + expect(buttonCreate).toBeEnabled(); + }); + + test('valid Git+SSH location w/o SSH keys', () => { + renderComponent(false); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'git@github.com:user/repo.git'); + + expect(input).toHaveValue('git@github.com:user/repo.git'); + expect(input).toBeInvalid(); + + const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); + expect(buttonCreate).toBeDisabled(); + + const buttonUserPreferences = screen.getByRole('button', { name: 'here' }); + + userEvent.click(buttonUserPreferences); + expect(history.location.pathname).toBe('/user-preferences'); + expect(history.location.search).toBe('?tab=SshKeys'); + }); +}); + +function getComponent(hasSshKeys: boolean) { + const store = new FakeStoreBuilder().build(); + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx index cad16cf42..3eb01eb06 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx @@ -10,66 +10,150 @@ * Red Hat, Inc. - initial API and implementation */ -import { Flex, FlexItem, FormGroup, Text, TextContent, TextVariants } from '@patternfly/react-core'; +import { + Button, + ButtonVariant, + Flex, + FlexItem, + Form, + FormGroup, + FormHelperText, + FormSection, + TextInput, + ValidatedOptions, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { History } from 'history'; import React from 'react'; -import { GitRepoLocationInput } from '@/pages/GetStarted/GetStartedTab/ImportFromGit/GitRepoLocationInput'; import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; -import * as FactoryResolverStore from '@/store/FactoryResolver'; +import { buildUserPreferencesLocation } from '@/services/helpers/location'; +import { UserPreferencesTab } from '@/services/helpers/types'; -type Props = { - onDevfileResolve: (resolverState: FactoryResolverStore.ResolverState, location: string) => void; +export type Props = { + hasSshKeys: boolean; + history: History; }; -type State = { - isLoading: boolean; +export type State = { + location: string; + validated: ValidatedOptions; }; -export default class ImportFromGit extends React.PureComponent { +export class ImportFromGit extends React.PureComponent { constructor(props: Props) { super(props); this.state = { - isLoading: false, + validated: ValidatedOptions.default, + location: '', }; } - private async handleLocationChange(location: string): Promise { - const factory = new FactoryLocationAdapter(location); + private handleCreate(): void { + const factory = new FactoryLocationAdapter(this.state.location); // open a new page to handle that window.open(`${window.location.origin}/#${factory.toString()}`, '_blank'); } - public render(): React.ReactNode { - const { isLoading } = this.state; + private handleChange(location: string): void { + const validated = this.validate(location); + this.setState({ + location, + validated, + }); + } + + private validate(location: string): ValidatedOptions { + const isValidHttp = FactoryLocationAdapter.isHttpLocation(location); + const isValidGitSsh = FactoryLocationAdapter.isSshLocation(location); + + if (isValidHttp === true) { + return ValidatedOptions.success; + } + + if (isValidGitSsh === true && this.props.hasSshKeys === true) { + return ValidatedOptions.success; + } + + return ValidatedOptions.error; + } + + private getErrorMessage(location: string): string | React.ReactNode { + const isValidGitSsh = FactoryLocationAdapter.isSshLocation(location); + + if (isValidGitSsh === true && this.props.hasSshKeys === false) { + return ( + } isHidden={false} isError={true}> + No SSH keys found. Please add your SSH keys{' '} + {' '} + and then try again. + + ); + } + + return 'The URL or SSHLocation is not valid.'; + } + + private openUserPreferences(): void { + const location = buildUserPreferencesLocation(UserPreferencesTab.SSH_KEYS); + this.props.history.push(location); + } + + public render() { + const { location, validated } = this.state; + + const fieldId = 'git-repo-url'; + const buttonDisabled = location === '' || validated === ValidatedOptions.error; + const errorMessage = this.getErrorMessage(location); return ( - <> - - Import from Git - +
{ + e.preventDefault(); + if (buttonDisabled === true) { + return false; } - > - - - - - Git Repo URL -  * - - - - - this.handleLocationChange(location)} - /> - - - - + this.handleCreate(); + }} + > + + } + helperText="Import from a Git repository to create your first workspace." + > + + + this.handleChange(value)} + value={location} + /> + + + + + + + +
); } } diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx index 98487aee4..02b1c884d 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx @@ -12,6 +12,7 @@ import { api } from '@eclipse-che/common'; import { render, RenderResult, screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; @@ -23,6 +24,11 @@ import { selectPvcStrategy } from '@/store/ServerConfig/selectors'; import { SamplesListTab } from '..'; +jest.mock('@/pages/GetStarted/GetStartedTab/ImportFromGit'); +const history = createMemoryHistory({ + initialEntries: ['/'], +}); + const onDevfileMock: ( devfileContent: string, stackName: string, @@ -84,6 +90,8 @@ describe('Samples list tab', () => { return render( { this.isLoading = false; } - private handleDevfileResolver(resolverState: ResolverState, stackName: string): Promise { - const devfileAdapter = new DevfileAdapter(resolverState.devfile); - devfileAdapter.storageType = this.props.preferredStorageType as che.WorkspaceStorageType; - const devfileContent = stringify(devfileAdapter.devfile); - - return this.props.onDevfile( - devfileContent, - stackName, - resolverState.optionalFilesContent || {}, - ); - } - public render(): React.ReactElement { + const { history, sshKeys } = this.props; + const storageType = this.getStorageType(); + const hasSshKeys = sshKeys.length !== 0; return ( - <> - - - - this.handleDevfileResolver(resolverState, location) - } - /> - - - - - - - - - this.handleTemporaryStorageChange(temporary) - } - /> - - - - this.handleSampleCardClick(devfileContent, stackName, optionalFilesContent) - } - storageType={storageType} - /> - + + + + + + + + + + + this.handleTemporaryStorageChange(temporary)} + /> + + + + this.handleSampleCardClick(devfileContent, stackName, optionalFilesContent) + } + storageType={storageType} + /> - + ); } } const mapStateToProps = (state: AppState) => ({ preferredStorageType: selectPvcStrategy(state), + sshKeys: selectSshKeys(state), }); const connector = connect(mapStateToProps); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx index 55cdcdb1f..04662d718 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx @@ -229,6 +229,7 @@ export class GetStarted extends React.PureComponent { { return this.handleDevfileContent( devfileContent, diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/AddModal/Form/SshPublicKey/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/AddModal/Form/SshPublicKey/index.tsx index ab249e82b..b85f0573c 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/AddModal/Form/SshPublicKey/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/SshKeys/AddModal/Form/SshPublicKey/index.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { TextFileUpload } from '@/components/TextFileUpload'; export const REQUIRED_ERROR = 'This field is required.'; -const MAX_LENGTH = 512; +const MAX_LENGTH = 4096; export const MAX_LENGTH_ERROR = `The value is too long. The maximum length is ${MAX_LENGTH} characters.`; export const WRONG_TYPE_ERROR = 'This file type is not supported.'; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..5929cfdc3 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UserPreferences snapshot 1`] = ` +[ +
+

+ User Preferences +

+
, +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
, + , +] +`; diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx index 30e2b3ca4..7faaa99d5 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/__tests__/index.spec.tsx @@ -11,12 +11,13 @@ */ import userEvent from '@testing-library/user-event'; -import { createHashHistory, History } from 'history'; +import { createMemoryHistory } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router'; import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { UserPreferencesTab } from '@/services/helpers/types'; import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; import UserPreferences from '..'; @@ -27,9 +28,10 @@ jest.mock('../GitServicesTab'); jest.mock('../PersonalAccessTokens'); jest.mock('../SshKeys'); -const { renderComponent } = getComponentRenderer(getComponent); +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const history = createMemoryHistory(); -let history: History; function getComponent(): React.ReactElement { const store = new FakeStoreBuilder().build(); return ( @@ -42,20 +44,15 @@ function getComponent(): React.ReactElement { } describe('UserPreferences', () => { - beforeEach(() => { - history = createHashHistory(); - }); - afterEach(() => { jest.clearAllMocks(); - window.location.href = '/'; }); - // TODO: figure out why screenshots fail on the `Tabs` component - // test('snapshot', () => { - // const snapshot = createSnapshot(); - // expect(snapshot.toJSON()).toMatchSnapshot(); - // }); + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + snapshot.unmount(); + }); it('should activate the Container Registries tab by default', () => { history.push('/user-preferences?tab=unknown-tab-name'); @@ -75,7 +72,7 @@ describe('UserPreferences', () => { }); it('should activate the Git Services tab', () => { - history.push('/user-preferences?tab=git-services'); + history.push(`/user-preferences?tab=${UserPreferencesTab.GIT_SERVICES}`); renderComponent(); @@ -83,7 +80,7 @@ describe('UserPreferences', () => { }); it('should activate the Personal Access Tokens tab', () => { - history.push('/user-preferences?tab=personal-access-tokens'); + history.push(`/user-preferences?tab=${UserPreferencesTab.PERSONAL_ACCESS_TOKENS}`); renderComponent(); @@ -91,7 +88,7 @@ describe('UserPreferences', () => { }); it('should activate the SSH Keys tab', () => { - history.push('/user-preferences?tab=ssh-keys'); + history.push(`/user-preferences?tab=${UserPreferencesTab.SSH_KEYS}`); renderComponent(); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index 181010059..c2d8146ee 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -27,12 +27,6 @@ import { AppState } from '@/store'; import { actionCreators } from '@/store/GitOauthConfig'; import { selectIsLoading } from '@/store/GitOauthConfig/selectors'; -const CONTAINER_REGISTRIES_TAB: UserPreferencesTab = 'container-registries'; -const GIT_SERVICES_TAB: UserPreferencesTab = 'git-services'; -const PERSONAL_ACCESS_TOKENS_TAB: UserPreferencesTab = 'personal-access-tokens'; -const GITCONFIG_TAB: UserPreferencesTab = 'gitconfig'; -const SSH_KEYS_TAB: UserPreferencesTab = 'ssh-keys'; - export type Props = { history: History; } & MappedProps; @@ -60,17 +54,17 @@ export class UserPreferences extends React.PureComponent { const tab = searchParam.get('tab'); if ( pathname === ROUTE.USER_PREFERENCES && - (tab === CONTAINER_REGISTRIES_TAB || - tab === GITCONFIG_TAB || - tab === GIT_SERVICES_TAB || - tab === PERSONAL_ACCESS_TOKENS_TAB || - tab === SSH_KEYS_TAB) + (tab === UserPreferencesTab.CONTAINER_REGISTRIES || + tab === UserPreferencesTab.GITCONFIG || + tab === UserPreferencesTab.GIT_SERVICES || + tab === UserPreferencesTab.PERSONAL_ACCESS_TOKENS || + tab === UserPreferencesTab.SSH_KEYS) ) { return searchParam.get('tab') as UserPreferencesTab; } } - return CONTAINER_REGISTRIES_TAB; + return UserPreferencesTab.CONTAINER_REGISTRIES; } private handleTabClick( @@ -102,19 +96,19 @@ export class UserPreferences extends React.PureComponent { mountOnEnter={true} unmountOnExit={true} > - + - + - + - + - + diff --git a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx index a18b7b239..45c4177a8 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/services/bootstrap/__tests__/index.spec.tsx @@ -31,6 +31,7 @@ jest.mock('@/services/resource-fetcher/appendLink', () => { }); // mute the outputs +console.error = jest.fn(); console.log = jest.fn(); describe('Dashboard bootstrap', () => { @@ -67,7 +68,7 @@ describe('Dashboard bootstrap', () => { test('requests which should be sent', async () => { prepareMocks(mockPost, 1, namespace); // provisionNamespace - prepareMocks(mockGet, 15, []); // branding, namespace, prefetch, server-config, cluster-info, userprofile, plugin-registry, default-editor, devfile-registry, getting-started-sample, devworkspacetemplates, devworkspaces, events, pods, cluster-config + prepareMocks(mockGet, 16, []); // branding, namespace, prefetch, server-config, cluster-info, userprofile, plugin-registry, default-editor, devfile-registry, getting-started-sample, devworkspacetemplates, devworkspaces, events, pods, cluster-config, ssh-key await preloadData.init(); @@ -79,7 +80,11 @@ describe('Dashboard bootstrap', () => { undefined, ); // wait for all GET requests to be sent - await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(15)); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(16)); + + await waitFor(() => + expect(mockGet).toHaveBeenCalledWith('/dashboard/api/namespace/test-che/ssh-key', undefined), + ); expect(mockGet).toHaveBeenCalledWith('./assets/branding/product.json'); expect(mockGet).toHaveBeenCalledWith('/api/kubernetes/namespace', undefined); expect(mockGet).toHaveBeenCalledWith('https://prefetch-che-cdn.test', { diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index fd8b849d1..5bb4c9319 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -54,6 +54,7 @@ import { selectPodsResourceVersion } from '@/store/Pods/selectors'; import * as SanityCheckStore from '@/store/SanityCheck'; import * as ServerConfigStore from '@/store/ServerConfig'; import { selectOpenVSXUrl, selectPluginRegistryUrl } from '@/store/ServerConfig/selectors'; +import * as SshKeysStore from '@/store/SshKeys'; import * as UserProfileStore from '@/store/User/Profile'; import * as WorkspacesStore from '@/store/Workspaces'; import * as DevWorkspacesStore from '@/store/Workspaces/devWorkspaces'; @@ -113,6 +114,7 @@ export default class Bootstrap { this.watchWebSocketPods(); }), this.fetchClusterConfig().then(() => this.updateFavicon()), + this.fetchSshKeys(), ]); const errors = results @@ -480,4 +482,9 @@ export default class Bootstrap { } } } + + private async fetchSshKeys(): Promise { + const { requestSshKeys } = SshKeysStore.actionCreators; + await requestSshKeys()(this.store.dispatch, this.store.getState, undefined); + } } diff --git a/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts b/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts index 1d9263f81..365a584bb 100644 --- a/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts +++ b/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts @@ -43,7 +43,7 @@ export type FactoryParams = { factoryUrl: string; policiesCreate: PoliciesCreate; sourceUrl: string; - useDevworkspaceResources: boolean; + useDevWorkspaceResources: boolean; overrides: Record | undefined; errorCode: ErrorCode | undefined; storageType: che.WorkspaceStorageType | undefined; @@ -71,7 +71,7 @@ export function buildFactoryParams(searchParams: URLSearchParams): FactoryParams sourceUrl: getSourceUrl(searchParams), storageType: getStorageType(searchParams), remotes: getRemotes(searchParams), - useDevworkspaceResources: getDevworkspaceResourcesUrl(searchParams) !== undefined, + useDevWorkspaceResources: getDevworkspaceResourcesUrl(searchParams) !== undefined, image: getImage(searchParams), useDefaultDevfile: isSafeWorkspaceStart(searchParams) !== undefined, debugWorkspaceStart: isDebugWorkspaceStart(searchParams) !== undefined, diff --git a/packages/dashboard-frontend/src/services/helpers/location.ts b/packages/dashboard-frontend/src/services/helpers/location.ts index 29d1bd4ba..8d8a15d95 100644 --- a/packages/dashboard-frontend/src/services/helpers/location.ts +++ b/packages/dashboard-frontend/src/services/helpers/location.ts @@ -14,6 +14,7 @@ import { History, Location } from 'history'; import { ROUTE } from '@/Routes/routes'; import { CreateWorkspaceTab, LoaderTab, WorkspaceDetailsTab } from '@/services/helpers/types'; +import { UserPreferencesTab } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; /* eslint-disable @typescript-eslint/no-non-null-assertion */ @@ -42,6 +43,16 @@ export function buildWorkspacesLocation(): Location { return _buildLocationObject(ROUTE.WORKSPACES); } +export function buildUserPreferencesLocation(tab?: UserPreferencesTab): Location { + let pathAndQuery: string; + if (!tab) { + pathAndQuery = ROUTE.USER_PREFERENCES; + } else { + pathAndQuery = ROUTE.USER_PREFERENCES_TAB.replace(':tabId', tab); + } + return _buildLocationObject(pathAndQuery); +} + export function buildGettingStartedLocation(tab?: CreateWorkspaceTab): Location { let pathAndQuery: string; if (!tab) { diff --git a/packages/dashboard-frontend/src/services/helpers/types.ts b/packages/dashboard-frontend/src/services/helpers/types.ts index 670e15137..1e8983a04 100644 --- a/packages/dashboard-frontend/src/services/helpers/types.ts +++ b/packages/dashboard-frontend/src/services/helpers/types.ts @@ -102,9 +102,10 @@ export enum WorkspaceAction { WORKSPACE_DETAILS = 'Workspace Details', } -export type UserPreferencesTab = - | 'container-registries' - | 'git-services' - | 'gitconfig' - | 'personal-access-tokens' - | 'ssh-keys'; +export enum UserPreferencesTab { + CONTAINER_REGISTRIES = 'ContainerRegistries', + GIT_SERVICES = 'GitServices', + GITCONFIG = 'Gitconfig', + PERSONAL_ACCESS_TOKENS = 'PersonalAccessTokens', + SSH_KEYS = 'SshKeys', +}